From 33cf4d729637445522debd8b2ce9c277ce3cdc5a Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 22 Aug 2026 21:53:02 +0900 Subject: [PATCH] fix: bound and attest prompt delivery --- ProwlCLI/Commands/CreateCommand.swift | 32 +++++++++----- .../Resources/cli-output-schema.json | 6 +++ ProwlCLITests/CreateCommandParsingTests.swift | 17 +++++++ ProwlCLITests/ProwlCLIIntegrationTests.swift | 36 ++++++++++++++- docs-ai/013-prowl-cli/contracts/create.md | 21 ++++++--- docs-ai/013-prowl-cli/contracts/input.md | 4 +- docs-ai/063-agent-workflows/000-plan.md | 2 +- .../005-cli-profile-launch.md | 12 ++--- docs/components/cli.md | 15 +++++-- skills/prowl-cli/SKILL.md | 7 +-- .../CLIService/LifecycleCommandHandler.swift | 10 ++++- supacode/CLIService/Shared/InputModels.swift | 4 ++ .../Shared/LifecycleCommandPayload.swift | 14 +++++- .../AgentProfile/AgentProfileLaunchPlan.swift | 28 ++++++++---- supacodeTests/AgentProfileTests.swift | 44 ++++++++++++++++++- .../CLILifecycleCommandHandlerTests.swift | 42 +++++++++++++++++- 16 files changed, 247 insertions(+), 47 deletions(-) diff --git a/ProwlCLI/Commands/CreateCommand.swift b/ProwlCLI/Commands/CreateCommand.swift index 9906bd7d..ffd931d1 100644 --- a/ProwlCLI/Commands/CreateCommand.swift +++ b/ProwlCLI/Commands/CreateCommand.swift @@ -16,17 +16,23 @@ struct CreateCommand: ParsableCommand { static func validateProfileLaunchResponse( _ response: CommandResponse, - requested: Bool + requested launchInput: CreateLaunchInput? ) throws { - guard requested else { return } + guard let launchInput else { return } guard let data = response.data, let payload = try? data.decode(as: LifecycleCommandPayload.self), - payload.launch != nil + let launch = payload.launch else { throw ExitError( code: CLIErrorCode.createFailed, - message: "The running Prowl app did not honor the Profile launch. Update or restart Prowl, then retry." + message: "The running Prowl app did not honor the Profile launch. An ordinary shell may have been created; inspect prowl list and close it before retrying. Update or restart Prowl." + ) + } + if launchInput.prompt != nil, launch.promptDelivery != .surfaceEnvironmentV1 { + throw ExitError( + code: CLIErrorCode.createFailed, + message: "The running Prowl app did not confirm safe prompt delivery. A Profile pane may have been created with a truncated prompt; inspect prowl list and close it before retrying. Update or restart Prowl." ) } } @@ -73,12 +79,18 @@ struct CreateLaunchOptions: ParsableArguments { message: "--prompt - requires piped stdin; it cannot read from an interactive terminal." ) } - guard - let data = try? readStdin(), - let text = String(data: data, encoding: .utf8) - else { + guard let data = try? readStdin() else { throw ExitError(code: CLIErrorCode.emptyInput, message: "Failed to read the kickoff prompt from stdin.") } + guard data.count <= CreateLaunchInput.maximumPromptUTF8ByteCount else { + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: "The kickoff prompt exceeds the 256 KiB UTF-8 limit." + ) + } + guard let text = String(data: data, encoding: .utf8) else { + throw ExitError(code: CLIErrorCode.emptyInput, message: "Failed to read the kickoff prompt as UTF-8.") + } guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw ExitError(code: CLIErrorCode.emptyInput, message: "The kickoff prompt is empty.") } @@ -110,7 +122,7 @@ struct CreateTabCommand: ParsableCommand { command: .create(input) ) try CLIRunner.execute(envelope) { response in - try CreateCommand.validateProfileLaunchResponse(response, requested: input.launch != nil) + try CreateCommand.validateProfileLaunchResponse(response, requested: input.launch) } } } @@ -175,7 +187,7 @@ struct CreatePaneCommand: ParsableCommand { command: .create(input) ) try CLIRunner.execute(envelope) { response in - try CreateCommand.validateProfileLaunchResponse(response, requested: input.launch != nil) + try CreateCommand.validateProfileLaunchResponse(response, requested: input.launch) } } } diff --git a/ProwlCLIContracts/Resources/cli-output-schema.json b/ProwlCLIContracts/Resources/cli-output-schema.json index 12e971c8..d7174006 100644 --- a/ProwlCLIContracts/Resources/cli-output-schema.json +++ b/ProwlCLIContracts/Resources/cli-output-schema.json @@ -196,6 +196,12 @@ "agent": { "type": "string", "minLength": 1 + }, + "prompt_delivery": { + "type": "string", + "enum": [ + "surface_env_v1" + ] } } }, diff --git a/ProwlCLITests/CreateCommandParsingTests.swift b/ProwlCLITests/CreateCommandParsingTests.swift index 57a3b1f3..e975917d 100644 --- a/ProwlCLITests/CreateCommandParsingTests.swift +++ b/ProwlCLITests/CreateCommandParsingTests.swift @@ -84,6 +84,23 @@ final class CreateCommandParsingTests: XCTestCase { XCTAssertFalse(didRead) } + func testPromptRejectsOversizedUTF8Input() { + var options = CreateLaunchOptions() + options.profile = "Reviewer" + options.prompt = "-" + + XCTAssertThrowsError( + try options.resolve( + stdinIsTerminal: false, + readStdin: { + Data(repeating: 0x78, count: CreateLaunchInput.maximumPromptUTF8ByteCount + 1) + } + ) + ) { error in + XCTAssertEqual((error as? ExitError)?.code, CLIErrorCode.invalidArgument) + } + } + func testPromptAndBackgroundRequireAProfile() throws { let prompt = try CreateTabCommand.parse(["App", "--prompt", "-"]) let background = try CreatePaneCommand.parse(["p12", "--direction", "right", "--background"]) diff --git a/ProwlCLITests/ProwlCLIIntegrationTests.swift b/ProwlCLITests/ProwlCLIIntegrationTests.swift index 572361af..568d9bee 100644 --- a/ProwlCLITests/ProwlCLIIntegrationTests.swift +++ b/ProwlCLITests/ProwlCLIIntegrationTests.swift @@ -475,7 +475,37 @@ final class ProwlCLIIntegrationTests: XCTestCase { let output = try jsonObject(from: result.stdout) let error = try XCTUnwrap(output["error"] as? [String: Any]) XCTAssertEqual(error["code"] as? String, CLIErrorCode.createFailed) - XCTAssertTrue((error["message"] as? String)?.contains("Update or restart Prowl") == true) + XCTAssertTrue((error["message"] as? String)?.contains("ordinary shell may have been created") == true) + XCTAssertTrue((error["message"] as? String)?.contains("prowl list") == true) + } + + func testCreatePromptFailsClosedWhenTheAppOmitsSafeDeliveryMetadata() throws { + let socketPath = temporarySocketPath(suffix: "create-prompt-delivery-version-skew") + let launch = LifecycleCommandLaunch( + profileID: UUID().uuidString, + profileName: "Reviewer", + agent: "claude" + ) + let response = try CommandResponse( + ok: true, + command: "create", + schemaVersion: "prowl.cli.create.v1", + data: RawJSON(encoding: makeLifecyclePayload(resource: .tab, launch: launch)) + ) + + let (_, result) = try runWithMockServer( + socketPath: socketPath, + response: response, + args: ["create", "tab", "App", "--profile", "Reviewer", "--prompt", "-", "--json"], + stdinData: Data("Review the diff.\n".utf8) + ) + + XCTAssertNotEqual(result.exitCode, 0) + let output = try jsonObject(from: result.stdout) + let error = try XCTUnwrap(output["error"] as? [String: Any]) + XCTAssertEqual(error["code"] as? String, CLIErrorCode.createFailed) + XCTAssertTrue((error["message"] as? String)?.contains("safe prompt delivery") == true) + XCTAssertTrue((error["message"] as? String)?.contains("truncated prompt") == true) } func testCreatePaneProfilePromptRoundTripsOverSocket() throws { @@ -483,7 +513,8 @@ final class ProwlCLIIntegrationTests: XCTestCase { let launch = LifecycleCommandLaunch( profileID: UUID().uuidString, profileName: "Reviewer", - agent: "claude" + agent: "claude", + promptDelivery: .surfaceEnvironmentV1 ) let response = try CommandResponse( ok: true, @@ -523,6 +554,7 @@ final class ProwlCLIIntegrationTests: XCTestCase { let outputLaunch = try XCTUnwrap(data["launch"] as? [String: Any]) XCTAssertEqual(outputLaunch["profile_name"] as? String, "Reviewer") XCTAssertEqual(outputLaunch["agent"] as? String, "claude") + XCTAssertEqual(outputLaunch["prompt_delivery"] as? String, "surface_env_v1") } func testProfilesListRoundTripsOverSocket() throws { diff --git a/docs-ai/013-prowl-cli/contracts/create.md b/docs-ai/013-prowl-cli/contracts/create.md index e33dddec..f2f8b125 100644 --- a/docs-ai/013-prowl-cli/contracts/create.md +++ b/docs-ai/013-prowl-cli/contracts/create.md @@ -35,9 +35,13 @@ UUID first, then an exact name among enabled profiles; duplicate enabled names f `PROFILE_NOT_UNIQUE`, while missing or disabled profiles fail with `PROFILE_NOT_FOUND`. Runtime availability is advisory and never blocks launch. `--prompt` accepts only `-` and reads a non-empty kickoff prompt from piped UTF-8 stdin; an interactive terminal is rejected -instead of waiting for EOF. `--prompt` and `--background` require `--profile`. Prompt text is -carried in the launch-scoped surface environment and expanded as one quoted argv token; it is -not written through Ghostty's initial PTY input stream. NUL bytes are rejected. +instead of waiting for EOF. The UTF-8 payload is capped at 256 KiB; oversized input returns +`INVALID_ARGUMENT` before any surface exists. `--prompt` and `--background` require +`--profile`. Prompt text is carried in a reserved surface-environment carrier and expanded as +one quoted argv token; it is not written through Ghostty's initial PTY input stream. Before +starting the Profile process, the shell copies the value into an unexported temporary variable +and unsets the carrier; after the process exits it unsets the temporary value. NUL bytes are +rejected. Foreground profile launches select the destination worktree/tab and focus the returned pane. A background tab is created without changing the selected worktree, tab, or pane. A @@ -103,16 +107,23 @@ an older app: "launch": { "profile_id": "…", "profile_name": "Reviewer", - "agent": "claude" + "agent": "claude", + "prompt_delivery": "surface_env_v1" } } ``` +`prompt_delivery` is present only for prompted launches. When `--prompt -` was requested, the +CLI requires `surface_env_v1`; launch metadata without that delivery marker is a contract +failure because an older app may still have used literal canonical-PTY input. + ## Errors `INVALID_ARGUMENT`, `EMPTY_INPUT`, `TARGET_NOT_FOUND`, `TARGET_NOT_UNIQUE`, `PROFILE_NOT_FOUND`, `PROFILE_NOT_UNIQUE`, `PATH_NOT_ALLOWED`, and `CREATE_FAILED` use the common error envelope. Unsupported prompted starts and invalid prompt values return `INVALID_ARGUMENT`; creation/provisioning failures retain a specific human-readable reason -under `CREATE_FAILED`. See +under `CREATE_FAILED`. Client-side version mismatch errors warn that an ordinary shell or +Profile pane may already have been created and direct the caller to inspect `prowl list` and +close it. See [`cli-output-schema.json`](../../../ProwlCLIContracts/Resources/cli-output-schema.json). diff --git a/docs-ai/013-prowl-cli/contracts/input.md b/docs-ai/013-prowl-cli/contracts/input.md index 413893b4..9ab49eca 100644 --- a/docs-ai/013-prowl-cli/contracts/input.md +++ b/docs-ai/013-prowl-cli/contracts/input.md @@ -49,8 +49,8 @@ prowl close --tab [--force] `create tab` requires a worktree-only target. `create pane` requires a pane-only anchor and explicit direction; it rejects `--target`, `--worktree`, `--tab`, bare numbers, and focus fallback. `--prompt` accepts only `-`, reads non-empty UTF-8 piped -stdin, rejects an interactive terminal and NUL bytes, and requires `--profile`; `--background` -also requires `--profile`. `profiles list` +stdin up to 256 KiB, rejects an interactive terminal and NUL bytes, and requires `--profile`; +`--background` also requires `--profile`. `profiles list` is a read-only global snapshot and accepts no target. `close` requires a pane-or-tab-only target and rejects `--target`, `--worktree`, bare-number positions, and focus fallback. See [create.md](create.md) and [close.md](close.md). diff --git a/docs-ai/063-agent-workflows/000-plan.md b/docs-ai/063-agent-workflows/000-plan.md index 2a04b829..fb51c3fb 100644 --- a/docs-ai/063-agent-workflows/000-plan.md +++ b/docs-ai/063-agent-workflows/000-plan.md @@ -589,4 +589,4 @@ attaches hooks through A2's launch boundary. - Updated 2026-08-22: Implemented A1 with the direct anchored split primitive and schema-governed `prowl create pane` command — see [003-cli-create-pane.md](003-cli-create-pane.md). - Updated 2026-08-22: Implemented A1b — `PROWL_PANE_ID` in every pane's environment, manual identity section, and the `prowl-cli` skill rewritten around it — see [004-pane-identity-env.md](004-pane-identity-env.md). - Updated 2026-08-22: Implemented A2 with the typed Profile launch boundary, prompted/background `create tab|pane`, and `profiles list`; the final A1/A2 Swift interface question is resolved — see [005-cli-profile-launch.md](005-cli-profile-launch.md). -- Updated 2026-08-22: Hardened A2 after review: prompts bypass canonical PTY input through a reserved surface-environment carrier, launch failures retain typed reasons, interactive stdin/version skew fail closed, and hidden-worktree background selection is covered — see [005-cli-profile-launch.md](005-cli-profile-launch.md#review-hardening). +- Updated 2026-08-22: Hardened A2 after review: capped prompts bypass canonical PTY input through a self-cleaning surface-environment carrier, prompted success declares its delivery protocol, launch failures retain typed reasons, interactive stdin/version skew fail closed, and hidden-worktree background selection is covered — see [005-cli-profile-launch.md](005-cli-profile-launch.md#review-hardening). diff --git a/docs-ai/063-agent-workflows/005-cli-profile-launch.md b/docs-ai/063-agent-workflows/005-cli-profile-launch.md index 930c6d6f..ba0c4d13 100644 --- a/docs-ai/063-agent-workflows/005-cli-profile-launch.md +++ b/docs-ai/063-agent-workflows/005-cli-profile-launch.md @@ -24,19 +24,19 @@ Release R1 already had deterministic anchored split creation (A1) and per-pane i ## Review hardening -- A post-implementation review identified that Ghostty writes `initial_input` before the interactive shell enters raw mode. macOS canonical PTYs cap one line at `TTYHOG` (1024 bytes), so a literal prompted invocation could lose its closing quote/newline and stall at `quote>`. Prompted Profile plans now carry the verbatim prompt in reserved `PROWL_LAUNCH_PROMPT` surface environment, render only `"$PROWL_LAUNCH_PROMPT"` into the short typed command, and use `env -u` so the launched process does not inherit the carrier. NUL is rejected at both socket and planner boundaries. +- A post-implementation review identified that Ghostty writes `initial_input` before the interactive shell enters raw mode. macOS canonical PTYs cap one line at `TTYHOG` (1024 bytes), so a literal prompted invocation could lose its closing quote/newline and stall at `quote>`. Prompted Profile plans now carry the verbatim prompt in reserved `PROWL_LAUNCH_PROMPT` surface environment and render only a variable reference into the short typed command. The shell copies the prompt into an unexported temporary value, unsets the carrier before child exec, removes the temporary value after return, and defensively removes it from the child environment. NUL and UTF-8 input over 256 KiB are rejected before surface creation. - CLI Profile launch providers now return a typed failure. Unsupported prompted starts map to `INVALID_ARGUMENT`; planning, provisioning, split, insertion, and resolution failures retain actionable messages under `CREATE_FAILED`. -- `--prompt -` rejects interactive stdin before reading. A CLI that receives a successful create response without requested launch metadata fails instead of silently accepting an ordinary shell from a mismatched older app. +- `--prompt -` rejects interactive stdin before reading. Prompted success declares `prompt_delivery: surface_env_v1`; the CLI requires both launch metadata and that delivery marker, so an older app's literal-PTY launch cannot silently pass validation. Mismatch errors warn that the older app may already have created a resource and direct callers to inspect `prowl list` and close it. - Manager-level coverage now uses visible and hidden worktrees to prove a background split preserves worktree, tab, and pane selection. Router coverage includes `profiles`, and the manual scopes recommendation memory/toasts to Toolbar and Command Palette launches. - `profiles list` intentionally remains a non-blocking cache snapshot. It does not trigger a shell probe; negative-result TTL and refresh ownership are unchanged. ## Verification -- TDD RED runs failed on the absent planner intent, launch request/result, CLI flags/wire fields, Profile lookup, profiles command, executable schema, prompt carrier, typed launch failures, TTY guard, and version-skew validation; the corresponding focused suites were then driven GREEN. -- Focused app suites (`WorktreeTerminalStateAgentProfileTests`, `WorktreeTerminalManagerTests`, `AgentProfileTests`, `AgentRuntimeAdapterTests`, `CLILifecycleCommandHandlerTests`, `CLIProfilesCommandHandlerTests`, `CLICommandRouterTests`): 131 tests passed. -- `make check`, `make build-cli`, `make test-cli-smoke`, and `make test-cli-integration` passed; integration verified 85 socket/schema tests. +- TDD RED runs failed on the absent planner intent, launch request/result, CLI flags/wire fields, Profile lookup, profiles command, executable schema, prompt carrier/cleanup, typed launch failures, TTY and size guards, and launch/delivery version-skew validation; the corresponding focused suites were then driven GREEN. +- Focused app suites (`WorktreeTerminalStateAgentProfileTests`, `WorktreeTerminalManagerTests`, `AgentProfileTests`, `AgentRuntimeAdapterTests`, `CLILifecycleCommandHandlerTests`, `CLIProfilesCommandHandlerTests`, `CLICommandRouterTests`): 133 tests passed. +- `make check`, `make build-cli`, `make test-cli-smoke`, and `make test-cli-integration` passed; integration verified 86 socket/schema tests. - `make build-app` passed with zero errors and warnings. -- Live isolated Debug verification used a dedicated `PROWL_CLI_SOCKET` and the repository CLI. A real Claude Profile received distinct kickoff prompts in an anchored right split and a background tab; `agents --json` identified both returned panes, `read --wait-stable` observed `PROWL_A2_SPLIT_OK` / `PROWL_A2_BACKGROUND_OK`, and the background target remained `selected: false`, `focused: false`. A second regression launch delivered a 2534-byte prompt containing a tab, observed `PROWL_A2_LONG_PROMPT_OK` with no `quote>` continuation, and remained in a background tab. Every created resource was closed and each isolated Debug process was terminated. +- Live isolated Debug verification used a dedicated `PROWL_CLI_SOCKET` and the repository CLI. A real Claude Profile received distinct kickoff prompts in an anchored right split and a background tab; `agents --json` identified both returned panes, `read --wait-stable` observed `PROWL_A2_SPLIT_OK` / `PROWL_A2_BACKGROUND_OK`, and the background target remained `selected: false`, `focused: false`. A second regression launch delivered a 2534-byte prompt containing a tab, observed `PROWL_A2_LONG_PROMPT_OK` with no `quote>` continuation, and remained in a background tab. Final review verification observed `prompt_delivery: surface_env_v1`, exited the launched Profile, and captured `PROWL_A2_ENV_CLEAN` from the pane shell with both prompt variables absent. A 256 KiB + 1 byte CLI prompt returned `INVALID_ARGUMENT` without changing the surface count. Every created resource was closed and each isolated Debug process was terminated. ## Refs diff --git a/docs/components/cli.md b/docs/components/cli.md index 5a1e746d..2fa66c9f 100644 --- a/docs/components/cli.md +++ b/docs/components/cli.md @@ -350,7 +350,11 @@ EOF `--prompt -` requires a pipe or heredoc; it rejects interactive stdin instead of waiting for `Ctrl-D`. Prowl carries the prompt outside the terminal's initial PTY input and expands it as one quoted argument, so multiline, tab-containing, and long review instructions are -not interpreted by the shell line editor. NUL bytes remain invalid. +not interpreted by the shell line editor. The carrier is removed before the Profile process +starts and its unexported temporary shell value is cleared when that process exits. NUL bytes +remain invalid, and UTF-8 prompt input over 256 KiB is rejected before creating a surface. +For larger requirement sets, keep the content in a repository file and use the kickoff prompt +to tell the Profile which file to read. `--background` is Profile-only and creates the tab without changing the selected worktree, tab, or pane. @@ -372,9 +376,12 @@ selecting a hidden anchor's worktree/tab. `.data.anchor` records the source pane as resolved before the split (its `focused` flag is pre-split state), `.data.direction` records the public direction, and a Profile launch adds -`.data.launch.{profile_id,profile_name,agent}`. The CLI requires this launch metadata when -`--profile` was requested, so a mismatched older app cannot silently return an ordinary -shell. The operation targets the anchor directly; it never depends on current UI focus. +`.data.launch.{profile_id,profile_name,agent}`. Prompted launches also return +`.data.launch.prompt_delivery = "surface_env_v1"`. The CLI requires launch metadata for +`--profile` and the delivery marker for `--prompt -`, so mismatched older apps cannot silently +report a potentially truncated launch as successful. A mismatch error warns that the older +app may already have created a resource; inspect `prowl list` and close it before retrying. +The operation targets the anchor directly; it never depends on current UI focus. ### `prowl close` Close one explicit tab or pane. The positional form uses a UUID, `pN`, or `tN`; the diff --git a/skills/prowl-cli/SKILL.md b/skills/prowl-cli/SKILL.md index 7d66bb10..84a70fbf 100644 --- a/skills/prowl-cli/SKILL.md +++ b/skills/prowl-cli/SKILL.md @@ -86,8 +86,9 @@ prowl read --pane "$pane" --last 200 --wait-stable --json ``` The returned pane is the launched agent; `.data.launch` records the resolved Profile. `--prompt -` -requires a pipe or heredoc (never interactive stdin); Prowl carries the prompt outside initial PTY -input, so long or multiline review instructions are safe. Use `read --wait-stable` today. When +requires a pipe or heredoc (never interactive stdin); Prowl carries up to 256 KiB outside initial +PTY input and reports `.data.launch.prompt_delivery = "surface_env_v1"`. Put larger requirement +sets in a repository file and prompt the Profile to read it. Use `read --wait-stable` today. When `agents wait` ships, prefer it for deterministic completion before the final read. Add `--background` when the split must not change focus or select a hidden anchor's tab/worktree. @@ -139,7 +140,7 @@ Key fields by command: - `agents read` → `.data.agent`, `.data.blocker.text`, `.data.result.{state,text}` — `pending`, `unavailable`, `missing`, `incomplete`, `too_large` carry no partial text. - `read` → `.data.text`, `.data.line_count`, `.data.truncated`, `.data.mode`, `.data.source`; `.data.stabilized` / `.data.waited_ms` with `--wait-stable`. - `send` → `.data.input`, `.data.wait.{exit_code,duration_ms}` when waiting, `.data.capture.{text,line_count,truncated}` with `--capture`. -- `create tab` / `open` → `.data.target.{pane,tab,worktree}`; `create pane` → `.data.anchor`, `.data.direction`, `.data.target`; Profile launches also include `.data.launch.{profile_id,profile_name,agent}`. +- `create tab` / `open` → `.data.target.{pane,tab,worktree}`; `create pane` → `.data.anchor`, `.data.direction`, `.data.target`; Profile launches also include `.data.launch.{profile_id,profile_name,agent}`, plus `.prompt_delivery` for prompted launches. - `profiles list` → `.data.profiles[]` with `.id`, `.name`, `.enabled`, `.runtime`, `.availability.{status,reason}`. Terminal text is `.data.text` (read) and `.data.capture.text` (send) — never `.content`, `.output`, or `.stdout`. diff --git a/supacode/CLIService/LifecycleCommandHandler.swift b/supacode/CLIService/LifecycleCommandHandler.swift index f5bff799..c5a568b4 100644 --- a/supacode/CLIService/LifecycleCommandHandler.swift +++ b/supacode/CLIService/LifecycleCommandHandler.swift @@ -143,6 +143,13 @@ final class LifecycleCommandHandler: CommandHandler { message: "The kickoff prompt must not contain NUL bytes." ) } + if prompt.utf8.count > CreateLaunchInput.maximumPromptUTF8ByteCount { + return errorResponse( + command: "create", + code: CLIErrorCode.invalidArgument, + message: "The kickoff prompt exceeds the 256 KiB UTF-8 limit." + ) + } } switch input.resource { case .tab: @@ -277,7 +284,8 @@ final class LifecycleCommandHandler: CommandHandler { launch: LifecycleCommandLaunch( profileID: profile.id.uuidString, profileName: profile.name, - agent: profile.runtime.agent.rawValue + agent: profile.runtime.agent.rawValue, + promptDelivery: launch.prompt == nil ? nil : .surfaceEnvironmentV1 ) ) } diff --git a/supacode/CLIService/Shared/InputModels.swift b/supacode/CLIService/Shared/InputModels.swift index 64718862..02b59460 100644 --- a/supacode/CLIService/Shared/InputModels.swift +++ b/supacode/CLIService/Shared/InputModels.swift @@ -217,6 +217,10 @@ public enum CreatePaneDirection: String, Codable, CaseIterable, Sendable, Equata } public struct CreateLaunchInput: Codable, Sendable, Equatable { + /// Keeps the surface-shell spawn and prompted child exec comfortably below + /// macOS ARG_MAX, including the inherited environment and configured argv. + public static let maximumPromptUTF8ByteCount = 256 * 1_024 + public let profile: String public let prompt: String? diff --git a/supacode/CLIService/Shared/LifecycleCommandPayload.swift b/supacode/CLIService/Shared/LifecycleCommandPayload.swift index 38a60281..228e47c5 100644 --- a/supacode/CLIService/Shared/LifecycleCommandPayload.swift +++ b/supacode/CLIService/Shared/LifecycleCommandPayload.swift @@ -1,20 +1,32 @@ import Foundation +public enum LifecyclePromptDelivery: String, Codable, Sendable, Equatable { + case surfaceEnvironmentV1 = "surface_env_v1" +} + public struct LifecycleCommandLaunch: Codable, Sendable, Equatable { public let profileID: String public let profileName: String public let agent: String + public let promptDelivery: LifecyclePromptDelivery? enum CodingKeys: String, CodingKey { case profileID = "profile_id" case profileName = "profile_name" case agent + case promptDelivery = "prompt_delivery" } - public init(profileID: String, profileName: String, agent: String) { + public init( + profileID: String, + profileName: String, + agent: String, + promptDelivery: LifecyclePromptDelivery? = nil + ) { self.profileID = profileID self.profileName = profileName self.agent = agent + self.promptDelivery = promptDelivery } } diff --git a/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift b/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift index fe52a6ee..818acca0 100644 --- a/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift +++ b/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift @@ -61,17 +61,23 @@ nonisolated struct AgentProfileLaunchPlan: Equatable, Sendable { let promptCarrier = surfaceEnvironment[AgentProfileLaunchPlanner.promptCarrierName] == nil ? nil : AgentProfileLaunchPlanner.promptCarrierName + guard let promptCarrier else { + guard !commandEnvironmentTokens.isEmpty else { return invocation.terminalInput } + return (["env"] + commandEnvironmentTokens + [invocation.terminalInput]).joined(separator: " ") + } + + let valueVariable = AgentProfileLaunchPlanner.promptShellValueName let invocationInput = invocation.terminalInput( - replacingFinalArgumentWithEnvironmentVariable: promptCarrier + replacingFinalArgumentWithEnvironmentVariable: valueVariable ) - var environmentTokens = commandEnvironmentTokens - if let promptCarrier { - // Expansion happens in the shell before env(1) removes the carrier from - // the launched process environment. The pane shell retains the carrier. - environmentTokens.insert(contentsOf: ["-u", promptCarrier], at: 0) - } - guard !environmentTokens.isEmpty else { return invocationInput } - return (["env"] + environmentTokens + [invocationInput]).joined(separator: " ") + let childCommand = (["env", "-u", valueVariable] + commandEnvironmentTokens + [invocationInput]).joined( + separator: " ") + return [ + "\(valueVariable)=\"$\(promptCarrier)\"", + "unset \(promptCarrier)", + childCommand, + "unset \(valueVariable)", + ].joined(separator: "; ") } var previewText: String { terminalInput } @@ -259,6 +265,10 @@ nonisolated enum AgentProfileLaunchPlanner { /// Reserved surface-environment carrier for prompted starts. The prompt is /// expanded as one quoted argv token and never enters Ghostty initial_input. static let promptCarrierName = "PROWL_LAUNCH_PROMPT" + /// Unexported shell variable used only between carrier cleanup and child + /// argv expansion. env(1) removes it defensively if the user exported the + /// same reserved name before launch; the final command clears it from the shell. + static let promptShellValueName = "PROWL_LAUNCH_PROMPT_VALUE" /// Resolves a profile into one launch plan. Pure: no filesystem access — /// home provisioning happens at the launch boundary, not here. diff --git a/supacodeTests/AgentProfileTests.swift b/supacodeTests/AgentProfileTests.swift index 488244fc..46c4b3ce 100644 --- a/supacodeTests/AgentProfileTests.swift +++ b/supacodeTests/AgentProfileTests.swift @@ -300,11 +300,51 @@ struct AgentProfileTests { #expect(plan.invocation.arguments == ["--model", "gpt-5.4", prompt]) #expect(plan.surfaceEnvironment[AgentProfileLaunchPlanner.promptCarrierName] == prompt) #expect(!plan.terminalInput.contains(prompt)) - #expect(plan.terminalInput.hasPrefix("env -u \(AgentProfileLaunchPlanner.promptCarrierName) ")) - #expect(plan.terminalInput.contains("\"$\(AgentProfileLaunchPlanner.promptCarrierName)\"")) + #expect( + plan.terminalInput.hasPrefix( + "PROWL_LAUNCH_PROMPT_VALUE=\"$PROWL_LAUNCH_PROMPT\"; unset PROWL_LAUNCH_PROMPT; " + ) + ) + #expect(plan.terminalInput.contains("env -u PROWL_LAUNCH_PROMPT_VALUE ")) + #expect(plan.terminalInput.contains("\"$PROWL_LAUNCH_PROMPT_VALUE\"")) + #expect(plan.terminalInput.hasSuffix("; unset PROWL_LAUNCH_PROMPT_VALUE")) #expect(plan.terminalInput.utf8.count < 1_024) } + @Test func promptedTerminalInputCleansCarrierAndTemporaryShellValue() throws { + let prompt = "Review the current diff." + let carrier = AgentProfileLaunchPlanner.promptCarrierName + let valueVariable = AgentProfileLaunchPlanner.promptShellValueName + let childCheck = "[[ \"$1\" == \"\(prompt)\" && -z ${\(carrier)+x} && -z ${\(valueVariable)+x} ]]" + let plan = AgentProfileLaunchPlan( + profileID: UUID(), + profileName: "Reviewer", + runtime: .claude, + invocation: AgentInvocation( + executable: "/bin/zsh", + arguments: ["-f", "-c", childCheck, "prowl-child", prompt] + ), + commandEnvironmentTokens: [], + placement: .tab, + splitDirection: .right, + surfaceEnvironment: [carrier: prompt], + dedicatedHome: nil + ) + let parentCheck = "[[ -z ${\(carrier)+x} && -z ${\(valueVariable)+x} ]]" + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + process.arguments = ["-f", "-c", "\(plan.terminalInput); \(parentCheck)"] + var environment = ProcessInfo.processInfo.environment + environment[carrier] = prompt + environment[valueVariable] = "stale-exported-value" + process.environment = environment + + try process.run() + process.waitUntilExit() + + #expect(process.terminationStatus == 0) + } + @Test func plannerRejectsPromptThatCannotRideInTheSurfaceEnvironment() { #expect(throws: AgentProfileLaunchPlanError.promptContainsNUL) { try AgentProfileLaunchPlanner.plan( diff --git a/supacodeTests/CLILifecycleCommandHandlerTests.swift b/supacodeTests/CLILifecycleCommandHandlerTests.swift index 764cb4a1..f7e7332d 100644 --- a/supacodeTests/CLILifecycleCommandHandlerTests.swift +++ b/supacodeTests/CLILifecycleCommandHandlerTests.swift @@ -202,7 +202,8 @@ struct CLILifecycleCommandHandlerTests { == LifecycleCommandLaunch( profileID: profile.id.uuidString, profileName: "Reviewer", - agent: "claude" + agent: "claude", + promptDelivery: .surfaceEnvironmentV1 ) ) } @@ -477,6 +478,45 @@ struct CLILifecycleCommandHandlerTests { #expect(!didResolve) } + @Test func oversizedProfilePromptIsRejectedBeforeResolution() async { + let target = makeTarget() + var didResolve = false + let handler = LifecycleCommandHandler( + resolveCreateTarget: { _ in + didResolve = true + return .success(target) + }, + resolveCloseTarget: { _ in .success(LifecycleResolvedTarget(resource: .pane, target: target)) }, + createTab: { _, _ in nil }, + createPane: { _, _ in nil }, + closeTab: { _, _ in true }, + closePane: { _, _ in true } + ) + + let response = await handler.handle( + envelope: CommandEnvelope( + output: .json, + command: .create( + CreateInput( + resource: .tab, + selector: .worktree("App"), + launch: CreateLaunchInput( + profile: "Reviewer", + prompt: String( + repeating: "x", + count: CreateLaunchInput.maximumPromptUTF8ByteCount + 1 + ) + ) + ) + ) + ) + ) + + #expect(!response.ok) + #expect(response.error?.code == CLIErrorCode.invalidArgument) + #expect(!didResolve) + } + @Test func backgroundWithoutProfileIsRejectedBeforeResolution() async { let target = makeTarget() var didResolve = false -- 2.51.2