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
14 changes: 14 additions & 0 deletions .claude/rules/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,20 @@ at:
confirm it now REQUIRES the authentication prompt to return data
instead of returning it silently — the regression test for the
finding this goal exists to close.
- **The real sudo askpass / Touch ID escalation prompt** (goal 0240
S5, `wrapArgvForAdmin`/`materializeAskpass`) — sudo's PAM
conversation (pam_tid's Touch ID sheet, or the osascript
hidden-answer password dialog) is out-of-process system UI no
headless harness can trigger or dismiss; unit tests pin the argv
wrapping, askpass content/mode, and the secrets-refusal, and the
guardrail tests pin the always-asks policy — never the real
prompt. Verify on an installed build: configure the seeded "Run
from clipboard" shell step with "Run with admin rights", run
`whoami`, approve the forced ask, confirm the Touch ID sheet
appears (pam_tid configured) and the output reads `root`; cancel
the prompt on a second run and confirm the step fails with sudo's
own error rather than hanging; then confirm an allow-listed
command STILL parked for approval while the toggle was on.
- **The real browser-tab approval notification** (goal 0132 slice A) —
requires a real granted browser permission and a real OS compositor;
verify via a server-mode instance reached from a real browser tab:
Expand Down
12 changes: 12 additions & 0 deletions internal/adapters/procexec/procexec.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"errors"
"io"
"os/exec"
"strings"
"sync"
"time"
)
Expand Down Expand Up @@ -116,6 +117,14 @@ type Spec struct {
// which case output is discarded but LastOutputAt still advances
// (idle-timeout tracking doesn't require a caller-supplied sink).
Output io.Writer

// Stdin, when non-empty, is written to the child's standard input
// (then closed, so a reader sees EOF after it). Empty keeps
// exec.Cmd's own default: the child reads from the null device.
// A string, not a Reader, deliberately: every caller's input is an
// already-materialized payload, and a bounded value can never hold
// the child open waiting on a slow producer.
Stdin string
}

// Result is one Start call's terminal outcome.
Expand Down Expand Up @@ -184,6 +193,9 @@ func Start(spec Spec) (*Handle, error) {
fw := newFanWriter(spec.Output)
cmd.Stdout = fw
cmd.Stderr = fw
if spec.Stdin != "" {
cmd.Stdin = strings.NewReader(spec.Stdin)
}

if err := cmd.Start(); err != nil {
return nil, err
Expand Down
43 changes: 43 additions & 0 deletions internal/adapters/procexec/procexec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,3 +286,46 @@ func TestCancel_EscalatesToSIGKILL_WhenSIGTERMIsTrapped(t *testing.T) {
}
waitForGroupGone(t, h.PGID())
}

// TestStart_StdinPipedToChild pins Spec.Stdin's contract (goal 0240
// S5): a non-empty Stdin reaches the child verbatim and is followed by
// EOF, so a stdin-reading command terminates on its own.
func TestStart_StdinPipedToChild(t *testing.T) {
var out bytes.Buffer
h, err := Start(Spec{
Argv: []string{"cat"},
Stdin: "line one\nline two\n",
Output: &out,
})
if err != nil {
t.Fatalf("Start() error: %v", err)
}
result := h.Wait()
if result.Outcome != OutcomeExited || result.ExitCode != 0 {
t.Fatalf("Outcome/ExitCode = %q/%d, want exited/0", result.Outcome, result.ExitCode)
}
if got := out.String(); got != "line one\nline two\n" {
t.Errorf("output = %q, want the stdin content verbatim", got)
}
}

// TestStart_EmptyStdin_ChildSeesEOF pins the zero value's behavior:
// no Stdin means the child reads the null device (immediate EOF), the
// pre-S5 behavior unchanged.
func TestStart_EmptyStdin_ChildSeesEOF(t *testing.T) {
var out bytes.Buffer
h, err := Start(Spec{
Argv: []string{"cat"},
Output: &out,
})
if err != nil {
t.Fatalf("Start() error: %v", err)
}
result := h.Wait()
if result.Outcome != OutcomeExited || result.ExitCode != 0 {
t.Fatalf("Outcome/ExitCode = %q/%d, want exited/0 (cat must see EOF, not hang)", result.Outcome, result.ExitCode)
}
if got := out.String(); got != "" {
t.Errorf("output = %q, want empty", got)
}
}
62 changes: 62 additions & 0 deletions internal/contract/contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -1862,6 +1862,23 @@
"Multiline": true,
"SystemManaged": false
},
{
"Key": "passInput",
"Label": "Pass input",
"Type": "options",
"Required": false,
"Default": "stdin",
"Description": "How a literal script receives the upstream payload: piped to stdin, or one argument per line ($1, $2, …).",
"Options": [
"stdin",
"arguments"
],
"Suggestions": null,
"Secret": false,
"RefKind": "",
"Multiline": false,
"SystemManaged": false
},
{
"Key": "timeoutSeconds",
"Label": "Timeout (seconds)",
Expand Down Expand Up @@ -2654,6 +2671,20 @@
"OptionalRef": true,
"Multiline": false,
"SystemManaged": false
},
{
"Key": "runWithAdmin",
"Label": "Run with admin rights",
"Type": "boolean",
"Required": false,
"Default": "false",
"Description": "Runs each command with administrator rights. macOS asks you to approve every run — Touch ID when it's set up for sudo, your password otherwise.",
"Options": null,
"Suggestions": null,
"Secret": false,
"RefKind": "",
"Multiline": false,
"SystemManaged": false
}
],
"Output": "combined stdout+stderr from every sub-command that ran",
Expand Down Expand Up @@ -4075,6 +4106,23 @@
"Multiline": true,
"SystemManaged": false
},
{
"Key": "passInput",
"Label": "Pass input",
"Type": "options",
"Required": false,
"Default": "stdin",
"Description": "How a literal script receives the upstream payload: piped to stdin, or one argument per line ($1, $2, …).",
"Options": [
"stdin",
"arguments"
],
"Suggestions": null,
"Secret": false,
"RefKind": "",
"Multiline": false,
"SystemManaged": false
},
{
"Key": "timeoutSeconds",
"Label": "Timeout (seconds)",
Expand Down Expand Up @@ -4867,6 +4915,20 @@
"OptionalRef": true,
"Multiline": false,
"SystemManaged": false
},
{
"Key": "runWithAdmin",
"Label": "Run with admin rights",
"Type": "boolean",
"Required": false,
"Default": "false",
"Description": "Runs each command with administrator rights. macOS asks you to approve every run — Touch ID when it's set up for sudo, your password otherwise.",
"Options": null,
"Suggestions": null,
"Secret": false,
"RefKind": "",
"Multiline": false,
"SystemManaged": false
}
],
"Output": "combined stdout+stderr from every sub-command that ran",
Expand Down
2 changes: 1 addition & 1 deletion internal/domain/composition/builtinworkflows.go
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,7 @@ func BuiltInWorkflows() []Workflow {
{ID: "example-mcp-e0", Source: mcpTriggerID, Target: mcpCallID},
},
BuiltIn: true,
Seed: seedorigin.Stamp(2),
Seed: seedorigin.Stamp(3),
},
{
ID: "example-codeexec-workflow",
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(2),
Seed: seedorigin.Stamp(3),
},
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func builtInSecretGuardWorkflows() []Workflow {
{ID: "example-secret-guard-e0", Source: secretGuardTriggerID, Target: ExampleSecretGuardStepID},
},
BuiltIn: true,
Seed: seedorigin.Stamp(1),
Seed: seedorigin.Stamp(2),
},
}
}
56 changes: 55 additions & 1 deletion internal/domain/composition/codeexec.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -175,6 +176,41 @@ func shellArgv(shell, profile, script string) []string {
}
}

// inputArgs turns an upstream payload into the argument list a
// pass-input=arguments script receives -- one argument per line, the
// Shortcuts convention for list-shaped input. Lines arrive VERBATIM
// (interior empty lines stay empty arguments); only a single trailing
// newline's empty remainder is dropped, so "a\nb\n" is two arguments,
// not three. CR is stripped per line for CRLF payloads. Empty input
// means no arguments at all.
func inputArgs(input string) []string {
if input == "" {
return nil
}
lines := strings.Split(strings.TrimSuffix(input, "\n"), "\n")
for i, l := range lines {
lines[i] = strings.TrimSuffix(l, "\r")
}
return lines
}

// appendShellArgs extends a shellArgv-built `-c` invocation with
// positional arguments. POSIX `-c` semantics: the first operand after
// the command string becomes $0, the rest $1..$N -- so the shell's own
// name is inserted as the $0 placeholder and the input lines land as
// "$@" exactly the way a terminal invocation would deliver them. A nil
// args returns argv unchanged (no stray $0 operand for the common
// no-arguments case).
func appendShellArgs(argv []string, args []string) []string {
if len(args) == 0 {
return argv
}
out := make([]string, 0, len(argv)+1+len(args))
out = append(out, argv...)
out = append(out, filepath.Base(argv[0]))
return append(out, args...)
}

// resolveDir turns an ExecEnv's Dir into a real, existing directory --
// TempDirSentinel mints a fresh one per run (os.MkdirTemp), matching
// the seeded "Safe sandbox" env's own design (nothing this env
Expand Down Expand Up @@ -220,6 +256,11 @@ func init() {
Description: "The command to run when \"Command source\" is literal. Ignored when source is payload.",
Default: "", Type: FieldText,
},
{
Key: "passInput", Label: "Pass input", Type: FieldOptions,
Description: "How a literal script receives the upstream payload: piped to stdin, or one argument per line ($1, $2, …).",
Default: "stdin", Options: []string{"stdin", "arguments"},
},
{
Key: "timeoutSeconds", Label: "Timeout (seconds)", Type: FieldNumber,
Description: "Kills the command if it hasn't finished within this many seconds.",
Expand All @@ -233,8 +274,20 @@ func init() {
}

script := ctx.Payload
// A literal script receives the upstream payload as INPUT --
// the Shortcuts/Automator pass-input convention (goal 0240 S5):
// piped to stdin (default), or one argument per line. Source
// "payload" runs the payload AS the script, so there is no
// separate input to route and both modes are no-ops there.
var stdin string
var extraArgs []string
if node.Config["source"] == "literal" {
script = node.Config["script"]
if node.Config["passInput"] == "arguments" {
extraArgs = inputArgs(ctx.Payload)
} else {
stdin = ctx.Payload
}
}
if strings.TrimSpace(script) == "" {
return ctx, fmt.Errorf("code-execution: nothing to run (empty command)")
Expand Down Expand Up @@ -263,10 +316,11 @@ func init() {

var out strings.Builder
handle, err := startProcessFn(procexec.Spec{
Argv: shellArgv(re.Shell, re.ProfileMode, script),
Argv: appendShellArgs(shellArgv(re.Shell, re.ProfileMode, script), extraArgs),
Dir: dir,
Env: env,
HardTimeout: timeout,
Stdin: stdin,
Output: &out,
})
if err != nil {
Expand Down
Loading
Loading