Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,17 @@ export interface Field {
*/
"RefKind": string;

/**
* OptionalRef marks a RefKind field whose EMPTY value is a legal,
* meaningful configuration rather than "not configured yet"
* (docs/goals/0240 S4's shell-step environment: empty means the
* user's real login shell, a deliberate posture, not a gap) --
* graph preflight skips its required-ref check. Meaningless
* without RefKind. JSON-tagged omitempty for the same
* byte-identical-marshaling reason Deprecated below documents.
*/
"OptionalRef"?: boolean;

/**
* ShowOnCard surfaces this field's value on a card's compact face
* (docs/goals/0152) -- read by Atlas Kinds only; other
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,17 @@ export interface Field {
*/
"RefKind": string;

/**
* OptionalRef marks a RefKind field whose EMPTY value is a legal,
* meaningful configuration rather than "not configured yet"
* (docs/goals/0240 S4's shell-step environment: empty means the
* user's real login shell, a deliberate posture, not a gap) --
* graph preflight skips its required-ref check. Meaningless
* without RefKind. JSON-tagged omitempty for the same
* byte-identical-marshaling reason Deprecated below documents.
*/
"OptionalRef"?: boolean;

/**
* ShowOnCard surfaces this field's value on a card's compact face
* (docs/goals/0152) -- read by Atlas Kinds only; other
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ export interface CommandBlockPreview {
"shell": string;
"dir": string;

/**
* EnvironmentLabel names the Configure-authored execution
* environment the seeded shell step is configured to run inside
* (docs/goals/0240 S4) -- empty in the default real-login-shell
* posture, where Shell/Dir above already tell the whole story.
*/
"environmentLabel": string;

/**
* GuardrailVerdict is "allow" | "ask" | "deny" -- the block-level
* gate decision (goal 0240 S3): the MOST RESTRICTIVE of every step's
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@

/**
* PluginService is Wails-bound. openURL is injected so tests never
* shell out to the real OS handler.
* shell out to the real OS handler. appVersion is the build-stamped
* Mill version minMillVersion enforcement compares against.
* @module
*/

Expand Down
3 changes: 2 additions & 1 deletion frontend/src/locales/en/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,8 @@
},
"startFailedTitle": "Couldn't start the run",
"cancel": "Cancel",
"run": "Run"
"run": "Run",
"targetEnv": "Runs in the {{env}} environment ({{shell}}, {{dir}})"
},
"running": {
"status": {
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/shared/CodingLoopConfirmState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ export function CodingLoopConfirmState({ preview, previewError, startError, type
{t('codingLoop.confirm.summary', { count: stepCount, plural: stepCount === 1 ? '' : 's' })}
</Text>
<Text as="p" size="small" className={styles.target} data-testid="coding-loop-confirm-target">
{t('codingLoop.confirm.target', { shell: preview.shell, dir: preview.dir })}
{preview.environmentLabel
? t('codingLoop.confirm.targetEnv', { env: preview.environmentLabel, shell: preview.shell, dir: preview.dir })
: t('codingLoop.confirm.target', { shell: preview.shell, dir: preview.dir })}
</Text>

<ol className={styles.stepList} data-testid="coding-loop-confirm-steps">
Expand Down
52 changes: 48 additions & 4 deletions internal/contract/contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@
"RefKind": {
"type": "string"
},
"OptionalRef": {
"type": "boolean"
},
"ShowOnCard": {
"type": "boolean"
},
Expand Down Expand Up @@ -402,6 +405,9 @@
"RefKind": {
"type": "string"
},
"OptionalRef": {
"type": "boolean"
},
"ShowOnCard": {
"type": "boolean"
},
Expand Down Expand Up @@ -587,6 +593,9 @@
"RefKind": {
"type": "string"
},
"OptionalRef": {
"type": "boolean"
},
"ShowOnCard": {
"type": "boolean"
},
Expand Down Expand Up @@ -1156,6 +1165,9 @@
"RefKind": {
"type": "string"
},
"OptionalRef": {
"type": "boolean"
},
"ShowOnCard": {
"type": "boolean"
},
Expand Down Expand Up @@ -2626,8 +2638,24 @@
"ID": "process-shell-command",
"Kind": "process",
"Label": "Run a captured command",
"Description": "Runs the captured payload in your real login shell, exactly as written -- no sandboxing, no stored environment profile. A piped command stays one step; commands separated by a new line or \u0026\u0026 show as separate steps. External effect -- the run asks for your approval by default. No configurable fields: this node always runs whatever payload it receives.",
"ConfigFields": null,
"Description": "Runs the captured payload exactly as written -- in your real login shell by default, or inside a Configure-authored execution environment (its shell, directory, and variables) when one is chosen. A piped command stays one step; commands separated by a new line or \u0026\u0026 show as separate steps. External effect -- the run asks for your approval by default.",
"ConfigFields": [
{
"Key": "envId",
"Label": "Execution environment",
"Type": "text",
"Required": false,
"Default": "",
"Description": "Runs the block inside a Configure-authored environment. Empty runs your real login shell.",
"Options": null,
"Suggestions": null,
"Secret": false,
"RefKind": "execenv",
"OptionalRef": true,
"Multiline": false,
"SystemManaged": false
}
],
"Output": "combined stdout+stderr from every sub-command that ran",
"Consumes": [
"text"
Expand Down Expand Up @@ -4823,8 +4851,24 @@
"ID": "process-shell-command",
"Kind": "process",
"Label": "Run a captured command",
"Description": "Runs the captured payload in your real login shell, exactly as written -- no sandboxing, no stored environment profile. A piped command stays one step; commands separated by a new line or \u0026\u0026 show as separate steps. External effect -- the run asks for your approval by default. No configurable fields: this node always runs whatever payload it receives.",
"ConfigFields": null,
"Description": "Runs the captured payload exactly as written -- in your real login shell by default, or inside a Configure-authored execution environment (its shell, directory, and variables) when one is chosen. A piped command stays one step; commands separated by a new line or \u0026\u0026 show as separate steps. External effect -- the run asks for your approval by default.",
"ConfigFields": [
{
"Key": "envId",
"Label": "Execution environment",
"Type": "text",
"Required": false,
"Default": "",
"Description": "Runs the block inside a Configure-authored environment. Empty runs your real login shell.",
"Options": null,
"Suggestions": null,
"Secret": false,
"RefKind": "execenv",
"OptionalRef": true,
"Multiline": false,
"SystemManaged": false
}
],
"Output": "combined stdout+stderr from every sub-command that ran",
"Consumes": [
"text"
Expand Down
3 changes: 3 additions & 0 deletions internal/contract/schemas/atlas.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@
"RefKind": {
"type": "string"
},
"OptionalRef": {
"type": "boolean"
},
"ShowOnCard": {
"type": "boolean"
},
Expand Down
3 changes: 3 additions & 0 deletions internal/contract/schemas/decision.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@
"RefKind": {
"type": "string"
},
"OptionalRef": {
"type": "boolean"
},
"ShowOnCard": {
"type": "boolean"
},
Expand Down
3 changes: 3 additions & 0 deletions internal/contract/schemas/list.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@
"RefKind": {
"type": "string"
},
"OptionalRef": {
"type": "boolean"
},
"ShowOnCard": {
"type": "boolean"
},
Expand Down
3 changes: 3 additions & 0 deletions internal/contract/schemas/workflow.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@
"RefKind": {
"type": "string"
},
"OptionalRef": {
"type": "boolean"
},
"ShowOnCard": {
"type": "boolean"
},
Expand Down
2 changes: 1 addition & 1 deletion internal/domain/composition/builtinworkflows_codingloop.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ func codingLoopBuiltInWorkflow() []Workflow {
{ID: "coding-loop-e2", Source: applyID, Target: notifyID},
},
BuiltIn: true,
Seed: seedorigin.Stamp(1),
Seed: seedorigin.Stamp(2),
},
}
}
4 changes: 4 additions & 0 deletions internal/domain/composition/codeexec.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ type ResolvedExecEnv struct {
ProfileMode string
Dir string
Env []string
// Label is the environment's own display name -- the shell step's
// target line names which environment a block runs inside
// (docs/goals/0240 S4); code-execution ignores it.
Label string
}

// lookupExecEnvFn defaults to erroring so a code-execution node run
Expand Down
1 change: 1 addition & 0 deletions internal/domain/composition/codingloopsecrets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ func TestProcessShellCommandExec_SecretChain_NoRefsLeavesEnvNil(t *testing.T) {
env, redactValues := resolveShellSecretEnv(
[]ParsedCommandStep{{Index: 0, Text: "echo hello"}},
ExecContext{},
nil,
)
if env != nil {
t.Errorf("env = %v, want nil (no secret placeholder referenced)", env)
Expand Down
82 changes: 75 additions & 7 deletions internal/domain/composition/executeshellcommand.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,57 @@ import (
type ResolvedShellCommandTarget struct {
Shell string
Dir string
// EnvLabel names the Configure-authored execution environment the
// block runs inside (docs/goals/0240 S4) -- empty in the default
// real-login-shell posture.
EnvLabel string
// env is the child process's exact environment when an ExecEnv is
// set (explicit-only, codeexec.go's materialize-don't-inherit
// posture); nil in the default posture, where the process inherits
// the caller's real environment.
env []string
// argvFor builds the spawn argv for one sub-command -- the ExecEnv
// path routes through codeexec.go's shellArgv (clean/login flags
// per the environment's ProfileMode); the default path is the bare
// login-shell `-c` invocation this node has always used.
argvFor func(script string) []string
}

// resolveShellCommandRunTarget picks the block's execution target from
// the node's own envId config (docs/goals/0240 S4): empty keeps the
// documented default posture (the user's real login shell and real
// environment, exactly as S1 shipped); a set envId resolves the
// Configure-authored ExecEnv through the SAME lookup code-execution
// uses -- shell flags via shellArgv, a per-BLOCK materialized dir via
// resolveDir (one temp dir for the whole block, so its sub-commands
// see each other's files), and the environment's explicit Env with
// codeexec.go's same minimal-PATH default when it declares none.
func resolveShellCommandRunTarget(envID string, run SecretAccessRun) (ResolvedShellCommandTarget, error) {
if strings.TrimSpace(envID) == "" {
t := ResolveShellCommandTarget()
t.argvFor = func(script string) []string { return []string{t.Shell, "-c", script} }
return t, nil
}
re, err := lookupExecEnvFn(envID, run)
if err != nil {
return ResolvedShellCommandTarget{}, fmt.Errorf("process-shell-command: %w", err)
}
dir, err := resolveDir(re.Dir)
if err != nil {
return ResolvedShellCommandTarget{}, fmt.Errorf("process-shell-command: %w", err)
}
env := re.Env
if len(env) == 0 {
env = []string{"PATH=/usr/bin:/bin:/usr/sbin:/sbin"}
}
shell, profile := re.Shell, re.ProfileMode
return ResolvedShellCommandTarget{
Shell: shellArgv(shell, profile, "")[0],
Dir: dir,
EnvLabel: re.Label,
env: env,
argvFor: func(script string) []string { return shellArgv(shell, profile, script) },
}, nil
}

// ResolveShellCommandTarget reads the process's own SHELL/HOME, exactly
Expand Down Expand Up @@ -141,13 +192,20 @@ type shellStepOutcome struct {
// "verbatim except the resolved secret" contract), and redactValues is
// every non-empty resolved value regardless of source, for the output
// redaction pass below to scrub.
func resolveShellSecretEnv(steps []ParsedCommandStep, ctx ExecContext) (env []string, redactValues []string) {
func resolveShellSecretEnv(steps []ParsedCommandStep, ctx ExecContext, baseEnv []string) (env []string, redactValues []string) {
names := ExtractSecretEnvRefsAll(steps)
if len(names) == 0 {
return nil, nil
// An ExecEnv target's environment stays explicit-only even with
// no secret refs to resolve (docs/goals/0240 S4) -- baseEnv nil
// is the default posture's inherit-the-real-environment case.
return baseEnv, nil
}
run := secretAccessRunFromCtx(ctx)
env = append(env, os.Environ()...)
if baseEnv != nil {
env = append(env, baseEnv...)
} else {
env = append(env, os.Environ()...)
}
for _, name := range names {
value, _, found := shellSecretResolverFn(name, ctx.SecretsToken, run)
if !found {
Expand Down Expand Up @@ -198,7 +256,7 @@ func runShellStep(node Node, step ParsedCommandStep, total int, target ResolvedS
}

handle, err := startShellProcessFn(procexec.Spec{
Argv: []string{target.Shell, "-c", step.Text},
Argv: target.argvFor(step.Text),
Dir: target.Dir,
// Env nil (the common case, resolveShellSecretEnv's own doc
// comment) falls back to the calling process's real environment
Expand Down Expand Up @@ -248,8 +306,11 @@ func runShellStep(node Node, step ParsedCommandStep, total int, target ResolvedS
// states. steps is always non-empty (the init() closure below rejects
// an empty parse before calling this).
func runShellCommandBlock(node Node, ctx ExecContext, steps []ParsedCommandStep) (ExecContext, error) {
target := ResolveShellCommandTarget()
env, redactValues := resolveShellSecretEnv(steps, ctx)
target, err := resolveShellCommandRunTarget(node.Config["envId"], secretAccessRunFromCtx(ctx))
if err != nil {
return ctx, err
}
env, redactValues := resolveShellSecretEnv(steps, ctx, target.env)
var combined strings.Builder
// lastFailed propagates a failure forward ONLY across && steps
// (docs/goals/0240 S1: "&&"'s own short-circuit meaning, preserved
Expand Down Expand Up @@ -299,7 +360,14 @@ func init() {
Produces: PayloadProduce{Kind: PayloadText},
Output: "combined stdout+stderr from every sub-command that ran",
Label: "Run a captured command",
Description: "Runs the captured payload in your real login shell, exactly as written -- no sandboxing, no stored environment profile. A piped command stays one step; commands separated by a new line or && show as separate steps. External effect -- the run asks for your approval by default. No configurable fields: this node always runs whatever payload it receives.",
Description: "Runs the captured payload exactly as written -- in your real login shell by default, or inside a Configure-authored execution environment (its shell, directory, and variables) when one is chosen. A piped command stays one step; commands separated by a new line or && show as separate steps. External effect -- the run asks for your approval by default.",
ConfigFields: []ConfigField{
{
Key: "envId", Label: "Execution environment",
Description: "Runs the block inside a Configure-authored environment. Empty runs your real login shell.",
Default: "", Type: FieldText, RefKind: "execenv", OptionalRef: true,
},
},
}, func(node Node, ctx ExecContext) (ExecContext, error) {
steps := ParseShellCommandBlock(ctx.Payload)
if len(steps) == 0 {
Expand Down
Loading
Loading