diff --git a/ProwlCLI/Commands/AgentsCommand.swift b/ProwlCLI/Commands/AgentsCommand.swift index 164f19d6..f2d24b37 100644 --- a/ProwlCLI/Commands/AgentsCommand.swift +++ b/ProwlCLI/Commands/AgentsCommand.swift @@ -10,6 +10,7 @@ struct AgentsCommand: ParsableCommand { subcommands: [ AgentsReadCommand.self, AgentsSignalCommand.self, + AgentsHookCommand.self, AgentsDispatchCompleteCommand.self, AgentsDispatchAbandonCommand.self, AgentsWaitCommand.self, diff --git a/ProwlCLI/Commands/AgentsHookCommand.swift b/ProwlCLI/Commands/AgentsHookCommand.swift new file mode 100644 index 00000000..cdf7d919 --- /dev/null +++ b/ProwlCLI/Commands/AgentsHookCommand.swift @@ -0,0 +1,110 @@ +import ArgumentParser +import Foundation +import ProwlCLIShared + +#if canImport(Darwin) + import Darwin +#elseif canImport(Glibc) + import Glibc +#endif + +extension AgentNativeHookRuntime: ExpressibleByArgument {} + +struct AgentsHookCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "_hook", + shouldDisplay: false + ) + + @Argument var runtime: AgentNativeHookRuntime + @Argument var nativeEvent: String + /// Codex appends its native JSON payload as the final notifier argv. + @Argument var payload: String? + + mutating func run() throws { + let environment = ProcessInfo.processInfo.environment + let lease = forwardingLease(environment: environment) + let stdin = readBoundedStdin() + if let input = try? makeInput(environment: environment, stdin: stdin) { + let envelope = CommandEnvelope(output: .json, command: .agentsHook(input)) + _ = try? SocketTransportClient.send(envelope, timeoutMilliseconds: 250) + } + if runtime == .codex, let lease, let payload { + execForwardedNotifier(lease.argv, payload: payload) + } + lease?.close() + } + + func makeInput( + environment: [String: String], + stdin: Data + ) throws -> AgentNativeHookInput { + guard let token = environment[AgentNativeHookInput.tokenEnvironmentKey], !token.isEmpty else { + throw ValidationError("Missing managed hook token.") + } + let nativePayload: Data + switch runtime { + case .claude: + guard payload == nil else { throw ValidationError("Claude hooks read JSON from stdin.") } + nativePayload = stdin + case .codex: + guard let payload else { throw ValidationError("Codex hooks require a final JSON payload argument.") } + nativePayload = Data(payload.utf8) + } + let signal = try AgentNativeHookDecoder.decode( + runtime: runtime, + nativeEvent: nativeEvent, + payload: nativePayload + ) + let input = AgentNativeHookInput(runtime: runtime, token: token, signal: signal) + if let message = input.validationErrorMessage { throw ValidationError(message) } + return input + } + + private func readBoundedStdin() -> Data { + guard runtime == .claude else { return Data() } + var payload = Data() + while payload.count <= AgentNativeHookDecoder.maximumPayloadBytes { + let remaining = AgentNativeHookDecoder.maximumPayloadBytes + 1 - payload.count + do { + guard let chunk = try FileHandle.standardInput.read(upToCount: remaining), + !chunk.isEmpty + else { break } + payload.append(chunk) + } catch { + break + } + } + return payload + } + + private func forwardingLease( + environment: [String: String] + ) -> CodexForwardingRecordLease? { + guard runtime == .codex, + let path = environment[AgentNativeHookInput.forwardRecordEnvironmentKey], + !path.isEmpty + else { return nil } + return try? CodexForwardingRecordReader.open( + URL(filePath: path, directoryHint: .notDirectory) + ) + } + + private func execForwardedNotifier(_ argv: [String], payload: String) -> Never { + unsetenv(AgentNativeHookInput.tokenEnvironmentKey) + unsetenv(AgentNativeHookInput.forwardRecordEnvironmentKey) + let arguments = argv + [payload] + var pointers: [UnsafeMutablePointer?] = [] + for argument in arguments { + guard let pointer = strdup(argument) else { + for allocated in pointers { free(allocated) } + _exit(127) + } + pointers.append(pointer) + } + pointers.append(nil) + execvp(pointers[0], &pointers) + for pointer in pointers { free(pointer) } + _exit(127) + } +} diff --git a/ProwlCLI/Output/OutputRenderer.swift b/ProwlCLI/Output/OutputRenderer.swift index ccbe9629..92c57251 100644 --- a/ProwlCLI/Output/OutputRenderer.swift +++ b/ProwlCLI/Output/OutputRenderer.swift @@ -145,6 +145,7 @@ enum OutputRenderer { let payload = try? data.decode(as: LifecycleCommandPayload.self) { print(renderLifecycle(payload, command: response.command)) + renderLifecycleWarnings(payload) return } @@ -477,6 +478,14 @@ enum OutputRenderer { return lines.joined(separator: "\n") } + private static func renderLifecycleWarnings(_ payload: LifecycleCommandPayload) { + guard let warnings = payload.warnings else { return } + for warning in warnings { + let line = "warning: [\(warning.code.rawValue)] \(warning.runtime): \(warning.message)\n" + FileHandle.standardError.write(Data(line.utf8)) + } + } + private static func renderLifecycle(_ payload: LifecycleCommandPayload, command: String) -> String { let wt = payload.target.worktree let tab = payload.target.tab diff --git a/ProwlCLI/Transport/SocketTransportClient.swift b/ProwlCLI/Transport/SocketTransportClient.swift index 2f7420f6..6e8bfade 100644 --- a/ProwlCLI/Transport/SocketTransportClient.swift +++ b/ProwlCLI/Transport/SocketTransportClient.swift @@ -14,7 +14,10 @@ enum SocketTransportClient { private static let maximumResponseLength = 32 * 1_024 * 1_024 /// Send a command envelope to the Prowl app and receive a response. - static func send(_ envelope: CommandEnvelope) throws -> Data { + static func send( + _ envelope: CommandEnvelope, + timeoutMilliseconds: Int? = nil + ) throws -> Data { let socketPath = ProwlSocket.defaultPath // Encode request @@ -31,6 +34,9 @@ enum SocketTransportClient { ) } defer { close(clientFD) } + if let timeoutMilliseconds { + try configureTimeout(clientFD, milliseconds: timeoutMilliseconds) + } let connection = SocketConnectionProbe.connect(socketFD: clientFD, socketPath: socketPath) if let error = connection.exitError() { @@ -60,6 +66,24 @@ enum SocketTransportClient { // MARK: - Low-level I/O using Darwin/Glibc read/write + private static func configureTimeout(_ descriptor: Int32, milliseconds: Int) throws { + let bounded = max(1, milliseconds) + var timeout = timeval( + tv_sec: bounded / 1_000, + tv_usec: Int32((bounded % 1_000) * 1_000) + ) + let size = socklen_t(MemoryLayout.size) + let receive = withUnsafePointer(to: &timeout) { + setsockopt(descriptor, SOL_SOCKET, SO_RCVTIMEO, $0, size) + } + let send = withUnsafePointer(to: &timeout) { + setsockopt(descriptor, SOL_SOCKET, SO_SNDTIMEO, $0, size) + } + guard receive == 0, send == 0 else { + throw ExitError(code: CLIErrorCode.transportFailed, message: "Failed to configure socket deadline.") + } + } + private static func fdWrite(fildes: Int32, buffer: UnsafeRawBufferPointer) throws { var offset = 0 while offset < buffer.count { diff --git a/ProwlCLIContracts/Resources/cli-output-schema.json b/ProwlCLIContracts/Resources/cli-output-schema.json index ab279167..a9088f4f 100644 --- a/ProwlCLIContracts/Resources/cli-output-schema.json +++ b/ProwlCLIContracts/Resources/cli-output-schema.json @@ -610,6 +610,31 @@ } ] }, + "lifecycleWarning": { + "type": "object", + "additionalProperties": false, + "required": [ + "code", + "runtime", + "message" + ], + "properties": { + "code": { + "const": "managed_hook_degraded" + }, + "runtime": { + "enum": [ + "claude", + "codex" + ] + }, + "message": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + } + } + }, "lifecycleLaunch": { "type": "object", "additionalProperties": false, @@ -652,6 +677,14 @@ "dispatch": { "$ref": "#/$defs/dispatchPendingRecord" }, + "warnings": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { + "$ref": "#/$defs/lifecycleWarning" + } + }, "target": { "$ref": "#/$defs/target" } @@ -687,6 +720,14 @@ "dispatch": { "$ref": "#/$defs/dispatchPendingRecord" }, + "warnings": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { + "$ref": "#/$defs/lifecycleWarning" + } + }, "target": { "$ref": "#/$defs/target" } @@ -1168,7 +1209,11 @@ "maximum": 100 }, "source": { - "const": "cooperative_cli" + "enum": [ + "cooperative_cli", + "hook_claude", + "hook_codex" + ] }, "confidence": { "const": "exact" diff --git a/ProwlCLITests/AgentsCommandParsingTests.swift b/ProwlCLITests/AgentsCommandParsingTests.swift index 5e52ace6..fe107e07 100644 --- a/ProwlCLITests/AgentsCommandParsingTests.swift +++ b/ProwlCLITests/AgentsCommandParsingTests.swift @@ -92,6 +92,47 @@ final class AgentsCommandParsingTests: XCTestCase { ) } + func testNativeHookCommandIsHiddenAndBuildsBoundedRuntimeInput() throws { + XCTAssertFalse(AgentsCommand.helpMessage().contains("_hook")) + let command = try AgentsHookCommand.parse([ + "codex", + "agent-turn-complete", + #"{"type":"agent-turn-complete","thread-id":"thread-1","cwd":"/tmp/project","last-assistant-message":"excluded"}"#, + ]) + let input = try command.makeInput( + environment: [AgentNativeHookInput.tokenEnvironmentKey: "token-1"], + stdin: Data() + ) + + XCTAssertEqual(input.runtime, .codex) + XCTAssertEqual(input.token, "token-1") + XCTAssertEqual(input.signal.event, .turnEnded) + XCTAssertEqual(input.signal.sessionID, "thread-1") + XCTAssertNil(input.signal.detail) + } + + func testNativeHookCommandRejectsMissingTokenWrongTransportAndOversizedPayload() throws { + let claude = try AgentsHookCommand.parse(["claude", "Stop"]) + let payload = Data( + #"{"hook_event_name":"Stop","session_id":"session-1","cwd":"/tmp/project"}"#.utf8 + ) + XCTAssertThrowsError(try claude.makeInput(environment: [:], stdin: payload)) + + let codex = try AgentsHookCommand.parse(["codex", "agent-turn-complete"]) + XCTAssertThrowsError( + try codex.makeInput( + environment: [AgentNativeHookInput.tokenEnvironmentKey: "token"], + stdin: payload + ) + ) + XCTAssertThrowsError( + try claude.makeInput( + environment: [AgentNativeHookInput.tokenEnvironmentKey: "token"], + stdin: Data(repeating: 0, count: AgentNativeHookDecoder.maximumPayloadBytes + 1) + ) + ) + } + func testDispatchCompleteParsesRequiredOutcomeAndSummaryFromImplicitContext() throws { let command = try AgentsDispatchCompleteCommand.parse([ "--outcome", "succeeded", diff --git a/ProwlCLITests/LifecycleWarningSchemaTests.swift b/ProwlCLITests/LifecycleWarningSchemaTests.swift new file mode 100644 index 00000000..30b29908 --- /dev/null +++ b/ProwlCLITests/LifecycleWarningSchemaTests.swift @@ -0,0 +1,49 @@ +import Foundation +import JSONSchema +import ProwlCLIContracts +import ProwlCLIShared +import XCTest + +final class LifecycleWarningSchemaTests: XCTestCase { + func testCreateSchemaAcceptsManagedHookWarningAndRejectsUnknownFields() throws { + let valid = #"{"ok":true,"command":"create","schema_version":"prowl.cli.create.v1","data":{"resource":"tab","launch":{"profile_id":"D2719F02-5F27-4D46-A62F-0FAF49410D4D","profile_name":"Codex","agent":"codex"},"warnings":[{"code":"managed_hook_degraded","runtime":"codex","message":"Notifier resolver unavailable."}],"target":{"worktree":{"id":"wt","name":"main","path":"/Projects/Prowl","root_path":"/Projects/Prowl","kind":"git"},"tab":{"id":"tab","title":"Tab","selected":true},"pane":{"id":"pane","title":"Pane","cwd":"/Projects/Prowl","focused":true}}}}"# + let invalid = valid.replacingOccurrences(of: #""message":"Notifier resolver unavailable.""#, with: #""message":"Notifier resolver unavailable.","secret":"leak""#) + + try assertValidity(valid, expected: true) + try assertValidity(invalid, expected: false) + } + + func testAgentSignalSchemaAcceptsManagedHookSourcesWithoutTokenField() throws { + let response = #"{"ok":true,"command":"agents.signal","schema_version":"prowl.cli.agents.signal.v1","data":{"pane":{"id":"D2719F02-5F27-4D46-A62F-0FAF49410D4D","worktree_id":"wt"},"signal":{"event":"turn-ended","source":"hook_codex","confidence":"exact","at":"2026-08-24T00:00:00.000Z","session_id":"thread-1"}}}"# + try assertValidity(response, expected: true) + XCTAssertFalse(response.contains("token")) + } + + func testLifecyclePayloadOmitsEmptyWarningsAndDecodesAdditiveWarnings() throws { + let target = TabTarget( + worktree: .init(id: "wt", name: "main", path: "/tmp", rootPath: "/tmp", kind: "git"), + tab: .init(id: "tab", title: "Tab", selected: true), + pane: .init(id: "pane", title: "Pane", cwd: "/tmp", focused: true) + ) + let empty = LifecycleCommandPayload(resource: .tab, warnings: [], target: target) + let emptyJSON = String(decoding: try JSONEncoder().encode(empty), as: UTF8.self) + XCTAssertFalse(emptyJSON.contains("warnings")) + + let warning = LifecycleCommandWarning( + code: .managedHookDegraded, + runtime: "codex", + message: "Resolver unavailable." + ) + let decoded = try JSONDecoder().decode( + LifecycleCommandPayload.self, + from: JSONEncoder().encode(LifecycleCommandPayload(resource: .tab, warnings: [warning], target: target)) + ) + XCTAssertEqual(decoded.warnings, [warning]) + } + + private func assertValidity(_ instance: String, expected: Bool) throws { + let schemaText = try XCTUnwrap(String(data: ProwlCLIContractBundle.schemaData, encoding: .utf8)) + let result = try Schema(instance: schemaText).validate(instance: instance) + XCTAssertEqual(result.isValid, expected, "Schema errors: \(result.errors)") + } +} diff --git a/ProwlCLITests/ProwlCLIIntegrationTests.swift b/ProwlCLITests/ProwlCLIIntegrationTests.swift index 06663e26..bdb47bb4 100644 --- a/ProwlCLITests/ProwlCLIIntegrationTests.swift +++ b/ProwlCLITests/ProwlCLIIntegrationTests.swift @@ -43,6 +43,82 @@ final class ProwlCLIIntegrationTests: XCTestCase { XCTAssertTrue(help.stdout.contains("close")) } + func testNativeHookBridgeIsHiddenSilentAndFailOpenWithoutListener() throws { + let help = try runProwl(args: ["agents", "--help"]) + XCTAssertEqual(help.exitCode, 0) + XCTAssertFalse(help.stdout.contains("_hook")) + + let payload = Data( + #"{"hook_event_name":"Stop","session_id":"session-1","cwd":"/tmp/project"}"#.utf8 + ) + let result = try runProwl( + args: ["agents", "_hook", "claude", "Stop"], + environment: [ + AgentNativeHookInput.tokenEnvironmentKey: "token-1", + ProwlSocket.environmentKey: temporarySocketPath(suffix: "missing-hook-listener"), + ], + stdinData: payload + ) + + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(result.stdout, "") + XCTAssertEqual(result.stderr, "") + } + + func testCodexHookForwardsExactPayloadOnTransportLossAndScrubsInternalEnvironment() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "prowl-hook-forward-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: root, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: root.path + ) + defer { try? FileManager.default.removeItem(at: root) } + let script = root.appendingPathComponent("notifier.py") + let output = root.appendingPathComponent("result.json") + try """ + import json, os, sys + with open(os.environ["PROWL_FORWARD_TEST_OUTPUT"], "w") as handle: + json.dump({ + "argv": sys.argv[1:], + "token": os.environ.get("PROWL_AGENT_HOOK_TOKEN"), + "record": os.environ.get("PROWL_AGENT_HOOK_FORWARD_RECORD") + }, handle, ensure_ascii=False) + raise SystemExit(7) + """.write(to: script, atomically: true, encoding: .utf8) + let record = root.appendingPathComponent("record.json") + let notifierArgv = ["/usr/bin/python3", script.path, "space value", "", "秘密-like"] + try JSONSerialization.data(withJSONObject: notifierArgv).write(to: record, options: .atomic) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: record.path) + let payload = #"{"type":"agent-turn-complete","thread-id":"thread-1","turn-id":"turn-1","cwd":"/tmp/project","last-assistant-message":"excluded"}"# + + let result = try runProwl( + args: ["agents", "_hook", "codex", "agent-turn-complete", payload], + environment: [ + AgentNativeHookInput.tokenEnvironmentKey: "invalid-but-forwarding-independent", + AgentNativeHookInput.forwardRecordEnvironmentKey: record.path, + ProwlSocket.environmentKey: temporarySocketPath(suffix: "missing-forward-listener"), + "PROWL_FORWARD_TEST_OUTPUT": output.path, + ] + ) + + XCTAssertEqual(result.exitCode, 7) + XCTAssertEqual(result.stdout, "") + XCTAssertEqual(result.stderr, "") + let forwarded = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(contentsOf: output)) as? [String: Any] + ) + XCTAssertEqual(forwarded["argv"] as? [String], ["space value", "", "秘密-like", payload]) + XCTAssertTrue(forwarded["token"] is NSNull) + XCTAssertTrue(forwarded["record"] is NSNull) + } + func testLegacyLifecycleHelpIsMarkedDeprecated() throws { let tabHelp = try runProwl(args: ["tab", "--help"]) XCTAssertEqual(tabHelp.exitCode, 0) @@ -709,6 +785,50 @@ final class ProwlCLIIntegrationTests: XCTestCase { } } + func testCreateWarningStaysInJSONAndRendersExactlyOnceToTextStderr() throws { + let launch = LifecycleCommandLaunch( + profileID: UUID().uuidString, + profileName: "Codex", + agent: "codex" + ) + let warning = LifecycleCommandWarning( + code: .managedHookDegraded, + runtime: "codex", + message: "Notifier resolver unavailable." + ) + let response = try CommandResponse( + ok: true, + command: "create", + schemaVersion: "prowl.cli.create.v1", + data: RawJSON( + encoding: makeLifecyclePayload(resource: .tab, launch: launch, warnings: [warning]) + ) + ) + + let textSocket = temporarySocketPath(suffix: "create-warning-text") + let (_, text) = try runWithMockServer( + socketPath: textSocket, + response: response, + args: ["create", "tab", "App", "--profile", launch.profileID] + ) + XCTAssertEqual(text.exitCode, 0) + XCTAssertEqual( + text.stderr, + "warning: [managed_hook_degraded] codex: Notifier resolver unavailable.\n" + ) + XCTAssertFalse(text.stdout.contains("managed_hook_degraded")) + + let jsonSocket = temporarySocketPath(suffix: "create-warning-json") + let (_, json) = try runWithMockServer( + socketPath: jsonSocket, + response: response, + args: ["create", "tab", "App", "--profile", launch.profileID, "--json"] + ) + XCTAssertEqual(json.exitCode, 0) + XCTAssertEqual(json.stderr, "") + XCTAssertTrue(json.stdout.contains("managed_hook_degraded")) + } + func testCreateProfileFailsClosedWhenTheAppOmitsLaunchMetadata() throws { let socketPath = temporarySocketPath(suffix: "create-profile-version-skew") let response = try CommandResponse( @@ -2730,7 +2850,8 @@ final class ProwlCLIIntegrationTests: XCTestCase { anchor: TabTarget? = nil, direction: CreatePaneDirection? = nil, launch: LifecycleCommandLaunch? = nil, - dispatch: DispatchPendingRecord? = nil + dispatch: DispatchPendingRecord? = nil, + warnings: [LifecycleCommandWarning]? = nil ) -> LifecycleCommandPayload { LifecycleCommandPayload( resource: resource, @@ -2738,6 +2859,7 @@ final class ProwlCLIIntegrationTests: XCTestCase { direction: direction, launch: launch, dispatch: dispatch, + warnings: warnings, target: makeTabTarget() ) } diff --git a/docs-ai/013-prowl-cli/contracts/agents-signal.md b/docs-ai/013-prowl-cli/contracts/agents-signal.md index 1ac3152f..99ec9b08 100644 --- a/docs-ai/013-prowl-cli/contracts/agents-signal.md +++ b/docs-ai/013-prowl-cli/contracts/agents-signal.md @@ -27,10 +27,28 @@ Events: - `session-start` / `session-end` — producer-reported session lifecycle. - `progress` — indeterminate when `--progress` is absent, otherwise 0 through 100. -S1 records every public invocation as `source: cooperative_cli`, `confidence: exact`. +Every public invocation is recorded as `source: cooperative_cli`, `confidence: exact`. Here `exact` means explicit channel plus exact caller-pane attribution; it does not make the producer's business judgment authoritative. `--origin` is caller-authored metadata only and -cannot upgrade source/confidence or satisfy a future native-hook capability check. +cannot upgrade source/confidence or satisfy a native-hook capability check. + +## Bundled native-hook ingress + +The bundled CLI also contains a hidden `agents _hook` bridge for Prowl-managed Claude Code +and Codex Profile launches. It is intentionally absent from help and shell completion and is +not a targetable public API. Claude payloads arrive on bounded stdin; Codex appends one bounded +JSON argv. The bridge ignores unknown fields, never forwards `last_assistant_message`, stays +silent, uses a bounded socket attempt, and exits successfully when Prowl rejects or cannot +receive evidence. + +The app accepts a hook only when an in-memory launch token, runtime/native event, normalized +launch cwd, exact caller pane, and current or pending process generation all match. A valid +receipt uses `source: hook_claude` or `hook_codex`; neither the token nor forwarding metadata +appears in the response. Public `agents signal` cannot supply hook context and always remains +`cooperative_cli`. + +Hook `turn-ended` is runtime evidence only. It never completes an assigned dispatch or +workflow. A matching `dispatch-complete` receipt retains priority over an adjacent hook edge. `--session` and `--origin` are non-empty, control-free UTF-8 up to 256 bytes. `--detail` is non-empty, control-free UTF-8 up to 32768 bytes. Detail is a short result or reason returned diff --git a/docs-ai/013-prowl-cli/contracts/create.md b/docs-ai/013-prowl-cli/contracts/create.md index 8d32af2a..183f703e 100644 --- a/docs-ai/013-prowl-cli/contracts/create.md +++ b/docs-ai/013-prowl-cli/contracts/create.md @@ -43,11 +43,18 @@ line is one `env -u` command with no assignment statement or shell builtin, so t runs in zsh, bash, and fish. `env -u` keeps the carrier out of the Profile process; the pane shell retains the reserved carrier for its lifetime. NUL bytes are rejected. +Profile launches first complete any bounded managed-signal preflight. No dispatch slot or +surface exists while that asynchronous work is suspended. After preflight, dispatch issuance, +surface creation, exact pre-input signal registration, dispatch binding, and rollback are one +synchronous transaction with no suspension point. One Profile launch owns one evidence epoch; +a prompted dispatch adopts the epoch already created by its managed hook registration. + Every prompted Profile launch is paired atomically with a pending dispatch; there is no opt-out. Prowl injects `PROWL_DISPATCH_ID` into the launched child only and appends the versioned completion instruction to the effective prompt. An unprompted Profile launch -retains its interactive behavior and creates no dispatch. If launch, target snapshot, or -dispatch binding fails, Prowl removes the new resource and cancels the unreturned receipt. +retains its interactive behavior and creates no dispatch. If launch, target snapshot, signal +registration, or dispatch binding fails, Prowl removes the new resource and cancels the +unreturned receipt. 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 @@ -136,6 +143,25 @@ otherwise have no completion contract: returned alongside it is the immutable target retained by the dispatch store for later wait success and error payloads. +A safe managed-hook preparation failure does not fail the Profile launch or alter its original +argv. Success instead adds exactly one optional warning (omitted when empty): + +```json +{ + "warnings": [ + { + "code": "managed_hook_degraded", + "runtime": "codex", + "message": "The effective Codex notifier could not be resolved." + } + ] +} +``` + +JSON mode retains `warnings` in stdout. Text mode renders the successful launch normally on +stdout and writes each warning exactly once to stderr. Degradation creates no persistent +public channel state and never changes dispatch receipt semantics. + ## Errors `INVALID_ARGUMENT`, `EMPTY_INPUT`, `TARGET_NOT_FOUND`, `TARGET_NOT_UNIQUE`, diff --git a/docs-ai/063-agent-workflows/release-plan.md b/docs-ai/063-agent-workflows/release-plan.md index 25f18825..6f26e573 100644 --- a/docs-ai/063-agent-workflows/release-plan.md +++ b/docs-ai/063-agent-workflows/release-plan.md @@ -34,7 +34,7 @@ user-facing surface may merge before "their" release and stay dormant. Three rel | A2 | Merged | #714 | | S1 | Merged | #715: bus, multicast observer, `agents signal` | | S2 | Merged | #718: paired dispatch receipt, strict ID wait, generic evidence wait; [action record](../064-agent-completion-signals/005-s2-action.md) | -| S3 wave 1 | Planning, next | Three merge-safe PRs (S3a–S3c); tier-A launch hooks consume S2 wait/channel infrastructure | +| S3 wave 1 | S3a implementation review | S3a Claude/Codex foundation implemented; S3b/S3c remain sequential before the slice is complete | | 065-S0/K1 | Planned, parallel | Skill-target spike + bundled-skill registry | | 065-K2/K3 | Planned | Follow S0/K1 inside R1 | @@ -122,6 +122,9 @@ R3+: V2 / S5 rest; delete HANDOFF_RETIRED stubs ## Change log +- 2026-08-24 — S3a implemented the trusted launch registration/epoch boundary, hidden native + ingress, Claude settings merge, Codex effective-notifier preservation, degradation warnings, + and focused/live contract coverage. S3 wave 1 remains incomplete pending S3b/S3c. - 2026-08-23 — S3 wave 2 was removed. Prowl ships launch-scoped hooks only for tier-A runtimes that need no global-config, dedicated-home, or project-file writes; Gemini, Qwen, Grok, Cline, Kimi, Cursor, and Amp remain on non-hook evidence layers. diff --git a/docs-ai/064-agent-completion-signals/000-plan.md b/docs-ai/064-agent-completion-signals/000-plan.md index 4cded7f9..208bfa37 100644 --- a/docs-ai/064-agent-completion-signals/000-plan.md +++ b/docs-ai/064-agent-completion-signals/000-plan.md @@ -2,7 +2,7 @@ | | | | --- | --- | -| **Status** | In progress — S1 merged in #715; S2 merged in #718; S3 wave 1 planning is active | +| **Status** | In progress — S1 #715 and S2 #718 merged; S3a Claude/Codex implementation is ready for review | | **Anchor date** | 2026-08-22 | | **Primary PRs** | #715 (S1); #718 (S2); S3a–S3c TBD | | **Related** | [063 agent-workflows](../063-agent-workflows/000-plan.md) (consumer; defines the `ObservedAgentState` observer this entry feeds), [030 agent-status-detection](../030-agent-status-detection/000-plan.md), [045 native-agent-session-detection](../045-native-agent-session-detection/000-plan.md), [055 agent-profile-runtimes](../055-agent-profile-runtimes/000-plan.md), [059 agent-transcript-snapshots](../059-agent-transcript-snapshots/000-plan.md), [060 cli-targeting-and-contract-governance](../060-prowl-cli-targeting-and-contract-governance/000-plan.md), [#473](https://github.com/onevcat/Prowl/issues/473), [#676](https://github.com/onevcat/Prowl/issues/676), `docs/components/agent-detection.md`, `docs/components/cli.md` | @@ -244,6 +244,10 @@ opencode; partial for qodercli/qwen/amp; docs/bundle for the rest). Key conclusi ## Amendments +- Updated 2026-08-24: implemented S3a's shared trusted-hook foundation plus Claude/Codex + adapters, resolver/forwarding boundary, launch transaction, hidden ingress, warnings, and + focused/live contract coverage. See [007-s3a-action.md](007-s3a-action.md). S3 wave 1 remains + incomplete until S3b/S3c. - Updated 2026-08-23: split S3 wave 1 into three merge-safe PRs: S3a foundation plus Claude/Codex, S3b Copilot/Droid/Qoder, and S3c Pi/OMP/OpenCode plus UI/docs/full closure. Detailed S3a research, implementation phases, and validation live in diff --git a/docs-ai/064-agent-completion-signals/006-s3-wave1-plan.md b/docs-ai/064-agent-completion-signals/006-s3-wave1-plan.md index 9d366e00..c9ca0d0e 100644 --- a/docs-ai/064-agent-completion-signals/006-s3-wave1-plan.md +++ b/docs-ai/064-agent-completion-signals/006-s3-wave1-plan.md @@ -2,9 +2,10 @@ ## Status -Planning and owner alignment. No implementation has started. +Owner-approved plan; S3a implemented on `feat/agent-signal-hooks-s3a-implementation`. +Implementation record: [007-s3a-action.md](007-s3a-action.md). -- Branch: `feat/agent-signal-hooks-s3a` +- Planning branch: `feat/agent-signal-hooks-s3a` - Prerequisites: 063-A2, 064-S1, and 064-S2 are merged. - Runtime baseline rechecked 2026-08-23: Claude Code 2.1.241; Codex CLI 0.149.0. - S3 has no wave 2. Managed hooks are limited to runtimes that accept process-scoped @@ -302,8 +303,9 @@ verification where unit tests cannot prove third-party behavior. scratch-home precedence matrix for absent/base/profile/final-CLI-override `notify`, including proof that project-layer `notify` is ignored; retain sanitized fixtures, not returned user/provider config. -- Freeze supported Codex `-C/--cd` token forms, last-wins behavior, relative-path base, and - config-read cwd against 0.149 help/live probes. +- Freeze supported Codex `-C/--cd` token forms, repeated-option behavior, relative-path base, and + config-read cwd against 0.149 help/live probes. Phase 0 found that repeated cwd options are + rejected rather than last-wins; S3a therefore degrades without injection and preserves argv. - Use scratch homes/directories only; never edit live user/global config. - Add representative official native payload fixtures, including optional/unknown fields, malformed data, oversized strings, Codex memories cwd, and paths with spaces/non-ASCII. diff --git a/docs-ai/064-agent-completion-signals/007-s3a-action.md b/docs-ai/064-agent-completion-signals/007-s3a-action.md new file mode 100644 index 00000000..26b9d49d --- /dev/null +++ b/docs-ai/064-agent-completion-signals/007-s3a-action.md @@ -0,0 +1,118 @@ +# 064.007 — S3a Claude/Codex Managed Hooks + +## Status + +Implemented on `feat/agent-signal-hooks-s3a-implementation`; implementation PR pending. +S3 wave 1 remains incomplete until S3b and S3c merge. + +## Delivered behavior + +- Claude Code and Codex adapters now declare only their approved S3a native-event capabilities. +- Profile launch preparation is asynchronous before dispatch issuance. The frozen target, + inheritance cwd, runtime cwd, home, profile, and config overrides are revalidated before a + synchronous surface/register/arm/bind transaction. +- Profile surface creation is genuinely two-phase: the exact view/surface identity is installed + with Ghostty creation deferred, one evidence epoch and hook registration are established, then + Ghostty is created with its native `initial_input`. Prompted dispatch binding adopts that epoch. +- Arbitrary argv values use typed child-only carriers. Settings JSON, notify overrides, token, + socket, and forwarding locator never enter typed terminal input; the pane shell does not export + the public hook token to later manual launches. +- Claude's final effective explicit `--settings` source is merged in memory with stable bounded + reads. Unknown fields and existing hook arrays survive; Prowl handlers deduplicate; malformed, + changed, unreadable, non-object, and oversized sources launch unchanged with one warning. +- Codex effective `notify` resolution uses its bounded official app-server initialize + + `config/read` JSONL protocol. Base config, profile-v2 files, and final top-level CLI override are + resolved without reading provider fields into logs or durable state. Project `notify` remains + excluded by Codex itself. +- Existing Codex notifiers are preserved through random owner-only records and a hidden bundled + CLI dispatcher. The bridge leases/reads before transport, keeps exact argv boundaries, scrubs + internal environment, and `exec`s the notifier with the original payload even after listener + loss. Trust revocation is immediate; retirement and orphan cleanup are lease-aware. +- The hidden `agents _hook` parser is absent from help/completion. Claude stdin and Codex final-argv + payloads are bounded and normalized without `last_assistant_message`. The app accepts only an + exact token/pane/runtime/event/cwd/generation registration and then exposes + `hook_claude|hook_codex` as `verified_live`. +- Safe preparation failure never blocks the runtime: GUI launches show one warning toast; CLI + success adds optional `warnings: [{code: "managed_hook_degraded", runtime, message}]`. JSON keeps + it in stdout and text renders it exactly once to stderr. +- The Settings Launch Preview remains the deterministic base invocation. Conditional execution + settings, tokens, socket paths, and forwarding locators are prepared only after live preflight + and stay redacted. This is more honest than rendering a Codex override that may be omitted after + notifier degradation; current docs now state that boundary explicitly. + +## Phase 0 runtime evidence + +Re-attested 2026-08-24: + +- Claude Code `2.1.241`; Codex CLI `0.149.0`; Pi `0.84.2`. +- Claude repeated `--settings` is final-source-wins. A scratch authenticated run produced + `SessionStart`, `Stop`, and `SessionEnd`; only the final settings handler fired. The observed Stop + payload included `last_assistant_message`, which the bridge deliberately excludes. +- Codex app-server accepted initialize/initialized/`config/read` against scratch homes. Effective + `notify` was `null` for a clean home, preserved exact Unicode/empty argv from base config, and + reflected final `-c notify=...`. A trusted project layer loaded unrelated values but still + excluded project `notify`. +- Codex profile-v2 is `$CODEX_HOME/.config.toml`; app-server rejects `--profile`. Prowl + therefore stable-reads only that file into an owner-only parser home and asks Codex to parse it + as temporary user config. +- Contrary to the frozen plan's “last cwd wins” wording, 0.149.0 rejects repeated `-C/--cd`. + Separate, joined (`-Cdir`), and equals forms are supported. Prowl autonomously chose the safe + behavior: repeated cwd degrades managed hooks and preserves the original (runtime-rejected) + launch rather than inventing an effective cwd. +- A real authenticated Codex notify run produced `agent-turn-complete` as the final argv with + thread id, turn id, cwd, and last assistant message. Sanitized payload fixtures retain the + shape while excluding real IDs/results. + +All scratch probes guarded the live Claude/Codex config hashes. Runtime-added trust/state entries +were removed only when the current hash still matched the probe result, restoring the exact +pre-probe hashes. + +## RED → GREEN record + +Focused failing tests were observed before each logic layer existed: + +1. native payload decoding, adapter capabilities, Claude settings merge, Codex notify rendering, + and arbitrary argv carriers; +2. app-server JSONL parsing, notifier absence/presence/profile/override precedence, cwd/profile + option parsing, malformed/recursive degradation; +3. forwarding record permissions, exact argv, shared-read/exclusive-cleanup leases, retirement, + and orphan sweep; +4. pending early-hook activation, exact rejection matrix, verified coverage, session rotation, + process replacement, and one-epoch dispatch adoption; +5. hidden CLI parsing, schema, kernel peer-PID socket routing, silent listener loss, exact notifier + `exec`, environment scrub, warning omission/output channels, and preflight cancellation. + +The first live Debug attempt exposed a real race not visible in pure tests: sending text immediately +after creating an unarmed surface could lose the command before the shell was ready. That approach +was removed. Regression coverage now asserts that the view/tree/profile identity is installed while +Ghostty creation is still unarmed, and that native `initial_input` is armed only after registration. + +## Validation + +Focused app suites: 100+ managed-hook/profile/CLI/lifecycle tests passing. The live Codex +`config/read` contract test passed against 0.149.0 with scratch absent/base/profile/override and +project-exclusion fixtures. CLI subprocess integration proved silent zero-exit listener loss and +exact notifier forwarding with empty/Unicode argv, unchanged payload, scrubbed internal variables, +and original notifier exit status. + +Repository gates passed on the implementation branch: `make build-cli`, `make test-cli-smoke`, +`make test-cli-integration` (95 integration tests), `make check` (including 34 script tests), +`make test` (xcresult verified 2506 tests, zero failures), and `make build-app`. The enabled live +Codex 0.149 scratch contract also passed. + +The full Debug GUI matrix was attempted with a freshly embedded bundle CLI, custom socket, +`CFFIXED_USER_HOME` scratch Prowl state, scratch workspace, owner-only Codex homes/auth copies, and +hash guards proving live Claude/Codex config restoration. The host GUI session was at `loginwindow`: +LaunchServices created the Debug process/socket but no visible main window, while direct launch had +no display and Ghostty reported `CVDisplayLink` invalid display count / surface initialization +failure. No shell/agent process could exist, so these GUI rows remain `INCONCLUSIVE`, not PASS. The +real framed socket/peer ancestry path, deferred pre-input registration ordering, hook decoding, +listener loss, notifier forwarding, and app composition are executable automated coverage; the +single-app visible-pane matrix remains a required manual/owner follow-up when a GUI session is +available. + +## Deferred scope + +S3b owns Copilot/Droid/Qoder adapters. S3c owns Pi/OMP/OpenCode adapters and the Active Agents exact +badge. Manual launches and those runtimes remain on existing cooperative/transcript/process/screen +evidence in this PR. diff --git a/docs-ai/064-agent-completion-signals/research-agent-completion-signals.md b/docs-ai/064-agent-completion-signals/research-agent-completion-signals.md index 83540b4d..993011f5 100644 --- a/docs-ai/064-agent-completion-signals/research-agent-completion-signals.md +++ b/docs-ai/064-agent-completion-signals/research-agent-completion-signals.md @@ -5,6 +5,13 @@ > enabled per launch, what the payload carries, and what is not achievable. Update rows in > place when a CLI changes; record the version and verification method per row. +**S3a re-attestation (2026-08-24):** Claude Code 2.1.241 · Codex CLI 0.149.0 · Pi 0.84.2. +Claude final repeated `--settings` wins and retained the documented payloads. Codex's official +app-server `config/read` returns effective base/CLI `notify`, excludes project `notify` even for +a trusted project, and profile-v2 lives at `$CODEX_HOME/.config.toml`; app-server rejects +`--profile`. Codex accepts `-C dir`, `--cd dir`, `--cd=dir`, and `-Cdir`, but rejects repeated cwd +options rather than applying last-wins. See [007-s3a-action.md](007-s3a-action.md). + **Baseline (2026-08-22, this Mac):** claude 2.1.239 · codex 0.147.0 · gemini 0.46.0 · cursor-agent 2026.05.09 · cline 3.0.48→3.0.56 · opencode 1.18.11 · copilot 1.0.77 · kimi 1.41.0 · droid 0.186.0 · amp 0.0.1783746383 · qodercli 1.0.48 · qwen 0.21.3 · @@ -55,7 +62,9 @@ Confidence: **V** verified locally (live run or binary/source) · **D** official https://learn.chatgpt.com/docs/config-file/config-advanced.md#notifications. Live: `codex exec -c 'notify=["/abs/capture.sh","tag"]'` fired `agent-turn-complete` (payload as last argv incl. `last-assistant-message`); `-c hooks.*` fired only with - `--dangerously-bypass-hook-trust`. An internal "memories" sub-session (cwd + `--dangerously-bypass-hook-trust`. S3a's 0.149 re-attestation pinned app-server + initialize/`config/read`, base/profile-v2/final-CLI notifier precedence, project-notify + exclusion, and repeated-cwd rejection. An internal "memories" sub-session (cwd `~/.codex/memories`, `transcript_path:null`) also fires SessionStart/End — filter on cwd. - **Gemini CLI** — https://geminicli.com/docs/hooks/reference/ , https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/notifications.md. Hooks diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md index cb4217a7..9edd8096 100644 --- a/docs/components/agent-detection.md +++ b/docs/components/agent-detection.md @@ -143,6 +143,36 @@ keeps the pane stream alive; pane closure emits `surfaceClosed` and finishes it. overflow is explicit so future waiters can re-subscribe and resnapshot rather than silently lose lifecycle or signal evidence. +## Managed native completion signals + +Prowl Agent Profile launches of **Claude Code** and **Codex** also attach process-scoped +native event bridges without writing user, dedicated-home, or project configuration: + +- Claude `SessionStart` verifies launch coverage; `Stop` / `StopFailure` report + `turn-ended`; `PermissionRequest` and supported elicitation notifications report + `needs-input`; `SessionEnd` reports `session-end`. +- Codex's native `agent-turn-complete` notifier reports `turn-ended`. Prowl never passes + Codex's hook-trust bypass flag. + +Only an app-issued token plus exact caller-process ancestry and matching pane/runtime/cwd can +produce `hook_claude` / `hook_codex` evidence. The channel is not advertised as +`verified_live` until a valid native event completes that end-to-end check. Early Claude +`SessionStart` payloads wait for the first timely process generation instead of being lost; +a late or replacement process, pane close, or launched-agent exit revokes coverage. + +Codex exposes only one effective notifier. Before launch, Prowl asks Codex's own bounded +`app-server config/read` protocol for the effective notifier, applies selected-profile and +final CLI-override precedence, and ignores project-layer `notify` exactly as Codex does. An +existing notifier is preserved through an owner-only ephemeral forwarding record and is +`exec`'d with the original payload whether Prowl transport succeeds or fails. If resolution +or record preparation is uncertain, Prowl launches the original argv unchanged, exposes no +exact coverage, and reports one non-blocking launch warning. + +Managed hooks apply only to Profile launches. Typing `claude`, `codex`, or any other runtime +manually keeps the existing cooperative/transcript/process/screen evidence. A hook +`turn-ended` still does not prove assigned-task completion: dispatch receipts and workflow +completion remain separate protocols. + ## How often it runs - **No polling** for cold panes that have not received recent input. diff --git a/docs/components/agent-profiles.md b/docs/components/agent-profiles.md index 755fb73d..980e1f08 100644 --- a/docs/components/agent-profiles.md +++ b/docs/components/agent-profiles.md @@ -62,6 +62,21 @@ name (frozen at launch — later renames don't relabel live panes). The identity lives exactly as long as the launched agent: once it exits, any agent started manually in that pane shows its own name and runs with your default environment and account. +Claude Code and Codex Profile launches automatically prepare launch-scoped native signal +bridges. Prowl writes no hook configuration to runtime homes or repositories. Claude merges +an explicit final `--settings` JSON/file source in memory while preserving unknown fields and +existing hook arrays; Codex preserves an effective user notifier through a private transparent +dispatcher. Hook JSON, channel tokens, socket paths, and notifier argv ride in child-only +carriers rather than terminal input, shell history, preview values, logs, or durable Profile +state. A manual runtime started later in the same pane inherits none of this coverage. + +Preparation is bounded and occurs before a prompted dispatch is issued. If Prowl cannot +safely merge Claude settings, resolve Codex configuration, or preserve a notifier, the +Profile still launches with its original argv and no exact managed channel. Toolbar and +Command Palette show one non-blocking warning toast. CLI JSON adds one optional +`warnings: [{code: "managed_hook_degraded", runtime, message}]`; text output writes the +warning once to stderr. Receipt behavior is unchanged. + A Toolbar or Command Palette launch that fails before its surface exists (e.g. home provisioning) shows a warning toast, and only a successful launch from those UI surfaces updates the per-repo "last launched" memory behind the @@ -159,8 +174,9 @@ later Extra Arguments may override the generated flags: Advanced arguments are authoritative, and Prowl does not attempt to interpret every runtime's full, evolving option and configuration surface. The editor opens with a **Profile** section (name, agent, icon), followed by -**Launch Preview** — the exact rendered invocation, including the env prefix -for bound profiles, using the same rendering as the real launch — then a +**Launch Preview** — the deterministic base invocation, including the env prefix +for bound profiles. Execution-only managed-signal settings, tokens, socket paths, and +forwarding locators are prepared later and remain redacted from the preview — then a **Details** section with the remaining launch options (model, reasoning effort, execution mode, placement). diff --git a/docs/components/cli.md b/docs/components/cli.md index 657794bf..d8cb8754 100644 --- a/docs/components/cli.md +++ b/docs/components/cli.md @@ -250,6 +250,14 @@ tmux/detached ancestry, and already-closed panes fail with `SOURCE_REQUIRED` or means explicit channel and caller-pane attribution, not verified business completion. Claimed origin never upgrades trust. JSON uses `prowl.cli.agents.signal.v1`. +Prowl's bundled CLI has a hidden, silent native-hook ingress for managed Claude Code and +Codex Profile launches. It is not a user command and does not appear in help/completion. +Only an app-issued in-memory token plus exact caller ancestry, runtime, native event, launch +cwd, and process generation can produce `source=hook_claude|hook_codex` and a +`verified_live` channel. Public `agents signal` cannot claim that provenance. Hook delivery +is bounded and fail-open for the runtime; native `turn-ended` remains observation evidence, +not dispatch or workflow completion. + ### Dispatch completion and waiting Every prompted Profile launch made by `prowl create tab|pane --profile … --prompt -` @@ -435,6 +443,13 @@ in a repository file and use the kickoff prompt to tell the Profile which file t `--background` is Profile-only and creates the tab without changing the selected worktree, tab, or pane. +Claude Code and Codex Profile launches complete managed-signal preflight before a dispatch +slot or surface is created. Safe preparation failure launches the original argv unchanged. +JSON success then includes one optional `.data.warnings[]` item with +`code=managed_hook_degraded`; text mode keeps launch output on stdout and renders the warning +exactly once on stderr. No warning array is encoded when empty, and degradation never changes +receipt semantics. + ### `prowl create pane` Create a split beside an explicit pane anchor. The anchor is a pane UUID or current-process `pN` handle, supplied positionally or with `--pane`; `--direction` is required. diff --git a/skills/prowl-cli/SKILL.md b/skills/prowl-cli/SKILL.md index e7f7e9c2..d01b3135 100644 --- a/skills/prowl-cli/SKILL.md +++ b/skills/prowl-cli/SKILL.md @@ -161,7 +161,7 @@ Key fields by command: - `agents wait --until …` → `.data.observation.{status,raw_state,source,confidence,at,revision}`, `.data.signals`, and optional `.data.screen`. - `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}`, and prompted launches require `.data.dispatch.{id,state,created_at}`. +- `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}`, prompted launches require `.data.dispatch.{id,state,created_at}`, and a safe managed-signal fallback may add `.data.warnings[]` with `code=managed_hook_degraded`. - `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`. @@ -169,6 +169,11 @@ Terminal text is `.data.text` (read) and `.data.capture.text` (send) — never ` ## Reading Agent Output - For Codex/Claude Code, `prowl agents read` beats scraping: check `.data.agent.status`, inspect `.data.blocker.text` before answering a prompt with `send`/`key` (read and write are not atomic), and only trust `.data.result.text` when `state == "complete"`. `--result-only` prints the raw trusted result and fails otherwise; it cannot combine with `--json`. +- Prowl-launched Claude Code and Codex Profiles may expose `verified_live` channels with + `source=hook_claude|hook_codex`; manually typing those runtimes does not. A managed hook + `turn-ended` proves only a runtime turn edge, never assigned-task completion. If Profile + creation returns `managed_hook_degraded`, keep the successful pane but expect honest + heuristic/cooperative fallback for that session. - For an unpaired or manually launched agent, use one condition wait instead of a polling loop: ```bash diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index d0c1d34d..b999f1d2 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -353,7 +353,12 @@ struct SupacodeApp: App { terminalManager.createTabInDirectory(worktree, directory: directory) }, launchAgentProfile: { worktree, request in - terminalManager.launchAgentProfile(request, in: worktree) + switch await terminalManager.prepareAgentProfileLaunch(request, in: worktree) { + case .success(let preparation): + terminalManager.launchPreparedAgentProfile(preparation, in: worktree) + case .failure(let error): + .failure(error) + } }, events: { terminalManager.eventStream() @@ -732,6 +737,12 @@ struct SupacodeApp: App { terminalManager.recordAgentSignal(signal, caller: caller) } ) + let agentHookHandler = AgentNativeHookCommandHandler( + resolveCaller: resolveAgentSignalCaller, + recordHook: { caller, input in + terminalManager.recordAgentNativeHook(input, caller: caller) + } + ) let dispatchCompleteHandler = AgentDispatchCompleteCommandHandler( resolveCaller: resolveAgentSignalCaller, complete: { dispatchID, outcome, summary, surfaceID in @@ -1003,6 +1014,13 @@ struct SupacodeApp: App { @Shared(.userGlobalSettings) var settings return settings.agentProfiles }, + prepareAgentProfile: { request in + await prepareCLIProfileLaunch( + request, + appStore: appStore, + terminalManager: terminalManager + ) + }, launchAgentProfile: { request in launchCLIProfile( request, @@ -1130,6 +1148,7 @@ struct SupacodeApp: App { agentsHandler: agentsHandler, agentsReadHandler: agentReadHandler, agentsSignalHandler: agentSignalHandler, + agentsHookHandler: agentHookHandler, agentsDispatchCompleteHandler: dispatchCompleteHandler, agentsDispatchAbandonHandler: dispatchAbandonHandler, agentsWaitHandler: agentWaitHandler, @@ -1394,11 +1413,11 @@ struct SupacodeApp: App { return nil } - private static func launchCLIProfile( + private static func prepareCLIProfileLaunch( _ request: CLIProfileLaunchRequest, appStore: StoreOf, terminalManager: WorktreeTerminalManager - ) -> Result { + ) async -> Result { let repositories = Array(appStore.state.repositories.repositories) guard let worktree = resolveCLITerminalWorktree( @@ -1408,27 +1427,23 @@ struct SupacodeApp: App { else { return .failure(.createFailed("The resolved worktree is no longer available.")) } - let intent = request.prompt.map(AgentStartIntent.prompt) ?? .interactive let plan: AgentProfileLaunchPlan do { plan = try AgentProfileLaunchPlanner.plan( for: request.profile, intent: intent, - homeBaseDirectory: SupacodePaths.agentProfileHomesDirectory, - dispatchID: request.dispatchID + homeBaseDirectory: SupacodePaths.agentProfileHomesDirectory ) } catch { return .failure(.planning(error, profile: request.profile)) } - let placement: AgentProfileLaunchRequest.Placement switch request.resource { case .tab: placement = .tab(background: request.background) case .pane: - guard - let anchor = UUID(uuidString: request.target.paneID), + guard let anchor = UUID(uuidString: request.target.paneID), let direction = request.direction else { return .failure(.createFailed("The resolved split anchor is invalid.")) @@ -1439,15 +1454,73 @@ struct SupacodeApp: App { background: request.background ) } - let directory = request.path.map { URL(fileURLWithPath: $0, isDirectory: true) } let launchRequest = AgentProfileLaunchRequest( plan: plan, placement: placement, - workingDirectoryOverride: directory, + workingDirectoryOverride: request.path.map { URL(fileURLWithPath: $0, isDirectory: true) }, title: request.profile.name ) + switch await terminalManager.prepareAgentProfileLaunch(launchRequest, in: worktree) { + case .failure(let error): + return .failure(.creation(error, profile: request.profile)) + case .success(let preparation): + return .success( + CLIProfileLaunchRequest( + resource: request.resource, + target: request.target, + profile: request.profile, + prompt: request.prompt, + path: request.path, + direction: request.direction, + background: request.background, + dispatchID: nil, + preparedLaunch: preparation + ) + ) + } + } + + private static func launchCLIProfile( + _ request: CLIProfileLaunchRequest, + appStore: StoreOf, + terminalManager: WorktreeTerminalManager + ) -> Result { + let repositories = Array(appStore.state.repositories.repositories) + guard + let worktree = resolveCLITerminalWorktree( + id: request.target.worktreeID, + repositories: repositories + ), + var preparation = request.preparedLaunch + else { + return .failure(.createFailed("The prepared Agent Profile launch is no longer available.")) + } + if let dispatchID = request.dispatchID, let prompt = request.prompt { + do { + let pairedPlan = try preparation.context.request.plan.attachingDispatch( + id: dispatchID, + userPrompt: prompt + ) + preparation = PreparedAgentProfileLaunch( + context: FrozenAgentProfileLaunchContext( + request: AgentProfileLaunchRequest( + plan: pairedPlan, + placement: preparation.context.request.placement, + workingDirectoryOverride: preparation.context.request.workingDirectoryOverride, + inheritanceAnchor: preparation.context.request.inheritanceAnchor, + title: preparation.context.request.title + ), + inheritedCWD: preparation.context.inheritedCWD, + anchorSurfaceID: preparation.context.anchorSurfaceID + ), + warnings: preparation.warnings + ) + } catch { + return .failure(.planning(error, profile: request.profile)) + } + } let launched: LaunchedSurface - switch terminalManager.launchAgentProfile(launchRequest, in: worktree) { + switch terminalManager.launchPreparedAgentProfile(preparation, in: worktree) { case .success(let surface): launched = surface case .failure(let error): diff --git a/supacode/CLIService/AgentNativeHookCommandHandler.swift b/supacode/CLIService/AgentNativeHookCommandHandler.swift new file mode 100644 index 00000000..c199bc6d --- /dev/null +++ b/supacode/CLIService/AgentNativeHookCommandHandler.swift @@ -0,0 +1,83 @@ +import Foundation + +@MainActor +final class AgentNativeHookCommandHandler: CommandHandler { + typealias ResolveCaller = @MainActor (pid_t) -> CallerPane? + typealias RecordHook = @MainActor (CallerPane, AgentNativeHookInput) -> Bool + + private let resolveCaller: ResolveCaller + private let recordHook: RecordHook + private let now: @Sendable () -> Date + private let dateFormatter: ISO8601DateFormatter + + init( + resolveCaller: @escaping ResolveCaller, + recordHook: @escaping RecordHook, + now: @escaping @Sendable () -> Date = Date.init + ) { + self.resolveCaller = resolveCaller + self.recordHook = recordHook + self.now = now + dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + } + + func handle(envelope: CommandEnvelope) async -> CommandResponse { + await handle(envelope: envelope, context: CLICommandContext()) + } + + // swiftlint:disable async_without_await + func handle( + envelope: CommandEnvelope, + context: CLICommandContext + ) async -> CommandResponse { + guard case .agentsHook(let input) = envelope.command, + input.validationErrorMessage == nil + else { + return failure(code: CLIErrorCode.invalidArgument) + } + guard let processID = context.callerProcessID, + let caller = resolveCaller(processID), + recordHook(caller, input) + else { + return failure(code: CLIErrorCode.sourceRequired) + } + let payload = AgentSignalCommandPayload( + pane: AgentSignalPanePayload( + id: caller.surfaceID.uuidString, + worktreeID: caller.worktreeID + ), + signal: AgentSignalPayload( + event: input.signal.event, + progress: nil, + source: "hook_\(input.runtime.rawValue)", + confidence: AgentSignal.Confidence.exact.rawValue, + timestamp: dateFormatter.string(from: now()), + sessionID: input.signal.sessionID, + detail: input.signal.detail, + claimedOrigin: nil + ) + ) + do { + return try CommandResponse( + ok: true, + command: "agents.signal", + schemaVersion: "prowl.cli.agents.signal.v1", + data: RawJSON(encoding: payload) + ) + } catch { + return failure(code: CLIErrorCode.agentsFailed) + } + } + // swiftlint:enable async_without_await + + private func failure(code: String) -> CommandResponse { + CommandResponse( + ok: false, + command: "agents.signal", + schemaVersion: "prowl.cli.agents.signal.v1", + error: CommandError(code: code, message: "Managed agent hook signal rejected.") + ) + } +} diff --git a/supacode/CLIService/CLICommandRouter.swift b/supacode/CLIService/CLICommandRouter.swift index 6121ff9a..e31f63e0 100644 --- a/supacode/CLIService/CLICommandRouter.swift +++ b/supacode/CLIService/CLICommandRouter.swift @@ -10,6 +10,7 @@ final class CLICommandRouter { private let agentsHandler: any CommandHandler private let agentsReadHandler: any CommandHandler private let agentsSignalHandler: any CommandHandler + private let agentsHookHandler: any CommandHandler private let agentsDispatchCompleteHandler: any CommandHandler private let agentsDispatchAbandonHandler: any CommandHandler private let agentsWaitHandler: any CommandHandler @@ -30,6 +31,7 @@ final class CLICommandRouter { agentsHandler: any CommandHandler = StubCommandHandler(command: "agents"), agentsReadHandler: any CommandHandler = StubCommandHandler(command: "agents.read"), agentsSignalHandler: any CommandHandler = StubCommandHandler(command: "agents.signal"), + agentsHookHandler: any CommandHandler = StubCommandHandler(command: "agents._hook"), agentsDispatchCompleteHandler: any CommandHandler = StubCommandHandler(command: "agents.dispatch-complete"), agentsDispatchAbandonHandler: any CommandHandler = StubCommandHandler(command: "agents.dispatch-abandon"), agentsWaitHandler: any CommandHandler = StubCommandHandler(command: "agents.wait"), @@ -49,6 +51,7 @@ final class CLICommandRouter { self.agentsHandler = agentsHandler self.agentsReadHandler = agentsReadHandler self.agentsSignalHandler = agentsSignalHandler + self.agentsHookHandler = agentsHookHandler self.agentsDispatchCompleteHandler = agentsDispatchCompleteHandler self.agentsDispatchAbandonHandler = agentsDispatchAbandonHandler self.agentsWaitHandler = agentsWaitHandler @@ -77,6 +80,7 @@ final class CLICommandRouter { case .agents: handler = agentsHandler case .agentsRead: handler = agentsReadHandler case .agentsSignal: handler = agentsSignalHandler + case .agentsHook: handler = agentsHookHandler case .agentsDispatchComplete: handler = agentsDispatchCompleteHandler case .agentsDispatchAbandon: handler = agentsDispatchAbandonHandler case .agentsWait: handler = agentsWaitHandler diff --git a/supacode/CLIService/LifecycleCommandHandler.swift b/supacode/CLIService/LifecycleCommandHandler.swift index 807b71c0..289e5fef 100644 --- a/supacode/CLIService/LifecycleCommandHandler.swift +++ b/supacode/CLIService/LifecycleCommandHandler.swift @@ -16,6 +16,30 @@ struct CLIProfileLaunchRequest: Sendable, Equatable { /// Present only for a CLI prompted launch. Internal launchers opt in /// explicitly rather than inheriting the dispatch protocol by accident. let dispatchID: String? + /// Frozen async preflight result. Nil before preparation and in legacy unit seams. + let preparedLaunch: PreparedAgentProfileLaunch? + + init( + resource: LifecycleResource, + target: TabResolvedTarget, + profile: AgentProfile, + prompt: String?, + path: String?, + direction: CreatePaneDirection?, + background: Bool, + dispatchID: String?, + preparedLaunch: PreparedAgentProfileLaunch? = nil + ) { + self.resource = resource + self.target = target + self.profile = profile + self.prompt = prompt + self.path = path + self.direction = direction + self.background = background + self.dispatchID = dispatchID + self.preparedLaunch = preparedLaunch + } } private enum CLIProfileLookupError: Error { @@ -70,6 +94,8 @@ enum CLIProfileLaunchFailure: Error, Equatable, Sendable { "Failed to create a tab for Agent Profile “\(profile.name)”." case .launchedSurfaceMissing: "The tab for Agent Profile “\(profile.name)” was created without a terminal surface." + case .hookRegistrationFailed: + "The managed signal channel for Agent Profile “\(profile.name)” could not be registered." } return .createFailed(message) } @@ -83,6 +109,8 @@ final class LifecycleCommandHandler: CommandHandler { typealias CreateTabProvider = @MainActor (TabResolvedTarget, String?) -> TabResolvedTarget? typealias CreatePaneProvider = @MainActor (TabResolvedTarget, CreatePaneDirection) -> TabResolvedTarget? typealias ProfilesProvider = @MainActor () -> [AgentProfile] + typealias PrepareProfileLaunchProvider = + @MainActor (CLIProfileLaunchRequest) async -> Result typealias ProfileLaunchProvider = @MainActor (CLIProfileLaunchRequest) -> Result typealias IssueDispatchProvider = @@ -99,6 +127,7 @@ final class LifecycleCommandHandler: CommandHandler { private let createTab: CreateTabProvider private let createPane: CreatePaneProvider private let profiles: ProfilesProvider + private let prepareAgentProfile: PrepareProfileLaunchProvider private let launchAgentProfile: ProfileLaunchProvider private let issueDispatch: IssueDispatchProvider private let bindDispatch: BindDispatchProvider @@ -113,6 +142,7 @@ final class LifecycleCommandHandler: CommandHandler { createTab: @escaping CreateTabProvider, createPane: @escaping CreatePaneProvider, profiles: @escaping ProfilesProvider = { [] }, + prepareAgentProfile: @escaping PrepareProfileLaunchProvider = { .success($0) }, launchAgentProfile: @escaping ProfileLaunchProvider = { _ in .failure(.createFailed("Failed to launch the Agent Profile.")) }, @@ -128,6 +158,7 @@ final class LifecycleCommandHandler: CommandHandler { self.createTab = createTab self.createPane = createPane self.profiles = profiles + self.prepareAgentProfile = prepareAgentProfile self.launchAgentProfile = launchAgentProfile self.issueDispatch = issueDispatch self.bindDispatch = bindDispatch @@ -137,11 +168,10 @@ final class LifecycleCommandHandler: CommandHandler { self.closePane = closePane } - // swiftlint:disable:next async_without_await func handle(envelope: CommandEnvelope) async -> CommandResponse { switch envelope.command { case .create(let input): - return handleCreate(input) + return await handleCreate(input) case .close(let input): return handleClose(input) default: @@ -150,7 +180,7 @@ final class LifecycleCommandHandler: CommandHandler { } } - private func handleCreate(_ input: CreateInput) -> CommandResponse { + private func handleCreate(_ input: CreateInput) async -> CommandResponse { if let prompt = input.launch?.prompt { if prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return errorResponse( @@ -176,13 +206,13 @@ final class LifecycleCommandHandler: CommandHandler { } switch input.resource { case .tab: - return handleCreateTab(input) + return await handleCreateTab(input) case .pane: - return handleCreatePane(input) + return await handleCreatePane(input) } } - private func handleCreateTab(_ input: CreateInput) -> CommandResponse { + private func handleCreateTab(_ input: CreateInput) async -> CommandResponse { guard case .worktree = input.selector, input.direction == nil else { return errorResponse( command: "create", @@ -211,7 +241,7 @@ final class LifecycleCommandHandler: CommandHandler { ) } if let launch = input.launch { - return handleProfileLaunch( + return await handleProfileLaunch( input: input, launch: launch, target: target, @@ -224,7 +254,7 @@ final class LifecycleCommandHandler: CommandHandler { return success(command: "create", resource: .tab, target: createdTarget) } - private func handleCreatePane(_ input: CreateInput) -> CommandResponse { + private func handleCreatePane(_ input: CreateInput) async -> CommandResponse { guard case .pane = input.selector, input.path == nil, let direction = input.direction else { return errorResponse( command: "create", @@ -245,7 +275,7 @@ final class LifecycleCommandHandler: CommandHandler { } if let launch = input.launch { - return handleProfileLaunch( + return await handleProfileLaunch( input: input, launch: launch, target: anchor, @@ -269,7 +299,7 @@ final class LifecycleCommandHandler: CommandHandler { launch: CreateLaunchInput, target: TabResolvedTarget, path: String? - ) -> CommandResponse { + ) async -> CommandResponse { let profile: AgentProfile switch resolveProfile(launch.profile) { case .success(let resolved): @@ -290,31 +320,29 @@ final class LifecycleCommandHandler: CommandHandler { background: input.background, dispatchID: nil ) - let dispatch: DispatchPendingRecord? - if launch.prompt != nil { - switch issueDispatch() { - case .success(let record): - dispatch = record - case .failure: - return errorResponse( - command: "create", - code: CLIErrorCode.dispatchCapacityExceeded, - message: "All dispatch receipt slots are occupied by pending work; " - + "complete or abandon a dispatch before launching another prompted task." - ) - } - } else { - dispatch = nil + let preparation = await preparedProfileRequest(request) + if let response = preparation.response { return response } + guard let preparedRequest = preparation.request else { + return errorResponse( + command: "create", + code: CLIErrorCode.createFailed, + message: "Failed to prepare the Agent Profile launch." + ) } + + let dispatchResult = issuedDispatch(prompt: launch.prompt) + if let response = dispatchResult.response { return response } + let dispatch = dispatchResult.record let pairedRequest = CLIProfileLaunchRequest( - resource: request.resource, - target: request.target, - profile: request.profile, - prompt: request.prompt, - path: request.path, - direction: request.direction, - background: request.background, - dispatchID: dispatch?.id + resource: preparedRequest.resource, + target: preparedRequest.target, + profile: preparedRequest.profile, + prompt: preparedRequest.prompt, + path: preparedRequest.path, + direction: preparedRequest.direction, + background: preparedRequest.background, + dispatchID: dispatch?.id, + preparedLaunch: preparedRequest.preparedLaunch ) let createdTarget: TabResolvedTarget switch launchAgentProfile(pairedRequest) { @@ -352,10 +380,44 @@ final class LifecycleCommandHandler: CommandHandler { profileName: profile.name, agent: profile.runtime.agent.rawValue ), - dispatch: dispatch + dispatch: dispatch, + warnings: preparedRequest.preparedLaunch?.warnings ) } + private func preparedProfileRequest( + _ request: CLIProfileLaunchRequest + ) async -> (request: CLIProfileLaunchRequest?, response: CommandResponse?) { + switch await prepareAgentProfile(request) { + case .success(let prepared): + return (prepared, nil) + case .failure(.invalidArgument(let message)): + return (nil, errorResponse(command: "create", code: CLIErrorCode.invalidArgument, message: message)) + case .failure(.createFailed(let message)): + return (nil, errorResponse(command: "create", code: CLIErrorCode.createFailed, message: message)) + } + } + + private func issuedDispatch( + prompt: String? + ) -> (record: DispatchPendingRecord?, response: CommandResponse?) { + guard prompt != nil else { return (nil, nil) } + switch issueDispatch() { + case .success(let record): + return (record, nil) + case .failure: + return ( + nil, + errorResponse( + command: "create", + code: CLIErrorCode.dispatchCapacityExceeded, + message: "All dispatch receipt slots are occupied by pending work; " + + "complete or abandon a dispatch before launching another prompted task." + ) + ) + } + } + private func resolveProfile(_ reference: String) -> Result { let allProfiles = profiles() if let id = UUID(uuidString: reference), @@ -444,7 +506,8 @@ final class LifecycleCommandHandler: CommandHandler { anchor: TabResolvedTarget? = nil, direction: CreatePaneDirection? = nil, launch: LifecycleCommandLaunch? = nil, - dispatch: DispatchPendingRecord? = nil + dispatch: DispatchPendingRecord? = nil, + warnings: [LifecycleCommandWarning]? = nil ) -> CommandResponse { do { return try CommandResponse( @@ -458,6 +521,7 @@ final class LifecycleCommandHandler: CommandHandler { direction: direction, launch: launch, dispatch: dispatch, + warnings: warnings, target: makePayloadTarget(from: target) ) ) diff --git a/supacode/CLIService/Shared/AgentNativeHookPayload.swift b/supacode/CLIService/Shared/AgentNativeHookPayload.swift new file mode 100644 index 00000000..3c60a0fd --- /dev/null +++ b/supacode/CLIService/Shared/AgentNativeHookPayload.swift @@ -0,0 +1,200 @@ +import Foundation + +nonisolated public enum AgentNativeHookRuntime: String, Codable, CaseIterable, Sendable { + case claude + case codex +} + +nonisolated public struct AgentNativeHookSignal: Codable, Equatable, Sendable { + public let event: AgentSignalEvent + public let nativeEvent: String + public let cwd: String + public let sessionID: String + public let detail: String? + + enum CodingKeys: String, CodingKey { + case event + case nativeEvent = "native_event" + case cwd + case sessionID = "session_id" + case detail + } + + public init( + event: AgentSignalEvent, + nativeEvent: String, + cwd: String, + sessionID: String, + detail: String? = nil + ) { + self.event = event + self.nativeEvent = nativeEvent + self.cwd = cwd + self.sessionID = sessionID + self.detail = detail + } +} + +nonisolated public struct AgentNativeHookInput: Codable, Equatable, Sendable { + public static let tokenEnvironmentKey = "PROWL_AGENT_HOOK_TOKEN" + public static let forwardRecordEnvironmentKey = "PROWL_AGENT_HOOK_FORWARD_RECORD" + public static let maximumTokenBytes = 256 + + public let runtime: AgentNativeHookRuntime + public let token: String + public let signal: AgentNativeHookSignal + + public init(runtime: AgentNativeHookRuntime, token: String, signal: AgentNativeHookSignal) { + self.runtime = runtime + self.token = token + self.signal = signal + } + + public var validationErrorMessage: String? { + guard !token.isEmpty, token.utf8.count <= Self.maximumTokenBytes, + !token.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) + else { + return "The managed hook token is invalid." + } + return nil + } +} + +nonisolated public enum AgentNativeHookDecodeError: Error, Equatable, Sendable { + case payloadTooLarge + case malformedPayload + case unsupportedEvent + case eventMismatch + case invalidField +} + +nonisolated public enum AgentNativeHookDecoder { + public static let maximumPayloadBytes = 1_024 * 1_024 + private static let maximumCWDBytes = 4 * 1_024 + private static let acceptedClaudeNotifications: Set = [ + "elicitation_dialog", + "idle_prompt", + "permission_prompt", + ] + + public static func decode( + runtime: AgentNativeHookRuntime, + nativeEvent: String, + payload: Data + ) throws -> AgentNativeHookSignal { + guard payload.count <= maximumPayloadBytes else { throw AgentNativeHookDecodeError.payloadTooLarge } + guard + let object = try? JSONSerialization.jsonObject(with: payload) as? [String: Any] + else { + throw AgentNativeHookDecodeError.malformedPayload + } + return switch runtime { + case .claude: + try decodeClaude(nativeEvent: nativeEvent, object: object) + case .codex: + try decodeCodex(nativeEvent: nativeEvent, object: object) + } + } + + private static func decodeClaude( + nativeEvent: String, + object: [String: Any] + ) throws -> AgentNativeHookSignal { + guard let payloadEvent = object["hook_event_name"] as? String else { + throw AgentNativeHookDecodeError.malformedPayload + } + guard payloadEvent == nativeEvent else { throw AgentNativeHookDecodeError.eventMismatch } + let event: AgentSignalEvent + let detail: String? + switch nativeEvent { + case "SessionStart": + event = .sessionStart + detail = nil + case "Stop", "StopFailure": + event = .turnEnded + detail = boundedOptionalString(object["reason"]) + case "PermissionRequest", "Elicitation": + event = .needsInput + detail = boundedOptionalString(object["tool_name"] ?? object["reason"]) + case "Notification": + guard let type = object["notification_type"] as? String, + acceptedClaudeNotifications.contains(type) + else { + throw AgentNativeHookDecodeError.unsupportedEvent + } + event = .needsInput + detail = type + case "SessionEnd": + event = .sessionEnd + detail = boundedOptionalString(object["reason"]) + default: + throw AgentNativeHookDecodeError.unsupportedEvent + } + return try makeSignal( + event: event, + nativeEvent: nativeEvent, + cwd: object["cwd"], + sessionID: object["session_id"], + detail: detail + ) + } + + private static func decodeCodex( + nativeEvent: String, + object: [String: Any] + ) throws -> AgentNativeHookSignal { + guard nativeEvent == "agent-turn-complete" else { + throw AgentNativeHookDecodeError.unsupportedEvent + } + guard let payloadEvent = object["type"] as? String else { + throw AgentNativeHookDecodeError.malformedPayload + } + guard payloadEvent == nativeEvent else { throw AgentNativeHookDecodeError.eventMismatch } + return try makeSignal( + event: .turnEnded, + nativeEvent: nativeEvent, + cwd: object["cwd"], + sessionID: object["thread-id"], + detail: nil + ) + } + + private static func makeSignal( + event: AgentSignalEvent, + nativeEvent: String, + cwd: Any?, + sessionID: Any?, + detail: String? + ) throws -> AgentNativeHookSignal { + guard let cwd = cwd as? String, isValid(cwd, maximumBytes: maximumCWDBytes), cwd.hasPrefix("/") else { + throw AgentNativeHookDecodeError.invalidField + } + guard let sessionID = sessionID as? String, + isValid(sessionID, maximumBytes: AgentSignalInput.maximumSessionIDBytes) + else { + throw AgentNativeHookDecodeError.invalidField + } + return AgentNativeHookSignal( + event: event, + nativeEvent: nativeEvent, + cwd: cwd, + sessionID: sessionID, + detail: detail + ) + } + + private static func boundedOptionalString(_ value: Any?) -> String? { + guard let value = value as? String, + isValid(value, maximumBytes: AgentSignalInput.maximumDetailBytes) + else { + return nil + } + return value + } + + private static func isValid(_ value: String, maximumBytes: Int) -> Bool { + !value.isEmpty + && value.utf8.count <= maximumBytes + && !value.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) + } +} diff --git a/supacode/CLIService/Shared/CodexForwardingRecord.swift b/supacode/CLIService/Shared/CodexForwardingRecord.swift new file mode 100644 index 00000000..b9ffa24a --- /dev/null +++ b/supacode/CLIService/Shared/CodexForwardingRecord.swift @@ -0,0 +1,115 @@ +import Foundation + +#if canImport(Darwin) + import Darwin +#elseif canImport(Glibc) + import Glibc +#endif + +nonisolated public enum CodexForwardingRecordError: Error, Equatable, Sendable { + case invalidRecord +} + +nonisolated public final class CodexForwardingRecordLease: @unchecked Sendable { + public let argv: [String] + private let lock = NSLock() + private var descriptor: Int32 + + fileprivate init(argv: [String], descriptor: Int32) { + self.argv = argv + self.descriptor = descriptor + } + + public func close() { + lock.lock() + let descriptor = self.descriptor + self.descriptor = -1 + lock.unlock() + if descriptor >= 0 { + flock(descriptor, LOCK_UN) + Darwin.close(descriptor) + } + } + + deinit { + close() + } +} + +nonisolated public enum CodexForwardingRecordReader { + public static let maximumRecordBytes = 64 * 1_024 + + public static func open(_ locator: URL) throws -> CodexForwardingRecordLease { + let parent = locator.deletingLastPathComponent() + guard validOwnerOnlyDirectory(parent) else { throw CodexForwardingRecordError.invalidRecord } + let descriptor = Darwin.open( + locator.path(percentEncoded: false), + O_RDONLY | O_NOFOLLOW | O_CLOEXEC + ) + guard descriptor >= 0 else { throw CodexForwardingRecordError.invalidRecord } + var shouldClose = true + defer { + if shouldClose { Darwin.close(descriptor) } + } + var metadata = stat() + guard fstat(descriptor, &metadata) == 0, + (metadata.st_mode & S_IFMT) == S_IFREG, + metadata.st_uid == geteuid(), + metadata.st_mode & 0o777 == 0o600, + metadata.st_size > 0, + metadata.st_size <= maximumRecordBytes, + flock(descriptor, LOCK_SH) == 0 + else { + throw CodexForwardingRecordError.invalidRecord + } + var data = Data(count: Int(metadata.st_size)) + var offset = 0 + while offset < data.count { + let count = data.withUnsafeMutableBytes { buffer in + Darwin.read(descriptor, buffer.baseAddress?.advanced(by: offset), buffer.count - offset) + } + guard count > 0 else { + flock(descriptor, LOCK_UN) + throw CodexForwardingRecordError.invalidRecord + } + offset += count + } + guard + let object = try? JSONSerialization.jsonObject(with: data), + let values = object as? [Any], + !values.isEmpty, + values.count <= 128 + else { + flock(descriptor, LOCK_UN) + throw CodexForwardingRecordError.invalidRecord + } + var argv: [String] = [] + var totalBytes = 0 + for value in values { + guard let value = value as? String, !value.contains("\0") else { + flock(descriptor, LOCK_UN) + throw CodexForwardingRecordError.invalidRecord + } + totalBytes += value.utf8.count + guard totalBytes <= maximumRecordBytes else { + flock(descriptor, LOCK_UN) + throw CodexForwardingRecordError.invalidRecord + } + argv.append(value) + } + guard !argv[0].isEmpty else { + flock(descriptor, LOCK_UN) + throw CodexForwardingRecordError.invalidRecord + } + shouldClose = false + return CodexForwardingRecordLease(argv: argv, descriptor: descriptor) + } + + private static func validOwnerOnlyDirectory(_ url: URL) -> Bool { + var metadata = stat() + guard lstat(url.path(percentEncoded: false), &metadata) == 0 else { return false } + return (metadata.st_mode & S_IFMT) == S_IFDIR + && metadata.st_uid == geteuid() + && metadata.st_mode & 0o777 == 0o700 + } +} diff --git a/supacode/CLIService/Shared/CommandEnvelope.swift b/supacode/CLIService/Shared/CommandEnvelope.swift index 70d0e40e..8ecd8b76 100644 --- a/supacode/CLIService/Shared/CommandEnvelope.swift +++ b/supacode/CLIService/Shared/CommandEnvelope.swift @@ -19,6 +19,7 @@ public enum Command: Codable, Sendable { case agents(AgentsInput) case agentsRead(AgentReadInput) case agentsSignal(AgentSignalInput) + case agentsHook(AgentNativeHookInput) case agentsDispatchComplete(DispatchCompleteInput) case agentsDispatchAbandon(DispatchAbandonInput) case agentsWait(AgentWaitInput) @@ -40,6 +41,7 @@ public enum Command: Codable, Sendable { case .agents: "agents" case .agentsRead: "agents.read" case .agentsSignal: "agents.signal" + case .agentsHook: "agents._hook" case .agentsDispatchComplete: "agents.dispatch-complete" case .agentsDispatchAbandon: "agents.dispatch-abandon" case .agentsWait: "agents.wait" diff --git a/supacode/CLIService/Shared/InputModels.swift b/supacode/CLIService/Shared/InputModels.swift index 0e05aa3d..41663d60 100644 --- a/supacode/CLIService/Shared/InputModels.swift +++ b/supacode/CLIService/Shared/InputModels.swift @@ -169,7 +169,7 @@ private enum CLIInputTextValidator { } } -public enum AgentSignalEvent: String, Codable, CaseIterable, Sendable { +nonisolated public enum AgentSignalEvent: String, Codable, CaseIterable, Hashable, Sendable { case turnEnded = "turn-ended" case needsInput = "needs-input" case sessionStart = "session-start" @@ -177,7 +177,7 @@ public enum AgentSignalEvent: String, Codable, CaseIterable, Sendable { case progress } -public struct AgentSignalInput: Codable, Sendable { +nonisolated public struct AgentSignalInput: Codable, Sendable { public static let maximumSessionIDBytes = 256 public static let maximumOriginBytes = 256 public static let maximumDetailBytes = 32 * 1_024 diff --git a/supacode/CLIService/Shared/LifecycleCommandPayload.swift b/supacode/CLIService/Shared/LifecycleCommandPayload.swift index 4b3694e8..b3aa970b 100644 --- a/supacode/CLIService/Shared/LifecycleCommandPayload.swift +++ b/supacode/CLIService/Shared/LifecycleCommandPayload.swift @@ -18,12 +18,29 @@ public struct LifecycleCommandLaunch: Codable, Sendable, Equatable { } } +nonisolated public enum LifecycleCommandWarningCode: String, Codable, Sendable { + case managedHookDegraded = "managed_hook_degraded" +} + +nonisolated public struct LifecycleCommandWarning: Codable, Sendable, Equatable { + public let code: LifecycleCommandWarningCode + public let runtime: String + public let message: String + + public init(code: LifecycleCommandWarningCode, runtime: String, message: String) { + self.code = code + self.runtime = runtime + self.message = message + } +} + public struct LifecycleCommandPayload: Codable, Sendable, Equatable { public let resource: LifecycleResource public let anchor: TabTarget? public let direction: CreatePaneDirection? public let launch: LifecycleCommandLaunch? public let dispatch: DispatchPendingRecord? + public let warnings: [LifecycleCommandWarning]? public let target: TabTarget public init( @@ -32,6 +49,7 @@ public struct LifecycleCommandPayload: Codable, Sendable, Equatable { direction: CreatePaneDirection? = nil, launch: LifecycleCommandLaunch? = nil, dispatch: DispatchPendingRecord? = nil, + warnings: [LifecycleCommandWarning]? = nil, target: TabTarget ) { self.resource = resource @@ -39,6 +57,7 @@ public struct LifecycleCommandPayload: Codable, Sendable, Equatable { self.direction = direction self.launch = launch self.dispatch = dispatch + self.warnings = warnings?.isEmpty == true ? nil : warnings self.target = target } } diff --git a/supacode/CLIService/Shared/SocketConstants.swift b/supacode/CLIService/Shared/SocketConstants.swift index 65912d65..00e36271 100644 --- a/supacode/CLIService/Shared/SocketConstants.swift +++ b/supacode/CLIService/Shared/SocketConstants.swift @@ -3,7 +3,7 @@ import Foundation -public enum ProwlSocket { +nonisolated public enum ProwlSocket { /// Environment variable for overriding socket path. public static let environmentKey = "PROWL_CLI_SOCKET" diff --git a/supacode/Clients/Terminal/TerminalClient.swift b/supacode/Clients/Terminal/TerminalClient.swift index 29f899c7..fab9cafc 100644 --- a/supacode/Clients/Terminal/TerminalClient.swift +++ b/supacode/Clients/Terminal/TerminalClient.swift @@ -9,7 +9,7 @@ struct TerminalClient { /// and pane identities. This is the CLI/workflow boundary; the legacy command /// remains event-driven for menu and palette launches. var launchAgentProfile: - @MainActor @Sendable (Worktree, AgentProfileLaunchRequest) -> Result + @MainActor @Sendable (Worktree, AgentProfileLaunchRequest) async -> Result var events: @MainActor @Sendable () -> AsyncStream /// Per-surface multicast stream. Independent from the single-consumer event stream. var observeAgentState: @MainActor @Sendable (UUID) -> AgentObservationStream @@ -94,6 +94,7 @@ struct TerminalClient { /// launch memory on this event — not at dispatch — so a failed launch /// never shifts the Recommended resolution (docs-ai 053/005). case agentProfileLaunched(worktreeID: Worktree.ID, profileID: AgentProfile.ID) + case agentProfileLaunchWarning(worktreeID: Worktree.ID, profileName: String, message: String) case agentProfileLaunchFailed(worktreeID: Worktree.ID, profileName: String) case runScriptStatusChanged(worktreeID: Worktree.ID, isRunning: Bool) case commandPaletteToggleRequested(worktreeID: Worktree.ID) diff --git a/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift b/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift index dda0b8a3..98002280 100644 --- a/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift +++ b/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift @@ -1,5 +1,19 @@ import Foundation +nonisolated struct AgentHookResources: Equatable, Sendable { + let bundledCLIPath: String + let socketPath: String +} + +nonisolated struct AgentHookLaunchRegistration: Equatable, Sendable { + let token: String + let runtime: AgentNativeHookRuntime + let launchCWD: URL + let nativeEvents: [String: AgentSignalEvent] + let coveredEvents: [AgentSignalEvent] + let forwardingRecord: CodexForwardingRecord? +} + /// The compiled result of resolving a profile for one launch (docs-ai 053): /// every decision is already made, downstream layers only execute. The same /// plan feeds the settings editor's launch preview and the actual launch. @@ -8,6 +22,11 @@ nonisolated struct AgentProfileLaunchPlan: Equatable, Sendable { let profileName: String let runtime: AgentProfileRuntime let invocation: AgentInvocation + /// Argv index -> owner-controlled surface carrier. This generalizes the + /// prompted-start carrier to large native hook settings/config arguments. + let argumentCarriers: [Int: String] + /// Present only on the execution copy immediately before surface creation. + let hookRegistration: AgentHookLaunchRegistration? /// `env(1)` assignment tokens typed ahead of the invocation. The whole /// environment patch is launch-scoped (docs-ai 053/006): it exists for the /// launched agent process only — the pane's shell keeps the user's normal @@ -35,6 +54,8 @@ nonisolated struct AgentProfileLaunchPlan: Equatable, Sendable { profileName: String, runtime: AgentProfileRuntime, invocation: AgentInvocation, + argumentCarriers: [Int: String] = [:], + hookRegistration: AgentHookLaunchRegistration? = nil, commandEnvironmentTokens: [String], placement: AgentProfilePlacement, splitDirection: UserCustomSplitDirection, @@ -46,6 +67,8 @@ nonisolated struct AgentProfileLaunchPlan: Equatable, Sendable { self.profileName = profileName self.runtime = runtime self.invocation = invocation + self.argumentCarriers = argumentCarriers + self.hookRegistration = hookRegistration self.commandEnvironmentTokens = commandEnvironmentTokens self.placement = placement self.splitDirection = splitDirection @@ -61,15 +84,26 @@ nonisolated struct AgentProfileLaunchPlan: Equatable, Sendable { let promptCarrier = surfaceEnvironment[AgentProfileLaunchPlanner.promptCarrierName] == nil ? nil : AgentProfileLaunchPlanner.promptCarrierName + var replacements = argumentCarriers + if let promptCarrier, !invocation.arguments.isEmpty { + replacements[invocation.arguments.index(before: invocation.arguments.endIndex)] = promptCarrier + } let invocationInput = invocation.terminalInput( - replacingFinalArgumentWithEnvironmentVariable: promptCarrier + replacingArgumentsWithEnvironmentVariables: replacements ) var environmentTokens = commandEnvironmentTokens - let carrierNames = [ + let knownCarriers = [ promptCarrier, surfaceEnvironment[AgentProfileLaunchPlanner.dispatchCarrierName] == nil ? nil : AgentProfileLaunchPlanner.dispatchCarrierName, + surfaceEnvironment[AgentProfileLaunchPlanner.hookTokenCarrierName] == nil + ? nil : AgentProfileLaunchPlanner.hookTokenCarrierName, + surfaceEnvironment[AgentProfileLaunchPlanner.hookSocketCarrierName] == nil + ? nil : AgentProfileLaunchPlanner.hookSocketCarrierName, + surfaceEnvironment[AgentProfileLaunchPlanner.hookForwardCarrierName] == nil + ? nil : AgentProfileLaunchPlanner.hookForwardCarrierName, ].compactMap { $0 } + let carrierNames = Set(knownCarriers + Array(argumentCarriers.values)).sorted() for carrier in carrierNames.reversed() { environmentTokens.insert(contentsOf: ["-u", carrier], at: 0) } @@ -78,6 +112,94 @@ nonisolated struct AgentProfileLaunchPlan: Equatable, Sendable { } var previewText: String { terminalInput } + + func applyingManagedHook( + _ prepared: AgentHookPreparedInvocation, + resources: AgentHookResources, + launchCWD: URL, + token: String, + nativeEvents: [String: AgentSignalEvent] = [:], + coveredEvents: [AgentSignalEvent], + forwardingRecord: CodexForwardingRecord? = nil + ) -> AgentProfileLaunchPlan { + guard let runtime = AgentNativeHookRuntime(rawValue: runtime.rawValue) else { return self } + var environment = surfaceEnvironment + var carriers: [Int: String] = [:] + for (offset, entry) in prepared.argumentValues.sorted(by: { $0.key < $1.key }).enumerated() { + let carrier = "PROWL_LAUNCH_HOOK_ARG_\(offset)" + carriers[entry.key] = carrier + environment[carrier] = entry.value + } + environment[AgentProfileLaunchPlanner.hookTokenCarrierName] = token + environment[AgentProfileLaunchPlanner.hookSocketCarrierName] = resources.socketPath + var commandTokens = + commandEnvironmentTokens + [ + "\(AgentNativeHookInput.tokenEnvironmentKey)=\"$\(AgentProfileLaunchPlanner.hookTokenCarrierName)\"", + "\(ProwlSocket.environmentKey)=\"$\(AgentProfileLaunchPlanner.hookSocketCarrierName)\"", + ] + if let forwardingRecord { + environment[AgentProfileLaunchPlanner.hookForwardCarrierName] = forwardingRecord.locator.path( + percentEncoded: false + ) + commandTokens.append( + "\(AgentNativeHookInput.forwardRecordEnvironmentKey)=\"$\(AgentProfileLaunchPlanner.hookForwardCarrierName)\"" + ) + } + return AgentProfileLaunchPlan( + profileID: profileID, + profileName: profileName, + runtime: self.runtime, + invocation: prepared.invocation, + argumentCarriers: carriers, + hookRegistration: AgentHookLaunchRegistration( + token: token, + runtime: runtime, + launchCWD: launchCWD.standardizedFileURL, + nativeEvents: nativeEvents, + coveredEvents: coveredEvents, + forwardingRecord: forwardingRecord + ), + commandEnvironmentTokens: commandTokens, + placement: placement, + splitDirection: splitDirection, + surfaceEnvironment: environment, + dedicatedHome: dedicatedHome, + sessionConfigRoot: sessionConfigRoot + ) + } + + func attachingDispatch(id: String, userPrompt: String) throws -> AgentProfileLaunchPlan { + guard !id.isEmpty, !id.contains("\0"), !userPrompt.contains("\0"), + !invocation.arguments.isEmpty, + surfaceEnvironment[AgentProfileLaunchPlanner.promptCarrierName] != nil + else { + throw AgentProfileLaunchPlanError.dispatchRequiresPrompt + } + let renderedPrompt = AgentDispatchPrompt.render(userPrompt: userPrompt) + var arguments = invocation.arguments + arguments[arguments.index(before: arguments.endIndex)] = renderedPrompt + var environment = surfaceEnvironment + environment[AgentProfileLaunchPlanner.promptCarrierName] = renderedPrompt + environment[AgentProfileLaunchPlanner.dispatchCarrierName] = id + var commandTokens = commandEnvironmentTokens + commandTokens.append( + "\(DispatchCompleteInput.environmentKey)=\"$\(AgentProfileLaunchPlanner.dispatchCarrierName)\"" + ) + return AgentProfileLaunchPlan( + profileID: profileID, + profileName: profileName, + runtime: runtime, + invocation: AgentInvocation(executable: invocation.executable, arguments: arguments), + argumentCarriers: argumentCarriers, + hookRegistration: hookRegistration, + commandEnvironmentTokens: commandTokens, + placement: placement, + splitDirection: splitDirection, + surfaceEnvironment: environment, + dedicatedHome: dedicatedHome, + sessionConfigRoot: sessionConfigRoot + ) + } } /// One deterministic profile launch request shared by the CLI, workflow runner, @@ -96,21 +218,35 @@ nonisolated struct AgentProfileLaunchRequest: Equatable, Sendable { let plan: AgentProfileLaunchPlan let placement: Placement let workingDirectoryOverride: URL? + let inheritanceAnchor: UUID? let title: String? init( plan: AgentProfileLaunchPlan, placement: Placement, workingDirectoryOverride: URL? = nil, + inheritanceAnchor: UUID? = nil, title: String? = nil ) { self.plan = plan self.placement = placement self.workingDirectoryOverride = workingDirectoryOverride + self.inheritanceAnchor = inheritanceAnchor self.title = title } } +nonisolated struct FrozenAgentProfileLaunchContext: Equatable, Sendable { + let request: AgentProfileLaunchRequest + let inheritedCWD: URL + let anchorSurfaceID: UUID? +} + +nonisolated struct PreparedAgentProfileLaunch: Equatable, Sendable { + let context: FrozenAgentProfileLaunchContext + let warnings: [LifecycleCommandWarning] +} + nonisolated struct LaunchedSurface: Equatable, Sendable { let tabID: TerminalTabID let surfaceID: UUID @@ -122,6 +258,7 @@ nonisolated enum AgentProfileLaunchError: Error, Equatable, Sendable { case splitCreationFailed(SplitCreationError) case tabCreationFailed case launchedSurfaceMissing(TerminalTabID) + case hookRegistrationFailed } /// Which env variable names a profile override may set, shared by the planner @@ -268,6 +405,9 @@ nonisolated enum AgentProfileLaunchPlanner { /// that child, so neither the pane shell nor a later manual runtime receives /// a stale public dispatch context. static let dispatchCarrierName = "PROWL_LAUNCH_DISPATCH" + static let hookTokenCarrierName = "PROWL_LAUNCH_HOOK_TOKEN" + static let hookSocketCarrierName = "PROWL_LAUNCH_HOOK_SOCKET" + static let hookForwardCarrierName = "PROWL_LAUNCH_HOOK_FORWARD" /// Resolves a profile into one launch plan. Pure: no filesystem access — /// home provisioning happens at the launch boundary, not here. diff --git a/supacode/Domain/AgentRuntime/AgentManagedHookPreparer.swift b/supacode/Domain/AgentRuntime/AgentManagedHookPreparer.swift new file mode 100644 index 00000000..756147af --- /dev/null +++ b/supacode/Domain/AgentRuntime/AgentManagedHookPreparer.swift @@ -0,0 +1,212 @@ +import Foundation + +nonisolated struct AgentManagedHookPreparation: Equatable, Sendable { + let preparedInvocation: AgentHookPreparedInvocation? + let capability: AgentSignalHookCapability? + let launchCWD: URL + let forwardingArgv: [String]? + let warning: LifecycleCommandWarning? +} + +nonisolated enum AgentManagedHookPreparer { + private struct CodexPreparationOptions { + let promptIndex: Int? + let processEnvironment: [String: String] + let configReadProcess: CodexConfigReadProcess + } + + private struct CodexRenderingOptions { + let promptIndex: Int? + let forwardingArgv: [String]? + } + static func prepare( + plan: AgentProfileLaunchPlan, + inheritedCWD: URL, + resources: AgentHookResources?, + processEnvironment: [String: String], + codexConfigReadProcess: CodexConfigReadProcess = CodexConfigReadProcess() + ) async -> AgentManagedHookPreparation { + guard + let capability = AgentRuntimeAdapterRegistry.profileAdapter(for: plan.runtime)?.signalHooks + else { + return AgentManagedHookPreparation( + preparedInvocation: nil, + capability: nil, + launchCWD: inheritedCWD, + forwardingArgv: nil, + warning: nil + ) + } + guard let resources, + resources.bundledCLIPath.hasPrefix("/"), + FileManager.default.isExecutableFile(atPath: resources.bundledCLIPath) + else { + return degraded( + plan: plan, + capability: capability, + launchCWD: inheritedCWD, + message: "The bundled Prowl hook bridge is unavailable." + ) + } + let promptIndex = + plan.surfaceEnvironment[AgentProfileLaunchPlanner.promptCarrierName] == nil + ? nil : plan.invocation.arguments.indices.last + switch capability.runtime { + case .claude: + return await prepareClaude( + plan: plan, + capability: capability, + inheritedCWD: inheritedCWD, + resources: resources, + promptIndex: promptIndex + ) + case .codex: + return await prepareCodex( + plan: plan, + capability: capability, + inheritedCWD: inheritedCWD, + resources: resources, + options: CodexPreparationOptions( + promptIndex: promptIndex, + processEnvironment: processEnvironment, + configReadProcess: codexConfigReadProcess + ) + ) + } + } + + private static func prepareClaude( + plan: AgentProfileLaunchPlan, + capability: AgentSignalHookCapability, + inheritedCWD: URL, + resources: AgentHookResources, + promptIndex: Int? + ) async -> AgentManagedHookPreparation { + var hookCommands: [String: String] = [:] + for event in capability.nativeEvents.keys.sorted() { + hookCommands[event] = [ + AgentInvocation.shellQuote(resources.bundledCLIPath), + "agents", + "_hook", + AgentNativeHookRuntime.claude.rawValue, + event, + ].joined(separator: " ") + } + let outcome = await Task.detached(priority: .userInitiated) { + ClaudeHookSettingsPreparer.prepare( + invocation: plan.invocation, + launchDirectory: inheritedCWD, + promptArgumentIndex: promptIndex, + hookCommands: hookCommands, + readFile: ClaudeSettingsStableReader.read + ) + }.value + return AgentManagedHookPreparation( + preparedInvocation: outcome.prepared, + capability: capability, + launchCWD: inheritedCWD, + forwardingArgv: nil, + warning: outcome.warning + ) + } + + private static func prepareCodex( + plan: AgentProfileLaunchPlan, + capability: AgentSignalHookCapability, + inheritedCWD: URL, + resources: AgentHookResources, + options: CodexPreparationOptions + ) async -> AgentManagedHookPreparation { + let context: CodexLaunchContext + do { + context = try CodexLaunchContext.capture( + invocation: plan.invocation, + inheritedCWD: inheritedCWD, + dedicatedHome: plan.dedicatedHome, + environment: options.processEnvironment, + promptArgumentIndex: options.promptIndex + ) + } catch { + return degraded( + plan: plan, + capability: capability, + launchCWD: inheritedCWD, + message: "The effective Codex launch context could not be resolved." + ) + } + let resolver = CodexEffectiveNotifyResolver( + bundledCLIPath: resources.bundledCLIPath, + query: options.configReadProcess.query + ) + switch await resolver.resolve(context) { + case .absent: + return preparedCodex( + plan: plan, + capability: capability, + context: context, + resources: resources, + options: CodexRenderingOptions( + promptIndex: options.promptIndex, + forwardingArgv: nil + ) + ) + case .present(let argv): + return preparedCodex( + plan: plan, + capability: capability, + context: context, + resources: resources, + options: CodexRenderingOptions( + promptIndex: options.promptIndex, + forwardingArgv: argv + ) + ) + case .degraded(let message): + return degraded( + plan: plan, + capability: capability, + launchCWD: context.effectiveCWD, + message: message + ) + } + } + + private static func preparedCodex( + plan: AgentProfileLaunchPlan, + capability: AgentSignalHookCapability, + context: CodexLaunchContext, + resources: AgentHookResources, + options: CodexRenderingOptions + ) -> AgentManagedHookPreparation { + AgentManagedHookPreparation( + preparedInvocation: CodexManagedNotifyRenderer.prepare( + invocation: plan.invocation, + bundledCLIPath: resources.bundledCLIPath, + promptArgumentIndex: options.promptIndex + ), + capability: capability, + launchCWD: context.effectiveCWD, + forwardingArgv: options.forwardingArgv, + warning: nil + ) + } + + private static func degraded( + plan: AgentProfileLaunchPlan, + capability: AgentSignalHookCapability, + launchCWD: URL, + message: String + ) -> AgentManagedHookPreparation { + AgentManagedHookPreparation( + preparedInvocation: nil, + capability: capability, + launchCWD: launchCWD, + forwardingArgv: nil, + warning: LifecycleCommandWarning( + code: .managedHookDegraded, + runtime: plan.runtime.rawValue, + message: message + ) + ) + } +} diff --git a/supacode/Domain/AgentRuntime/AgentRuntimeAdapter.swift b/supacode/Domain/AgentRuntime/AgentRuntimeAdapter.swift index 4d5aa8ca..cb525022 100644 --- a/supacode/Domain/AgentRuntime/AgentRuntimeAdapter.swift +++ b/supacode/Domain/AgentRuntime/AgentRuntimeAdapter.swift @@ -1,5 +1,17 @@ import Foundation +nonisolated struct AgentSignalHookCapability: Equatable, Sendable { + let runtime: AgentNativeHookRuntime + let nativeEvents: [String: AgentSignalEvent] + let coveredEvents: [AgentSignalEvent] + + init(runtime: AgentNativeHookRuntime, nativeEvents: [String: AgentSignalEvent]) { + self.runtime = runtime + self.nativeEvents = nativeEvents + self.coveredEvents = Array(Set(nativeEvents.values)).sorted { $0.rawValue < $1.rawValue } + } +} + /// Interactive and headless launch behavior for one supported runtime. /// Handoff briefing is authored by the live source agent and does not resume /// native sessions through this adapter boundary (docs-ai 055). @@ -13,6 +25,7 @@ nonisolated protocol AgentRuntimeAdapter: Sendable { /// inverse guarded flags for CLIs whose default is auto-approved. var executionModeOptions: [AgentExecutionMode] { get } var accountIsolation: AgentProfileHomeRelocation? { get } + var signalHooks: AgentSignalHookCapability? { get } var reasoningEffortSuggestions: [String] { get } var modelSuggestions: [String] { get } @@ -27,6 +40,7 @@ nonisolated extension AgentRuntimeAdapter { var supportsReasoningEffort: Bool { false } var executionModeOptions: [AgentExecutionMode] { [] } var accountIsolation: AgentProfileHomeRelocation? { nil } + var signalHooks: AgentSignalHookCapability? { nil } var reasoningEffortSuggestions: [String] { [] } var modelSuggestions: [String] { [] } @@ -205,21 +219,30 @@ nonisolated struct AgentInvocation: Equatable, Sendable { } var terminalInput: String { - terminalInput(replacingFinalArgumentWithEnvironmentVariable: nil) + terminalInput(replacingArgumentsWithEnvironmentVariables: [:]) } - /// Profile plans can carry a prompted start's final argv value through the - /// surface environment instead of typing it into a canonical PTY. Adapters - /// append prompt text as the final argument; the logical invocation retains - /// the real value while only the shell rendering substitutes the reference. - func terminalInput(replacingFinalArgumentWithEnvironmentVariable variable: String?) -> String { + /// Profile plans can carry arbitrary argv values through the surface + /// environment instead of typing them into a canonical PTY. The logical + /// invocation retains the real values while shell rendering substitutes + /// quoted variable references at the declared indexes. + func terminalInput( + replacingArgumentsWithEnvironmentVariables replacements: [Int: String] + ) -> String { var tokens = arguments.map(Self.shellQuote) - if let variable, !tokens.isEmpty { - tokens[tokens.index(before: tokens.endIndex)] = "\"$\(variable)\"" + for (index, variable) in replacements where tokens.indices.contains(index) { + tokens[index] = "\"$\(variable)\"" } return ([Self.shellQuote(executable)] + tokens).joined(separator: " ") } + func terminalInput(replacingFinalArgumentWithEnvironmentVariable variable: String?) -> String { + guard let variable, !arguments.isEmpty else { return terminalInput } + return terminalInput( + replacingArgumentsWithEnvironmentVariables: [arguments.index(before: arguments.endIndex): variable] + ) + } + static func shellQuote(_ argument: String) -> String { "'" + argument.replacing("'", with: "'\"'\"'") + "'" } @@ -309,6 +332,10 @@ nonisolated private struct CodexRuntimeAdapter: AgentRuntimeAdapter { let supportsReasoningEffort = true let executionModeOptions = AgentExecutionMode.allCases let accountIsolation: AgentProfileHomeRelocation? = AgentProfileHomeRelocation(environmentVariable: "CODEX_HOME") + let signalHooks: AgentSignalHookCapability? = AgentSignalHookCapability( + runtime: .codex, + nativeEvents: ["agent-turn-complete": .turnEnded] + ) let reasoningEffortSuggestions = ["low", "medium", "high", "xhigh", "max"] let modelSuggestions = ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] @@ -347,6 +374,18 @@ nonisolated private struct ClaudeCodeRuntimeAdapter: AgentRuntimeAdapter { let accountIsolation: AgentProfileHomeRelocation? = AgentProfileHomeRelocation( environmentVariable: "CLAUDE_CONFIG_DIR" ) + let signalHooks: AgentSignalHookCapability? = AgentSignalHookCapability( + runtime: .claude, + nativeEvents: [ + "Elicitation": .needsInput, + "Notification": .needsInput, + "PermissionRequest": .needsInput, + "SessionEnd": .sessionEnd, + "SessionStart": .sessionStart, + "Stop": .turnEnded, + "StopFailure": .turnEnded, + ] + ) let reasoningEffortSuggestions = ["low", "medium", "high", "xhigh", "max"] let modelSuggestions = [ "claude-fable-5", diff --git a/supacode/Domain/AgentRuntime/CodexConfigReadProcess.swift b/supacode/Domain/AgentRuntime/CodexConfigReadProcess.swift new file mode 100644 index 00000000..65932487 --- /dev/null +++ b/supacode/Domain/AgentRuntime/CodexConfigReadProcess.swift @@ -0,0 +1,238 @@ +import Foundation + +#if canImport(Darwin) + import Darwin +#elseif canImport(Glibc) + import Glibc +#endif + +nonisolated enum CodexConfigReadProcessError: Error, Equatable, Sendable { + case cancelled + case executableUnavailable + case invalidProfile + case outputTooLarge + case processFailed + case timeout +} + +nonisolated struct CodexConfigReadProcess: Sendable { + private final class ProcessBox: @unchecked Sendable { + private let lock = NSLock() + private var process: Process? + + func install(_ process: Process) { + lock.lock() + self.process = process + lock.unlock() + } + + func terminate() { + lock.lock() + let process = self.process + lock.unlock() + if process?.isRunning == true { process?.terminate() } + } + } + + let executableURL: URL + let temporaryBaseDirectory: URL + let timeout: TimeInterval + + init( + executableURL: URL = URL(filePath: "/usr/bin/env", directoryHint: .notDirectory), + temporaryBaseDirectory: URL = FileManager.default.temporaryDirectory, + timeout: TimeInterval = 1 + ) { + self.executableURL = executableURL + self.temporaryBaseDirectory = temporaryBaseDirectory + self.timeout = max(0.05, timeout) + } + + func query(_ query: CodexConfigQuery) async throws -> Data { + let fileManager = FileManager.default + var parserHome: URL? + let effectiveHome: URL + switch query.kind { + case .base: + effectiveHome = query.codexHome + case .profile(let profileURL): + let data = try readStableProfile(profileURL) + let home = try makeTemporaryHome(fileManager: fileManager) + parserHome = home + let configURL = home.appending(path: "config.toml", directoryHint: .notDirectory) + try data.write(to: configURL, options: .atomic) + try fileManager.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: configURL.path(percentEncoded: false) + ) + effectiveHome = home + case .explicitNotify: + let home = try makeTemporaryHome(fileManager: fileManager) + parserHome = home + effectiveHome = home + } + defer { + if let parserHome { try? fileManager.removeItem(at: parserHome) } + } + + let processBox = ProcessBox() + let task = Task.detached(priority: .userInitiated) { + try Self.run( + query: query, + effectiveHome: effectiveHome, + executableURL: executableURL, + timeout: timeout, + processBox: processBox + ) + } + return try await withTaskCancellationHandler { + try await task.value + } onCancel: { + task.cancel() + processBox.terminate() + } + } + + private func makeTemporaryHome(fileManager: FileManager) throws -> URL { + try fileManager.createDirectory( + at: temporaryBaseDirectory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + let home = temporaryBaseDirectory.appending( + path: "prowl-codex-parser-\(UUID().uuidString)", + directoryHint: .isDirectory + ) + try fileManager.createDirectory( + at: home, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700] + ) + try fileManager.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: home.path(percentEncoded: false) + ) + return home + } + + private func readStableProfile(_ url: URL) throws -> Data { + let path = url.path(percentEncoded: false) + let descriptor = open(path, O_RDONLY | O_NOFOLLOW) + guard descriptor >= 0 else { throw CodexConfigReadProcessError.invalidProfile } + defer { close(descriptor) } + var before = stat() + guard fstat(descriptor, &before) == 0, + (before.st_mode & S_IFMT) == S_IFREG, + before.st_uid == geteuid(), + before.st_size >= 0, + before.st_size <= 256 * 1_024 + else { + throw CodexConfigReadProcessError.invalidProfile + } + var data = Data(count: Int(before.st_size)) + var offset = 0 + while offset < data.count { + let count = data.withUnsafeMutableBytes { buffer in + read(descriptor, buffer.baseAddress?.advanced(by: offset), buffer.count - offset) + } + guard count > 0 else { throw CodexConfigReadProcessError.invalidProfile } + offset += count + } + var after = stat() + guard fstat(descriptor, &after) == 0, + before.st_ino == after.st_ino, + before.st_size == after.st_size, + before.st_mtimespec.tv_sec == after.st_mtimespec.tv_sec, + before.st_mtimespec.tv_nsec == after.st_mtimespec.tv_nsec + else { + throw CodexConfigReadProcessError.invalidProfile + } + return data + } + + private static func run( + query: CodexConfigQuery, + effectiveHome: URL, + executableURL: URL, + timeout: TimeInterval, + processBox: ProcessBox + ) throws -> Data { + guard FileManager.default.isExecutableFile(atPath: executableURL.path(percentEncoded: false)) else { + throw CodexConfigReadProcessError.executableUnavailable + } + let process = Process() + process.executableURL = executableURL + var arguments: [String] + if executableURL.path(percentEncoded: false) == "/usr/bin/env" { + arguments = ["codex", "app-server", "--listen", "stdio://"] + } else { + arguments = ["app-server", "--listen", "stdio://"] + } + for override in query.overrides { + arguments += ["-c", override] + } + process.arguments = arguments + process.currentDirectoryURL = query.cwd + var environment = ProcessInfo.processInfo.environment + environment["CODEX_HOME"] = effectiveHome.path(percentEncoded: false) + process.environment = environment + let input = Pipe() + let output = Pipe() + process.standardInput = input + process.standardOutput = output + process.standardError = FileHandle.nullDevice + processBox.install(process) + try process.run() + let request = CodexConfigReadProtocol.requestData( + cwd: query.cwd.path(percentEncoded: false) + ) + try input.fileHandleForWriting.write(contentsOf: request) + try input.fileHandleForWriting.close() + + let descriptor = output.fileHandleForReading.fileDescriptor + let deadline = DispatchTime.now().uptimeNanoseconds + UInt64(timeout * 1_000_000_000) + var transcript = Data() + defer { + stop(process) + try? output.fileHandleForReading.close() + } + + while DispatchTime.now().uptimeNanoseconds < deadline { + if Task.isCancelled { throw CodexConfigReadProcessError.cancelled } + var pollDescriptor = pollfd(fd: descriptor, events: Int16(POLLIN | POLLHUP), revents: 0) + let status = poll(&pollDescriptor, 1, 25) + if status < 0 { + if errno == EINTR { continue } + throw CodexConfigReadProcessError.processFailed + } + if status == 0 { continue } + var buffer = [UInt8](repeating: 0, count: 64 * 1_024) + let count = buffer.withUnsafeMutableBytes { + Darwin.read(descriptor, $0.baseAddress, $0.count) + } + if count > 0 { + transcript.append(contentsOf: buffer.prefix(count)) + guard transcript.count <= 1_024 * 1_024 else { + throw CodexConfigReadProcessError.outputTooLarge + } + if (try? CodexConfigReadProtocol.decodeNotify(from: transcript)) != nil { + return transcript + } + } else if count == 0 { + throw CodexConfigReadProcessError.processFailed + } else if errno != EINTR && errno != EAGAIN { + throw CodexConfigReadProcessError.processFailed + } + } + throw CodexConfigReadProcessError.timeout + } + + private static func stop(_ process: Process) { + if process.isRunning { process.terminate() } + for _ in 0..<100 where process.isRunning { + usleep(1_000) + } + if process.isRunning { kill(process.processIdentifier, SIGKILL) } + process.waitUntilExit() + } +} diff --git a/supacode/Domain/AgentRuntime/CodexEffectiveNotifyResolver.swift b/supacode/Domain/AgentRuntime/CodexEffectiveNotifyResolver.swift new file mode 100644 index 00000000..f8a5ee95 --- /dev/null +++ b/supacode/Domain/AgentRuntime/CodexEffectiveNotifyResolver.swift @@ -0,0 +1,350 @@ +import Foundation + +nonisolated enum CodexLaunchContextError: Error, Equatable, Sendable { + case malformedOption + case repeatedWorkingDirectory + case repeatedProfile + case ignoredUserConfig +} + +nonisolated private struct CodexLaunchOptionScanner { + let arguments: [String] + let promptArgumentIndex: Int? + var cwdValue: String? + var profileName: String? + var configOverrides: [String] = [] + var explicitNotifyOverride: String? + private var index = 0 + + init(arguments: [String], promptArgumentIndex: Int?) { + self.arguments = arguments + self.promptArgumentIndex = promptArgumentIndex + } + + mutating func scan() throws { + while index < arguments.count { + if index == promptArgumentIndex { + index += 1 + continue + } + let argument = arguments[index] + if argument == "--ignore-user-config" { + throw CodexLaunchContextError.ignoredUserConfig + } + if try consumeCWD(argument) { continue } + if try consumeProfile(argument) { continue } + if try consumeConfigOverride(argument) { continue } + index += 1 + } + } + + private mutating func consumeCWD(_ argument: String) throws -> Bool { + if argument == "-C" || argument == "--cd" { + guard cwdValue == nil else { throw CodexLaunchContextError.repeatedWorkingDirectory } + cwdValue = try nextValue() + index += 2 + return true + } + guard argument.hasPrefix("--cd=") || (argument.hasPrefix("-C") && argument != "-C") else { + return false + } + guard cwdValue == nil else { throw CodexLaunchContextError.repeatedWorkingDirectory } + cwdValue = + argument.hasPrefix("--cd=") + ? String(argument.dropFirst("--cd=".count)) + : String(argument.dropFirst(2)) + index += 1 + return true + } + + private mutating func consumeProfile(_ argument: String) throws -> Bool { + if argument == "-p" || argument == "--profile" { + guard profileName == nil else { throw CodexLaunchContextError.repeatedProfile } + profileName = try nextValue() + index += 2 + return true + } + guard argument.hasPrefix("--profile=") || (argument.hasPrefix("-p") && argument != "-p") else { + return false + } + guard profileName == nil else { throw CodexLaunchContextError.repeatedProfile } + profileName = + argument.hasPrefix("--profile=") + ? String(argument.dropFirst("--profile=".count)) + : String(argument.dropFirst(2)) + index += 1 + return true + } + + private mutating func consumeConfigOverride(_ argument: String) throws -> Bool { + if argument == "-c" || argument == "--config" { + appendOverride(try nextValue()) + index += 2 + return true + } + guard argument.hasPrefix("--config=") || (argument.hasPrefix("-c") && argument != "-c") else { + return false + } + let value = + argument.hasPrefix("--config=") + ? String(argument.dropFirst("--config=".count)) + : String(argument.dropFirst(2)) + appendOverride(value) + index += 1 + return true + } + + private func nextValue() throws -> String { + guard arguments.indices.contains(index + 1), index + 1 != promptArgumentIndex else { + throw CodexLaunchContextError.malformedOption + } + return arguments[index + 1] + } + + private mutating func appendOverride(_ value: String) { + let key = value.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false).first? + .trimmingCharacters(in: .whitespacesAndNewlines) + if key == "notify" { + explicitNotifyOverride = value + } else { + configOverrides.append(value) + } + } +} + +nonisolated struct CodexLaunchContext: Equatable, Sendable { + let inheritedCWD: URL + let effectiveCWD: URL + let codexHome: URL + let configOverrides: [String] + let profileName: String? + let explicitNotifyOverride: String? + + init( + inheritedCWD: URL, + effectiveCWD: URL, + codexHome: URL, + configOverrides: [String], + profileName: String?, + explicitNotifyOverride: String? + ) { + self.inheritedCWD = inheritedCWD.standardizedFileURL + self.effectiveCWD = effectiveCWD.standardizedFileURL + self.codexHome = codexHome.standardizedFileURL + self.configOverrides = configOverrides + self.profileName = profileName + self.explicitNotifyOverride = explicitNotifyOverride + } + + var profileURL: URL? { + guard let profileName else { return nil } + return codexHome.appending(path: "\(profileName).config.toml", directoryHint: .notDirectory) + } + + static func capture( + invocation: AgentInvocation, + inheritedCWD: URL, + dedicatedHome: URL? = nil, + environment: [String: String], + promptArgumentIndex: Int? = nil + ) throws -> CodexLaunchContext { + let inheritedCWD = inheritedCWD.standardizedFileURL + var options = CodexLaunchOptionScanner( + arguments: invocation.arguments, + promptArgumentIndex: promptArgumentIndex + ) + try options.scan() + guard options.cwdValue?.contains("\0") != true, options.profileName?.isEmpty != true, + options.profileName?.contains("\0") != true, options.profileName?.contains("/") != true, + options.profileName?.contains("\\") != true + else { + throw CodexLaunchContextError.malformedOption + } + let effectiveCWD: URL + if let cwdValue = options.cwdValue { + guard !cwdValue.isEmpty else { throw CodexLaunchContextError.malformedOption } + effectiveCWD = URL(filePath: cwdValue, relativeTo: inheritedCWD).standardizedFileURL + } else { + effectiveCWD = inheritedCWD + } + let codexHome: URL + if let dedicatedHome { + codexHome = dedicatedHome + } else if let configured = environment["CODEX_HOME"], !configured.isEmpty { + codexHome = URL(filePath: configured, directoryHint: .isDirectory) + } else { + codexHome = FileManager.default.homeDirectoryForCurrentUser + .appending(path: ".codex", directoryHint: .isDirectory) + } + return CodexLaunchContext( + inheritedCWD: inheritedCWD, + effectiveCWD: effectiveCWD, + codexHome: codexHome, + configOverrides: options.configOverrides, + profileName: options.profileName, + explicitNotifyOverride: options.explicitNotifyOverride + ) + } +} + +nonisolated struct CodexConfigQuery: Equatable, Sendable { + nonisolated enum Kind: Equatable, Sendable { + case base + case profile(URL) + case explicitNotify + } + + let kind: Kind + let codexHome: URL + let cwd: URL + let overrides: [String] +} + +nonisolated enum CodexEffectiveNotifyResult: Equatable, Sendable { + case absent + case present([String]) + case degraded(String) +} + +nonisolated enum CodexConfigReadProtocol { + static func requestData(cwd: String) -> Data { + let messages: [[String: Any]] = [ + [ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": [ + "clientInfo": ["name": "prowl", "version": "1"], + "capabilities": ["experimentalApi": true], + ], + ], + [ + "jsonrpc": "2.0", + "method": "initialized", + "params": [:], + ], + [ + "jsonrpc": "2.0", + "id": 2, + "method": "config/read", + "params": ["cwd": cwd, "includeLayers": true], + ], + ] + var result = Data() + for message in messages { + guard let data = try? JSONSerialization.data(withJSONObject: message, options: [.sortedKeys]) else { + continue + } + result.append(data) + result.append(UInt8(ascii: "\n")) + } + return result + } + + static func decodeNotify(from transcript: Data) throws -> [String]? { + for line in transcript.split(separator: UInt8(ascii: "\n")) { + guard let object = try? JSONSerialization.jsonObject(with: Data(line)) as? [String: Any] else { + continue + } + let id = object["id"] as? Int ?? (object["id"] as? NSNumber)?.intValue + guard id == 2 else { continue } + guard let result = object["result"] as? [String: Any], + let config = result["config"] as? [String: Any] + else { + throw CodexConfigReadError.malformedResponse + } + guard let notify = config["notify"], !(notify is NSNull) else { return nil } + guard let values = notify as? [Any] else { throw CodexConfigReadError.malformedResponse } + var argv: [String] = [] + argv.reserveCapacity(values.count) + for value in values { + guard let value = value as? String else { throw CodexConfigReadError.malformedResponse } + argv.append(value) + } + return argv + } + throw CodexConfigReadError.missingResponse + } +} + +nonisolated enum CodexConfigReadError: Error, Equatable, Sendable { + case malformedResponse + case missingResponse +} + +nonisolated struct CodexEffectiveNotifyResolver { + typealias Query = (CodexConfigQuery) async throws -> Data + + private let bundledCLIPath: String? + private let query: Query + + init( + bundledCLIPath: String? = nil, + query: @escaping Query + ) { + self.bundledCLIPath = bundledCLIPath + self.query = query + } + + func resolve(_ context: CodexLaunchContext) async -> CodexEffectiveNotifyResult { + do { + let base = try await resolveQuery( + CodexConfigQuery( + kind: .base, + codexHome: context.codexHome, + cwd: context.effectiveCWD, + overrides: context.configOverrides + ) + ) + var effective = base + if let profileURL = context.profileURL { + let profile = try await resolveQuery( + CodexConfigQuery( + kind: .profile(profileURL), + codexHome: context.codexHome, + cwd: context.effectiveCWD, + overrides: [] + ) + ) + if profile != nil { effective = profile } + } + if let explicit = context.explicitNotifyOverride { + effective = try await resolveQuery( + CodexConfigQuery( + kind: .explicitNotify, + codexHome: context.codexHome, + cwd: context.effectiveCWD, + overrides: [explicit] + ) + ) + } + guard let argv = effective else { return .absent } + guard isValid(argv), !isRecursive(argv) else { + return .degraded("The effective Codex notifier could not be preserved safely.") + } + return .present(argv) + } catch { + return .degraded("The effective Codex notifier could not be resolved.") + } + } + + private func resolveQuery(_ queryValue: CodexConfigQuery) async throws -> [String]? { + try CodexConfigReadProtocol.decodeNotify(from: await query(queryValue)) + } + + private func isValid(_ argv: [String]) -> Bool { + guard !argv.isEmpty, !argv[0].isEmpty, argv.count <= 128 else { return false } + var total = 0 + for argument in argv { + guard !argument.contains("\0") else { return false } + total += argument.utf8.count + guard total <= 64 * 1_024 else { return false } + } + return true + } + + private func isRecursive(_ argv: [String]) -> Bool { + guard let bundledCLIPath, argv.first == bundledCLIPath else { return false } + return argv.dropFirst().starts(with: ["agents", "_hook"]) + } +} diff --git a/supacode/Domain/AgentRuntime/CodexForwardingRecordStore.swift b/supacode/Domain/AgentRuntime/CodexForwardingRecordStore.swift new file mode 100644 index 00000000..eccd77b7 --- /dev/null +++ b/supacode/Domain/AgentRuntime/CodexForwardingRecordStore.swift @@ -0,0 +1,199 @@ +import Foundation + +#if canImport(Darwin) + import Darwin +#elseif canImport(Glibc) + import Glibc +#endif + +nonisolated struct CodexForwardingRecord: Equatable, Sendable { + let locator: URL +} + +@MainActor +final class CodexForwardingRecordStore { + private let baseDirectory: URL + private let sessionDirectory: URL + private let retirementGrace: TimeInterval + private let orphanMaximumAge: TimeInterval + private let now: @MainActor () -> Date + private var retired: [URL: Date] = [:] + + init( + baseDirectory: URL, + retirementGrace: TimeInterval = 2, + orphanMaximumAge: TimeInterval = 24 * 60 * 60, + now: @escaping @MainActor () -> Date = Date.init + ) throws { + self.baseDirectory = baseDirectory.standardizedFileURL + self.retirementGrace = max(0, retirementGrace) + self.orphanMaximumAge = max(0, orphanMaximumAge) + self.now = now + try Self.ensureOwnerOnlyDirectory(self.baseDirectory) + sessionDirectory = self.baseDirectory.appending( + path: "session-\(UUID().uuidString)", + directoryHint: .isDirectory + ) + try Self.ensureOwnerOnlyDirectory(sessionDirectory) + try FileManager.default.setAttributes( + [.modificationDate: now()], + ofItemAtPath: sessionDirectory.path(percentEncoded: false) + ) + } + + func create(argv: [String]) throws -> CodexForwardingRecord { + guard !argv.isEmpty, !argv[0].isEmpty, argv.count <= 128, + argv.allSatisfy({ !$0.contains("\0") }), + let data = try? JSONSerialization.data(withJSONObject: argv), + data.count <= CodexForwardingRecordReader.maximumRecordBytes + else { + throw CodexForwardingRecordError.invalidRecord + } + let locator = sessionDirectory.appending( + path: "record-\(UUID().uuidString).json", + directoryHint: .notDirectory + ) + let temporary = sessionDirectory.appending( + path: ".record-\(UUID().uuidString).tmp", + directoryHint: .notDirectory + ) + let temporaryPath = temporary.path(percentEncoded: false) + let descriptor = Darwin.open(temporaryPath, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0o600) + guard descriptor >= 0 else { throw CodexForwardingRecordError.invalidRecord } + var succeeded = false + defer { + Darwin.close(descriptor) + if !succeeded { + try? FileManager.default.removeItem(at: temporary) + try? FileManager.default.removeItem(at: locator) + } + } + try data.withUnsafeBytes { buffer in + var offset = 0 + while offset < buffer.count { + let count = Darwin.write( + descriptor, + buffer.baseAddress?.advanced(by: offset), + buffer.count - offset + ) + guard count > 0 else { throw CodexForwardingRecordError.invalidRecord } + offset += count + } + } + guard fsync(descriptor) == 0, + rename(temporaryPath, locator.path(percentEncoded: false)) == 0, + chmod(locator.path(percentEncoded: false), 0o600) == 0 + else { + throw CodexForwardingRecordError.invalidRecord + } + succeeded = true + try FileManager.default.setAttributes( + [.modificationDate: now()], + ofItemAtPath: sessionDirectory.path(percentEncoded: false) + ) + return CodexForwardingRecord(locator: locator) + } + + func discardUnexposed(_ record: CodexForwardingRecord) { + retired.removeValue(forKey: record.locator) + try? FileManager.default.removeItem(at: record.locator) + } + + func retire(_ record: CodexForwardingRecord) { + retired[record.locator] = now().addingTimeInterval(retirementGrace) + } + + func cleanupRetired() { + let date = now() + for (locator, eligibleAt) in retired where date >= eligibleAt { + if removeIfExclusivelyLeased(locator) { + retired.removeValue(forKey: locator) + } + } + } + + func sweepOrphans() { + guard + let entries = try? FileManager.default.contentsOfDirectory( + at: baseDirectory, + includingPropertiesForKeys: [.contentModificationDateKey, .isDirectoryKey], + options: [.skipsHiddenFiles] + ) + else { return } + let cutoff = now().addingTimeInterval(-orphanMaximumAge) + for directory in entries where directory.lastPathComponent.hasPrefix("session-") { + guard directory.standardizedFileURL != sessionDirectory.standardizedFileURL, + validOwnerOnlyDirectory(directory), + let values = try? directory.resourceValues(forKeys: [.contentModificationDateKey, .isDirectoryKey]), + values.isDirectory == true, + let modified = values.contentModificationDate, + modified <= cutoff, + canExclusivelyLeaseEveryRecord(in: directory) + else { continue } + try? FileManager.default.removeItem(at: directory) + } + } + + private func canExclusivelyLeaseEveryRecord(in directory: URL) -> Bool { + guard + let files = try? FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + else { return false } + var descriptors: [Int32] = [] + defer { + for descriptor in descriptors { + flock(descriptor, LOCK_UN) + Darwin.close(descriptor) + } + } + for file in files { + let descriptor = Darwin.open(file.path(percentEncoded: false), O_RDONLY | O_NOFOLLOW) + guard descriptor >= 0, flock(descriptor, LOCK_EX | LOCK_NB) == 0 else { + if descriptor >= 0 { Darwin.close(descriptor) } + return false + } + descriptors.append(descriptor) + } + return true + } + + private func removeIfExclusivelyLeased(_ locator: URL) -> Bool { + let path = locator.path(percentEncoded: false) + let descriptor = Darwin.open(path, O_RDONLY | O_NOFOLLOW) + if descriptor < 0 { return unlink(path) == 0 || errno == ENOENT } + defer { Darwin.close(descriptor) } + guard flock(descriptor, LOCK_EX | LOCK_NB) == 0 else { return false } + defer { flock(descriptor, LOCK_UN) } + return unlink(path) == 0 || errno == ENOENT + } + + private static func ensureOwnerOnlyDirectory(_ directory: URL) throws { + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: directory.path(percentEncoded: false) + ) + guard validOwnerOnlyDirectory(directory) else { + throw CodexForwardingRecordError.invalidRecord + } + } + + private static func validOwnerOnlyDirectory(_ directory: URL) -> Bool { + var metadata = stat() + guard lstat(directory.path(percentEncoded: false), &metadata) == 0 else { return false } + return (metadata.st_mode & S_IFMT) == S_IFDIR + && metadata.st_uid == geteuid() + && metadata.st_mode & 0o777 == 0o700 + } + + private func validOwnerOnlyDirectory(_ directory: URL) -> Bool { + Self.validOwnerOnlyDirectory(directory) + } +} diff --git a/supacode/Domain/AgentRuntime/ManagedHookRendering.swift b/supacode/Domain/AgentRuntime/ManagedHookRendering.swift new file mode 100644 index 00000000..f9f845e1 --- /dev/null +++ b/supacode/Domain/AgentRuntime/ManagedHookRendering.swift @@ -0,0 +1,328 @@ +import Foundation + +#if canImport(Darwin) + import Darwin +#elseif canImport(Glibc) + import Glibc +#endif + +nonisolated enum ClaudeSettingsReadResult: Equatable, Sendable { + case stable(Data) + case changed + case oversized + case unreadable +} + +nonisolated enum ClaudeSettingsStableReader { + static func read(_ url: URL, maximumBytes: Int) -> ClaudeSettingsReadResult { + let descriptor = Darwin.open(url.path(percentEncoded: false), O_RDONLY | O_NOFOLLOW) + guard descriptor >= 0 else { return .unreadable } + defer { Darwin.close(descriptor) } + var before = stat() + guard fstat(descriptor, &before) == 0, + (before.st_mode & S_IFMT) == S_IFREG, + before.st_uid == geteuid(), + before.st_size >= 0 + else { return .unreadable } + guard before.st_size <= maximumBytes else { return .oversized } + var data = Data(count: Int(before.st_size)) + var offset = 0 + while offset < data.count { + let count = data.withUnsafeMutableBytes { buffer in + Darwin.read(descriptor, buffer.baseAddress?.advanced(by: offset), buffer.count - offset) + } + guard count > 0 else { return .unreadable } + offset += count + } + var after = stat() + guard fstat(descriptor, &after) == 0, + before.st_ino == after.st_ino, + before.st_size == after.st_size, + before.st_mtimespec.tv_sec == after.st_mtimespec.tv_sec, + before.st_mtimespec.tv_nsec == after.st_mtimespec.tv_nsec + else { return .changed } + return .stable(data) + } +} + +nonisolated struct AgentHookPreparedInvocation: Equatable, Sendable { + let invocation: AgentInvocation + /// Actual argv values carried through owner-controlled surface environment. + /// Keys are indexes in `invocation.arguments`. + let argumentValues: [Int: String] +} + +nonisolated struct AgentHookPreparationOutcome: Equatable, Sendable { + let originalInvocation: AgentInvocation + let prepared: AgentHookPreparedInvocation? + let warning: LifecycleCommandWarning? +} + +nonisolated enum ClaudeHookSettingsPreparer { + static let maximumSettingsBytes = 256 * 1_024 + + static func prepare( + invocation: AgentInvocation, + launchDirectory: URL, + promptArgumentIndex: Int? = nil, + hookCommands: [String: String], + readFile: (URL, Int) -> ClaudeSettingsReadResult + ) -> AgentHookPreparationOutcome { + let source: SettingsSource? + switch finalSettingsSource( + in: invocation.arguments, + promptArgumentIndex: promptArgumentIndex + ) { + case .none: + source = nil + case .source(let value): + source = value + case .malformed: + return degraded(invocation) + } + let baseObject: [String: Any] + if let source { + switch settingsObject( + source.value, + launchDirectory: launchDirectory, + readFile: readFile + ) { + case .success(let object): + baseObject = object + case .failure: + return degraded(invocation) + } + } else { + baseObject = [:] + } + + guard let merged = mergedObject(baseObject, hookCommands: hookCommands), + JSONSerialization.isValidJSONObject(merged), + let data = try? JSONSerialization.data( + withJSONObject: merged, + options: [.sortedKeys, .withoutEscapingSlashes] + ), + data.count <= maximumSettingsBytes, + let json = String(data: data, encoding: .utf8) + else { + return degraded(invocation) + } + + var arguments = invocation.arguments + let carrierIndex: Int + if let source { + switch source.form { + case .separate: + carrierIndex = source.argumentIndex + case .joined: + arguments[source.argumentIndex] = "--settings" + arguments.insert("{}", at: source.argumentIndex + 1) + carrierIndex = source.argumentIndex + 1 + } + } else { + let insertionIndex = + resolvedPromptIndex( + promptArgumentIndex, + arguments: arguments, + executable: invocation.executable + ) ?? arguments.endIndex + arguments.insert(contentsOf: ["--settings", "{}"], at: insertionIndex) + carrierIndex = insertionIndex + 1 + } + + return AgentHookPreparationOutcome( + originalInvocation: invocation, + prepared: AgentHookPreparedInvocation( + invocation: AgentInvocation(executable: invocation.executable, arguments: arguments), + argumentValues: [carrierIndex: json] + ), + warning: nil + ) + } + + private enum SettingsForm { + case separate + case joined + } + + private enum SettingsScan { + case none + case source(SettingsSource) + case malformed + } + + private struct SettingsSource { + let form: SettingsForm + let argumentIndex: Int + let value: String + } + + private enum SettingsObjectResult { + case success([String: Any]) + case failure + } + + private static func finalSettingsSource( + in arguments: [String], + promptArgumentIndex: Int? + ) -> SettingsScan { + var result: SettingsSource? + var index = 0 + while index < arguments.count { + if index == promptArgumentIndex { + index += 1 + continue + } + let argument = arguments[index] + if argument == "--settings" { + guard arguments.indices.contains(index + 1), index + 1 != promptArgumentIndex else { + return .malformed + } + result = SettingsSource(form: .separate, argumentIndex: index + 1, value: arguments[index + 1]) + index += 2 + continue + } + if argument.hasPrefix("--settings=") { + result = SettingsSource( + form: .joined, + argumentIndex: index, + value: String(argument.dropFirst("--settings=".count)) + ) + } + index += 1 + } + return result.map(SettingsScan.source) ?? .none + } + + private static func settingsObject( + _ source: String, + launchDirectory: URL, + readFile: (URL, Int) -> ClaudeSettingsReadResult + ) -> SettingsObjectResult { + let trimmed = source.trimmingCharacters(in: .whitespacesAndNewlines) + let data: Data + if trimmed.hasPrefix("{") { + data = Data(trimmed.utf8) + guard data.count <= maximumSettingsBytes else { return .failure } + } else { + let sourceURL = URL(filePath: source, relativeTo: launchDirectory).standardizedFileURL + switch readFile(sourceURL, maximumSettingsBytes) { + case .stable(let value): data = value + case .changed, .oversized, .unreadable: return .failure + } + } + guard + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + return .failure + } + return .success(object) + } + + private static func mergedObject( + _ object: [String: Any], + hookCommands: [String: String] + ) -> [String: Any]? { + var object = object + var hooks: [String: Any] + if let existing = object["hooks"] { + guard let existing = existing as? [String: Any] else { return nil } + hooks = existing + } else { + hooks = [:] + } + + for event in hookCommands.keys.sorted() { + guard let command = hookCommands[event] else { continue } + var matchers: [[String: Any]] + if let existing = hooks[event] { + guard let existing = existing as? [[String: Any]] else { return nil } + matchers = existing + } else { + matchers = [] + } + if !containsCommand(command, in: matchers) { + matchers.append([ + "hooks": [ + [ + "command": command, + "type": "command", + ] + ] + ]) + } + hooks[event] = matchers + } + object["hooks"] = hooks + return object + } + + private static func containsCommand(_ command: String, in matchers: [[String: Any]]) -> Bool { + matchers.contains { matcher in + guard let handlers = matcher["hooks"] as? [[String: Any]] else { return false } + return handlers.contains { + ($0["type"] as? String) == "command" && ($0["command"] as? String) == command + } + } + } + + private static func resolvedPromptIndex( + _ explicit: Int?, + arguments: [String], + executable: String + ) -> Int? { + if let explicit, arguments.indices.contains(explicit) { return explicit } + if executable == "claude", arguments.first == "-p", arguments.count >= 2 { + return arguments.index(before: arguments.endIndex) + } + return nil + } + + private static func degraded(_ invocation: AgentInvocation) -> AgentHookPreparationOutcome { + AgentHookPreparationOutcome( + originalInvocation: invocation, + prepared: nil, + warning: LifecycleCommandWarning( + code: .managedHookDegraded, + runtime: AgentNativeHookRuntime.claude.rawValue, + message: "Managed Claude hooks could not be prepared; launching with the original settings." + ) + ) + } +} + +nonisolated enum CodexManagedNotifyRenderer { + static func prepare( + invocation: AgentInvocation, + bundledCLIPath: String, + promptArgumentIndex: Int? = nil + ) -> AgentHookPreparedInvocation { + let notifyArgv = [ + bundledCLIPath, + "agents", + "_hook", + AgentNativeHookRuntime.codex.rawValue, + "agent-turn-complete", + ] + let notifyData = try? JSONSerialization.data( + withJSONObject: notifyArgv, + options: [.withoutEscapingSlashes] + ) + let notifyJSON = notifyData.flatMap { String(data: $0, encoding: .utf8) } ?? "[]" + var arguments = invocation.arguments + let inferredPromptIndex: Int? = + if let promptArgumentIndex, arguments.indices.contains(promptArgumentIndex) { + promptArgumentIndex + } else if arguments.first == "exec", arguments.count >= 2 { + arguments.index(before: arguments.endIndex) + } else { + nil + } + let insertionIndex = inferredPromptIndex ?? arguments.endIndex + arguments.insert(contentsOf: ["-c", "notify=[]"], at: insertionIndex) + return AgentHookPreparedInvocation( + invocation: AgentInvocation(executable: invocation.executable, arguments: arguments), + argumentValues: [insertionIndex + 1: "notify=\(notifyJSON)"] + ) + } +} diff --git a/supacode/Features/App/Reducer/AppFeature+TerminalEvents.swift b/supacode/Features/App/Reducer/AppFeature+TerminalEvents.swift index c2fac88a..d9f03a57 100644 --- a/supacode/Features/App/Reducer/AppFeature+TerminalEvents.swift +++ b/supacode/Features/App/Reducer/AppFeature+TerminalEvents.swift @@ -164,6 +164,13 @@ extension AppFeature { $userRepositorySettings.withLock { $0.lastLaunchedAgentProfileID = profileID } return .none + case .agentProfileLaunchWarning(_, let profileName, let message): + return .send( + .repositories( + .showToast(.warning("“\(profileName)” launched without managed signals. \(message)")) + ) + ) + case .agentProfileLaunchFailed(_, let profileName): return .send(.repositories(.showToast(.warning("Couldn't launch “\(profileName)”")))) diff --git a/supacode/Features/Terminal/BusinessLogic/AgentObservationStore.swift b/supacode/Features/Terminal/BusinessLogic/AgentObservationStore.swift index 2b820740..11df5559 100644 --- a/supacode/Features/Terminal/BusinessLogic/AgentObservationStore.swift +++ b/supacode/Features/Terminal/BusinessLogic/AgentObservationStore.swift @@ -1,12 +1,37 @@ import Foundation private struct AgentSignalChannelRecord { + var state: AgentSignalChannelState var confidence: AgentSignal.Confidence var events: [AgentSignalEvent] var lastSeenAt: Date var sessionID: String? } +private struct PendingManagedHookSignal { + let input: AgentNativeHookInput + let callerAncestry: [AgentProcessGeneration] +} + +private struct ManagedHookRegistrationRecord { + let launch: AgentHookLaunchRegistration + var evidenceEpoch: UUID + var processGeneration: AgentProcessGeneration? + var verified = false + var pendingSignals: [PendingManagedHookSignal] = [] +} + +enum ManagedHookRecordResult: Equatable, Sendable { + case rejected + case pending + case accepted(signal: AgentSignal, evidenceEpoch: UUID) +} + +struct AgentEvidenceEpochUpdate: Equatable, Sendable { + var activatedSignals: [AgentSignal] = [] + var revokedForwardingRecords: [CodexForwardingRecord] = [] +} + struct AgentCurrentSignalEvidence: Sendable { let activeTerminal: AgentSignal? let latest: AgentSignal? @@ -30,6 +55,7 @@ final class AgentObservationStore { var awaitingFirstProcessGeneration = false var firstProcessGenerationStartedBefore: Date? var channels: [String: AgentSignalChannelRecord] = [:] + var managedHook: ManagedHookRegistrationRecord? var revision: UInt64 = 0 var subscribers: [UUID: AgentObservationStream.Continuation] = [:] @@ -145,6 +171,7 @@ final class AgentObservationStore { var channel = record.channels[source] ?? AgentSignalChannelRecord( + state: .observed, confidence: signal.confidence, events: [], lastSeenAt: signal.timestamp, @@ -199,6 +226,112 @@ final class AgentObservationStore { ) } + func registerManagedHook( + _ registration: AgentHookLaunchRegistration, + surfaceID: UUID + ) -> UUID { + let epoch = beginDispatchEpoch(surfaceID: surfaceID) + var record = records[surfaceID] ?? SurfaceRecord() + record.managedHook = ManagedHookRegistrationRecord( + launch: registration, + evidenceEpoch: epoch + ) + records[surfaceID] = record + return epoch + } + + func hasManagedHook(surfaceID: UUID) -> Bool { + records[surfaceID]?.managedHook != nil + } + + func revokeManagedHook(surfaceID: UUID) -> CodexForwardingRecord? { + guard var record = records[surfaceID], let managed = record.managedHook else { return nil } + record.managedHook = nil + record.evidenceEpoch = UUID() + record.processGeneration = nil + record.sessionID = nil + record.awaitingFirstProcessGeneration = false + record.firstProcessGenerationStartedBefore = nil + record.channels.removeAll() + record.latestCurrentSignal = nil + record.activeTerminalSignal = nil + if record.latestSignal != nil { record.latestSignalBinding = .stale } + records[surfaceID] = record + return managed.launch.forwardingRecord + } + + func recordManagedHook( + _ input: AgentNativeHookInput, + callerAncestry: [AgentProcessGeneration], + surfaceID: UUID + ) -> ManagedHookRecordResult { + guard input.validationErrorMessage == nil, + var record = records[surfaceID], + var managed = record.managedHook, + managed.launch.token == input.token, + managed.launch.runtime == input.runtime, + managed.launch.nativeEvents[input.signal.nativeEvent] == input.signal.event, + normalizedPath(managed.launch.launchCWD) == normalizedPath(input.signal.cwd) + else { + return .rejected + } + guard let generation = managed.processGeneration ?? record.processGeneration else { + guard record.awaitingFirstProcessGeneration else { return .rejected } + if managed.pendingSignals.count < 8 { + managed.pendingSignals.append( + PendingManagedHookSignal(input: input, callerAncestry: callerAncestry) + ) + record.managedHook = managed + records[surfaceID] = record + } + return .pending + } + guard callerAncestry.contains(generation), managed.processGeneration == generation else { + return .rejected + } + + if input.runtime == .claude, input.signal.event == .sessionStart, + let currentSession = record.sessionID, + currentSession != input.signal.sessionID + { + record.evidenceEpoch = UUID() + record.channels.removeAll() + record.latestCurrentSignal = nil + record.activeTerminalSignal = nil + if record.latestSignal != nil { record.latestSignalBinding = .stale } + managed.evidenceEpoch = record.evidenceEpoch + managed.verified = false + } else if let currentSession = record.sessionID, + currentSession != input.signal.sessionID + { + return .rejected + } + record.sessionID = input.signal.sessionID + managed.verified = true + let runtime = AgentProfileRuntime(rawValue: input.runtime.rawValue) ?? .claude + let signal = AgentSignal( + kind: signalKind(input.signal.event), + source: .hook(runtime: runtime, event: input.signal.nativeEvent), + confidence: .exact, + timestamp: now(), + sessionID: input.signal.sessionID, + detail: input.signal.detail, + claimedOrigin: nil + ) + let source = signal.source.payloadName + record.channels[source] = AgentSignalChannelRecord( + state: .verifiedLive, + confidence: .exact, + events: managed.launch.coveredEvents, + lastSeenAt: signal.timestamp, + sessionID: signal.sessionID + ) + record.managedHook = managed + records[surfaceID] = record + publishSignal(signal, binding: .current, surfaceID: surfaceID) + return .accepted(signal: signal, evidenceEpoch: managed.evidenceEpoch) + } + func beginDispatchEpoch(surfaceID: UUID) -> UUID { var record = records[surfaceID] ?? SurfaceRecord() record.evidenceEpoch = UUID() @@ -221,11 +354,12 @@ final class AgentObservationStore { records[surfaceID]?.evidenceEpoch } + @discardableResult func updateEvidenceEpoch( surfaceID: UUID, processGeneration: AgentProcessGeneration?, sessionID: String? - ) { + ) -> AgentEvidenceEpochUpdate { var record = records[surfaceID] ?? SurfaceRecord() let firstGenerationIsTimely = processGeneration.map { @@ -248,6 +382,7 @@ final class AgentObservationStore { && record.sessionID != nil && sessionID != nil && record.sessionID != sessionID + var update = AgentEvidenceEpochUpdate() if processChanged || sessionChanged { record.evidenceEpoch = UUID() record.channels.removeAll() @@ -255,14 +390,45 @@ final class AgentObservationStore { record.activeTerminalSignal = nil if record.latestSignal != nil { record.latestSignalBinding = .stale } record.sessionlessSignalsAllowed = processChanged + if processChanged, let managed = record.managedHook { + if let forwardingRecord = managed.launch.forwardingRecord { + update.revokedForwardingRecords.append(forwardingRecord) + } + record.managedHook = nil + } else if sessionChanged, var managed = record.managedHook { + managed.evidenceEpoch = record.evidenceEpoch + managed.verified = false + managed.pendingSignals.removeAll() + record.managedHook = managed + } } if attachesFirstLaunchGeneration || rejectsLateFirstGeneration { record.awaitingFirstProcessGeneration = false record.firstProcessGenerationStartedBefore = nil } + if attachesFirstLaunchGeneration, var managed = record.managedHook { + managed.processGeneration = processGeneration + let pending = managed.pendingSignals + managed.pendingSignals.removeAll() + record.managedHook = managed + record.processGeneration = processGeneration + record.sessionID = sessionID + records[surfaceID] = record + for pendingSignal in pending { + if case .accepted(let signal, _) = recordManagedHook( + pendingSignal.input, + callerAncestry: pendingSignal.callerAncestry, + surfaceID: surfaceID + ) { + update.activatedSignals.append(signal) + } + } + return update + } record.processGeneration = processGeneration record.sessionID = sessionID records[surfaceID] = record + return update } func bindingForSignal( @@ -292,7 +458,7 @@ final class AgentObservationStore { .map { source, channel in AgentSignalChannelPayload( source: source, - state: .observed, + state: channel.state, confidence: channel.confidence.rawValue, events: channel.events.sorted { $0.rawValue < $1.rawValue }, lastSeenAt: formatter.string(from: channel.lastSeenAt), @@ -313,6 +479,27 @@ final class AgentObservationStore { ) } + private func signalKind(_ event: AgentSignalEvent) -> AgentSignal.Kind { + switch event { + case .turnEnded: .turnEnded + case .needsInput: .needsInput + case .sessionStart: .sessionStart + case .sessionEnd: .sessionEnd + case .progress: .progress(nil) + } + } + + private func normalizedPath(_ url: URL) -> String { + normalizedPath(url.path(percentEncoded: false)) + } + + private func normalizedPath(_ path: String) -> String { + let value = URL(filePath: path, directoryHint: .isDirectory).standardizedFileURL.path( + percentEncoded: false + ) + return value.count > 1 && value.hasSuffix("/") ? String(value.dropLast()) : value + } + private func publish(_ event: ObservedAgentState, surfaceID: UUID) { guard let subscribers = records[surfaceID]?.subscribers else { return } for (subscriberID, continuation) in subscribers { diff --git a/supacode/Features/Terminal/BusinessLogic/TerminalEventCoalescer.swift b/supacode/Features/Terminal/BusinessLogic/TerminalEventCoalescer.swift index c265303f..6de13a74 100644 --- a/supacode/Features/Terminal/BusinessLogic/TerminalEventCoalescer.swift +++ b/supacode/Features/Terminal/BusinessLogic/TerminalEventCoalescer.swift @@ -37,7 +37,7 @@ struct TerminalEventCoalescer { case .customCommandSucceeded, .notificationReceived, .notificationIndicatorChanged, .tabCreated, .tabClosed, .agentEntryRemoved, .commandPaletteToggleRequested, .setupScriptConsumed, .layoutRestored, .layoutRestoreFailed, - .agentProfileLaunched, .agentProfileLaunchFailed: + .agentProfileLaunched, .agentProfileLaunchWarning, .agentProfileLaunchFailed: return nil } } diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index e9000265..65488030 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -14,6 +14,10 @@ final class WorktreeTerminalManager { private let targetHandleRegistry = TerminalTargetHandleRegistry() @ObservationIgnored private let agentObservationStore: AgentObservationStore @ObservationIgnored private let agentDispatchStore: AgentDispatchStore + @ObservationIgnored private let codexConfigReadProcess: CodexConfigReadProcess + @ObservationIgnored private let hookResourcesProvider: @MainActor () -> AgentHookResources? + @ObservationIgnored private let forwardingRecordBaseDirectory: URL + @ObservationIgnored private var codexForwardingRecordStore: CodexForwardingRecordStore? private var states: [Worktree.ID: WorktreeTerminalState] = [:] private var notificationsEnabled = true private var commandFinishedNotificationEnabled = true @@ -38,13 +42,25 @@ final class WorktreeTerminalManager { preferredFontSize: Float32? = nil, layoutPersistence: TerminalLayoutPersistenceClient = .liveValue, agentObservationBufferCapacity: Int = 64, - agentDispatchStore: AgentDispatchStore = AgentDispatchStore() + agentDispatchStore: AgentDispatchStore = AgentDispatchStore(), + codexConfigReadProcess: CodexConfigReadProcess = CodexConfigReadProcess(), + hookResourcesProvider: @escaping @MainActor () -> AgentHookResources? = { + guard let url = SupacodePaths.bundledCLIURL else { return nil } + return AgentHookResources( + bundledCLIPath: url.path(percentEncoded: false), + socketPath: ProwlSocket.defaultPath + ) + }, + forwardingRecordBaseDirectory: URL = SupacodePaths.agentHookForwardingDirectory ) { self.runtime = runtime self.layoutPersistence = layoutPersistence self.preferredFontSize = preferredFontSize self.agentObservationStore = AgentObservationStore(bufferCapacity: agentObservationBufferCapacity) self.agentDispatchStore = agentDispatchStore + self.codexConfigReadProcess = codexConfigReadProcess + self.hookResourcesProvider = hookResourcesProvider + self.forwardingRecordBaseDirectory = forwardingRecordBaseDirectory baselineFontSize = runtime.defaultFontSize() } @@ -75,14 +91,151 @@ final class WorktreeTerminalManager { state(for: worktree).launchAgentProfile(request) } + func prepareAgentProfileLaunch( + _ request: AgentProfileLaunchRequest, + in worktree: Worktree + ) async -> Result { + let terminalState = state(for: worktree) + guard terminalState.provisionAgentProfileHome(for: request.plan) else { + return .failure(.homeProvisioningFailed) + } + var latestContext: FrozenAgentProfileLaunchContext? + for attempt in 0..<2 { + let context: FrozenAgentProfileLaunchContext + switch terminalState.freezeAgentProfileLaunchContext(request) { + case .success(let value): context = value + case .failure(let error): return .failure(error) + } + latestContext = context + let resources = hookResourcesProvider() + let preparation = await AgentManagedHookPreparer.prepare( + plan: context.request.plan, + inheritedCWD: context.inheritedCWD, + resources: resources, + processEnvironment: ProcessInfo.processInfo.environment, + codexConfigReadProcess: codexConfigReadProcess + ) + guard terminalState.isAgentProfileLaunchContextValid(context) else { + if attempt == 0 { continue } + let warning = LifecycleCommandWarning( + code: .managedHookDegraded, + runtime: request.plan.runtime.rawValue, + message: "The launch target changed during managed hook preparation." + ) + return .success(PreparedAgentProfileLaunch(context: context, warnings: [warning])) + } + guard let capability = preparation.capability, + let preparedInvocation = preparation.preparedInvocation, + let resources + else { + return .success( + PreparedAgentProfileLaunch( + context: context, + warnings: preparation.warning.map { [$0] } ?? [] + ) + ) + } + + var forwardingRecord: CodexForwardingRecord? + if let argv = preparation.forwardingArgv { + guard let store = forwardingRecordStore(), + let record = try? store.create(argv: argv) + else { + let warning = LifecycleCommandWarning( + code: .managedHookDegraded, + runtime: request.plan.runtime.rawValue, + message: "The existing Codex notifier could not be preserved safely." + ) + return .success(PreparedAgentProfileLaunch(context: context, warnings: [warning])) + } + forwardingRecord = record + } + let executionPlan = context.request.plan.applyingManagedHook( + preparedInvocation, + resources: resources, + launchCWD: preparation.launchCWD, + token: UUID().uuidString, + nativeEvents: capability.nativeEvents, + coveredEvents: capability.coveredEvents, + forwardingRecord: forwardingRecord + ) + let preparedContext = FrozenAgentProfileLaunchContext( + request: AgentProfileLaunchRequest( + plan: executionPlan, + placement: context.request.placement, + workingDirectoryOverride: context.request.workingDirectoryOverride, + inheritanceAnchor: context.request.inheritanceAnchor, + title: context.request.title + ), + inheritedCWD: context.inheritedCWD, + anchorSurfaceID: context.anchorSurfaceID + ) + return .success( + PreparedAgentProfileLaunch( + context: preparedContext, + warnings: preparation.warning.map { [$0] } ?? [] + ) + ) + } + guard let latestContext else { return .failure(.tabCreationFailed) } + return .success(PreparedAgentProfileLaunch(context: latestContext, warnings: [])) + } + + func launchPreparedAgentProfile( + _ preparation: PreparedAgentProfileLaunch, + in worktree: Worktree + ) -> Result { + let result = state(for: worktree).launchAgentProfile(preparation.context.request) + if case .failure = result, + let record = preparation.context.request.plan.hookRegistration?.forwardingRecord + { + codexForwardingRecordStore?.discardUnexposed(record) + } + return result + } + + private func forwardingRecordStore() -> CodexForwardingRecordStore? { + if let codexForwardingRecordStore { return codexForwardingRecordStore } + guard + let store = try? CodexForwardingRecordStore(baseDirectory: forwardingRecordBaseDirectory) + else { return nil } + store.sweepOrphans() + codexForwardingRecordStore = store + return store + } + /// The launch outcome is reported as an event either way: the reducer /// records the per-repo launch memory only on success and surfaces the /// failure as a toast (docs-ai 053/005). private func launchAgentProfile(_ plan: AgentProfileLaunchPlan, in worktree: Worktree) { - if state(for: worktree).launchAgentProfile(plan) != nil { - emit(.agentProfileLaunched(worktreeID: worktree.id, profileID: plan.profileID)) - } else { - emit(.agentProfileLaunchFailed(worktreeID: worktree.id, profileName: plan.profileName)) + Task { @MainActor [weak self] in + guard let self else { return } + let request = AgentProfileLaunchRequest( + plan: plan, + placement: plan.placement == .split + ? .split(anchor: nil, direction: plan.splitDirection, background: false) + : .tab(background: false) + ) + switch await prepareAgentProfileLaunch(request, in: worktree) { + case .failure: + emit(.agentProfileLaunchFailed(worktreeID: worktree.id, profileName: plan.profileName)) + case .success(let preparation): + switch launchPreparedAgentProfile(preparation, in: worktree) { + case .success: + for warning in preparation.warnings { + emit( + .agentProfileLaunchWarning( + worktreeID: worktree.id, + profileName: plan.profileName, + message: warning.message + ) + ) + } + emit(.agentProfileLaunched(worktreeID: worktree.id, profileID: plan.profileID)) + case .failure: + emit(.agentProfileLaunchFailed(worktreeID: worktree.id, profileName: plan.profileName)) + } + } } } @@ -246,10 +399,13 @@ final class WorktreeTerminalManager { func recordAgentSignal(_ signal: AgentSignal, caller: CallerPane) -> Bool { guard containsSurface(caller.surfaceID) else { return false } let evidence = currentAgentEvidence(surfaceID: caller.surfaceID) - agentObservationStore.updateEvidenceEpoch( - surfaceID: caller.surfaceID, - processGeneration: evidence.generation, - sessionID: evidence.sessionID + handleEvidenceEpochUpdate( + agentObservationStore.updateEvidenceEpoch( + surfaceID: caller.surfaceID, + processGeneration: evidence.generation, + sessionID: evidence.sessionID + ), + surfaceID: caller.surfaceID ) let generationMatches = evidence.generation.map(caller.processAncestry.contains) ?? false let binding = agentObservationStore.bindingForSignal( @@ -270,6 +426,55 @@ final class WorktreeTerminalManager { return true } + @discardableResult + func recordAgentNativeHook(_ input: AgentNativeHookInput, caller: CallerPane) -> Bool { + guard containsSurface(caller.surfaceID) else { return false } + let evidence = currentAgentEvidence(surfaceID: caller.surfaceID) + handleEvidenceEpochUpdate( + agentObservationStore.updateEvidenceEpoch( + surfaceID: caller.surfaceID, + processGeneration: evidence.generation, + sessionID: evidence.sessionID + ), + surfaceID: caller.surfaceID + ) + switch agentObservationStore.recordManagedHook( + input, + callerAncestry: caller.processAncestry, + surfaceID: caller.surfaceID + ) { + case .rejected: + return false + case .pending: + return true + case .accepted(let signal, let evidenceEpoch): + noteDispatchEvidence(signal, surfaceID: caller.surfaceID, evidenceEpoch: evidenceEpoch) + return true + } + } + + private func handleEvidenceEpochUpdate( + _ update: AgentEvidenceEpochUpdate, + surfaceID: UUID + ) { + for signal in update.activatedSignals { + guard let epoch = agentObservationStore.currentEvidenceEpoch(surfaceID: surfaceID) else { continue } + noteDispatchEvidence(signal, surfaceID: surfaceID, evidenceEpoch: epoch) + } + for record in update.revokedForwardingRecords { + retireForwardingRecord(record) + } + } + + private func retireForwardingRecord(_ record: CodexForwardingRecord) { + guard let store = codexForwardingRecordStore else { return } + store.retire(record) + Task { @MainActor [weak self] in + try? await ContinuousClock().sleep(for: .seconds(3)) + self?.codexForwardingRecordStore?.cleanupRetired() + } + } + private func noteDispatchEvidence( _ signal: AgentSignal, surfaceID: UUID, @@ -308,6 +513,10 @@ final class WorktreeTerminalManager { agentObservationStore.snapshot(surfaceID: surfaceID) } + func agentEvidenceEpochForTesting(surfaceID: UUID) -> UUID? { + agentObservationStore.currentEvidenceEpoch(surfaceID: surfaceID) + } + func isSurfaceLive(_ surfaceID: UUID) -> Bool { containsSurface(surfaceID) } @@ -317,10 +526,13 @@ final class WorktreeTerminalManager { includeDiagnosticLast: Bool = true ) -> AgentSignalsPayload { let evidence = currentAgentEvidence(surfaceID: surfaceID) - agentObservationStore.updateEvidenceEpoch( - surfaceID: surfaceID, - processGeneration: evidence.generation, - sessionID: evidence.sessionID + handleEvidenceEpochUpdate( + agentObservationStore.updateEvidenceEpoch( + surfaceID: surfaceID, + processGeneration: evidence.generation, + sessionID: evidence.sessionID + ), + surfaceID: surfaceID ) return agentObservationStore.signalsPayload( surfaceID: surfaceID, @@ -390,7 +602,9 @@ final class WorktreeTerminalManager { binding: AgentDispatchBinding( surfaceID: surfaceID, target: target, - evidenceEpoch: agentObservationStore.beginDispatchEpoch(surfaceID: surfaceID) + evidenceEpoch: agentObservationStore.hasManagedHook(surfaceID: surfaceID) + ? (agentObservationStore.currentEvidenceEpoch(surfaceID: surfaceID) ?? UUID()) + : agentObservationStore.beginDispatchEpoch(surfaceID: surfaceID) ) ) } @@ -509,34 +723,7 @@ final class WorktreeTerminalManager { state.onTaskStatusChanged = { [weak self] status in self?.emit(.taskStatusChanged(worktreeID: worktree.id, status: status)) } - state.onAgentEntryChanged = { [weak self] entry in - guard let self else { return } - let beganWorking = agentObservationStore.publishAgentChanged(entry) - if beganWorking { - let evidence = currentAgentEvidence(surfaceID: entry.surfaceID) - agentObservationStore.updateEvidenceEpoch( - surfaceID: entry.surfaceID, - processGeneration: evidence.generation, - sessionID: evidence.sessionID - ) - if let evidenceEpoch = agentObservationStore.currentEvidenceEpoch(surfaceID: entry.surfaceID) { - agentDispatchStore.noteActivity( - surfaceID: entry.surfaceID, - evidenceEpoch: evidenceEpoch - ) - } - } - emit(.agentEntryChanged(entry)) - } - state.onAgentEntryRemoved = { [weak self] id in - guard let self else { return } - agentObservationStore.publishAgentRemoved(surfaceID: id) - emit(.agentEntryRemoved(id)) - } - state.onSurfaceClosed = { [weak self] surfaceID in - self?.agentObservationStore.publishSurfaceClosed(surfaceID: surfaceID) - self?.agentDispatchStore.surfaceClosed(surfaceID: surfaceID) - } + configureAgentObservationCallbacks(state) state.onRunScriptStatusChanged = { [weak self] isRunning in self?.emit(.runScriptStatusChanged(worktreeID: worktree.id, isRunning: isRunning)) } @@ -557,6 +744,50 @@ final class WorktreeTerminalManager { return state } + private func configureAgentObservationCallbacks(_ state: WorktreeTerminalState) { + state.onAgentEntryChanged = { [weak self] entry in + guard let self else { return } + let beganWorking = agentObservationStore.publishAgentChanged(entry) + let evidence = currentAgentEvidence(surfaceID: entry.surfaceID) + handleEvidenceEpochUpdate( + agentObservationStore.updateEvidenceEpoch( + surfaceID: entry.surfaceID, + processGeneration: evidence.generation, + sessionID: evidence.sessionID + ), + surfaceID: entry.surfaceID + ) + if beganWorking, + let evidenceEpoch = agentObservationStore.currentEvidenceEpoch(surfaceID: entry.surfaceID) + { + agentDispatchStore.noteActivity(surfaceID: entry.surfaceID, evidenceEpoch: evidenceEpoch) + } + emit(.agentEntryChanged(entry)) + } + state.onAgentEntryRemoved = { [weak self] surfaceID in + guard let self else { return } + if let record = agentObservationStore.revokeManagedHook(surfaceID: surfaceID) { + retireForwardingRecord(record) + } + agentObservationStore.publishAgentRemoved(surfaceID: surfaceID) + emit(.agentEntryRemoved(surfaceID)) + } + state.onSurfaceClosed = { [weak self] surfaceID in + guard let self else { return } + if let record = agentObservationStore.revokeManagedHook(surfaceID: surfaceID) { + retireForwardingRecord(record) + } + agentObservationStore.publishSurfaceClosed(surfaceID: surfaceID) + agentDispatchStore.surfaceClosed(surfaceID: surfaceID) + } + state.onAgentProfileSurfacePrepared = { [weak self] surfaceID, plan in + guard let self, containsSurface(surfaceID) else { return false } + guard let registration = plan.hookRegistration else { return true } + _ = agentObservationStore.registerManagedHook(registration, surfaceID: surfaceID) + return true + } + } + @discardableResult private func createTabAsync( in worktree: Worktree, @@ -979,6 +1210,9 @@ final class WorktreeTerminalManager { self.preferredFontSize = nil self.agentObservationStore = AgentObservationStore(bufferCapacity: 64) self.agentDispatchStore = AgentDispatchStore() + self.codexConfigReadProcess = CodexConfigReadProcess() + self.hookResourcesProvider = { nil } + self.forwardingRecordBaseDirectory = SupacodePaths.agentHookForwardingDirectory self.baselineFontSize = 13 } #endif diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift index 25b80891..e06cc082 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift @@ -74,7 +74,8 @@ extension WorktreeTerminalState { initialInput: String? = nil, workingDirectoryOverride: URL? = nil, context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_TAB, - additionalEnvironment: [String: String] = [:] + additionalEnvironment: [String: String] = [:], + defersSurfaceCreation: Bool = false ) -> SplitTree { guard tabManager.tabs.contains(where: { $0.id == tabId }) else { return SplitTree() @@ -88,7 +89,8 @@ extension WorktreeTerminalState { inheritingFromSurfaceId: inheritingFromSurfaceId, workingDirectoryOverride: workingDirectoryOverride, context: context, - additionalEnvironment: additionalEnvironment + additionalEnvironment: additionalEnvironment, + defersSurfaceCreation: defersSurfaceCreation ) let tree = SplitTree(view: surface) trees[tabId] = tree @@ -103,8 +105,10 @@ extension WorktreeTerminalState { of anchorSurfaceID: UUID, direction: UserCustomSplitDirection, initialInput: String?, + workingDirectoryOverride: URL? = nil, additionalEnvironment: [String: String] = [:], - focusing: Bool = true + focusing: Bool = true, + defersSurfaceCreation: Bool = false ) -> Result { guard let tabID = tabId(containing: anchorSurfaceID), let tree = trees[tabID], @@ -117,8 +121,10 @@ extension WorktreeTerminalState { tabId: tabID, initialInput: initialInput.flatMap { runScriptInput($0) }, inheritingFromSurfaceId: anchorSurfaceID, + workingDirectoryOverride: workingDirectoryOverride, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - additionalEnvironment: additionalEnvironment + additionalEnvironment: additionalEnvironment, + defersSurfaceCreation: defersSurfaceCreation ) do { let newTree = try tree.inserting( @@ -351,7 +357,8 @@ extension WorktreeTerminalState { inheritingFromSurfaceId: UUID?, workingDirectoryOverride: URL? = nil, context: ghostty_surface_context_e, - additionalEnvironment: [String: String] = [:] + additionalEnvironment: [String: String] = [:], + defersSurfaceCreation: Bool = false ) -> GhosttySurfaceView { let inherited = inheritedSurfaceConfig(fromSurfaceId: inheritingFromSurfaceId, context: context) let resolvedFontSize = Self.resolvedFontSizeForNewSurface( @@ -365,7 +372,8 @@ extension WorktreeTerminalState { initialInput: initialInput, fontSize: resolvedFontSize, context: context, - environment: worktree.scriptEnvironment.merging(additionalEnvironment) { _, patched in patched } + environment: worktree.scriptEnvironment.merging(additionalEnvironment) { _, patched in patched }, + defersSurfaceCreation: defersSurfaceCreation ) // Sending a no-op font size action marks the Ghostty surface as // "font_size_adjusted", which prevents config reloads (triggered by @@ -377,7 +385,7 @@ extension WorktreeTerminalState { configureBridgeCallbacks(for: view, tabId: tabId) configureSurfaceCallbacks(for: view, tabId: tabId) surfaces[view.id] = view - if initialInput?.isEmpty == false { + if !defersSurfaceCreation, initialInput?.isEmpty == false { wakeAgentDetection(for: view, tabId: tabId) } return view diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 991753fc..8a1d67bd 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -268,6 +268,9 @@ final class WorktreeTerminalState { var onAgentEntryRemoved: ((ActiveAgentEntry.ID) -> Void)? /// Emitted exactly once after agent cleanup for each torn-down surface. var onSurfaceClosed: ((UUID) -> Void)? + /// The exact surface is installed but its Profile command has not been sent. + /// Returning false rolls the surface back before agent input can execute. + var onAgentProfileSurfacePrepared: ((UUID, AgentProfileLaunchPlan) -> Bool)? var onRunScriptStatusChanged: ((Bool) -> Void)? var onCommandPaletteToggle: (() -> Void)? var onSetupScriptConsumed: (() -> Void)? @@ -481,6 +484,52 @@ final class WorktreeTerminalState { return tabId } + func freezeAgentProfileLaunchContext( + _ request: AgentProfileLaunchRequest + ) -> Result { + let anchor: UUID? + let context: ghostty_surface_context_e + switch request.placement { + case .tab: + anchor = request.inheritanceAnchor ?? currentFocusedSurfaceId() + context = GHOSTTY_SURFACE_CONTEXT_TAB + case .split(let requestedAnchor, _, _): + guard let resolved = requestedAnchor ?? currentFocusedSurfaceId(), surfaces[resolved] != nil else { + return .failure(.splitAnchorUnavailable) + } + anchor = resolved + context = GHOSTTY_SURFACE_CONTEXT_SPLIT + } + let inheritedCWD = + request.workingDirectoryOverride + ?? inheritedSurfaceConfig(fromSurfaceId: anchor, context: context).workingDirectory + ?? worktree.workingDirectory + let frozenPlacement: AgentProfileLaunchRequest.Placement = + switch request.placement { + case .tab(let background): .tab(background: background) + case .split(_, let direction, let background): + .split(anchor: anchor, direction: direction, background: background) + } + return .success( + FrozenAgentProfileLaunchContext( + request: AgentProfileLaunchRequest( + plan: request.plan, + placement: frozenPlacement, + workingDirectoryOverride: inheritedCWD, + inheritanceAnchor: anchor, + title: request.title + ), + inheritedCWD: inheritedCWD.standardizedFileURL, + anchorSurfaceID: anchor + ) + ) + } + + func isAgentProfileLaunchContextValid(_ context: FrozenAgentProfileLaunchContext) -> Bool { + guard let anchor = context.anchorSurfaceID else { return true } + return surfaces[anchor] != nil + } + /// Launches an agent profile through the deterministic A2 boundary. Explicit /// split placement never falls back to a tab; callers receive both identities /// synchronously and can resolve the exact created target without using focus. @@ -521,7 +570,7 @@ final class WorktreeTerminalState { ).get().surfaceID } - private func provisionAgentProfileHome(for plan: AgentProfileLaunchPlan) -> Bool { + func provisionAgentProfileHome(for plan: AgentProfileLaunchPlan) -> Bool { guard let home = plan.dedicatedHome else { return true } do { try AgentProfileHomeProvisioner.provision( @@ -551,8 +600,10 @@ final class WorktreeTerminalState { of: anchor, direction: direction, initialInput: plan.terminalInput, + workingDirectoryOverride: request.workingDirectoryOverride, additionalEnvironment: plan.surfaceEnvironment, - focusing: !background + focusing: !background, + defersSurfaceCreation: true ) { case .success(let surfaceID): guard let tabID = tabID(containing: surfaceID) else { @@ -577,6 +628,14 @@ final class WorktreeTerminalState { { applyResolvedIcon(icon, surfaceId: surface.surfaceID, tabId: surface.tabID) } + guard onAgentProfileSurfacePrepared?(surface.surfaceID, plan) != false, + let view = surfaces[surface.surfaceID], + view.armSurfaceCreation() + else { + rollbackAgentProfileSurface(surface, placement: request.placement) + return .failure(.hookRegistrationFailed) + } + wakeAgentDetection(for: view, tabId: surface.tabID) return launched } @@ -594,10 +653,11 @@ final class WorktreeTerminalState { initialInput: runScriptInput(plan.terminalInput), focusing: !background, selecting: !background, - inheritingFromSurfaceId: currentFocusedSurfaceId(), + inheritingFromSurfaceId: request.inheritanceAnchor ?? currentFocusedSurfaceId(), context: GHOSTTY_SURFACE_CONTEXT_TAB, workingDirectoryOverride: request.workingDirectoryOverride, - additionalEnvironment: plan.surfaceEnvironment + additionalEnvironment: plan.surfaceEnvironment, + defersSurfaceCreation: true ) ) else { @@ -609,6 +669,18 @@ final class WorktreeTerminalState { return .success(LaunchedSurface(tabID: tabID, surfaceID: surfaceID)) } + private func rollbackAgentProfileSurface( + _ surface: LaunchedSurface, + placement: AgentProfileLaunchRequest.Placement + ) { + switch placement { + case .tab: + _ = closeTab(surface.tabID, confirmation: .skip) + case .split: + _ = closeSurface(id: surface.surfaceID, confirmation: .skip) + } + } + /// Icon for a profile launch. The launch path knows its runtime, so it /// resolves the brand icon directly instead of waiting for `CommandIconMap` /// to recognise the shell title: a profile that sets launch-scoped @@ -696,6 +768,7 @@ final class WorktreeTerminalState { let context: ghostty_surface_context_e let workingDirectoryOverride: URL? var additionalEnvironment: [String: String] = [:] + var defersSurfaceCreation = false } private func createTab(_ creation: TabCreation) -> TerminalTabID? { @@ -711,7 +784,8 @@ final class WorktreeTerminalState { initialInput: creation.initialInput, workingDirectoryOverride: creation.workingDirectoryOverride, context: creation.context, - additionalEnvironment: creation.additionalEnvironment + additionalEnvironment: creation.additionalEnvironment, + defersSurfaceCreation: creation.defersSurfaceCreation ) _ = registerTargetHandle(for: tabId) for surface in tree.leaves() { diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift index a7c82d36..90b6eb96 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift @@ -145,6 +145,7 @@ final class GhosttySurfaceView: NSView, Identifiable { context } private let skipsSurfaceCreationForTesting: Bool + private(set) var surfaceCreationArmed = false private var trackingArea: NSTrackingArea? private var lastBackingSize: CGSize = .zero var lastPerformKeyEvent: TimeInterval? @@ -267,7 +268,8 @@ final class GhosttySurfaceView: NSView, Identifiable { fontSize: Float32? = nil, context: ghostty_surface_context_e, environment: [String: String] = [:], - skipsSurfaceCreationForTesting: Bool = false + skipsSurfaceCreationForTesting: Bool = false, + defersSurfaceCreation: Bool = false ) { let id = UUID() self.id = id @@ -323,11 +325,8 @@ final class GhosttySurfaceView: NSView, Identifiable { super.init(frame: NSRect(x: 0, y: 0, width: 800, height: 600)) wantsLayer = true bridge.surfaceView = self - if !skipsSurfaceCreationForTesting { - createSurface() - if let surface { - surfaceRef = runtime.registerSurface(surface) - } + if !skipsSurfaceCreationForTesting, !defersSurfaceCreation { + _ = armSurfaceCreation() } registerForDraggedTypes(Array(Self.dropTypes)) @@ -365,6 +364,20 @@ final class GhosttySurfaceView: NSView, Identifiable { } } + @discardableResult + func armSurfaceCreation() -> Bool { + guard !surfaceCreationArmed else { return true } + surfaceCreationArmed = true + guard !skipsSurfaceCreationForTesting else { return true } + createSurface() + if let surface { + surfaceRef = runtime.registerSurface(surface) + } + // Surface construction has historically been best-effort at this layer; + // lifecycle failure is reported by the surrounding state boundary. + return true + } + func closeSurface() { clearNotificationObservers() if let surface { diff --git a/supacode/Support/SupacodePaths.swift b/supacode/Support/SupacodePaths.swift index 6b95b47a..50d20276 100644 --- a/supacode/Support/SupacodePaths.swift +++ b/supacode/Support/SupacodePaths.swift @@ -39,6 +39,17 @@ nonisolated enum SupacodePaths { Bundle.main.resourceURL?.appending(path: "docs", directoryHint: .isDirectory) } + static var bundledCLIURL: URL? { + Bundle.main.resourceURL?.appending( + path: "prowl-cli/prowl", + directoryHint: .notDirectory + ) + } + + static var agentHookForwardingDirectory: URL { + cacheDirectory.appending(path: "agent-hook-forwarding", directoryHint: .isDirectory) + } + /// On-disk path to the bundled docs index (`docs/README.md`), e.g. /// `/Applications/Prowl.app/Contents/Resources/docs/README.md`. `nil` only /// if the bundle has no resource directory (should not happen at runtime). diff --git a/supacodeTests/AgentHookRenderingTests.swift b/supacodeTests/AgentHookRenderingTests.swift new file mode 100644 index 00000000..960ff093 --- /dev/null +++ b/supacodeTests/AgentHookRenderingTests.swift @@ -0,0 +1,168 @@ +import Foundation +import Testing + +@testable import supacode + +struct AgentHookRenderingTests { + private let hookCommands = [ + "SessionStart": + "'/Applications/Prowl Debug.app/Contents/Resources/prowl-cli/prowl' agents _hook claude SessionStart", + "Stop": "'/Applications/Prowl Debug.app/Contents/Resources/prowl-cli/prowl' agents _hook claude Stop", + "SessionEnd": "'/Applications/Prowl Debug.app/Contents/Resources/prowl-cli/prowl' agents _hook claude SessionEnd", + ] + + @Test func adaptersDeclareOnlyApprovedS3aCapabilities() throws { + let claude = try #require(AgentRuntimeAdapterRegistry.profileAdapter(for: .claude)?.signalHooks) + #expect(claude.runtime == .claude) + #expect(claude.coveredEvents == [.needsInput, .sessionEnd, .sessionStart, .turnEnded]) + #expect(claude.nativeEvents["Stop"] == .turnEnded) + + let codex = try #require(AgentRuntimeAdapterRegistry.profileAdapter(for: .codex)?.signalHooks) + #expect(codex.runtime == .codex) + #expect(codex.coveredEvents == [.turnEnded]) + #expect(codex.nativeEvents == ["agent-turn-complete": .turnEnded]) + + for runtime in AgentProfileRuntime.allCases where runtime != .claude && runtime != .codex { + #expect(AgentRuntimeAdapterRegistry.profileAdapter(for: runtime)?.signalHooks == nil) + } + } + + @Test func claudeSettingsMergePreservesUnknownFieldsAndEveryExistingHandler() throws { + let source = Data( + #""" + { + "future": {"enabled": true}, + "hooks": { + "Stop": [{"matcher": "main", "hooks": [{"type": "command", "command": "/tmp/user stop"}]}], + "FutureEvent": [{"hooks": [{"type": "command", "command": "/tmp/future"}]}] + } + } + """#.utf8 + ) + let invocation = AgentInvocation( + executable: "claude", + arguments: ["--model", "opus", "--settings", "settings.json", "Prompt"] + ) + let outcome = ClaudeHookSettingsPreparer.prepare( + invocation: invocation, + launchDirectory: URL(filePath: "/tmp/project", directoryHint: .isDirectory), + hookCommands: hookCommands, + readFile: { url, _ in + #expect(url.path(percentEncoded: false) == "/tmp/project/settings.json") + return .stable(source) + } + ) + let prepared = try #require(outcome.prepared) + #expect(outcome.warning == nil) + #expect(prepared.invocation.arguments == invocation.arguments) + let mergedString = try #require(prepared.argumentValues[3]) + let mergedData = Data(mergedString.utf8) + let merged = try #require(JSONSerialization.jsonObject(with: mergedData) as? [String: Any]) + #expect((merged["future"] as? [String: Bool])?["enabled"] == true) + let hooks = try #require(merged["hooks"] as? [String: Any]) + let stop = try #require(hooks["Stop"] as? [[String: Any]]) + #expect(stop.count == 2) + #expect((stop[0]["matcher"] as? String) == "main") + #expect((hooks["FutureEvent"] as? [[String: Any]])?.count == 1) + } + + @Test func claudeSettingsUsesFinalEffectiveSourceAndAvoidsProwlDuplicates() throws { + let existingCommand = try #require(hookCommands["Stop"]) + let inline = + "{\"marker\":\"final\",\"hooks\":{\"Stop\":[{\"hooks\":[" + + "{\"type\":\"command\",\"command\":\"\(existingCommand)\"}]}]}}" + let invocation = AgentInvocation( + executable: "claude", + arguments: ["--settings", "ignored.json", "--settings=\(inline)", "Prompt"] + ) + var reads = 0 + let outcome = ClaudeHookSettingsPreparer.prepare( + invocation: invocation, + launchDirectory: URL(filePath: "/tmp", directoryHint: .isDirectory), + hookCommands: hookCommands, + readFile: { _, _ in + reads += 1 + return .unreadable + } + ) + let prepared = try #require(outcome.prepared) + #expect(reads == 0) + let mergedString = try #require(prepared.argumentValues[3]) + let mergedData = Data(mergedString.utf8) + let merged = try #require(JSONSerialization.jsonObject(with: mergedData) as? [String: Any]) + #expect(merged["marker"] as? String == "final") + let hooks = try #require(merged["hooks"] as? [String: Any]) + let stop = try #require(hooks["Stop"] as? [[String: Any]]) + #expect(stop.count == 1) + } + + @Test func claudeMalformedChangedAndOversizedSettingsPreserveInvocation() { + let invocation = AgentInvocation(executable: "claude", arguments: ["--settings", "bad.json", "Prompt"]) + let missingValue = ClaudeHookSettingsPreparer.prepare( + invocation: AgentInvocation(executable: "claude", arguments: ["--settings", "Prompt"]), + launchDirectory: URL(filePath: "/tmp", directoryHint: .isDirectory), + promptArgumentIndex: 1, + hookCommands: hookCommands, + readFile: { _, _ in .unreadable } + ) + #expect(missingValue.prepared == nil) + #expect(missingValue.originalInvocation.arguments == ["--settings", "Prompt"]) + + let results: [ClaudeSettingsReadResult] = [ + .stable(Data("[]".utf8)), + .stable(Data("{".utf8)), + .changed, + .oversized, + .unreadable, + ] + + for result in results { + let outcome = ClaudeHookSettingsPreparer.prepare( + invocation: invocation, + launchDirectory: URL(filePath: "/tmp", directoryHint: .isDirectory), + hookCommands: hookCommands, + readFile: { _, _ in result } + ) + #expect(outcome.prepared == nil) + #expect(outcome.warning?.code == .managedHookDegraded) + #expect(outcome.originalInvocation == invocation) + } + } + + @Test func claudeAndCodexHookArgumentsStayBeforeThePositionalPrompt() throws { + let claude = AgentInvocation(executable: "claude", arguments: ["-p", "Prompt"]) + let claudeOutcome = ClaudeHookSettingsPreparer.prepare( + invocation: claude, + launchDirectory: URL(filePath: "/tmp", directoryHint: .isDirectory), + hookCommands: hookCommands, + readFile: { _, _ in .unreadable } + ) + let preparedClaude = try #require(claudeOutcome.prepared) + #expect(preparedClaude.invocation.arguments.last == "Prompt") + #expect(preparedClaude.invocation.arguments.dropLast().contains("--settings")) + + let codex = CodexManagedNotifyRenderer.prepare( + invocation: AgentInvocation(executable: "codex", arguments: ["exec", "Prompt"]), + bundledCLIPath: "/Applications/Prowl Debug.app/Contents/Resources/prowl-cli/prowl" + ) + #expect(codex.invocation.arguments.last == "Prompt") + #expect(codex.invocation.arguments.contains("-c")) + #expect(codex.argumentValues.values.first?.contains("notify=") == true) + #expect(codex.argumentValues.values.first?.contains("dangerously-bypass-hook-trust") == false) + } + + @Test func arbitraryArgumentCarriersNeverRenderValuesIntoTerminalInput() { + let invocation = AgentInvocation( + executable: "claude", + arguments: ["-p", "--settings", "{\"secret\":true}", "long prompt"] + ) + let rendered = invocation.terminalInput( + replacingArgumentsWithEnvironmentVariables: [2: "PROWL_HOOK_ARG_0", 3: "PROWL_LAUNCH_PROMPT"] + ) + + #expect(rendered.contains("\"$PROWL_HOOK_ARG_0\"")) + #expect(rendered.contains("\"$PROWL_LAUNCH_PROMPT\"")) + #expect(!rendered.contains("secret")) + #expect(!rendered.contains("long prompt")) + } +} diff --git a/supacodeTests/AgentNativeHookPayloadTests.swift b/supacodeTests/AgentNativeHookPayloadTests.swift new file mode 100644 index 00000000..30e9cb61 --- /dev/null +++ b/supacodeTests/AgentNativeHookPayloadTests.swift @@ -0,0 +1,132 @@ +import Foundation +import Testing + +@testable import supacode + +struct AgentNativeHookPayloadTests { + @Test func claudePayloadsNormalizeWithoutCopyingAssistantOutput() throws { + let cases: [(String, AgentSignalEvent)] = [ + ("SessionStart", .sessionStart), + ("Stop", .turnEnded), + ("StopFailure", .turnEnded), + ("PermissionRequest", .needsInput), + ("Elicitation", .needsInput), + ("SessionEnd", .sessionEnd), + ] + + for (nativeEvent, expectedEvent) in cases { + let payload = Data( + """ + { + "hook_event_name": "\(nativeEvent)", + "session_id": "session-123", + "cwd": "/tmp/Project Space/界", + "last_assistant_message": "must not cross the bridge", + "future_field": {"accepted": true}, + "reason": "completed" + } + """.utf8 + ) + let signal = try AgentNativeHookDecoder.decode( + runtime: .claude, + nativeEvent: nativeEvent, + payload: payload + ) + + #expect(signal.event == expectedEvent) + #expect(signal.nativeEvent == nativeEvent) + #expect(signal.sessionID == "session-123") + #expect(signal.cwd == "/tmp/Project Space/界") + #expect(signal.detail != "must not cross the bridge") + #expect(signal.event.rawValue == expectedEvent.rawValue) + } + } + + @Test func claudeNotificationOnlyAcceptsSupportedAttentionTypes() throws { + let accepted = Data( + #"{"hook_event_name":"Notification","notification_type":"permission_prompt","session_id":"s","cwd":"/tmp/p"}"# + .utf8 + ) + let signal = try AgentNativeHookDecoder.decode( + runtime: .claude, + nativeEvent: "Notification", + payload: accepted + ) + #expect(signal.event == .needsInput) + #expect(signal.detail == "permission_prompt") + + let ignored = Data( + #"{"hook_event_name":"Notification","notification_type":"future_notice","session_id":"s","cwd":"/tmp/p"}"# + .utf8 + ) + #expect(throws: AgentNativeHookDecodeError.unsupportedEvent) { + try AgentNativeHookDecoder.decode(runtime: .claude, nativeEvent: "Notification", payload: ignored) + } + } + + @Test func codexPayloadNormalizesFinalArgAndExcludesMessage() throws { + let payload = Data( + #""" + { + "type": "agent-turn-complete", + "thread-id": "thread-123", + "turn-id": "turn-456", + "cwd": "/tmp/Project Space/界", + "last-assistant-message": "secret result", + "future": true + } + """#.utf8 + ) + let signal = try AgentNativeHookDecoder.decode( + runtime: .codex, + nativeEvent: "agent-turn-complete", + payload: payload + ) + + #expect(signal.event == .turnEnded) + #expect(signal.sessionID == "thread-123") + #expect(signal.cwd == "/tmp/Project Space/界") + #expect(signal.detail == nil) + } + + @Test func malformedUnknownAndOversizedPayloadsFailClosed() { + #expect(throws: AgentNativeHookDecodeError.malformedPayload) { + try AgentNativeHookDecoder.decode( + runtime: .claude, + nativeEvent: "Stop", + payload: Data("[]".utf8) + ) + } + #expect(throws: AgentNativeHookDecodeError.eventMismatch) { + try AgentNativeHookDecoder.decode( + runtime: .claude, + nativeEvent: "Stop", + payload: Data(#"{"hook_event_name":"SessionEnd","session_id":"s","cwd":"/tmp"}"#.utf8) + ) + } + #expect(throws: AgentNativeHookDecodeError.unsupportedEvent) { + try AgentNativeHookDecoder.decode( + runtime: .codex, + nativeEvent: "future-event", + payload: Data(#"{"type":"future-event","thread-id":"s","cwd":"/tmp"}"#.utf8) + ) + } + #expect(throws: AgentNativeHookDecodeError.payloadTooLarge) { + try AgentNativeHookDecoder.decode( + runtime: .codex, + nativeEvent: "agent-turn-complete", + payload: Data(repeating: 0, count: AgentNativeHookDecoder.maximumPayloadBytes + 1) + ) + } + #expect(throws: AgentNativeHookDecodeError.invalidField) { + try AgentNativeHookDecoder.decode( + runtime: .codex, + nativeEvent: "agent-turn-complete", + payload: Data( + "{\"type\":\"agent-turn-complete\",\"thread-id\":\"\(String(repeating: "x", count: 257))\",\"cwd\":\"/tmp\"}" + .utf8 + ) + ) + } + } +} diff --git a/supacodeTests/AgentProfileHookCarrierTests.swift b/supacodeTests/AgentProfileHookCarrierTests.swift new file mode 100644 index 00000000..b1d9c2db --- /dev/null +++ b/supacodeTests/AgentProfileHookCarrierTests.swift @@ -0,0 +1,120 @@ +import Foundation +import Testing + +@testable import supacode + +struct AgentProfileHookCarrierTests { + @Test func managedHookValuesUseChildOnlyCarriersAndNeverTerminalInput() throws { + let base = makePlan( + invocation: AgentInvocation(executable: "claude", arguments: ["-p", "Prompt"]), + prompt: "Prompt" + ) + let preparedInvocation = AgentHookPreparedInvocation( + invocation: AgentInvocation(executable: "claude", arguments: ["-p", "--settings", "{}", "Prompt"]), + argumentValues: [2: #"{"secret":"hook-json"}"#] + ) + let token = "token-should-never-be-typed" + let socket = "/tmp/prowl custom.sock" + let prepared = base.applyingManagedHook( + preparedInvocation, + resources: AgentHookResources( + bundledCLIPath: "/Applications/Prowl Debug.app/Contents/Resources/prowl-cli/prowl", + socketPath: socket + ), + launchCWD: URL(filePath: "/tmp/Project Space/界", directoryHint: .isDirectory), + token: token, + coveredEvents: [.needsInput, .sessionStart, .turnEnded] + ) + + #expect(prepared.hookRegistration?.token == token) + #expect( + prepared.hookRegistration?.launchCWD.path(percentEncoded: false).trimmingCharacters( + in: CharacterSet(charactersIn: "/")) + == "tmp/Project Space/界" + ) + #expect(prepared.terminalInput.contains("\"$PROWL_LAUNCH_HOOK_ARG_0\"")) + #expect(prepared.terminalInput.contains("-u PROWL_LAUNCH_HOOK_TOKEN")) + #expect(prepared.terminalInput.contains("-u PROWL_LAUNCH_HOOK_SOCKET")) + #expect(prepared.terminalInput.contains("PROWL_AGENT_HOOK_TOKEN=\"$PROWL_LAUNCH_HOOK_TOKEN\"")) + #expect(prepared.terminalInput.contains("PROWL_CLI_SOCKET=\"$PROWL_LAUNCH_HOOK_SOCKET\"")) + #expect(!prepared.terminalInput.contains(token)) + #expect(!prepared.terminalInput.contains(socket)) + #expect(!prepared.terminalInput.contains("hook-json")) + #expect(!prepared.terminalInput.contains("Prompt")) + } + + @Test func forwardingLocatorIsAChildOnlyCarrierAndRecordContentsStayOutOfEnvironment() { + let base = makePlan(invocation: AgentInvocation(executable: "codex", arguments: [])) + let record = CodexForwardingRecord( + locator: URL(filePath: "/tmp/private/session/opaque.json", directoryHint: .notDirectory) + ) + let prepared = base.applyingManagedHook( + AgentHookPreparedInvocation( + invocation: AgentInvocation(executable: "codex", arguments: ["-c", "notify=[]"]), + argumentValues: [1: #"notify=["/bundle/prowl","agents","_hook","codex","agent-turn-complete"]"#] + ), + resources: AgentHookResources(bundledCLIPath: "/bundle/prowl", socketPath: "/tmp/prowl.sock"), + launchCWD: URL(filePath: "/tmp/project", directoryHint: .isDirectory), + token: "opaque-token", + coveredEvents: [.turnEnded], + forwardingRecord: record + ) + + #expect( + prepared.surfaceEnvironment[AgentProfileLaunchPlanner.hookForwardCarrierName] + == record.locator.path(percentEncoded: false) + ) + #expect( + prepared.commandEnvironmentTokens.contains( + "PROWL_AGENT_HOOK_FORWARD_RECORD=\"$PROWL_LAUNCH_HOOK_FORWARD\"" + ) + ) + #expect(!prepared.terminalInput.contains(record.locator.path(percentEncoded: false))) + #expect(!prepared.surfaceEnvironment.values.contains("/tmp/user-notifier-secret")) + } + + @Test func attachingDispatchAfterPreflightKeepsOnePreparedHookPlan() throws { + let base = makePlan( + invocation: AgentInvocation(executable: "codex", arguments: ["User prompt"]), + prompt: "User prompt" + ) + let hooked = base.applyingManagedHook( + AgentHookPreparedInvocation( + invocation: AgentInvocation(executable: "codex", arguments: ["-c", "notify=[]", "User prompt"]), + argumentValues: [1: "notify=[]"] + ), + resources: AgentHookResources(bundledCLIPath: "/bundle/prowl", socketPath: "/tmp/prowl.sock"), + launchCWD: URL(filePath: "/tmp/project", directoryHint: .isDirectory), + token: "token", + coveredEvents: [.turnEnded] + ) + let paired = try hooked.attachingDispatch(id: "dispatch-123", userPrompt: "User prompt") + + #expect(paired.hookRegistration == hooked.hookRegistration) + #expect(paired.surfaceEnvironment[AgentProfileLaunchPlanner.dispatchCarrierName] == "dispatch-123") + #expect( + paired.surfaceEnvironment[AgentProfileLaunchPlanner.promptCarrierName]? + .contains("Prowl dispatch completion protocol v1") == true + ) + #expect(paired.invocation.arguments.last == paired.surfaceEnvironment[AgentProfileLaunchPlanner.promptCarrierName]) + } + + private func makePlan( + invocation: AgentInvocation, + prompt: String? = nil + ) -> AgentProfileLaunchPlan { + var environment: [String: String] = [:] + if let prompt { environment[AgentProfileLaunchPlanner.promptCarrierName] = prompt } + return AgentProfileLaunchPlan( + profileID: UUID(), + profileName: "Test", + runtime: invocation.executable == "codex" ? .codex : .claude, + invocation: invocation, + commandEnvironmentTokens: [], + placement: .tab, + splitDirection: .right, + surfaceEnvironment: environment, + dedicatedHome: nil + ) + } +} diff --git a/supacodeTests/AppFeatureAgentProfileTests.swift b/supacodeTests/AppFeatureAgentProfileTests.swift index 3416dce3..bad8f2aa 100644 --- a/supacodeTests/AppFeatureAgentProfileTests.swift +++ b/supacodeTests/AppFeatureAgentProfileTests.swift @@ -83,6 +83,40 @@ struct AppFeatureAgentProfileTests { #expect(repoSettings.wrappedValue.lastLaunchedAgentProfileID == nil) } + @Test(.dependencies) func degradedHookEventShowsOneNonBlockingWarningToast() async { + let worktree = makeWorktree() + let repositories = makeRepositoriesState(worktree: worktree) + let storage = SettingsTestStorage() + let store = withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + TestStore( + initialState: AppFeature.State( + repositories: repositories, + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } + } + store.exhaustivity = .off + + await store.send( + .terminalEvent( + .agentProfileLaunchWarning( + worktreeID: worktree.id, + profileName: "Codex", + message: "Notifier resolver unavailable." + ) + ) + ) + await store.receive(\.repositories.showToast) + #expect( + store.state.repositories.statusToast + == .warning("“Codex” launched without managed signals. Notifier resolver unavailable.") + ) + } + @Test(.dependencies) func launchIgnoresDisabledOrUnknownProfiles() async { let worktree = makeWorktree() let repositories = makeRepositoriesState(worktree: worktree) diff --git a/supacodeTests/CLIAgentNativeHookCommandHandlerTests.swift b/supacodeTests/CLIAgentNativeHookCommandHandlerTests.swift new file mode 100644 index 00000000..b550eb39 --- /dev/null +++ b/supacodeTests/CLIAgentNativeHookCommandHandlerTests.swift @@ -0,0 +1,67 @@ +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct CLIAgentNativeHookCommandHandlerTests { + @Test func exactCallerAndRegistrationProduceHookReceiptWithoutToken() async throws { + let caller = CallerPane(worktreeID: "wt-1", surfaceID: UUID()) + var recorded: (CallerPane, AgentNativeHookInput)? + let handler = AgentNativeHookCommandHandler( + resolveCaller: { _ in caller }, + recordHook: { resolved, input in + recorded = (resolved, input) + return true + }, + now: { Date(timeIntervalSince1970: 100) } + ) + let input = makeInput() + + let response = await handler.handle( + envelope: CommandEnvelope(output: .json, command: .agentsHook(input)), + context: CLICommandContext(callerProcessID: 42) + ) + + #expect(response.ok) + #expect(recorded?.0 == caller) + #expect(recorded?.1 == input) + let payload = try #require(try response.data?.decode(as: AgentSignalCommandPayload.self)) + #expect(payload.signal.source == "hook_claude") + #expect(payload.signal.confidence == "exact") + let encoded = try JSONEncoder().encode(response) + let encodedText = try #require(String(bytes: encoded, encoding: .utf8)) + #expect(!encodedText.contains(input.token)) + } + + @Test func missingCallerAndRejectedRegistrationFailClosed() async { + let input = makeInput() + let missing = AgentNativeHookCommandHandler(resolveCaller: { _ in nil }, recordHook: { _, _ in true }) + let missingResponse = await missing.handle( + envelope: CommandEnvelope(output: .json, command: .agentsHook(input)), + context: CLICommandContext(callerProcessID: 42) + ) + #expect(!missingResponse.ok) + + let caller = CallerPane(worktreeID: "wt-1", surfaceID: UUID()) + let rejected = AgentNativeHookCommandHandler(resolveCaller: { _ in caller }, recordHook: { _, _ in false }) + let rejectedResponse = await rejected.handle( + envelope: CommandEnvelope(output: .json, command: .agentsHook(input)), + context: CLICommandContext(callerProcessID: 42) + ) + #expect(!rejectedResponse.ok) + } + + private func makeInput() -> AgentNativeHookInput { + AgentNativeHookInput( + runtime: .claude, + token: "private-token", + signal: AgentNativeHookSignal( + event: .sessionStart, + nativeEvent: "SessionStart", + cwd: "/tmp/project", + sessionID: "session-1" + ) + ) + } +} diff --git a/supacodeTests/CLILifecycleCommandHandlerTests.swift b/supacodeTests/CLILifecycleCommandHandlerTests.swift index 7b5295ec..0332139b 100644 --- a/supacodeTests/CLILifecycleCommandHandlerTests.swift +++ b/supacodeTests/CLILifecycleCommandHandlerTests.swift @@ -1,3 +1,4 @@ +import Clocks import Foundation import Testing @@ -169,6 +170,11 @@ struct CLILifecycleCommandHandlerTests { createTab: { _, _ in nil }, createPane: { _, _ in nil }, profiles: { [profile] }, + prepareAgentProfile: { request in + lifecycle.append("preflight") + #expect(request.dispatchID == nil) + return .success(request) + }, launchAgentProfile: { request in lifecycle.append("launch") launchRequest = request @@ -208,7 +214,7 @@ struct CLILifecycleCommandHandlerTests { #expect(launchRequest?.prompt == "Review the diff.") #expect(launchRequest?.dispatchID == "d1") #expect(launchRequest?.background == true) - #expect(lifecycle == ["issue", "launch", "bind:d1:created-pane"]) + #expect(lifecycle == ["preflight", "issue", "launch", "bind:d1:created-pane"]) let data = try #require(response.data) let payload = try data.decode(as: LifecycleCommandPayload.self) #expect( @@ -222,6 +228,60 @@ struct CLILifecycleCommandHandlerTests { #expect(payload.dispatch?.id == "d1") } + @Test func cancellationDuringPreflightNeverIssuesDispatchOrLaunchesSurface() async { + let base = makeTarget() + let profile = AgentProfile(name: "Reviewer", runtime: .codex) + let clock = TestClock() + var issued = false + var launched = false + let handler = LifecycleCommandHandler( + resolveCreateTarget: { _ in .success(base) }, + resolveCloseTarget: { _ in .success(.init(resource: .pane, target: base)) }, + createTab: { _, _ in nil }, + createPane: { _, _ in nil }, + profiles: { [profile] }, + prepareAgentProfile: { request in + do { + try await clock.sleep(for: .seconds(10)) + return .success(request) + } catch { + return .failure(.createFailed("Cancelled.")) + } + }, + launchAgentProfile: { _ in + launched = true + return .success(base) + }, + issueDispatch: { + issued = true + return .failure(.capacityExceeded) + }, + closeTab: { _, _ in true }, + closePane: { _, _ in true } + ) + let task = Task { + await handler.handle( + envelope: CommandEnvelope( + output: .json, + command: .create( + .init( + resource: .tab, + selector: .worktree("App"), + launch: .init(profile: "Reviewer", prompt: "Review") + ) + ) + ) + ) + } + await Task.yield() + + task.cancel() + _ = await task.value + + #expect(!issued) + #expect(!launched) + } + @Test func promptedLaunchFailureCancelsIssuedDispatch() async { let base = makeTarget() let profile = AgentProfile(name: "Reviewer", runtime: .claude) diff --git a/supacodeTests/CLISocketServerTests.swift b/supacodeTests/CLISocketServerTests.swift index 5463796a..52df8553 100644 --- a/supacodeTests/CLISocketServerTests.swift +++ b/supacodeTests/CLISocketServerTests.swift @@ -133,6 +133,56 @@ struct CLISocketServerTests { #expect(recordedSignal?.detail == "socket result") } + @Test func nativeHookRoundTripThreadsKernelPeerPIDWithoutExposingToken() async throws { + let socketPath = temporarySocketPath(suffix: "hook-context") + let pane = CallerPane(worktreeID: "wt", surfaceID: UUID()) + let input = AgentNativeHookInput( + runtime: .codex, + token: "private-token", + signal: AgentNativeHookSignal( + event: .turnEnded, + nativeEvent: "agent-turn-complete", + cwd: "/tmp/project", + sessionID: "thread-1" + ) + ) + var recordedInput: AgentNativeHookInput? + let handler = AgentNativeHookCommandHandler( + resolveCaller: { processID in + #expect(processID == getpid()) + return pane + }, + recordHook: { caller, received in + #expect(caller == pane) + recordedInput = received + return true + } + ) + let server = CLISocketServer( + router: CLICommandRouter(agentsHookHandler: handler), + socketPath: socketPath + ) + try server.start() + defer { server.stop() } + let requestData = try JSONEncoder().encode( + CommandEnvelope(output: .json, command: .agentsHook(input)) + ) + + let responseData = try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + continuation.resume( + with: Result { try Self.send(requestData: requestData, socketPath: socketPath) } + ) + } + } + let response = try JSONDecoder().decode(CommandResponse.self, from: responseData) + + #expect(response.ok) + #expect(recordedInput == input) + let responseText = try #require(String(bytes: responseData, encoding: .utf8)) + #expect(!responseText.contains(input.token)) + } + @Test func closingPeerCancelsInFlightWaitRequest() async throws { let socketPath = temporarySocketPath(suffix: "wait-peer-eof") let probe = CancellationProbe() diff --git a/supacodeTests/CodexConfigReadLiveContractTests.swift b/supacodeTests/CodexConfigReadLiveContractTests.swift new file mode 100644 index 00000000..b2e4d2ef --- /dev/null +++ b/supacodeTests/CodexConfigReadLiveContractTests.swift @@ -0,0 +1,85 @@ +import Foundation +import Testing + +@testable import supacode + +struct CodexConfigReadLiveContractTests { + @Test(.enabled(if: ProcessInfo.processInfo.environment["PROWL_RUN_LIVE_CODEX_CONTRACT"] == "1")) + func codex0149ScratchPrecedenceAndProjectExclusion() async throws { + let executable = URL(filePath: "/opt/homebrew/bin/codex", directoryHint: .notDirectory) + let root = FileManager.default.temporaryDirectory.appending( + path: "prowl-codex-live-contract-\(UUID().uuidString)", + directoryHint: .isDirectory + ) + let home = root.appending(path: "home", directoryHint: .isDirectory) + let workspace = root.appending(path: "workspace", directoryHint: .isDirectory) + let parser = root.appending(path: "parser", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: workspace.appending(path: ".codex", directoryHint: .isDirectory), + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: root) } + try """ + notify = ["/tmp/base notifier", "base"] + [projects."\(workspace.path(percentEncoded: false))"] + trust_level = "trusted" + """.write( + to: home.appending(path: "config.toml"), + atomically: true, + encoding: .utf8 + ) + try #"notify = ["/tmp/profile notifier", "profile 界", ""]"#.write( + to: home.appending(path: "selected.config.toml"), + atomically: true, + encoding: .utf8 + ) + try #"notify = ["/tmp/project notifier", "must-be-ignored"]"#.write( + to: workspace.appending(path: ".codex/config.toml"), + atomically: true, + encoding: .utf8 + ) + let process = CodexConfigReadProcess( + executableURL: executable, + temporaryBaseDirectory: parser, + timeout: 2 + ) + let resolver = CodexEffectiveNotifyResolver( + bundledCLIPath: "/bundle/prowl", + query: process.query + ) + let base = CodexLaunchContext( + inheritedCWD: workspace, + effectiveCWD: workspace, + codexHome: home, + configOverrides: [], + profileName: nil, + explicitNotifyOverride: nil + ) + #expect(await resolver.resolve(base) == .present(["/tmp/base notifier", "base"])) + + let profile = CodexLaunchContext( + inheritedCWD: workspace, + effectiveCWD: workspace, + codexHome: home, + configOverrides: [], + profileName: "selected", + explicitNotifyOverride: nil + ) + #expect( + await resolver.resolve(profile) + == .present(["/tmp/profile notifier", "profile 界", ""]) + ) + + let override = CodexLaunchContext( + inheritedCWD: workspace, + effectiveCWD: workspace, + codexHome: home, + configOverrides: [], + profileName: "selected", + explicitNotifyOverride: #"notify=["/tmp/cli notifier","cli"]"# + ) + #expect(await resolver.resolve(override) == .present(["/tmp/cli notifier", "cli"])) + #expect((try? FileManager.default.contentsOfDirectory(atPath: parser.path))?.isEmpty == true) + } +} diff --git a/supacodeTests/CodexConfigReadProcessTests.swift b/supacodeTests/CodexConfigReadProcessTests.swift new file mode 100644 index 00000000..02782bc4 --- /dev/null +++ b/supacodeTests/CodexConfigReadProcessTests.swift @@ -0,0 +1,113 @@ +import Foundation +import Testing + +@testable import supacode + +struct CodexConfigReadProcessTests { + @Test func profileParserHomeIsOwnerOnlyAndRemovedAfterResponse() async throws { + let root = temporaryDirectory("codex-process") + let parser = root.appending(path: "parser", directoryHint: .isDirectory) + let home = root.appending(path: "home", directoryHint: .isDirectory) + let profile = home.appending(path: "selected.config.toml", directoryHint: .notDirectory) + let report = root.appending(path: "report.json", directoryHint: .notDirectory) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + try #"notify = ["/tmp/profile", "space value", ""]"#.write( + to: profile, + atomically: true, + encoding: .utf8 + ) + let executable = try makeFakeCodex(in: root, report: report, sleeps: false) + defer { try? FileManager.default.removeItem(at: root) } + let process = CodexConfigReadProcess( + executableURL: executable, + temporaryBaseDirectory: parser, + timeout: 2 + ) + let query = CodexConfigQuery( + kind: .profile(profile), + codexHome: home, + cwd: root, + overrides: [] + ) + + let transcript = try await process.query(query) + + #expect(try CodexConfigReadProtocol.decodeNotify(from: transcript) == ["/tmp/profile", "space value", ""]) + let observed = try #require( + JSONSerialization.jsonObject(with: Data(contentsOf: report)) as? [String: Any] + ) + #expect((observed["directory_mode"] as? NSNumber)?.intValue == 0o700) + #expect((observed["config_mode"] as? NSNumber)?.intValue == 0o600) + #expect((try? FileManager.default.contentsOfDirectory(atPath: parser.path))?.isEmpty == true) + } + + @Test func cancellationTerminatesParserAndRemovesScratchHomeWithoutDispatchSleep() async throws { + let root = temporaryDirectory("codex-cancel") + let parser = root.appending(path: "parser", directoryHint: .isDirectory) + let home = root.appending(path: "home", directoryHint: .isDirectory) + let profile = home.appending(path: "selected.config.toml", directoryHint: .notDirectory) + let marker = root.appending(path: "started", directoryHint: .notDirectory) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + try "notify = [\"/tmp/profile\"]".write(to: profile, atomically: true, encoding: .utf8) + let executable = try makeFakeCodex(in: root, report: marker, sleeps: true) + defer { try? FileManager.default.removeItem(at: root) } + let process = CodexConfigReadProcess( + executableURL: executable, + temporaryBaseDirectory: parser, + timeout: 30 + ) + let task = Task { + try await process.query( + CodexConfigQuery( + kind: .profile(profile), + codexHome: home, + cwd: root, + overrides: [] + ) + ) + } + for _ in 0..<10_000 + where ((try? FileManager.default.contentsOfDirectory(atPath: parser.path)) ?? []).isEmpty { + await Task.yield() + } + #expect(((try? FileManager.default.contentsOfDirectory(atPath: parser.path)) ?? []).count == 1) + + task.cancel() + await #expect(throws: (any Error).self) { try await task.value } + #expect((try? FileManager.default.contentsOfDirectory(atPath: parser.path))?.isEmpty == true) + } + + private func makeFakeCodex(in root: URL, report: URL, sleeps: Bool) throws -> URL { + let executable = root.appending(path: "fake-codex.py", directoryHint: .notDirectory) + let script = """ + #!/usr/bin/python3 + import json, os, pathlib, stat, sys, time + for _ in range(3): + sys.stdin.readline() + home = pathlib.Path(os.environ["CODEX_HOME"]) + config = home / "config.toml" + report = pathlib.Path(\(String(reflecting: report.path(percentEncoded: false)))) + if \(sleeps ? "True" : "False"): + report.write_text("started") + time.sleep(30) + text = config.read_text() if config.exists() else "" + notify = ["/tmp/profile", "space value", ""] if "space value" in text else None + report.write_text(json.dumps({ + "directory_mode": stat.S_IMODE(home.stat().st_mode), + "config_mode": stat.S_IMODE(config.stat().st_mode) if config.exists() else None + })) + print(json.dumps({"jsonrpc":"2.0","id":2,"result":{"config":{"notify":notify}}}), flush=True) + time.sleep(30) + """ + try script.write(to: executable, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: executable.path) + return executable + } + + private func temporaryDirectory(_ name: String) -> URL { + FileManager.default.temporaryDirectory.appending( + path: "prowl-tests-\(name)-\(UUID().uuidString)", + directoryHint: .isDirectory + ) + } +} diff --git a/supacodeTests/CodexEffectiveNotifyResolverTests.swift b/supacodeTests/CodexEffectiveNotifyResolverTests.swift new file mode 100644 index 00000000..ae5f190d --- /dev/null +++ b/supacodeTests/CodexEffectiveNotifyResolverTests.swift @@ -0,0 +1,222 @@ +import Foundation +import Testing + +@testable import supacode + +struct CodexEffectiveNotifyResolverTests { + @Test func configReadProtocolUsesInitializeThenEffectiveCWDRequest() throws { + let transcript = CodexConfigReadProtocol.requestData(cwd: "/tmp/Project Space/界") + let lines = try transcript.split(separator: UInt8(ascii: "\n")).map { + try #require(JSONSerialization.jsonObject(with: Data($0)) as? [String: Any]) + } + + #expect(lines.count == 3) + #expect(lines[0]["method"] as? String == "initialize") + #expect(lines[1]["method"] as? String == "initialized") + #expect(lines[2]["method"] as? String == "config/read") + let params = try #require(lines[2]["params"] as? [String: Any]) + #expect(params["cwd"] as? String == "/tmp/Project Space/界") + #expect(params["includeLayers"] as? Bool == true) + } + + @Test func resolverDistinguishesAbsentAndPresentNotifier() async { + let context = makeContext() + let absent = CodexEffectiveNotifyResolver(query: { _ in response(notify: nil) }) + #expect(await absent.resolve(context) == .absent) + + let present = CodexEffectiveNotifyResolver(query: { _ in + response(notify: ["/tmp/notifier", "space value", "", "秘密"]) + }) + #expect( + await present.resolve(context) + == .present(["/tmp/notifier", "space value", "", "秘密"]) + ) + } + + @Test func selectedProfileWinsBaseAndMissingProfileNotifyFallsBack() async { + let selected = makeContext(profileName: "selected") + let profileWins = CodexEffectiveNotifyResolver(query: { query in + switch query.kind { + case .base: return response(notify: ["/tmp/base"]) + case .profile: return response(notify: ["/tmp/profile", "α"]) + case .explicitNotify: + Issue.record("unexpected explicit override") + return response(notify: nil) + } + }) + #expect(await profileWins.resolve(selected) == .present(["/tmp/profile", "α"])) + + let fallback = CodexEffectiveNotifyResolver(query: { query in + switch query.kind { + case .base: return response(notify: ["/tmp/base"]) + case .profile: return response(notify: nil) + case .explicitNotify: + Issue.record("unexpected explicit override") + return response(notify: nil) + } + }) + #expect(await fallback.resolve(selected) == .present(["/tmp/base"])) + } + + @Test func finalTopLevelCLIOverrideWinsProfile() async { + let context = makeContext( + profileName: "selected", + explicitNotifyOverride: #"notify=["/tmp/cli notifier","quote=\"x\"",""]"# + ) + let resolver = CodexEffectiveNotifyResolver(query: { query in + switch query.kind { + case .base: return response(notify: ["/tmp/base"]) + case .profile: return response(notify: ["/tmp/profile"]) + case .explicitNotify: + #expect(query.overrides == [#"notify=["/tmp/cli notifier","quote=\"x\"",""]"#]) + return response(notify: ["/tmp/cli notifier", #"quote="x""#, ""]) + } + }) + #expect( + await resolver.resolve(context) + == .present(["/tmp/cli notifier", #"quote="x""#, ""]) + ) + } + + @Test func malformedEmptyRecursiveAndTimeoutDegradeWithoutInjection() async { + let context = makeContext() + let malformed = CodexEffectiveNotifyResolver(query: { _ in Data("not-json\n".utf8) }) + #expect(await malformed.resolve(context).isDegraded) + + let empty = CodexEffectiveNotifyResolver(query: { _ in response(notify: []) }) + #expect(await empty.resolve(context).isDegraded) + + let recursive = CodexEffectiveNotifyResolver( + bundledCLIPath: "/Applications/Prowl.app/Contents/Resources/prowl-cli/prowl", + query: { _ in + response(notify: [ + "/Applications/Prowl.app/Contents/Resources/prowl-cli/prowl", + "agents", "_hook", "codex", "agent-turn-complete", + ]) + } + ) + #expect(await recursive.resolve(context).isDegraded) + + let failed = CodexEffectiveNotifyResolver(query: { _ in throw ProbeError.timeout }) + #expect(await failed.resolve(context).isDegraded) + } + + @Test func launchContextFreezesSupportedCWDFormsAndRejectsRepeats() throws { + let base = temporaryDirectory("codex-context") + defer { try? FileManager.default.removeItem(at: base) } + let child = base.appending(path: "space 界", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: child, withIntermediateDirectories: true) + + for arguments in [ + ["-C", "space 界"], + ["--cd", "space 界"], + ["--cd=space 界"], + ["-Cspace 界"], + ] { + let context = try CodexLaunchContext.capture( + invocation: AgentInvocation(executable: "codex", arguments: arguments), + inheritedCWD: base, + environment: [:] + ) + #expect( + context.effectiveCWD.path(percentEncoded: false) + == child.standardizedFileURL.path(percentEncoded: false).trimmingTrailingSlashForTest + ) + } + + #expect(throws: CodexLaunchContextError.repeatedWorkingDirectory) { + try CodexLaunchContext.capture( + invocation: AgentInvocation(executable: "codex", arguments: ["-C", "space 界", "--cd=space 界"]), + inheritedCWD: base, + environment: [:] + ) + } + } + + @Test func positionalPromptNeverBecomesAConfigOverride() throws { + let base = temporaryDirectory("codex-prompt") + let invocation = AgentInvocation( + executable: "codex", + arguments: ["-c", "model=\"x\"", #"-cnotify=["/tmp/notifier"]"#] + ) + let context = try CodexLaunchContext.capture( + invocation: invocation, + inheritedCWD: base, + environment: [:], + promptArgumentIndex: 2 + ) + #expect(context.configOverrides == ["model=\"x\""]) + #expect(context.explicitNotifyOverride == nil) + } + + @Test func launchContextCapturesHomeProfileAndOrderedOverrides() throws { + let base = temporaryDirectory("codex-options") + defer { try? FileManager.default.removeItem(at: base) } + let home = base.appending(path: "home", directoryHint: .isDirectory) + let invocation = AgentInvocation( + executable: "codex", + arguments: [ + "-c", "model=\"x\"", + "--profile=selected", + "--config", #"notify=["/tmp/first"]"#, + "-cnotify=[\"/tmp/final\",\"秘密\"]", + ] + ) + let context = try CodexLaunchContext.capture( + invocation: invocation, + inheritedCWD: base, + dedicatedHome: home, + environment: ["CODEX_HOME": "/must/not/win"] + ) + + #expect(context.codexHome == home.standardizedFileURL) + #expect(context.profileName == "selected") + #expect(context.configOverrides == ["model=\"x\""]) + #expect(context.explicitNotifyOverride == "notify=[\"/tmp/final\",\"秘密\"]") + } + + private enum ProbeError: Error { + case timeout + } + + private func makeContext( + profileName: String? = nil, + explicitNotifyOverride: String? = nil + ) -> CodexLaunchContext { + CodexLaunchContext( + inheritedCWD: URL(filePath: "/tmp/project", directoryHint: .isDirectory), + effectiveCWD: URL(filePath: "/tmp/project", directoryHint: .isDirectory), + codexHome: URL(filePath: "/tmp/codex-home", directoryHint: .isDirectory), + configOverrides: [], + profileName: profileName, + explicitNotifyOverride: explicitNotifyOverride + ) + } + + private func response(notify: [String]?) -> Data { + let value: Any = notify ?? NSNull() + let data = try? JSONSerialization.data( + withJSONObject: ["jsonrpc": "2.0", "id": 2, "result": ["config": ["notify": value]]], + options: [.sortedKeys] + ) + return (data ?? Data()) + Data([UInt8(ascii: "\n")]) + } + + private func temporaryDirectory(_ name: String) -> URL { + FileManager.default.temporaryDirectory + .appending(path: "prowl-tests-\(name)-\(UUID().uuidString)", directoryHint: .isDirectory) + } +} + +extension String { + fileprivate var trimmingTrailingSlashForTest: String { + count > 1 && hasSuffix("/") ? String(dropLast()) : self + } +} + +extension CodexEffectiveNotifyResult { + fileprivate var isDegraded: Bool { + if case .degraded = self { return true } + return false + } +} diff --git a/supacodeTests/CodexForwardingRecordStoreTests.swift b/supacodeTests/CodexForwardingRecordStoreTests.swift new file mode 100644 index 00000000..b35ce325 --- /dev/null +++ b/supacodeTests/CodexForwardingRecordStoreTests.swift @@ -0,0 +1,107 @@ +import Darwin +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct CodexForwardingRecordStoreTests { + @Test func recordUsesRandomOwnerOnlyDirectoryAndExactArgv() throws { + let base = temporaryDirectory("forward-record") + defer { try? FileManager.default.removeItem(at: base) } + let store = try CodexForwardingRecordStore(baseDirectory: base, retirementGrace: 2) + let argv = ["/tmp/notifier with space", "", "quote=\"x\"", "秘密-like"] + + let first = try store.create(argv: argv) + let second = try store.create(argv: argv) + #expect(first.locator != second.locator) + #expect(mode(of: first.locator.deletingLastPathComponent()) == 0o700) + #expect(mode(of: first.locator) == 0o600) + + let lease = try CodexForwardingRecordReader.open(first.locator) + #expect(lease.argv == argv) + lease.close() + } + + @Test func retirementWaitsForGraceAndActiveSharedLease() throws { + let base = temporaryDirectory("forward-retire") + defer { try? FileManager.default.removeItem(at: base) } + var now = Date(timeIntervalSince1970: 100) + let store = try CodexForwardingRecordStore( + baseDirectory: base, + retirementGrace: 2, + now: { now } + ) + let record = try store.create(argv: ["/tmp/notifier"]) + let lease = try CodexForwardingRecordReader.open(record.locator) + + store.retire(record) + store.cleanupRetired() + #expect(FileManager.default.fileExists(atPath: record.locator.path(percentEncoded: false))) + + now.addTimeInterval(3) + store.cleanupRetired() + #expect(FileManager.default.fileExists(atPath: record.locator.path(percentEncoded: false))) + + lease.close() + store.cleanupRetired() + #expect(!FileManager.default.fileExists(atPath: record.locator.path(percentEncoded: false))) + } + + @Test func readerRejectsSymlinkPermissionDriftAndOversizedRecords() throws { + let base = temporaryDirectory("forward-invalid") + defer { try? FileManager.default.removeItem(at: base) } + let store = try CodexForwardingRecordStore(baseDirectory: base, retirementGrace: 0) + let record = try store.create(argv: ["/tmp/notifier"]) + + chmod(record.locator.path(percentEncoded: false), 0o644) + #expect(throws: CodexForwardingRecordError.invalidRecord) { + try CodexForwardingRecordReader.open(record.locator) + } + + let target = base.appending(path: "target", directoryHint: .notDirectory) + try Data(#"["/tmp/notifier"]"#.utf8).write(to: target) + let link = base.appending(path: "link", directoryHint: .notDirectory) + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: target) + #expect(throws: CodexForwardingRecordError.invalidRecord) { + try CodexForwardingRecordReader.open(link) + } + } + + @Test func orphanSweepOnlyRemovesAgedOwnedSessionDirectories() throws { + let base = temporaryDirectory("forward-orphans") + defer { try? FileManager.default.removeItem(at: base) } + var now = Date(timeIntervalSince1970: 1_000) + let oldStore = try CodexForwardingRecordStore( + baseDirectory: base, + retirementGrace: 0, + orphanMaximumAge: 60, + now: { now } + ) + let oldRecord = try oldStore.create(argv: ["/tmp/old"]) + now.addTimeInterval(120) + + let liveStore = try CodexForwardingRecordStore( + baseDirectory: base, + retirementGrace: 0, + orphanMaximumAge: 60, + now: { now } + ) + let liveRecord = try liveStore.create(argv: ["/tmp/live"]) + liveStore.sweepOrphans() + + #expect(!FileManager.default.fileExists(atPath: oldRecord.locator.path(percentEncoded: false))) + #expect(FileManager.default.fileExists(atPath: liveRecord.locator.path(percentEncoded: false))) + } + + private func temporaryDirectory(_ name: String) -> URL { + FileManager.default.temporaryDirectory + .appending(path: "prowl-tests-\(name)-\(UUID().uuidString)", directoryHint: .isDirectory) + } + + private func mode(of url: URL) -> mode_t? { + var value = stat() + guard lstat(url.path(percentEncoded: false), &value) == 0 else { return nil } + return value.st_mode & 0o777 + } +} diff --git a/supacodeTests/Fixtures/AgentNativeHooks/claude-2.1.241-session-start.json b/supacodeTests/Fixtures/AgentNativeHooks/claude-2.1.241-session-start.json new file mode 100644 index 00000000..5485ead7 --- /dev/null +++ b/supacodeTests/Fixtures/AgentNativeHooks/claude-2.1.241-session-start.json @@ -0,0 +1,10 @@ +{ + "session_id": "fixture-claude-session", + "transcript_path": "/tmp/Prowl Fixtures/界/transcript.jsonl", + "cwd": "/tmp/Prowl Fixtures/界", + "hook_event_name": "SessionStart", + "source": "startup", + "future_field": { + "ignored": true + } +} diff --git a/supacodeTests/Fixtures/AgentNativeHooks/claude-2.1.241-stop.json b/supacodeTests/Fixtures/AgentNativeHooks/claude-2.1.241-stop.json new file mode 100644 index 00000000..1bc500ef --- /dev/null +++ b/supacodeTests/Fixtures/AgentNativeHooks/claude-2.1.241-stop.json @@ -0,0 +1,10 @@ +{ + "session_id": "fixture-claude-session", + "transcript_path": "/tmp/Prowl Fixtures/界/transcript.jsonl", + "cwd": "/tmp/Prowl Fixtures/界", + "permission_mode": "manual", + "hook_event_name": "Stop", + "stop_hook_active": false, + "last_assistant_message": "This fixture field must never cross the Prowl hook bridge.", + "future_optional": null +} diff --git a/supacodeTests/Fixtures/AgentNativeHooks/codex-0.149.0-agent-turn-complete.json b/supacodeTests/Fixtures/AgentNativeHooks/codex-0.149.0-agent-turn-complete.json new file mode 100644 index 00000000..3045e13e --- /dev/null +++ b/supacodeTests/Fixtures/AgentNativeHooks/codex-0.149.0-agent-turn-complete.json @@ -0,0 +1,10 @@ +{ + "type": "agent-turn-complete", + "thread-id": "fixture-codex-thread", + "turn-id": "fixture-codex-turn", + "cwd": "/tmp/Prowl Fixtures/界", + "input-messages": ["fixture prompt"], + "last-assistant-message": "This fixture field must never cross the Prowl hook bridge.", + "client": "fixture", + "future-field": true +} diff --git a/supacodeTests/ManagedAgentHookObservationTests.swift b/supacodeTests/ManagedAgentHookObservationTests.swift new file mode 100644 index 00000000..60ba4db2 --- /dev/null +++ b/supacodeTests/ManagedAgentHookObservationTests.swift @@ -0,0 +1,226 @@ +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct ManagedAgentHookObservationTests { + @Test func earlyHookWaitsForFirstTimelyGenerationThenVerifiesDeclaredCoverage() throws { + let now = Date(timeIntervalSince1970: 100) + let store = AgentObservationStore(bufferCapacity: 8, now: { now }) + let surfaceID = UUID() + let registration = makeRegistration(runtime: .claude, cwd: "/tmp/project") + let epoch = store.registerManagedHook(registration, surfaceID: surfaceID) + let input = makeInput( + runtime: .claude, + token: registration.token, + nativeEvent: "SessionStart", + event: .sessionStart, + cwd: "/tmp/project" + ) + + #expect( + store.recordManagedHook( + input, + callerAncestry: [AgentProcessGeneration(pid: 900, startedAt: now)], + surfaceID: surfaceID + ) == .pending + ) + #expect( + store.signalsPayload(surfaceID: surfaceID, formatter: formatter, includeDiagnosticLast: true).channels.isEmpty) + + let update = store.updateEvidenceEpoch( + surfaceID: surfaceID, + processGeneration: AgentProcessGeneration(pid: 900, startedAt: now), + sessionID: nil + ) + #expect(update.activatedSignals.count == 1) + #expect(update.activatedSignals[0].source == .hook(runtime: .claude, event: "SessionStart")) + #expect(store.currentEvidenceEpoch(surfaceID: surfaceID) == epoch) + let channel = try #require( + store.signalsPayload(surfaceID: surfaceID, formatter: formatter, includeDiagnosticLast: true).channels.first + ) + #expect(channel.state == .verifiedLive) + #expect(channel.events == [.needsInput, .sessionEnd, .sessionStart, .turnEnded]) + } + + @Test func wrongTokenRuntimeEventCWDAndGenerationFailClosed() { + let now = Date(timeIntervalSince1970: 100) + let store = AgentObservationStore(bufferCapacity: 8, now: { now }) + let surfaceID = UUID() + let registration = makeRegistration(runtime: .codex, cwd: "/tmp/project") + _ = store.registerManagedHook(registration, surfaceID: surfaceID) + _ = store.updateEvidenceEpoch( + surfaceID: surfaceID, + processGeneration: AgentProcessGeneration(pid: 900, startedAt: now), + sessionID: nil + ) + + let rejected = [ + makeInput(runtime: .codex, token: "wrong", nativeEvent: "agent-turn-complete", cwd: "/tmp/project"), + makeInput(runtime: .claude, token: registration.token, nativeEvent: "Stop", cwd: "/tmp/project"), + makeInput(runtime: .codex, token: registration.token, nativeEvent: "future", cwd: "/tmp/project"), + makeInput(runtime: .codex, token: registration.token, nativeEvent: "agent-turn-complete", cwd: "/tmp/other"), + ] + for input in rejected { + #expect( + store.recordManagedHook( + input, + callerAncestry: [AgentProcessGeneration(pid: 900, startedAt: now)], + surfaceID: surfaceID + ) == .rejected + ) + } + let valid = makeInput( + runtime: .codex, + token: registration.token, + nativeEvent: "agent-turn-complete", + cwd: "/tmp/project" + ) + #expect( + store.recordManagedHook( + valid, + callerAncestry: [AgentProcessGeneration(pid: 901, startedAt: now)], + surfaceID: surfaceID + ) == .rejected + ) + } + + @Test func processReplacementRevokesTrustAndReturnsForwardRecordForRetirement() { + let now = Date(timeIntervalSince1970: 100) + let store = AgentObservationStore(bufferCapacity: 8, now: { now }) + let surfaceID = UUID() + let forward = CodexForwardingRecord(locator: URL(filePath: "/tmp/private/record.json")) + let registration = makeRegistration(runtime: .codex, cwd: "/tmp/project", forwardingRecord: forward) + _ = store.registerManagedHook(registration, surfaceID: surfaceID) + _ = store.updateEvidenceEpoch( + surfaceID: surfaceID, + processGeneration: AgentProcessGeneration(pid: 900, startedAt: now), + sessionID: nil + ) + + let replacement = store.updateEvidenceEpoch( + surfaceID: surfaceID, + processGeneration: AgentProcessGeneration(pid: 901, startedAt: now.addingTimeInterval(1)), + sessionID: nil + ) + #expect(replacement.revokedForwardingRecords == [forward]) + let input = makeInput( + runtime: .codex, + token: registration.token, + nativeEvent: "agent-turn-complete", + cwd: "/tmp/project" + ) + #expect( + store.recordManagedHook( + input, + callerAncestry: [AgentProcessGeneration(pid: 901, startedAt: now.addingTimeInterval(1))], + surfaceID: surfaceID + ) == .rejected + ) + } + + @Test func validClaudeSessionStartRotatesFreshnessButRetainsLaunchChannel() throws { + let now = Date(timeIntervalSince1970: 100) + let store = AgentObservationStore(bufferCapacity: 8, now: { now }) + let surfaceID = UUID() + let registration = makeRegistration(runtime: .claude, cwd: "/tmp/project") + let firstEpoch = store.registerManagedHook(registration, surfaceID: surfaceID) + _ = store.updateEvidenceEpoch( + surfaceID: surfaceID, + processGeneration: AgentProcessGeneration(pid: 900, startedAt: now), + sessionID: nil + ) + let first = makeInput( + runtime: .claude, + token: registration.token, + nativeEvent: "SessionStart", + event: .sessionStart, + cwd: "/tmp/project", + sessionID: "session-1" + ) + #expect( + store.recordManagedHook( + first, + callerAncestry: [AgentProcessGeneration(pid: 900, startedAt: now)], + surfaceID: surfaceID + ).isAccepted + ) + + let second = makeInput( + runtime: .claude, + token: registration.token, + nativeEvent: "SessionStart", + event: .sessionStart, + cwd: "/tmp/project", + sessionID: "session-2" + ) + #expect( + store.recordManagedHook( + second, + callerAncestry: [AgentProcessGeneration(pid: 900, startedAt: now)], + surfaceID: surfaceID + ).isAccepted + ) + #expect(store.currentEvidenceEpoch(surfaceID: surfaceID) != firstEpoch) + let channel = try #require( + store.signalsPayload(surfaceID: surfaceID, formatter: formatter, includeDiagnosticLast: true).channels.first + ) + #expect(channel.state == .verifiedLive) + #expect(channel.sessionID == "session-2") + } + + private var formatter: ISO8601DateFormatter { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + } + + private func makeRegistration( + runtime: AgentNativeHookRuntime, + cwd: String, + forwardingRecord: CodexForwardingRecord? = nil + ) -> AgentHookLaunchRegistration { + let nativeEvents: [String: AgentSignalEvent] = + runtime == .claude + ? [ + "SessionStart": .sessionStart, "Stop": .turnEnded, "SessionEnd": .sessionEnd, "PermissionRequest": .needsInput, + ] + : ["agent-turn-complete": .turnEnded] + return AgentHookLaunchRegistration( + token: "token-123", + runtime: runtime, + launchCWD: URL(filePath: cwd, directoryHint: .isDirectory), + nativeEvents: nativeEvents, + coveredEvents: Array(Set(nativeEvents.values)).sorted { $0.rawValue < $1.rawValue }, + forwardingRecord: forwardingRecord + ) + } + + private func makeInput( + runtime: AgentNativeHookRuntime, + token: String, + nativeEvent: String, + event: AgentSignalEvent = .turnEnded, + cwd: String, + sessionID: String = "session-1" + ) -> AgentNativeHookInput { + AgentNativeHookInput( + runtime: runtime, + token: token, + signal: AgentNativeHookSignal( + event: event, + nativeEvent: nativeEvent, + cwd: cwd, + sessionID: sessionID + ) + ) + } +} + +extension ManagedHookRecordResult { + fileprivate var isAccepted: Bool { + if case .accepted = self { return true } + return false + } +} diff --git a/supacodeTests/WorktreeTerminalManagerTests.swift b/supacodeTests/WorktreeTerminalManagerTests.swift index 1b535005..26913860 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -178,6 +178,90 @@ struct WorktreeTerminalManagerTests { #expect(launched.tabID == hiddenTab) } + @Test func unavailableHookResourcesWarnOnceAndLaunchTheOriginalInvocation() async throws { + let manager = WorktreeTerminalManager( + runtime: GhosttyRuntime(), + hookResourcesProvider: { nil } + ) + let worktree = makeWorktree() + let original = AgentProfileLaunchPlan( + profileID: UUID(), + profileName: "Codex", + runtime: .codex, + invocation: AgentInvocation(executable: "codex", arguments: ["Prompt"]), + commandEnvironmentTokens: [], + placement: .tab, + splitDirection: .right, + surfaceEnvironment: [AgentProfileLaunchPlanner.promptCarrierName: "Prompt"], + dedicatedHome: nil + ) + let request = AgentProfileLaunchRequest(plan: original, placement: .tab(background: false)) + + let preparation = try await manager.prepareAgentProfileLaunch(request, in: worktree).get() + + #expect(preparation.warnings.count == 1) + #expect(preparation.warnings[0].code == .managedHookDegraded) + #expect(preparation.context.request.plan.invocation == original.invocation) + #expect(preparation.context.request.plan.hookRegistration == nil) + let launched = try manager.launchPreparedAgentProfile(preparation, in: worktree).get() + #expect(manager.state(for: worktree).surfaceView(for: launched.surfaceID) != nil) + manager.state(for: worktree).cleanupAllAgentDetectionState() + } + + @Test func promptedManagedHookLaunchBindsDispatchToTheRegistrationEpoch() throws { + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let worktree = makeWorktree() + let state = manager.state(for: worktree) + let base = AgentProfileLaunchPlan( + profileID: UUID(), + profileName: "Codex", + runtime: .codex, + invocation: AgentInvocation(executable: "codex", arguments: ["Prompt"]), + commandEnvironmentTokens: [], + placement: .tab, + splitDirection: .right, + surfaceEnvironment: [AgentProfileLaunchPlanner.promptCarrierName: "Prompt"], + dedicatedHome: nil + ) + let plan = base.applyingManagedHook( + AgentHookPreparedInvocation( + invocation: AgentInvocation(executable: "codex", arguments: ["-c", "notify=[]", "Prompt"]), + argumentValues: [1: "notify=[]"] + ), + resources: AgentHookResources(bundledCLIPath: "/bundle/prowl", socketPath: "/tmp/prowl.sock"), + launchCWD: worktree.workingDirectory, + token: "token", + nativeEvents: ["agent-turn-complete": .turnEnded], + coveredEvents: [.turnEnded] + ) + let launched = try manager.launchAgentProfile( + AgentProfileLaunchRequest(plan: plan, placement: .tab(background: false)), + in: worktree + ).get() + let registrationEpoch = try #require(manager.agentEvidenceEpochForTesting(surfaceID: launched.surfaceID)) + let dispatch = try manager.issueAgentDispatch() + let dispatchID = dispatch.record.id + let target = TabResolvedTarget( + worktreeID: worktree.id, + worktreeName: worktree.name, + worktreePath: worktree.workingDirectory.path, + worktreeRootPath: worktree.repositoryRootURL.path, + worktreeKind: "git", + tabID: launched.tabID.rawValue.uuidString, + tabTitle: "Codex", + tabSelected: true, + paneID: launched.surfaceID.uuidString, + paneTitle: "codex", + paneCWD: worktree.workingDirectory.path, + paneFocused: true + ) + + try manager.bindAgentDispatch(dispatchID: dispatchID, target: target) + + #expect(manager.agentDispatchSnapshot(dispatchID: dispatchID)?.binding?.evidenceEpoch == registrationEpoch) + state.cleanupAllAgentDetectionState() + } + @Test func firstTabUsesTabSurfaceContext() throws { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() diff --git a/supacodeTests/WorktreeTerminalStateAgentProfileTests.swift b/supacodeTests/WorktreeTerminalStateAgentProfileTests.swift index 407e6eaf..7f5723b8 100644 --- a/supacodeTests/WorktreeTerminalStateAgentProfileTests.swift +++ b/supacodeTests/WorktreeTerminalStateAgentProfileTests.swift @@ -150,6 +150,50 @@ struct WorktreeTerminalStateAgentProfileTests { #expect(state.tabID(containing: surfaceID) == state.tabManager.tabs.first?.id) } + @Test func surfaceIsInstalledAndRegisteredBeforeProfileInputIsArmed() throws { + let state = makeState() + var callbackSurfaceID: UUID? + var callbackObservedInstalledSurface = false + var callbackObservedUnarmedSurface = false + state.onAgentProfileSurfacePrepared = { surfaceID, _ in + callbackSurfaceID = surfaceID + callbackObservedInstalledSurface = + state.surfaceView(for: surfaceID) != nil + && state.launchProfilesBySurface[surfaceID] != nil + callbackObservedUnarmedSurface = state.surfaceView(for: surfaceID)?.surfaceCreationArmed == false + return true + } + + let launched = try state.launchAgentProfile( + AgentProfileLaunchRequest( + plan: makePlan(dedicatedHome: nil), + placement: .tab(background: false) + ) + ).get() + + #expect(callbackSurfaceID == launched.surfaceID) + #expect(callbackObservedInstalledSurface) + #expect(callbackObservedUnarmedSurface) + #expect(state.surfaceView(for: launched.surfaceID)?.surfaceCreationArmed == true) + } + + @Test func registrationFailureRollsBackBeforeLeavingALiveSurface() { + let state = makeState() + state.onAgentProfileSurfacePrepared = { _, _ in false } + + let result = state.launchAgentProfile( + AgentProfileLaunchRequest( + plan: makePlan(dedicatedHome: nil), + placement: .tab(background: false) + ) + ) + + #expect(result == .failure(.hookRegistrationFailed)) + #expect(state.tabManager.tabs.isEmpty) + #expect(state.surfaces.isEmpty) + #expect(state.launchProfilesBySurface.isEmpty) + } + @Test func launchProfileNameOnlyAppliesToTheLaunchedRuntime() { let state = makeState() let surfaceID = UUID() -- 2.51.2 From ab103026f51ff12da393a2be1814aff629e4e8c4 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 24 Aug 2026 03:00:12 +0900 Subject: [PATCH 2/8] docs(ai): link S3a implementation PR --- docs-ai/063-agent-workflows/release-plan.md | 2 +- docs-ai/064-agent-completion-signals/000-plan.md | 2 +- docs-ai/064-agent-completion-signals/007-s3a-action.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs-ai/063-agent-workflows/release-plan.md b/docs-ai/063-agent-workflows/release-plan.md index 6f26e573..47f7b417 100644 --- a/docs-ai/063-agent-workflows/release-plan.md +++ b/docs-ai/063-agent-workflows/release-plan.md @@ -34,7 +34,7 @@ user-facing surface may merge before "their" release and stay dormant. Three rel | A2 | Merged | #714 | | S1 | Merged | #715: bus, multicast observer, `agents signal` | | S2 | Merged | #718: paired dispatch receipt, strict ID wait, generic evidence wait; [action record](../064-agent-completion-signals/005-s2-action.md) | -| S3 wave 1 | S3a implementation review | S3a Claude/Codex foundation implemented; S3b/S3c remain sequential before the slice is complete | +| S3 wave 1 | S3a PR #721 | S3a foundation implemented; S3b/S3c remain sequential before the slice is complete | | 065-S0/K1 | Planned, parallel | Skill-target spike + bundled-skill registry | | 065-K2/K3 | Planned | Follow S0/K1 inside R1 | diff --git a/docs-ai/064-agent-completion-signals/000-plan.md b/docs-ai/064-agent-completion-signals/000-plan.md index 208bfa37..948131dc 100644 --- a/docs-ai/064-agent-completion-signals/000-plan.md +++ b/docs-ai/064-agent-completion-signals/000-plan.md @@ -4,7 +4,7 @@ | --- | --- | | **Status** | In progress — S1 #715 and S2 #718 merged; S3a Claude/Codex implementation is ready for review | | **Anchor date** | 2026-08-22 | -| **Primary PRs** | #715 (S1); #718 (S2); S3a–S3c TBD | +| **Primary PRs** | #715 (S1); #718 (S2); #721 (S3a); S3b–S3c TBD | | **Related** | [063 agent-workflows](../063-agent-workflows/000-plan.md) (consumer; defines the `ObservedAgentState` observer this entry feeds), [030 agent-status-detection](../030-agent-status-detection/000-plan.md), [045 native-agent-session-detection](../045-native-agent-session-detection/000-plan.md), [055 agent-profile-runtimes](../055-agent-profile-runtimes/000-plan.md), [059 agent-transcript-snapshots](../059-agent-transcript-snapshots/000-plan.md), [060 cli-targeting-and-contract-governance](../060-prowl-cli-targeting-and-contract-governance/000-plan.md), [#473](https://github.com/onevcat/Prowl/issues/473), [#676](https://github.com/onevcat/Prowl/issues/676), `docs/components/agent-detection.md`, `docs/components/cli.md` | ## Background diff --git a/docs-ai/064-agent-completion-signals/007-s3a-action.md b/docs-ai/064-agent-completion-signals/007-s3a-action.md index 26b9d49d..b151b338 100644 --- a/docs-ai/064-agent-completion-signals/007-s3a-action.md +++ b/docs-ai/064-agent-completion-signals/007-s3a-action.md @@ -2,7 +2,7 @@ ## Status -Implemented on `feat/agent-signal-hooks-s3a-implementation`; implementation PR pending. +Implemented on `feat/agent-signal-hooks-s3a-implementation`; implementation PR [#721](https://github.com/onevcat/Prowl/pull/721). S3 wave 1 remains incomplete until S3b and S3c merge. ## Delivered behavior -- 2.51.2 From 753d1bbc18e0595987eb2f9b073d74b028bb79f6 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 24 Aug 2026 03:25:29 +0900 Subject: [PATCH 3/8] fix: harden managed signal launch lifecycle --- .../007-s3a-action.md | 29 ++++++ supacode/App/supacodeApp.swift | 5 + .../CLIService/LifecycleCommandHandler.swift | 27 +++++- .../AgentProfile/AgentProfileLaunchPlan.swift | 2 + .../AgentManagedHookPreparer.swift | 32 ++++-- .../AgentRuntime/CodexConfigReadProcess.swift | 8 ++ .../CodexEffectiveNotifyResolver.swift | 3 + .../CodexForwardingRecordStore.swift | 24 ++++- .../CodexShellLaunchEnvironment.swift | 64 ++++++++++++ .../BusinessLogic/AgentObservationStore.swift | 10 +- .../WorktreeTerminalManager.swift | 60 +++++++++--- .../WorktreeTerminalState+Surfaces.swift | 1 + .../Models/WorktreeTerminalState.swift | 14 ++- .../Ghostty/GhosttySurfaceView.swift | 8 +- .../CLILifecycleCommandHandlerTests.swift | 10 +- .../CodexForwardingRecordStoreTests.swift | 27 ++++++ .../CodexShellLaunchEnvironmentTests.swift | 96 ++++++++++++++++++ .../ManagedAgentHookObservationTests.swift | 97 +++++++++++++++++++ .../WorktreeTerminalManagerTests.swift | 35 ++++++- ...rktreeTerminalStateAgentProfileTests.swift | 31 +++++- 20 files changed, 540 insertions(+), 43 deletions(-) create mode 100644 supacode/Domain/AgentRuntime/CodexShellLaunchEnvironment.swift create mode 100644 supacodeTests/CodexShellLaunchEnvironmentTests.swift diff --git a/docs-ai/064-agent-completion-signals/007-s3a-action.md b/docs-ai/064-agent-completion-signals/007-s3a-action.md index b151b338..d72db927 100644 --- a/docs-ai/064-agent-completion-signals/007-s3a-action.md +++ b/docs-ai/064-agent-completion-signals/007-s3a-action.md @@ -111,6 +111,35 @@ listener loss, notifier forwarding, and app composition are executable automated single-app visible-pane matrix remains a required manual/owner follow-up when a GUI session is available. +## Adversarial review + +### Round 1 — trust, launch transaction, epochs, and forwarding lifecycle + +The first independent full-diff review blocked on five valid P1 findings. All were reproduced +against the current code and corrected with focused regression coverage: + +- Codex preflight now resolves an absolute executable plus `HOME`/`CODEX_HOME` through the same + non-logging login-shell environment a Profile command uses. An unprovable/non-absolute result + degrades without injection; the app's launchd PATH/home can no longer authorize notifier + replacement. +- Peer/task cancellation is checked after every preparation await, before and after forwarding + record creation, and immediately before dispatch issuance. Capacity/failure/cancellation paths + explicitly discard unexposed records; the cancellation test deliberately returns a successful + preparation after observing cancellation and still proves no issue/launch. +- Managed-hook session identity is independent from detector hints. Detector `nil` cannot erase a + verified session, and detector-first same-process replacement remains unverified until Claude + sends the matching `SessionStart`. +- Deferred Ghostty arming now fails when `ghostty_surface_new` returns nil, resets its armed state, + and rolls back the exact tab/split registration instead of reporting a launch with no process. +- Forwarding cleanup initializes an orphan sweep at app startup and owns a clock-driven retry loop + until every retired record can take the exclusive lease; one busy first pass no longer leaves + sensitive argv indefinitely. + +The review also confirmed the peer-PID ancestry boundary, pre-input order, bounded early-event +buffer, exact cwd rejection (including memories), hidden bridge silence/deadline/`execvp`, payload +exclusion, carrier redaction, and owner/mode/no-follow/lease checks. Focused validation after the +fixes passed 89 tests plus `make check` (34 script tests). + ## Deferred scope S3b owns Copilot/Droid/Qoder adapters. S3c owns Pi/OMP/OpenCode adapters and the Active Agents exact diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index b999f1d2..db74c159 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -198,6 +198,7 @@ struct SupacodeApp: App { runtime: runtime, preferredFontSize: initialSettings.terminalFontSize ) + terminalManager.startAgentHookRuntimeMaintenance() _terminalManager = State(initialValue: terminalManager) let worktreeInfoWatcher = WorktreeInfoWatcherManager() _worktreeInfoWatcher = State(initialValue: worktreeInfoWatcher) @@ -1028,6 +1029,10 @@ struct SupacodeApp: App { terminalManager: terminalManager ) }, + cancelProfilePreparation: { request in + guard let preparation = request.preparedLaunch else { return } + terminalManager.discardPreparedAgentProfileLaunch(preparation) + }, issueDispatch: { do { let snapshot = try terminalManager.issueAgentDispatch() diff --git a/supacode/CLIService/LifecycleCommandHandler.swift b/supacode/CLIService/LifecycleCommandHandler.swift index 289e5fef..838bd144 100644 --- a/supacode/CLIService/LifecycleCommandHandler.swift +++ b/supacode/CLIService/LifecycleCommandHandler.swift @@ -96,6 +96,10 @@ enum CLIProfileLaunchFailure: Error, Equatable, Sendable { "The tab for Agent Profile “\(profile.name)” was created without a terminal surface." case .hookRegistrationFailed: "The managed signal channel for Agent Profile “\(profile.name)” could not be registered." + case .surfaceCreationFailed: + "The terminal surface for Agent Profile “\(profile.name)” could not be created." + case .preparationCancelled: + "Agent Profile “\(profile.name)” launch preparation was cancelled." } return .createFailed(message) } @@ -113,6 +117,7 @@ final class LifecycleCommandHandler: CommandHandler { @MainActor (CLIProfileLaunchRequest) async -> Result typealias ProfileLaunchProvider = @MainActor (CLIProfileLaunchRequest) -> Result + typealias CancelProfilePreparationProvider = @MainActor (CLIProfileLaunchRequest) -> Void typealias IssueDispatchProvider = @MainActor () -> Result typealias BindDispatchProvider = @@ -129,6 +134,7 @@ final class LifecycleCommandHandler: CommandHandler { private let profiles: ProfilesProvider private let prepareAgentProfile: PrepareProfileLaunchProvider private let launchAgentProfile: ProfileLaunchProvider + private let cancelProfilePreparation: CancelProfilePreparationProvider private let issueDispatch: IssueDispatchProvider private let bindDispatch: BindDispatchProvider private let cancelDispatch: CancelDispatchProvider @@ -146,6 +152,7 @@ final class LifecycleCommandHandler: CommandHandler { launchAgentProfile: @escaping ProfileLaunchProvider = { _ in .failure(.createFailed("Failed to launch the Agent Profile.")) }, + cancelProfilePreparation: @escaping CancelProfilePreparationProvider = { _ in }, issueDispatch: @escaping IssueDispatchProvider = { .failure(.capacityExceeded) }, bindDispatch: @escaping BindDispatchProvider = { _, _ in .failure(.notFound) }, cancelDispatch: @escaping CancelDispatchProvider = { _ in }, @@ -160,6 +167,7 @@ final class LifecycleCommandHandler: CommandHandler { self.profiles = profiles self.prepareAgentProfile = prepareAgentProfile self.launchAgentProfile = launchAgentProfile + self.cancelProfilePreparation = cancelProfilePreparation self.issueDispatch = issueDispatch self.bindDispatch = bindDispatch self.cancelDispatch = cancelDispatch @@ -330,8 +338,15 @@ final class LifecycleCommandHandler: CommandHandler { ) } + if Task.isCancelled { + cancelProfilePreparation(preparedRequest) + return cancelledPreparationResponse() + } let dispatchResult = issuedDispatch(prompt: launch.prompt) - if let response = dispatchResult.response { return response } + if let response = dispatchResult.response { + cancelProfilePreparation(preparedRequest) + return response + } let dispatch = dispatchResult.record let pairedRequest = CLIProfileLaunchRequest( resource: preparedRequest.resource, @@ -350,9 +365,11 @@ final class LifecycleCommandHandler: CommandHandler { createdTarget = target case .failure(.invalidArgument(let message)): if let dispatch { cancelDispatch(dispatch.id) } + cancelProfilePreparation(preparedRequest) return errorResponse(command: "create", code: CLIErrorCode.invalidArgument, message: message) case .failure(.createFailed(let message)): if let dispatch { cancelDispatch(dispatch.id) } + cancelProfilePreparation(preparedRequest) return errorResponse(command: "create", code: CLIErrorCode.createFailed, message: message) } if let dispatch { @@ -398,6 +415,14 @@ final class LifecycleCommandHandler: CommandHandler { } } + private func cancelledPreparationResponse() -> CommandResponse { + errorResponse( + command: "create", + code: CLIErrorCode.createFailed, + message: "Agent Profile launch preparation was cancelled." + ) + } + private func issuedDispatch( prompt: String? ) -> (record: DispatchPendingRecord?, response: CommandResponse?) { diff --git a/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift b/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift index 98002280..2f507d20 100644 --- a/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift +++ b/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift @@ -259,6 +259,8 @@ nonisolated enum AgentProfileLaunchError: Error, Equatable, Sendable { case tabCreationFailed case launchedSurfaceMissing(TerminalTabID) case hookRegistrationFailed + case surfaceCreationFailed + case preparationCancelled } /// Which env variable names a profile override may set, shared by the planner diff --git a/supacode/Domain/AgentRuntime/AgentManagedHookPreparer.swift b/supacode/Domain/AgentRuntime/AgentManagedHookPreparer.swift index 756147af..0e585031 100644 --- a/supacode/Domain/AgentRuntime/AgentManagedHookPreparer.swift +++ b/supacode/Domain/AgentRuntime/AgentManagedHookPreparer.swift @@ -11,7 +11,7 @@ nonisolated struct AgentManagedHookPreparation: Equatable, Sendable { nonisolated enum AgentManagedHookPreparer { private struct CodexPreparationOptions { let promptIndex: Int? - let processEnvironment: [String: String] + let shellEnvironment: CodexShellLaunchEnvironment? let configReadProcess: CodexConfigReadProcess } @@ -23,7 +23,7 @@ nonisolated enum AgentManagedHookPreparer { plan: AgentProfileLaunchPlan, inheritedCWD: URL, resources: AgentHookResources?, - processEnvironment: [String: String], + codexShellEnvironment: CodexShellLaunchEnvironment? = nil, codexConfigReadProcess: CodexConfigReadProcess = CodexConfigReadProcess() ) async -> AgentManagedHookPreparation { guard @@ -68,7 +68,7 @@ nonisolated enum AgentManagedHookPreparer { resources: resources, options: CodexPreparationOptions( promptIndex: promptIndex, - processEnvironment: processEnvironment, + shellEnvironment: codexShellEnvironment, configReadProcess: codexConfigReadProcess ) ) @@ -117,13 +117,25 @@ nonisolated enum AgentManagedHookPreparer { resources: AgentHookResources, options: CodexPreparationOptions ) async -> AgentManagedHookPreparation { + guard let shellEnvironment = options.shellEnvironment else { + return degraded( + plan: plan, + capability: capability, + launchCWD: inheritedCWD, + message: "The effective Codex shell environment could not be resolved." + ) + } + let invocation = AgentInvocation( + executable: shellEnvironment.executableURL.path(percentEncoded: false), + arguments: plan.invocation.arguments + ) let context: CodexLaunchContext do { context = try CodexLaunchContext.capture( - invocation: plan.invocation, + invocation: invocation, inheritedCWD: inheritedCWD, dedicatedHome: plan.dedicatedHome, - environment: options.processEnvironment, + environment: shellEnvironment.processEnvironment, promptArgumentIndex: options.promptIndex ) } catch { @@ -136,12 +148,12 @@ nonisolated enum AgentManagedHookPreparer { } let resolver = CodexEffectiveNotifyResolver( bundledCLIPath: resources.bundledCLIPath, - query: options.configReadProcess.query + query: options.configReadProcess.usingExecutable(shellEnvironment.executableURL).query ) switch await resolver.resolve(context) { case .absent: return preparedCodex( - plan: plan, + invocation: invocation, capability: capability, context: context, resources: resources, @@ -152,7 +164,7 @@ nonisolated enum AgentManagedHookPreparer { ) case .present(let argv): return preparedCodex( - plan: plan, + invocation: invocation, capability: capability, context: context, resources: resources, @@ -172,7 +184,7 @@ nonisolated enum AgentManagedHookPreparer { } private static func preparedCodex( - plan: AgentProfileLaunchPlan, + invocation: AgentInvocation, capability: AgentSignalHookCapability, context: CodexLaunchContext, resources: AgentHookResources, @@ -180,7 +192,7 @@ nonisolated enum AgentManagedHookPreparer { ) -> AgentManagedHookPreparation { AgentManagedHookPreparation( preparedInvocation: CodexManagedNotifyRenderer.prepare( - invocation: plan.invocation, + invocation: invocation, bundledCLIPath: resources.bundledCLIPath, promptArgumentIndex: options.promptIndex ), diff --git a/supacode/Domain/AgentRuntime/CodexConfigReadProcess.swift b/supacode/Domain/AgentRuntime/CodexConfigReadProcess.swift index 65932487..153b6f25 100644 --- a/supacode/Domain/AgentRuntime/CodexConfigReadProcess.swift +++ b/supacode/Domain/AgentRuntime/CodexConfigReadProcess.swift @@ -48,6 +48,14 @@ nonisolated struct CodexConfigReadProcess: Sendable { self.timeout = max(0.05, timeout) } + func usingExecutable(_ executableURL: URL) -> CodexConfigReadProcess { + CodexConfigReadProcess( + executableURL: executableURL, + temporaryBaseDirectory: temporaryBaseDirectory, + timeout: timeout + ) + } + func query(_ query: CodexConfigQuery) async throws -> Data { let fileManager = FileManager.default var parserHome: URL? diff --git a/supacode/Domain/AgentRuntime/CodexEffectiveNotifyResolver.swift b/supacode/Domain/AgentRuntime/CodexEffectiveNotifyResolver.swift index f8a5ee95..258b765b 100644 --- a/supacode/Domain/AgentRuntime/CodexEffectiveNotifyResolver.swift +++ b/supacode/Domain/AgentRuntime/CodexEffectiveNotifyResolver.swift @@ -172,6 +172,9 @@ nonisolated struct CodexLaunchContext: Equatable, Sendable { codexHome = dedicatedHome } else if let configured = environment["CODEX_HOME"], !configured.isEmpty { codexHome = URL(filePath: configured, directoryHint: .isDirectory) + } else if let home = environment["HOME"], !home.isEmpty { + codexHome = URL(filePath: home, directoryHint: .isDirectory) + .appending(path: ".codex", directoryHint: .isDirectory) } else { codexHome = FileManager.default.homeDirectoryForCurrentUser .appending(path: ".codex", directoryHint: .isDirectory) diff --git a/supacode/Domain/AgentRuntime/CodexForwardingRecordStore.swift b/supacode/Domain/AgentRuntime/CodexForwardingRecordStore.swift index eccd77b7..cd73ac99 100644 --- a/supacode/Domain/AgentRuntime/CodexForwardingRecordStore.swift +++ b/supacode/Domain/AgentRuntime/CodexForwardingRecordStore.swift @@ -17,18 +17,22 @@ final class CodexForwardingRecordStore { private let retirementGrace: TimeInterval private let orphanMaximumAge: TimeInterval private let now: @MainActor () -> Date + private let retirementClock: any Clock private var retired: [URL: Date] = [:] + private var cleanupTask: Task? init( baseDirectory: URL, retirementGrace: TimeInterval = 2, orphanMaximumAge: TimeInterval = 24 * 60 * 60, - now: @escaping @MainActor () -> Date = Date.init + now: @escaping @MainActor () -> Date = Date.init, + retirementClock: any Clock = ContinuousClock() ) throws { self.baseDirectory = baseDirectory.standardizedFileURL self.retirementGrace = max(0, retirementGrace) self.orphanMaximumAge = max(0, orphanMaximumAge) self.now = now + self.retirementClock = retirementClock try Self.ensureOwnerOnlyDirectory(self.baseDirectory) sessionDirectory = self.baseDirectory.appending( path: "session-\(UUID().uuidString)", @@ -101,6 +105,7 @@ final class CodexForwardingRecordStore { func retire(_ record: CodexForwardingRecord) { retired[record.locator] = now().addingTimeInterval(retirementGrace) + scheduleCleanupIfNeeded() } func cleanupRetired() { @@ -112,6 +117,23 @@ final class CodexForwardingRecordStore { } } + private func scheduleCleanupIfNeeded() { + guard cleanupTask == nil else { return } + let clock = retirementClock + let milliseconds = max(100, Int(retirementGrace * 1_000)) + cleanupTask = Task { @MainActor [weak self] in + while let self, !retired.isEmpty { + do { + try await clock.sleep(for: .milliseconds(milliseconds)) + } catch { + break + } + cleanupRetired() + } + self?.cleanupTask = nil + } + } + func sweepOrphans() { guard let entries = try? FileManager.default.contentsOfDirectory( diff --git a/supacode/Domain/AgentRuntime/CodexShellLaunchEnvironment.swift b/supacode/Domain/AgentRuntime/CodexShellLaunchEnvironment.swift new file mode 100644 index 00000000..741b6ab3 --- /dev/null +++ b/supacode/Domain/AgentRuntime/CodexShellLaunchEnvironment.swift @@ -0,0 +1,64 @@ +import Foundation + +nonisolated struct CodexShellLaunchEnvironment: Equatable, Sendable { + let executableURL: URL + let processEnvironment: [String: String] +} + +nonisolated enum CodexShellLaunchEnvironmentProbe { + private static let executableMarker = "__PROWL_CODEX_EXECUTABLE__" + private static let homeMarker = "__PROWL_CODEX_HOME_BASE__" + private static let codexHomeMarker = "__PROWL_CODEX_HOME__" + private static let script = """ + executable="$(command -v -- codex)" || exit 1 + printf '%s%s\n' '\(executableMarker)' "$executable" + printf '%s%s\n' '\(homeMarker)' "${HOME-}" + printf '%s%s\n' '\(codexHomeMarker)' "${CODEX_HOME-}" + """ + + static func resolve( + cwd: URL, + shell: ShellClient = .live, + isExecutable: (String) -> Bool = { FileManager.default.isExecutableFile(atPath: $0) } + ) async -> CodexShellLaunchEnvironment? { + guard + let output = try? await shell.runLogin( + URL(filePath: "/bin/sh", directoryHint: .notDirectory), + ["-c", script], + cwd, + log: false + ), + output.exitCode == 0, + output.stdout.utf8.count <= 16 * 1_024, + let values = parse(output.stdout), + let executable = values[executableMarker], executable.hasPrefix("/"), + isExecutable(executable), + let home = values[homeMarker], home.hasPrefix("/") + else { return nil } + + var environment = ["HOME": home] + if let codexHome = values[codexHomeMarker], !codexHome.isEmpty { + guard codexHome.hasPrefix("/") else { return nil } + environment["CODEX_HOME"] = codexHome + } + return CodexShellLaunchEnvironment( + executableURL: URL(filePath: executable, directoryHint: .notDirectory).standardizedFileURL, + processEnvironment: environment + ) + } + + private static func parse(_ output: String) -> [String: String]? { + let lines = output.split(separator: "\n", omittingEmptySubsequences: false) + guard lines.count == 4, lines.last?.isEmpty == true else { return nil } + var values: [String: String] = [:] + for line in lines.dropLast() { + let value = String(line) + guard + let marker = [executableMarker, homeMarker, codexHomeMarker].first(where: value.hasPrefix), + values[marker] == nil + else { return nil } + values[marker] = String(value.dropFirst(marker.count)) + } + return values.count == 3 ? values : nil + } +} diff --git a/supacode/Features/Terminal/BusinessLogic/AgentObservationStore.swift b/supacode/Features/Terminal/BusinessLogic/AgentObservationStore.swift index 11df5559..24a68eef 100644 --- a/supacode/Features/Terminal/BusinessLogic/AgentObservationStore.swift +++ b/supacode/Features/Terminal/BusinessLogic/AgentObservationStore.swift @@ -17,6 +17,7 @@ private struct ManagedHookRegistrationRecord { let launch: AgentHookLaunchRegistration var evidenceEpoch: UUID var processGeneration: AgentProcessGeneration? + var sessionID: String? var verified = false var pendingSignals: [PendingManagedHookSignal] = [] } @@ -291,7 +292,7 @@ final class AgentObservationStore { } if input.runtime == .claude, input.signal.event == .sessionStart, - let currentSession = record.sessionID, + let currentSession = managed.sessionID, currentSession != input.signal.sessionID { record.evidenceEpoch = UUID() @@ -301,12 +302,13 @@ final class AgentObservationStore { if record.latestSignal != nil { record.latestSignalBinding = .stale } managed.evidenceEpoch = record.evidenceEpoch managed.verified = false - } else if let currentSession = record.sessionID, + } else if let currentSession = managed.sessionID, currentSession != input.signal.sessionID { return .rejected } record.sessionID = input.signal.sessionID + managed.sessionID = input.signal.sessionID managed.verified = true let runtime = AgentProfileRuntime(rawValue: input.runtime.rawValue) ?? .claude let signal = AgentSignal( @@ -412,7 +414,7 @@ final class AgentObservationStore { managed.pendingSignals.removeAll() record.managedHook = managed record.processGeneration = processGeneration - record.sessionID = sessionID + if let sessionID { record.sessionID = sessionID } records[surfaceID] = record for pendingSignal in pending { if case .accepted(let signal, _) = recordManagedHook( @@ -426,7 +428,7 @@ final class AgentObservationStore { return update } record.processGeneration = processGeneration - record.sessionID = sessionID + if let sessionID { record.sessionID = sessionID } records[surfaceID] = record return update } diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 65488030..f26fd3da 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -11,10 +11,12 @@ private let layoutRestoreFailureMessage = "Saved terminal layout was invalid and final class WorktreeTerminalManager { private let runtime: GhosttyRuntime? private let layoutPersistence: TerminalLayoutPersistenceClient + private let skipsSurfaceCreationForTesting: Bool private let targetHandleRegistry = TerminalTargetHandleRegistry() @ObservationIgnored private let agentObservationStore: AgentObservationStore @ObservationIgnored private let agentDispatchStore: AgentDispatchStore @ObservationIgnored private let codexConfigReadProcess: CodexConfigReadProcess + @ObservationIgnored private let codexShellEnvironmentResolver: @Sendable (URL) async -> CodexShellLaunchEnvironment? @ObservationIgnored private let hookResourcesProvider: @MainActor () -> AgentHookResources? @ObservationIgnored private let forwardingRecordBaseDirectory: URL @ObservationIgnored private var codexForwardingRecordStore: CodexForwardingRecordStore? @@ -44,6 +46,9 @@ final class WorktreeTerminalManager { agentObservationBufferCapacity: Int = 64, agentDispatchStore: AgentDispatchStore = AgentDispatchStore(), codexConfigReadProcess: CodexConfigReadProcess = CodexConfigReadProcess(), + codexShellEnvironmentResolver: @escaping @Sendable (URL) async -> CodexShellLaunchEnvironment? = { + await CodexShellLaunchEnvironmentProbe.resolve(cwd: $0) + }, hookResourcesProvider: @escaping @MainActor () -> AgentHookResources? = { guard let url = SupacodePaths.bundledCLIURL else { return nil } return AgentHookResources( @@ -51,14 +56,17 @@ final class WorktreeTerminalManager { socketPath: ProwlSocket.defaultPath ) }, - forwardingRecordBaseDirectory: URL = SupacodePaths.agentHookForwardingDirectory + forwardingRecordBaseDirectory: URL = SupacodePaths.agentHookForwardingDirectory, + skipsSurfaceCreationForTesting: Bool = false ) { self.runtime = runtime self.layoutPersistence = layoutPersistence + self.skipsSurfaceCreationForTesting = skipsSurfaceCreationForTesting self.preferredFontSize = preferredFontSize self.agentObservationStore = AgentObservationStore(bufferCapacity: agentObservationBufferCapacity) self.agentDispatchStore = agentDispatchStore self.codexConfigReadProcess = codexConfigReadProcess + self.codexShellEnvironmentResolver = codexShellEnvironmentResolver self.hookResourcesProvider = hookResourcesProvider self.forwardingRecordBaseDirectory = forwardingRecordBaseDirectory baselineFontSize = runtime.defaultFontSize() @@ -108,13 +116,21 @@ final class WorktreeTerminalManager { } latestContext = context let resources = hookResourcesProvider() + let codexShellEnvironment: CodexShellLaunchEnvironment? + if context.request.plan.runtime == .codex, resources != nil { + codexShellEnvironment = await codexShellEnvironmentResolver(context.inheritedCWD) + } else { + codexShellEnvironment = nil + } + guard !Task.isCancelled else { return .failure(.preparationCancelled) } let preparation = await AgentManagedHookPreparer.prepare( plan: context.request.plan, inheritedCWD: context.inheritedCWD, resources: resources, - processEnvironment: ProcessInfo.processInfo.environment, + codexShellEnvironment: codexShellEnvironment, codexConfigReadProcess: codexConfigReadProcess ) + guard !Task.isCancelled else { return .failure(.preparationCancelled) } guard terminalState.isAgentProfileLaunchContextValid(context) else { if attempt == 0 { continue } let warning = LifecycleCommandWarning( @@ -150,6 +166,10 @@ final class WorktreeTerminalManager { } forwardingRecord = record } + if Task.isCancelled { + if let forwardingRecord { codexForwardingRecordStore?.discardUnexposed(forwardingRecord) } + return .failure(.preparationCancelled) + } let executionPlan = context.request.plan.applyingManagedHook( preparedInvocation, resources: resources, @@ -181,6 +201,11 @@ final class WorktreeTerminalManager { return .success(PreparedAgentProfileLaunch(context: latestContext, warnings: [])) } + func discardPreparedAgentProfileLaunch(_ preparation: PreparedAgentProfileLaunch) { + guard let record = preparation.context.request.plan.hookRegistration?.forwardingRecord else { return } + codexForwardingRecordStore?.discardUnexposed(record) + } + func launchPreparedAgentProfile( _ preparation: PreparedAgentProfileLaunch, in worktree: Worktree @@ -194,6 +219,10 @@ final class WorktreeTerminalManager { return result } + func startAgentHookRuntimeMaintenance() { + _ = forwardingRecordStore() + } + private func forwardingRecordStore() -> CodexForwardingRecordStore? { if let codexForwardingRecordStore { return codexForwardingRecordStore } guard @@ -217,6 +246,8 @@ final class WorktreeTerminalManager { : .tab(background: false) ) switch await prepareAgentProfileLaunch(request, in: worktree) { + case .failure where Task.isCancelled: + return case .failure: emit(.agentProfileLaunchFailed(worktreeID: worktree.id, profileName: plan.profileName)) case .success(let preparation): @@ -467,12 +498,7 @@ final class WorktreeTerminalManager { } private func retireForwardingRecord(_ record: CodexForwardingRecord) { - guard let store = codexForwardingRecordStore else { return } - store.retire(record) - Task { @MainActor [weak self] in - try? await ContinuousClock().sleep(for: .seconds(3)) - self?.codexForwardingRecordStore?.cleanupRetired() - } + codexForwardingRecordStore?.retire(record) } private func noteDispatchEvidence( @@ -597,14 +623,21 @@ final class WorktreeTerminalManager { guard snapshot.record.state == .pending else { throw AgentDispatchStoreError.alreadyTerminal } + let evidenceEpoch: UUID + if agentObservationStore.hasManagedHook(surfaceID: surfaceID) { + guard let current = agentObservationStore.currentEvidenceEpoch(surfaceID: surfaceID) else { + throw AgentDispatchStoreError.bindingMissing + } + evidenceEpoch = current + } else { + evidenceEpoch = agentObservationStore.beginDispatchEpoch(surfaceID: surfaceID) + } try agentDispatchStore.bind( dispatchID: dispatchID, binding: AgentDispatchBinding( surfaceID: surfaceID, target: target, - evidenceEpoch: agentObservationStore.hasManagedHook(surfaceID: surfaceID) - ? (agentObservationStore.currentEvidenceEpoch(surfaceID: surfaceID) ?? UUID()) - : agentObservationStore.beginDispatchEpoch(surfaceID: surfaceID) + evidenceEpoch: evidenceEpoch ) ) } @@ -685,7 +718,8 @@ final class WorktreeTerminalManager { worktree: worktree, runSetupScript: runSetupScript, defaultFontSize: preferredFontSize, - targetHandleRegistry: targetHandleRegistry + targetHandleRegistry: targetHandleRegistry, + skipsSurfaceCreationForTesting: skipsSurfaceCreationForTesting ) state.setNotificationsEnabled(notificationsEnabled) state.setCommandFinishedNotification( @@ -1207,10 +1241,12 @@ final class WorktreeTerminalManager { private init(preview: Void) { self.runtime = nil self.layoutPersistence = .liveValue + self.skipsSurfaceCreationForTesting = true self.preferredFontSize = nil self.agentObservationStore = AgentObservationStore(bufferCapacity: 64) self.agentDispatchStore = AgentDispatchStore() self.codexConfigReadProcess = CodexConfigReadProcess() + self.codexShellEnvironmentResolver = { _ in nil } self.hookResourcesProvider = { nil } self.forwardingRecordBaseDirectory = SupacodePaths.agentHookForwardingDirectory self.baselineFontSize = 13 diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift index e06cc082..4eff8472 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift @@ -373,6 +373,7 @@ extension WorktreeTerminalState { fontSize: resolvedFontSize, context: context, environment: worktree.scriptEnvironment.merging(additionalEnvironment) { _, patched in patched }, + skipsSurfaceCreationForTesting: skipsSurfaceCreationForTesting, defersSurfaceCreation: defersSurfaceCreation ) // Sending a no-op font size action marks the Ghostty surface as diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 8a1d67bd..43eedbdf 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -95,6 +95,7 @@ final class WorktreeTerminalState { let runtime: GhosttyRuntime let worktree: Worktree private let targetHandleRegistry: TerminalTargetHandleRegistry + let skipsSurfaceCreationForTesting: Bool @ObservationIgnored @SharedReader private var repositorySettings: RepositorySettings var trees: [TerminalTabID: SplitTree] = [:] @@ -285,11 +286,13 @@ final class WorktreeTerminalState { runSetupScript: Bool = false, defaultFontSize: Float32? = nil, targetHandleRegistry: TerminalTargetHandleRegistry? = nil, - titleFlushClock: any Clock = ContinuousClock() + titleFlushClock: any Clock = ContinuousClock(), + skipsSurfaceCreationForTesting: Bool = false ) { self.runtime = runtime self.worktree = worktree self.targetHandleRegistry = targetHandleRegistry ?? TerminalTargetHandleRegistry() + self.skipsSurfaceCreationForTesting = skipsSurfaceCreationForTesting self.pendingSetupScript = runSetupScript self.defaultFontSize = defaultFontSize self.tabManager = TerminalTabManager(titleFlushClock: titleFlushClock) @@ -628,13 +631,14 @@ final class WorktreeTerminalState { { applyResolvedIcon(icon, surfaceId: surface.surfaceID, tabId: surface.tabID) } - guard onAgentProfileSurfacePrepared?(surface.surfaceID, plan) != false, - let view = surfaces[surface.surfaceID], - view.armSurfaceCreation() - else { + guard onAgentProfileSurfacePrepared?(surface.surfaceID, plan) != false else { rollbackAgentProfileSurface(surface, placement: request.placement) return .failure(.hookRegistrationFailed) } + guard let view = surfaces[surface.surfaceID], view.armSurfaceCreation() else { + rollbackAgentProfileSurface(surface, placement: request.placement) + return .failure(.surfaceCreationFailed) + } wakeAgentDetection(for: view, tabId: surface.tabID) return launched } diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift index 90b6eb96..32834130 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift @@ -370,11 +370,11 @@ final class GhosttySurfaceView: NSView, Identifiable { surfaceCreationArmed = true guard !skipsSurfaceCreationForTesting else { return true } createSurface() - if let surface { - surfaceRef = runtime.registerSurface(surface) + guard let surface else { + surfaceCreationArmed = false + return false } - // Surface construction has historically been best-effort at this layer; - // lifecycle failure is reported by the surrounding state boundary. + surfaceRef = runtime.registerSurface(surface) return true } diff --git a/supacodeTests/CLILifecycleCommandHandlerTests.swift b/supacodeTests/CLILifecycleCommandHandlerTests.swift index 0332139b..91b166cb 100644 --- a/supacodeTests/CLILifecycleCommandHandlerTests.swift +++ b/supacodeTests/CLILifecycleCommandHandlerTests.swift @@ -234,6 +234,7 @@ struct CLILifecycleCommandHandlerTests { let clock = TestClock() var issued = false var launched = false + var cancelledPreparation = false let handler = LifecycleCommandHandler( resolveCreateTarget: { _ in .success(base) }, resolveCloseTarget: { _ in .success(.init(resource: .pane, target: base)) }, @@ -245,13 +246,16 @@ struct CLILifecycleCommandHandlerTests { try await clock.sleep(for: .seconds(10)) return .success(request) } catch { - return .failure(.createFailed("Cancelled.")) + // Model a production resolver that catches cancellation and returns + // an ordinary degraded/successful preparation. + return .success(request) } }, launchAgentProfile: { _ in launched = true return .success(base) }, + cancelProfilePreparation: { _ in cancelledPreparation = true }, issueDispatch: { issued = true return .failure(.capacityExceeded) @@ -280,6 +284,7 @@ struct CLILifecycleCommandHandlerTests { #expect(!issued) #expect(!launched) + #expect(cancelledPreparation) } @Test func promptedLaunchFailureCancelsIssuedDispatch() async { @@ -325,6 +330,7 @@ struct CLILifecycleCommandHandlerTests { let base = makeTarget() let profile = AgentProfile(name: "Reviewer", runtime: .claude) var didLaunch = false + var cancelledPreparation = false let handler = LifecycleCommandHandler( resolveCreateTarget: { _ in .success(base) }, resolveCloseTarget: { _ in .success(LifecycleResolvedTarget(resource: .pane, target: base)) }, @@ -335,6 +341,7 @@ struct CLILifecycleCommandHandlerTests { didLaunch = true return .success(base) }, + cancelProfilePreparation: { _ in cancelledPreparation = true }, issueDispatch: { .failure(.capacityExceeded) }, closeTab: { _, _ in true }, closePane: { _, _ in true } @@ -356,6 +363,7 @@ struct CLILifecycleCommandHandlerTests { #expect(!response.ok) #expect(response.error?.code == CLIErrorCode.dispatchCapacityExceeded) #expect(!didLaunch) + #expect(cancelledPreparation) } @Test func unpromptedProfileLaunchDoesNotIssueOrBindDispatch() async throws { diff --git a/supacodeTests/CodexForwardingRecordStoreTests.swift b/supacodeTests/CodexForwardingRecordStoreTests.swift index b35ce325..2375db23 100644 --- a/supacodeTests/CodexForwardingRecordStoreTests.swift +++ b/supacodeTests/CodexForwardingRecordStoreTests.swift @@ -1,3 +1,4 @@ +import Clocks import Darwin import Foundation import Testing @@ -48,6 +49,32 @@ struct CodexForwardingRecordStoreTests { #expect(!FileManager.default.fileExists(atPath: record.locator.path(percentEncoded: false))) } + @Test func scheduledCleanupRetriesAfterTheFirstLeaseConflict() async throws { + let base = temporaryDirectory("forward-scheduled-retire") + defer { try? FileManager.default.removeItem(at: base) } + var now = Date(timeIntervalSince1970: 100) + let clock = TestClock() + let store = try CodexForwardingRecordStore( + baseDirectory: base, + retirementGrace: 1, + now: { now }, + retirementClock: clock + ) + let record = try store.create(argv: ["/tmp/notifier"]) + let lease = try CodexForwardingRecordReader.open(record.locator) + store.retire(record) + await Task.yield() + + now.addTimeInterval(2) + await clock.advance(by: .seconds(1)) + #expect(FileManager.default.fileExists(atPath: record.locator.path(percentEncoded: false))) + + lease.close() + now.addTimeInterval(2) + await clock.advance(by: .seconds(1)) + #expect(!FileManager.default.fileExists(atPath: record.locator.path(percentEncoded: false))) + } + @Test func readerRejectsSymlinkPermissionDriftAndOversizedRecords() throws { let base = temporaryDirectory("forward-invalid") defer { try? FileManager.default.removeItem(at: base) } diff --git a/supacodeTests/CodexShellLaunchEnvironmentTests.swift b/supacodeTests/CodexShellLaunchEnvironmentTests.swift new file mode 100644 index 00000000..2133b820 --- /dev/null +++ b/supacodeTests/CodexShellLaunchEnvironmentTests.swift @@ -0,0 +1,96 @@ +import Foundation +import Testing + +@testable import supacode + +struct CodexShellLaunchEnvironmentTests { + @Test func probeUsesLoginShellAndReturnsOnlyValidatedLaunchFacts() async throws { + let cwd = URL(filePath: "/tmp/Project Space/界", directoryHint: .isDirectory) + let shell = ShellClient( + run: { _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) }, + runLoginImpl: { executable, arguments, currentDirectory, log in + #expect(executable.path(percentEncoded: false) == "/bin/sh") + #expect(arguments.first == "-c") + #expect(currentDirectory == cwd) + #expect(!log) + return ShellOutput( + stdout: """ + __PROWL_CODEX_EXECUTABLE__/opt/custom/bin/codex + __PROWL_CODEX_HOME_BASE__/Users/tester + __PROWL_CODEX_HOME__/tmp/codex-home + + """, + stderr: "ignored", + exitCode: 0 + ) + } + ) + + let environment = try #require( + await CodexShellLaunchEnvironmentProbe.resolve( + cwd: cwd, + shell: shell, + isExecutable: { $0 == "/opt/custom/bin/codex" } + ) + ) + + #expect(environment.executableURL.path(percentEncoded: false) == "/opt/custom/bin/codex") + #expect( + environment.processEnvironment == [ + "HOME": "/Users/tester", + "CODEX_HOME": "/tmp/codex-home", + ]) + } + + @Test func malformedNonAbsoluteAndFailedProbeDegrade() async { + for output in [ + ShellOutput(stdout: "not-json", stderr: "", exitCode: 0), + ShellOutput( + stdout: """ + __PROWL_CODEX_EXECUTABLE__codex + __PROWL_CODEX_HOME_BASE__/Users/tester + __PROWL_CODEX_HOME__ + + """, + stderr: "", + exitCode: 0 + ), + ShellOutput( + stdout: """ + __PROWL_CODEX_EXECUTABLE__/opt/codex + __PROWL_CODEX_HOME_BASE__/Users/tester + __PROWL_CODEX_HOME__ + + """, + stderr: "", + exitCode: 1 + ), + ] { + let shell = ShellClient( + run: { _, _, _ in output }, + runLoginImpl: { _, _, _, _ in output } + ) + #expect( + await CodexShellLaunchEnvironmentProbe.resolve( + cwd: URL(filePath: "/tmp", directoryHint: .isDirectory), + shell: shell, + isExecutable: { $0 == "/opt/codex" } + ) == nil + ) + } + } + + @Test func capturedShellHomeDefinesDefaultCodexHome() throws { + let shell = CodexShellLaunchEnvironment( + executableURL: URL(filePath: "/opt/codex"), + processEnvironment: ["HOME": "/Users/shell-home"] + ) + let context = try CodexLaunchContext.capture( + invocation: AgentInvocation(executable: "/opt/codex", arguments: []), + inheritedCWD: URL(filePath: "/tmp/project", directoryHint: .isDirectory), + environment: shell.processEnvironment + ) + + #expect(context.codexHome.path(percentEncoded: false) == "/Users/shell-home/.codex/") + } +} diff --git a/supacodeTests/ManagedAgentHookObservationTests.swift b/supacodeTests/ManagedAgentHookObservationTests.swift index 60ba4db2..ca81c4bd 100644 --- a/supacodeTests/ManagedAgentHookObservationTests.swift +++ b/supacodeTests/ManagedAgentHookObservationTests.swift @@ -86,6 +86,103 @@ struct ManagedAgentHookObservationTests { ) } + @Test func detectorNilCannotEraseVerifiedClaudeSessionOrAdmitDifferentStopSession() { + let now = Date(timeIntervalSince1970: 100) + let generation = AgentProcessGeneration(pid: 900, startedAt: now) + let store = AgentObservationStore(bufferCapacity: 8, now: { now }) + let surfaceID = UUID() + let registration = makeRegistration(runtime: .claude, cwd: "/tmp/project") + _ = store.registerManagedHook(registration, surfaceID: surfaceID) + _ = store.updateEvidenceEpoch( + surfaceID: surfaceID, + processGeneration: generation, + sessionID: nil + ) + let start = makeInput( + runtime: .claude, + token: registration.token, + nativeEvent: "SessionStart", + event: .sessionStart, + cwd: "/tmp/project", + sessionID: "session-1" + ) + #expect( + store.recordManagedHook(start, callerAncestry: [generation], surfaceID: surfaceID).isAccepted + ) + + _ = store.updateEvidenceEpoch( + surfaceID: surfaceID, + processGeneration: generation, + sessionID: nil + ) + let wrongStop = makeInput( + runtime: .claude, + token: registration.token, + nativeEvent: "Stop", + event: .turnEnded, + cwd: "/tmp/project", + sessionID: "session-2" + ) + + #expect( + store.recordManagedHook(wrongStop, callerAncestry: [generation], surfaceID: surfaceID) + == .rejected + ) + } + + @Test func detectorSessionReplacementRequiresClaudeSessionStartBeforeReverification() throws { + let now = Date(timeIntervalSince1970: 100) + let generation = AgentProcessGeneration(pid: 900, startedAt: now) + let store = AgentObservationStore(bufferCapacity: 8, now: { now }) + let surfaceID = UUID() + let registration = makeRegistration(runtime: .claude, cwd: "/tmp/project") + _ = store.registerManagedHook(registration, surfaceID: surfaceID) + _ = store.updateEvidenceEpoch(surfaceID: surfaceID, processGeneration: generation, sessionID: nil) + let first = makeInput( + runtime: .claude, + token: registration.token, + nativeEvent: "SessionStart", + event: .sessionStart, + cwd: "/tmp/project", + sessionID: "session-1" + ) + #expect(store.recordManagedHook(first, callerAncestry: [generation], surfaceID: surfaceID).isAccepted) + + _ = store.updateEvidenceEpoch( + surfaceID: surfaceID, + processGeneration: generation, + sessionID: "session-2" + ) + let stop = makeInput( + runtime: .claude, + token: registration.token, + nativeEvent: "Stop", + event: .turnEnded, + cwd: "/tmp/project", + sessionID: "session-2" + ) + #expect(store.recordManagedHook(stop, callerAncestry: [generation], surfaceID: surfaceID) == .rejected) + #expect( + store.signalsPayload(surfaceID: surfaceID, formatter: formatter, includeDiagnosticLast: true) + .channels.isEmpty + ) + + let second = makeInput( + runtime: .claude, + token: registration.token, + nativeEvent: "SessionStart", + event: .sessionStart, + cwd: "/tmp/project", + sessionID: "session-2" + ) + #expect(store.recordManagedHook(second, callerAncestry: [generation], surfaceID: surfaceID).isAccepted) + let channel = try #require( + store.signalsPayload(surfaceID: surfaceID, formatter: formatter, includeDiagnosticLast: true) + .channels.first + ) + #expect(channel.sessionID == "session-2") + } + @Test func processReplacementRevokesTrustAndReturnsForwardRecordForRetirement() { let now = Date(timeIntervalSince1970: 100) let store = AgentObservationStore(bufferCapacity: 8, now: { now }) diff --git a/supacodeTests/WorktreeTerminalManagerTests.swift b/supacodeTests/WorktreeTerminalManagerTests.swift index 26913860..4d60151e 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -134,7 +134,10 @@ struct WorktreeTerminalManagerTests { } @Test func backgroundProfileSplitInHiddenWorktreePreservesVisibleSelection() throws { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeTerminalManager( + runtime: GhosttyRuntime(), + skipsSurfaceCreationForTesting: true + ) let visibleWorktree = makeWorktree(id: "/tmp/repo/visible", name: "visible") let hiddenWorktree = makeWorktree(id: "/tmp/repo/hidden", name: "hidden") let visibleState = manager.state(for: visibleWorktree) @@ -178,10 +181,33 @@ struct WorktreeTerminalManagerTests { #expect(launched.tabID == hiddenTab) } + @Test func startupHookMaintenanceSweepsAgedCrashForwardingRecords() throws { + let base = FileManager.default.temporaryDirectory.appending( + path: "prowl-tests-forward-startup-\(UUID().uuidString)", + directoryHint: .isDirectory + ) + defer { try? FileManager.default.removeItem(at: base) } + let oldStore = try CodexForwardingRecordStore( + baseDirectory: base, + orphanMaximumAge: 60, + now: { Date(timeIntervalSince1970: 100) } + ) + let oldRecord = try oldStore.create(argv: ["/tmp/notifier"]) + let manager = WorktreeTerminalManager( + runtime: GhosttyRuntime(), + forwardingRecordBaseDirectory: base + ) + + manager.startAgentHookRuntimeMaintenance() + + #expect(!FileManager.default.fileExists(atPath: oldRecord.locator.path(percentEncoded: false))) + } + @Test func unavailableHookResourcesWarnOnceAndLaunchTheOriginalInvocation() async throws { let manager = WorktreeTerminalManager( runtime: GhosttyRuntime(), - hookResourcesProvider: { nil } + hookResourcesProvider: { nil }, + skipsSurfaceCreationForTesting: true ) let worktree = makeWorktree() let original = AgentProfileLaunchPlan( @@ -209,7 +235,10 @@ struct WorktreeTerminalManagerTests { } @Test func promptedManagedHookLaunchBindsDispatchToTheRegistrationEpoch() throws { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeTerminalManager( + runtime: GhosttyRuntime(), + skipsSurfaceCreationForTesting: true + ) let worktree = makeWorktree() let state = manager.state(for: worktree) let base = AgentProfileLaunchPlan( diff --git a/supacodeTests/WorktreeTerminalStateAgentProfileTests.swift b/supacodeTests/WorktreeTerminalStateAgentProfileTests.swift index 7f5723b8..f1e1aa1e 100644 --- a/supacodeTests/WorktreeTerminalStateAgentProfileTests.swift +++ b/supacodeTests/WorktreeTerminalStateAgentProfileTests.swift @@ -177,6 +177,30 @@ struct WorktreeTerminalStateAgentProfileTests { #expect(state.surfaceView(for: launched.surfaceID)?.surfaceCreationArmed == true) } + @Test func deferredGhosttyCreationFailureRollsBackRegistrationAndSurface() { + let state = makeState(skipsSurfaceCreationForTesting: false) + var registeredSurface: UUID? + var closedSurface: UUID? + state.onAgentProfileSurfacePrepared = { surfaceID, _ in + registeredSurface = surfaceID + return true + } + state.onSurfaceClosed = { closedSurface = $0 } + + let result = state.launchAgentProfile( + AgentProfileLaunchRequest( + plan: makePlan(dedicatedHome: nil), + placement: .tab(background: false) + ) + ) + + #expect(result == .failure(.surfaceCreationFailed)) + #expect(closedSurface == registeredSurface) + #expect(state.tabManager.tabs.isEmpty) + #expect(state.surfaces.isEmpty) + #expect(state.launchProfilesBySurface.isEmpty) + } + @Test func registrationFailureRollsBackBeforeLeavingALiveSurface() { let state = makeState() state.onAgentProfileSurfacePrepared = { _, _ in false } @@ -311,7 +335,9 @@ struct WorktreeTerminalStateAgentProfileTests { #expect(state.tabManager.tabs.first?.iconLock == .script) } - private func makeState() -> WorktreeTerminalState { + private func makeState( + skipsSurfaceCreationForTesting: Bool = true + ) -> WorktreeTerminalState { WorktreeTerminalState( runtime: GhosttyRuntime(), worktree: Worktree( @@ -320,7 +346,8 @@ struct WorktreeTerminalStateAgentProfileTests { detail: "", workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-1"), repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") - ) + ), + skipsSurfaceCreationForTesting: skipsSurfaceCreationForTesting ) } -- 2.51.2 From 55b44f759ff76f025782b3e40f421101bc92c488 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 24 Aug 2026 03:49:58 +0900 Subject: [PATCH 4/8] fix: bound hook preflight and revalidate launch context --- .../007-s3a-action.md | 20 +++ supacode/App/supacodeApp.swift | 4 +- .../AgentProfile/AgentProfileLaunchPlan.swift | 23 +++ .../AgentManagedHookPreparer.swift | 2 +- .../AgentRuntime/CodexConfigReadProcess.swift | 33 +--- .../CodexShellLaunchEnvironment.swift | 20 ++- .../AgentRuntime/CodexShellProbeProcess.swift | 166 ++++++++++++++++++ .../AgentRuntime/ManagedHookRendering.swift | 69 ++------ .../AgentRuntime/StableOwnerFileReader.swift | 64 +++++++ .../WorktreeTerminalManager.swift | 18 +- .../Models/WorktreeTerminalState.swift | 31 +++- supacodeTests/AgentHookRenderingTests.swift | 48 ++++- .../CodexShellLaunchEnvironmentTests.swift | 59 ++++--- .../CodexShellProbeProcessTests.swift | 79 +++++++++ ...rktreeTerminalStateAgentProfileTests.swift | 38 ++++ 15 files changed, 551 insertions(+), 123 deletions(-) create mode 100644 supacode/Domain/AgentRuntime/CodexShellProbeProcess.swift create mode 100644 supacode/Domain/AgentRuntime/StableOwnerFileReader.swift create mode 100644 supacodeTests/CodexShellProbeProcessTests.swift diff --git a/docs-ai/064-agent-completion-signals/007-s3a-action.md b/docs-ai/064-agent-completion-signals/007-s3a-action.md index d72db927..b5f59aef 100644 --- a/docs-ai/064-agent-completion-signals/007-s3a-action.md +++ b/docs-ai/064-agent-completion-signals/007-s3a-action.md @@ -140,6 +140,26 @@ buffer, exact cwd rejection (including memories), hidden bridge silence/deadline exclusion, carrier redaction, and owner/mode/no-follow/lease checks. Focused validation after the fixes passed 89 tests plus `make check` (34 script tests). +### Round 2 — races, cancellation, argv rendering, and runtime compatibility + +The second independent review accepted four additional P1 findings: + +- Login-shell environment resolution had no hard deadline or streaming output cap. It now uses a + purpose-built process runner with a one-second deadline, combined stdout/stderr bound, + cancellation, TERM-to-KILL escalation, and tests for a noisy shell plus a TERM-ignoring hang. +- Frozen menu/palette target validation checked only anchor existence. It now tracks whether focus + and cwd were inherited dynamically, re-reads both immediately after preflight, and retries or + degrades rather than launching a stale context. +- Renderer fallback inferred any final argv after `exec`/`-p` as a prompt. Only the planner-owned + prompt index can move insertion before a prompt now; arbitrary option/value argv remains exact. +- Stable settings/profile reads compared only the open descriptor. Claude and Codex now share one + bounded owner-file reader that also `lstat`s the source path and compares device/inode/type, + size, and mtime, so atomic replacement degrades safely. + +A follow-up hardening discovered while verifying the first finding also carries an explicit Profile +`PATH` override into login-shell executable resolution; the prepared runtime invocation then uses +the attested absolute executable. Focused round-2 validation passed 77 tests plus `make check`. + ## Deferred scope S3b owns Copilot/Droid/Qoder adapters. S3c owns Pi/OMP/OpenCode adapters and the Active Agents exact diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index db74c159..0ac025cf 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -1516,7 +1516,9 @@ struct SupacodeApp: App { title: preparation.context.request.title ), inheritedCWD: preparation.context.inheritedCWD, - anchorSurfaceID: preparation.context.anchorSurfaceID + anchorSurfaceID: preparation.context.anchorSurfaceID, + tracksFocusedAnchor: preparation.context.tracksFocusedAnchor, + tracksInheritedCWD: preparation.context.tracksInheritedCWD ), warnings: preparation.warnings ) diff --git a/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift b/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift index 2f507d20..5d06602e 100644 --- a/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift +++ b/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift @@ -42,6 +42,8 @@ nonisolated struct AgentProfileLaunchPlan: Equatable, Sendable { /// nothing but the launch command reads them, and the real variable names /// never exist in the pane's shell. let surfaceEnvironment: [String: String] + /// Validated user overrides retained in-memory for preflight facts such as PATH. + let profileEnvironmentOverrides: [String: String] /// Dedicated home to provision before launch; nil for pure presets. let dedicatedHome: URL? /// Runtime-specific session root under the managed home. This is direct for @@ -60,6 +62,7 @@ nonisolated struct AgentProfileLaunchPlan: Equatable, Sendable { placement: AgentProfilePlacement, splitDirection: UserCustomSplitDirection, surfaceEnvironment: [String: String], + profileEnvironmentOverrides: [String: String] = [:], dedicatedHome: URL?, sessionConfigRoot: URL? = nil ) { @@ -73,6 +76,7 @@ nonisolated struct AgentProfileLaunchPlan: Equatable, Sendable { self.placement = placement self.splitDirection = splitDirection self.surfaceEnvironment = surfaceEnvironment + self.profileEnvironmentOverrides = profileEnvironmentOverrides self.dedicatedHome = dedicatedHome self.sessionConfigRoot = sessionConfigRoot ?? dedicatedHome } @@ -163,6 +167,7 @@ nonisolated struct AgentProfileLaunchPlan: Equatable, Sendable { placement: placement, splitDirection: splitDirection, surfaceEnvironment: environment, + profileEnvironmentOverrides: profileEnvironmentOverrides, dedicatedHome: dedicatedHome, sessionConfigRoot: sessionConfigRoot ) @@ -196,6 +201,7 @@ nonisolated struct AgentProfileLaunchPlan: Equatable, Sendable { placement: placement, splitDirection: splitDirection, surfaceEnvironment: environment, + profileEnvironmentOverrides: profileEnvironmentOverrides, dedicatedHome: dedicatedHome, sessionConfigRoot: sessionConfigRoot ) @@ -240,6 +246,22 @@ nonisolated struct FrozenAgentProfileLaunchContext: Equatable, Sendable { let request: AgentProfileLaunchRequest let inheritedCWD: URL let anchorSurfaceID: UUID? + let tracksFocusedAnchor: Bool + let tracksInheritedCWD: Bool + + init( + request: AgentProfileLaunchRequest, + inheritedCWD: URL, + anchorSurfaceID: UUID?, + tracksFocusedAnchor: Bool = false, + tracksInheritedCWD: Bool = false + ) { + self.request = request + self.inheritedCWD = inheritedCWD + self.anchorSurfaceID = anchorSurfaceID + self.tracksFocusedAnchor = tracksFocusedAnchor + self.tracksInheritedCWD = tracksInheritedCWD + } } nonisolated struct PreparedAgentProfileLaunch: Equatable, Sendable { @@ -501,6 +523,7 @@ nonisolated enum AgentProfileLaunchPlanner { placement: profile.placement, splitDirection: profile.splitDirection, surfaceEnvironment: surfaceEnvironment, + profileEnvironmentOverrides: overrides, dedicatedHome: dedicatedHome, sessionConfigRoot: sessionConfigRoot ) diff --git a/supacode/Domain/AgentRuntime/AgentManagedHookPreparer.swift b/supacode/Domain/AgentRuntime/AgentManagedHookPreparer.swift index 0e585031..398d76cf 100644 --- a/supacode/Domain/AgentRuntime/AgentManagedHookPreparer.swift +++ b/supacode/Domain/AgentRuntime/AgentManagedHookPreparer.swift @@ -98,7 +98,7 @@ nonisolated enum AgentManagedHookPreparer { launchDirectory: inheritedCWD, promptArgumentIndex: promptIndex, hookCommands: hookCommands, - readFile: ClaudeSettingsStableReader.read + readFile: { ClaudeSettingsStableReader.read($0, maximumBytes: $1) } ) }.value return AgentManagedHookPreparation( diff --git a/supacode/Domain/AgentRuntime/CodexConfigReadProcess.swift b/supacode/Domain/AgentRuntime/CodexConfigReadProcess.swift index 153b6f25..c162f18e 100644 --- a/supacode/Domain/AgentRuntime/CodexConfigReadProcess.swift +++ b/supacode/Domain/AgentRuntime/CodexConfigReadProcess.swift @@ -124,34 +124,11 @@ nonisolated struct CodexConfigReadProcess: Sendable { } private func readStableProfile(_ url: URL) throws -> Data { - let path = url.path(percentEncoded: false) - let descriptor = open(path, O_RDONLY | O_NOFOLLOW) - guard descriptor >= 0 else { throw CodexConfigReadProcessError.invalidProfile } - defer { close(descriptor) } - var before = stat() - guard fstat(descriptor, &before) == 0, - (before.st_mode & S_IFMT) == S_IFREG, - before.st_uid == geteuid(), - before.st_size >= 0, - before.st_size <= 256 * 1_024 - else { - throw CodexConfigReadProcessError.invalidProfile - } - var data = Data(count: Int(before.st_size)) - var offset = 0 - while offset < data.count { - let count = data.withUnsafeMutableBytes { buffer in - read(descriptor, buffer.baseAddress?.advanced(by: offset), buffer.count - offset) - } - guard count > 0 else { throw CodexConfigReadProcessError.invalidProfile } - offset += count - } - var after = stat() - guard fstat(descriptor, &after) == 0, - before.st_ino == after.st_ino, - before.st_size == after.st_size, - before.st_mtimespec.tv_sec == after.st_mtimespec.tv_sec, - before.st_mtimespec.tv_nsec == after.st_mtimespec.tv_nsec + guard + case .stable(let data) = StableOwnerFileReader.read( + url, + maximumBytes: 256 * 1_024 + ) else { throw CodexConfigReadProcessError.invalidProfile } diff --git a/supacode/Domain/AgentRuntime/CodexShellLaunchEnvironment.swift b/supacode/Domain/AgentRuntime/CodexShellLaunchEnvironment.swift index 741b6ab3..b61226f1 100644 --- a/supacode/Domain/AgentRuntime/CodexShellLaunchEnvironment.swift +++ b/supacode/Domain/AgentRuntime/CodexShellLaunchEnvironment.swift @@ -18,16 +18,22 @@ nonisolated enum CodexShellLaunchEnvironmentProbe { static func resolve( cwd: URL, - shell: ShellClient = .live, + pathOverride: String? = nil, + run: (@Sendable (URL, String) async throws -> ShellOutput)? = nil, isExecutable: (String) -> Bool = { FileManager.default.isExecutableFile(atPath: $0) } ) async -> CodexShellLaunchEnvironment? { + let execute = + run ?? { cwd, script in + try await CodexShellProbeProcess().run(cwd: cwd, script: script) + } + let effectiveScript: String + if let pathOverride { + effectiveScript = "PATH=\(AgentInvocation.shellQuote(pathOverride)); export PATH\n" + script + } else { + effectiveScript = script + } guard - let output = try? await shell.runLogin( - URL(filePath: "/bin/sh", directoryHint: .notDirectory), - ["-c", script], - cwd, - log: false - ), + let output = try? await execute(cwd, effectiveScript), output.exitCode == 0, output.stdout.utf8.count <= 16 * 1_024, let values = parse(output.stdout), diff --git a/supacode/Domain/AgentRuntime/CodexShellProbeProcess.swift b/supacode/Domain/AgentRuntime/CodexShellProbeProcess.swift new file mode 100644 index 00000000..233328e6 --- /dev/null +++ b/supacode/Domain/AgentRuntime/CodexShellProbeProcess.swift @@ -0,0 +1,166 @@ +import Foundation + +#if canImport(Darwin) + import Darwin +#elseif canImport(Glibc) + import Glibc +#endif + +nonisolated enum CodexShellProbeProcessError: Error, Equatable, Sendable { + case cancelled + case outputTooLarge + case processFailed + case timeout +} + +nonisolated struct CodexShellProbeProcess: Sendable { + private struct RunOptions { + let timeout: TimeInterval + let maximumOutputBytes: Int + let shellOverride: URL? + } + + private final class ProcessBox: @unchecked Sendable { + private let lock = NSLock() + private var process: Process? + + func install(_ process: Process) { + lock.withLock { self.process = process } + } + + func terminate() { + lock.withLock { + if process?.isRunning == true { process?.terminate() } + } + } + } + + let timeout: TimeInterval + let maximumOutputBytes: Int + let shellOverride: URL? + + init( + timeout: TimeInterval = 1, + maximumOutputBytes: Int = 16 * 1_024, + shellOverride: URL? = nil + ) { + self.timeout = max(0.05, timeout) + self.maximumOutputBytes = max(1, maximumOutputBytes) + self.shellOverride = shellOverride + } + + func run(cwd: URL, script: String) async throws -> ShellOutput { + let processBox = ProcessBox() + let task = Task.detached(priority: .userInitiated) { + try Self.runSynchronously( + cwd: cwd, + script: script, + options: RunOptions( + timeout: timeout, + maximumOutputBytes: maximumOutputBytes, + shellOverride: shellOverride + ), + processBox: processBox + ) + } + return try await withTaskCancellationHandler { + try await task.value + } onCancel: { + task.cancel() + processBox.terminate() + } + } + + private static func runSynchronously( + cwd: URL, + script: String, + options: RunOptions, + processBox: ProcessBox + ) throws -> ShellOutput { + let process = Process() + if let shellOverride = options.shellOverride { + process.executableURL = shellOverride + process.arguments = [] + } else { + let invocation = ShellClient.loginShellInvocation(userShell: defaultShellURL()) + process.executableURL = invocation.shell + process.arguments = [ + "-l", "-c", invocation.command, "--", + "/bin/sh", "-c", script, + ] + } + process.currentDirectoryURL = cwd + process.standardInput = FileHandle.nullDevice + let output = Pipe() + let errors = Pipe() + process.standardOutput = output + process.standardError = errors + processBox.install(process) + try process.run() + let descriptors = [ + output.fileHandleForReading.fileDescriptor, + errors.fileHandleForReading.fileDescriptor, + ] + let deadline = DispatchTime.now().uptimeNanoseconds + UInt64(options.timeout * 1_000_000_000) + var stdout = Data() + var stderr = Data() + defer { + stop(process) + try? output.fileHandleForReading.close() + try? errors.fileHandleForReading.close() + } + + while process.isRunning || hasReadableData(descriptors) { + if Task.isCancelled { throw CodexShellProbeProcessError.cancelled } + guard DispatchTime.now().uptimeNanoseconds < deadline else { + throw CodexShellProbeProcessError.timeout + } + var pollDescriptors = descriptors.map { pollfd(fd: $0, events: Int16(POLLIN), revents: 0) } + let status = poll(&pollDescriptors, nfds_t(pollDescriptors.count), 25) + if status < 0 { + if errno == EINTR { continue } + throw CodexShellProbeProcessError.processFailed + } + for index in pollDescriptors.indices where pollDescriptors[index].revents & Int16(POLLIN) != 0 { + var chunk = [UInt8](repeating: 0, count: 4 * 1_024) + let count = chunk.withUnsafeMutableBytes { + Darwin.read(descriptors[index], $0.baseAddress, $0.count) + } + if count > 0 { + if index == 0 { + stdout.append(contentsOf: chunk.prefix(count)) + } else { + stderr.append(contentsOf: chunk.prefix(count)) + } + guard stdout.count + stderr.count <= options.maximumOutputBytes else { + throw CodexShellProbeProcessError.outputTooLarge + } + } + } + } + guard process.terminationStatus == 0 else { throw CodexShellProbeProcessError.processFailed } + guard let stdoutText = String(data: stdout, encoding: .utf8), + let stderrText = String(data: stderr, encoding: .utf8) + else { throw CodexShellProbeProcessError.processFailed } + return ShellOutput(stdout: stdoutText, stderr: stderrText, exitCode: process.terminationStatus) + } + + private static func hasReadableData(_ descriptors: [Int32]) -> Bool { + var values = descriptors.map { pollfd(fd: $0, events: Int16(POLLIN), revents: 0) } + return poll(&values, nfds_t(values.count), 0) > 0 + } + + private static func stop(_ process: Process) { + if process.isRunning { process.terminate() } + for _ in 0..<100 where process.isRunning { usleep(1_000) } + if process.isRunning { kill(process.processIdentifier, SIGKILL) } + process.waitUntilExit() + } + + private static func defaultShellURL() -> URL { + if let shell = ProcessInfo.processInfo.environment["SHELL"], !shell.isEmpty { + return URL(filePath: shell, directoryHint: .notDirectory) + } + return URL(filePath: "/bin/zsh", directoryHint: .notDirectory) + } +} diff --git a/supacode/Domain/AgentRuntime/ManagedHookRendering.swift b/supacode/Domain/AgentRuntime/ManagedHookRendering.swift index f9f845e1..3370716c 100644 --- a/supacode/Domain/AgentRuntime/ManagedHookRendering.swift +++ b/supacode/Domain/AgentRuntime/ManagedHookRendering.swift @@ -1,11 +1,5 @@ import Foundation -#if canImport(Darwin) - import Darwin -#elseif canImport(Glibc) - import Glibc -#endif - nonisolated enum ClaudeSettingsReadResult: Equatable, Sendable { case stable(Data) case changed @@ -14,34 +8,17 @@ nonisolated enum ClaudeSettingsReadResult: Equatable, Sendable { } nonisolated enum ClaudeSettingsStableReader { - static func read(_ url: URL, maximumBytes: Int) -> ClaudeSettingsReadResult { - let descriptor = Darwin.open(url.path(percentEncoded: false), O_RDONLY | O_NOFOLLOW) - guard descriptor >= 0 else { return .unreadable } - defer { Darwin.close(descriptor) } - var before = stat() - guard fstat(descriptor, &before) == 0, - (before.st_mode & S_IFMT) == S_IFREG, - before.st_uid == geteuid(), - before.st_size >= 0 - else { return .unreadable } - guard before.st_size <= maximumBytes else { return .oversized } - var data = Data(count: Int(before.st_size)) - var offset = 0 - while offset < data.count { - let count = data.withUnsafeMutableBytes { buffer in - Darwin.read(descriptor, buffer.baseAddress?.advanced(by: offset), buffer.count - offset) - } - guard count > 0 else { return .unreadable } - offset += count + static func read( + _ url: URL, + maximumBytes: Int, + afterRead: () -> Void = {} + ) -> ClaudeSettingsReadResult { + switch StableOwnerFileReader.read(url, maximumBytes: maximumBytes, afterRead: afterRead) { + case .stable(let data): .stable(data) + case .changed: .changed + case .oversized: .oversized + case .unreadable: .unreadable } - var after = stat() - guard fstat(descriptor, &after) == 0, - before.st_ino == after.st_ino, - before.st_size == after.st_size, - before.st_mtimespec.tv_sec == after.st_mtimespec.tv_sec, - before.st_mtimespec.tv_nsec == after.st_mtimespec.tv_nsec - else { return .changed } - return .stable(data) } } @@ -123,8 +100,7 @@ nonisolated enum ClaudeHookSettingsPreparer { let insertionIndex = resolvedPromptIndex( promptArgumentIndex, - arguments: arguments, - executable: invocation.executable + arguments: arguments ) ?? arguments.endIndex arguments.insert(contentsOf: ["--settings", "{}"], at: insertionIndex) carrierIndex = insertionIndex + 1 @@ -268,14 +244,10 @@ nonisolated enum ClaudeHookSettingsPreparer { private static func resolvedPromptIndex( _ explicit: Int?, - arguments: [String], - executable: String + arguments: [String] ) -> Int? { - if let explicit, arguments.indices.contains(explicit) { return explicit } - if executable == "claude", arguments.first == "-p", arguments.count >= 2 { - return arguments.index(before: arguments.endIndex) - } - return nil + guard let explicit, arguments.indices.contains(explicit) else { return nil } + return explicit } private static func degraded(_ invocation: AgentInvocation) -> AgentHookPreparationOutcome { @@ -310,15 +282,10 @@ nonisolated enum CodexManagedNotifyRenderer { ) let notifyJSON = notifyData.flatMap { String(data: $0, encoding: .utf8) } ?? "[]" var arguments = invocation.arguments - let inferredPromptIndex: Int? = - if let promptArgumentIndex, arguments.indices.contains(promptArgumentIndex) { - promptArgumentIndex - } else if arguments.first == "exec", arguments.count >= 2 { - arguments.index(before: arguments.endIndex) - } else { - nil - } - let insertionIndex = inferredPromptIndex ?? arguments.endIndex + let explicitPromptIndex = promptArgumentIndex.flatMap { + arguments.indices.contains($0) ? $0 : nil + } + let insertionIndex = explicitPromptIndex ?? arguments.endIndex arguments.insert(contentsOf: ["-c", "notify=[]"], at: insertionIndex) return AgentHookPreparedInvocation( invocation: AgentInvocation(executable: invocation.executable, arguments: arguments), diff --git a/supacode/Domain/AgentRuntime/StableOwnerFileReader.swift b/supacode/Domain/AgentRuntime/StableOwnerFileReader.swift new file mode 100644 index 00000000..747de675 --- /dev/null +++ b/supacode/Domain/AgentRuntime/StableOwnerFileReader.swift @@ -0,0 +1,64 @@ +import Foundation + +#if canImport(Darwin) + import Darwin +#elseif canImport(Glibc) + import Glibc +#endif + +nonisolated enum StableOwnerFileReadResult: Equatable, Sendable { + case stable(Data) + case changed + case oversized + case unreadable +} + +nonisolated enum StableOwnerFileReader { + static func read( + _ url: URL, + maximumBytes: Int, + afterRead: () -> Void = {} + ) -> StableOwnerFileReadResult { + let path = url.path(percentEncoded: false) + let descriptor = Darwin.open(path, O_RDONLY | O_NOFOLLOW) + guard descriptor >= 0 else { return .unreadable } + defer { Darwin.close(descriptor) } + var before = stat() + guard fstat(descriptor, &before) == 0, + (before.st_mode & S_IFMT) == S_IFREG, + before.st_uid == geteuid(), + before.st_size >= 0 + else { return .unreadable } + guard before.st_size <= maximumBytes else { return .oversized } + + var data = Data(count: Int(before.st_size)) + var offset = 0 + while offset < data.count { + let count = data.withUnsafeMutableBytes { buffer in + Darwin.read(descriptor, buffer.baseAddress?.advanced(by: offset), buffer.count - offset) + } + guard count > 0 else { return .unreadable } + offset += count + } + afterRead() + + var descriptorAfter = stat() + var pathAfter = stat() + guard fstat(descriptor, &descriptorAfter) == 0, + lstat(path, &pathAfter) == 0, + sameSnapshot(before, descriptorAfter), + sameSnapshot(descriptorAfter, pathAfter), + (pathAfter.st_mode & S_IFMT) == S_IFREG, + pathAfter.st_uid == geteuid() + else { return .changed } + return .stable(data) + } + + private static func sameSnapshot(_ lhs: stat, _ rhs: stat) -> Bool { + lhs.st_dev == rhs.st_dev + && lhs.st_ino == rhs.st_ino + && lhs.st_size == rhs.st_size + && lhs.st_mtimespec.tv_sec == rhs.st_mtimespec.tv_sec + && lhs.st_mtimespec.tv_nsec == rhs.st_mtimespec.tv_nsec + } +} diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index f26fd3da..0b4380e5 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -16,7 +16,8 @@ final class WorktreeTerminalManager { @ObservationIgnored private let agentObservationStore: AgentObservationStore @ObservationIgnored private let agentDispatchStore: AgentDispatchStore @ObservationIgnored private let codexConfigReadProcess: CodexConfigReadProcess - @ObservationIgnored private let codexShellEnvironmentResolver: @Sendable (URL) async -> CodexShellLaunchEnvironment? + @ObservationIgnored private let codexShellEnvironmentResolver: + @Sendable (URL, String?) async -> CodexShellLaunchEnvironment? @ObservationIgnored private let hookResourcesProvider: @MainActor () -> AgentHookResources? @ObservationIgnored private let forwardingRecordBaseDirectory: URL @ObservationIgnored private var codexForwardingRecordStore: CodexForwardingRecordStore? @@ -46,8 +47,8 @@ final class WorktreeTerminalManager { agentObservationBufferCapacity: Int = 64, agentDispatchStore: AgentDispatchStore = AgentDispatchStore(), codexConfigReadProcess: CodexConfigReadProcess = CodexConfigReadProcess(), - codexShellEnvironmentResolver: @escaping @Sendable (URL) async -> CodexShellLaunchEnvironment? = { - await CodexShellLaunchEnvironmentProbe.resolve(cwd: $0) + codexShellEnvironmentResolver: @escaping @Sendable (URL, String?) async -> CodexShellLaunchEnvironment? = { + await CodexShellLaunchEnvironmentProbe.resolve(cwd: $0, pathOverride: $1) }, hookResourcesProvider: @escaping @MainActor () -> AgentHookResources? = { guard let url = SupacodePaths.bundledCLIURL else { return nil } @@ -118,7 +119,10 @@ final class WorktreeTerminalManager { let resources = hookResourcesProvider() let codexShellEnvironment: CodexShellLaunchEnvironment? if context.request.plan.runtime == .codex, resources != nil { - codexShellEnvironment = await codexShellEnvironmentResolver(context.inheritedCWD) + codexShellEnvironment = await codexShellEnvironmentResolver( + context.inheritedCWD, + context.request.plan.profileEnvironmentOverrides["PATH"] + ) } else { codexShellEnvironment = nil } @@ -188,7 +192,9 @@ final class WorktreeTerminalManager { title: context.request.title ), inheritedCWD: context.inheritedCWD, - anchorSurfaceID: context.anchorSurfaceID + anchorSurfaceID: context.anchorSurfaceID, + tracksFocusedAnchor: context.tracksFocusedAnchor, + tracksInheritedCWD: context.tracksInheritedCWD ) return .success( PreparedAgentProfileLaunch( @@ -1246,7 +1252,7 @@ final class WorktreeTerminalManager { self.agentObservationStore = AgentObservationStore(bufferCapacity: 64) self.agentDispatchStore = AgentDispatchStore() self.codexConfigReadProcess = CodexConfigReadProcess() - self.codexShellEnvironmentResolver = { _ in nil } + self.codexShellEnvironmentResolver = { _, _ in nil } self.hookResourcesProvider = { nil } self.forwardingRecordBaseDirectory = SupacodePaths.agentHookForwardingDirectory self.baselineFontSize = 13 diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 43eedbdf..17cbfefd 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -492,16 +492,19 @@ final class WorktreeTerminalState { ) -> Result { let anchor: UUID? let context: ghostty_surface_context_e + let tracksFocusedAnchor: Bool switch request.placement { case .tab: anchor = request.inheritanceAnchor ?? currentFocusedSurfaceId() context = GHOSTTY_SURFACE_CONTEXT_TAB + tracksFocusedAnchor = request.inheritanceAnchor == nil case .split(let requestedAnchor, _, _): guard let resolved = requestedAnchor ?? currentFocusedSurfaceId(), surfaces[resolved] != nil else { return .failure(.splitAnchorUnavailable) } anchor = resolved context = GHOSTTY_SURFACE_CONTEXT_SPLIT + tracksFocusedAnchor = requestedAnchor == nil } let inheritedCWD = request.workingDirectoryOverride @@ -523,14 +526,34 @@ final class WorktreeTerminalState { title: request.title ), inheritedCWD: inheritedCWD.standardizedFileURL, - anchorSurfaceID: anchor + anchorSurfaceID: anchor, + tracksFocusedAnchor: tracksFocusedAnchor, + tracksInheritedCWD: request.workingDirectoryOverride == nil ) ) } - func isAgentProfileLaunchContextValid(_ context: FrozenAgentProfileLaunchContext) -> Bool { - guard let anchor = context.anchorSurfaceID else { return true } - return surfaces[anchor] != nil + func isAgentProfileLaunchContextValid( + _ context: FrozenAgentProfileLaunchContext, + inheritedCWDOverride: URL? = nil + ) -> Bool { + if let anchor = context.anchorSurfaceID, surfaces[anchor] == nil { return false } + if context.tracksFocusedAnchor, currentFocusedSurfaceId() != context.anchorSurfaceID { return false } + guard context.tracksInheritedCWD else { return true } + let surfaceContext: ghostty_surface_context_e = + switch context.request.placement { + case .tab: GHOSTTY_SURFACE_CONTEXT_TAB + case .split: GHOSTTY_SURFACE_CONTEXT_SPLIT + } + let currentCWD = + inheritedCWDOverride + ?? inheritedSurfaceConfig( + fromSurfaceId: context.anchorSurfaceID, + context: surfaceContext + ).workingDirectory + ?? worktree.workingDirectory + return AgentProfileLaunchPlanner.pathString(currentCWD) + == AgentProfileLaunchPlanner.pathString(context.inheritedCWD) } /// Launches an agent profile through the deterministic A2 boundary. Explicit diff --git a/supacodeTests/AgentHookRenderingTests.swift b/supacodeTests/AgentHookRenderingTests.swift index 960ff093..24340e0a 100644 --- a/supacodeTests/AgentHookRenderingTests.swift +++ b/supacodeTests/AgentHookRenderingTests.swift @@ -96,6 +96,23 @@ struct AgentHookRenderingTests { #expect(stop.count == 1) } + @Test func stableReaderRejectsAtomicPathReplacement() throws { + let directory = FileManager.default.temporaryDirectory.appending( + path: "prowl-settings-replacement-\(UUID().uuidString)", + directoryHint: .isDirectory + ) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let settings = directory.appending(path: "settings.json", directoryHint: .notDirectory) + try Data(#"{"version":1}"#.utf8).write(to: settings) + + let result = ClaudeSettingsStableReader.read(settings, maximumBytes: 1_024) { + try? Data(#"{"version":2}"#.utf8).write(to: settings, options: .atomic) + } + + #expect(result == .changed) + } + @Test func claudeMalformedChangedAndOversizedSettingsPreserveInvocation() { let invocation = AgentInvocation(executable: "claude", arguments: ["--settings", "bad.json", "Prompt"]) let missingValue = ClaudeHookSettingsPreparer.prepare( @@ -134,6 +151,7 @@ struct AgentHookRenderingTests { let claudeOutcome = ClaudeHookSettingsPreparer.prepare( invocation: claude, launchDirectory: URL(filePath: "/tmp", directoryHint: .isDirectory), + promptArgumentIndex: 1, hookCommands: hookCommands, readFile: { _, _ in .unreadable } ) @@ -143,7 +161,8 @@ struct AgentHookRenderingTests { let codex = CodexManagedNotifyRenderer.prepare( invocation: AgentInvocation(executable: "codex", arguments: ["exec", "Prompt"]), - bundledCLIPath: "/Applications/Prowl Debug.app/Contents/Resources/prowl-cli/prowl" + bundledCLIPath: "/Applications/Prowl Debug.app/Contents/Resources/prowl-cli/prowl", + promptArgumentIndex: 1 ) #expect(codex.invocation.arguments.last == "Prompt") #expect(codex.invocation.arguments.contains("-c")) @@ -151,6 +170,33 @@ struct AgentHookRenderingTests { #expect(codex.argumentValues.values.first?.contains("dangerously-bypass-hook-trust") == false) } + @Test func unpromptedOptionValuePairsAreNeverInferredAsPrompts() throws { + let claude = ClaudeHookSettingsPreparer.prepare( + invocation: AgentInvocation(executable: "claude", arguments: ["-p", "--model", "opus"]), + launchDirectory: URL(filePath: "/tmp", directoryHint: .isDirectory), + hookCommands: hookCommands, + readFile: { _, _ in .unreadable } + ) + let preparedClaude = try #require(claude.prepared) + #expect( + Array(preparedClaude.invocation.arguments.prefix(3)) + == ["-p", "--model", "opus"] + ) + #expect(preparedClaude.invocation.arguments.suffix(2) == ["--settings", "{}"]) + + for original in [ + ["exec", "-C", "/tmp/project"], + ["exec", "--cd=/tmp/project"], + ] { + let codex = CodexManagedNotifyRenderer.prepare( + invocation: AgentInvocation(executable: "codex", arguments: original), + bundledCLIPath: "/bundle/prowl" + ) + #expect(Array(codex.invocation.arguments.prefix(original.count)) == original) + #expect(codex.invocation.arguments.suffix(2) == ["-c", "notify=[]"]) + } + } + @Test func arbitraryArgumentCarriersNeverRenderValuesIntoTerminalInput() { let invocation = AgentInvocation( executable: "claude", diff --git a/supacodeTests/CodexShellLaunchEnvironmentTests.swift b/supacodeTests/CodexShellLaunchEnvironmentTests.swift index 2133b820..fc186ffc 100644 --- a/supacodeTests/CodexShellLaunchEnvironmentTests.swift +++ b/supacodeTests/CodexShellLaunchEnvironmentTests.swift @@ -6,30 +6,25 @@ import Testing struct CodexShellLaunchEnvironmentTests { @Test func probeUsesLoginShellAndReturnsOnlyValidatedLaunchFacts() async throws { let cwd = URL(filePath: "/tmp/Project Space/界", directoryHint: .isDirectory) - let shell = ShellClient( - run: { _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) }, - runLoginImpl: { executable, arguments, currentDirectory, log in - #expect(executable.path(percentEncoded: false) == "/bin/sh") - #expect(arguments.first == "-c") - #expect(currentDirectory == cwd) - #expect(!log) - return ShellOutput( - stdout: """ - __PROWL_CODEX_EXECUTABLE__/opt/custom/bin/codex - __PROWL_CODEX_HOME_BASE__/Users/tester - __PROWL_CODEX_HOME__/tmp/codex-home + let run: @Sendable (URL, String) async throws -> ShellOutput = { currentDirectory, script in + #expect(currentDirectory == cwd) + #expect(script.contains("command -v -- codex")) + return ShellOutput( + stdout: """ + __PROWL_CODEX_EXECUTABLE__/opt/custom/bin/codex + __PROWL_CODEX_HOME_BASE__/Users/tester + __PROWL_CODEX_HOME__/tmp/codex-home - """, - stderr: "ignored", - exitCode: 0 - ) - } - ) + """, + stderr: "ignored", + exitCode: 0 + ) + } let environment = try #require( await CodexShellLaunchEnvironmentProbe.resolve( cwd: cwd, - shell: shell, + run: run, isExecutable: { $0 == "/opt/custom/bin/codex" } ) ) @@ -66,20 +61,36 @@ struct CodexShellLaunchEnvironmentTests { exitCode: 1 ), ] { - let shell = ShellClient( - run: { _, _, _ in output }, - runLoginImpl: { _, _, _, _ in output } - ) #expect( await CodexShellLaunchEnvironmentProbe.resolve( cwd: URL(filePath: "/tmp", directoryHint: .isDirectory), - shell: shell, + run: { _, _ in output }, isExecutable: { $0 == "/opt/codex" } ) == nil ) } } + @Test func profilePATHOverrideParticipatesInExecutableResolution() async throws { + let output = """ + __PROWL_CODEX_EXECUTABLE__/custom/bin/codex + __PROWL_CODEX_HOME_BASE__/Users/tester + __PROWL_CODEX_HOME__ + + """ + let result = await CodexShellLaunchEnvironmentProbe.resolve( + cwd: URL(filePath: "/tmp", directoryHint: .isDirectory), + pathOverride: "/custom/bin:/usr/bin", + run: { _, script in + #expect(script.contains("PATH='/custom/bin:/usr/bin'; export PATH")) + return ShellOutput(stdout: output, stderr: "", exitCode: 0) + }, + isExecutable: { $0 == "/custom/bin/codex" } + ) + + #expect(result?.executableURL.path(percentEncoded: false) == "/custom/bin/codex") + } + @Test func capturedShellHomeDefinesDefaultCodexHome() throws { let shell = CodexShellLaunchEnvironment( executableURL: URL(filePath: "/opt/codex"), diff --git a/supacodeTests/CodexShellProbeProcessTests.swift b/supacodeTests/CodexShellProbeProcessTests.swift new file mode 100644 index 00000000..c890f21a --- /dev/null +++ b/supacodeTests/CodexShellProbeProcessTests.swift @@ -0,0 +1,79 @@ +import Darwin +import Foundation +import Testing + +@testable import supacode + +struct CodexShellProbeProcessTests { + @Test func hardTimeoutKillsLoginShellThatIgnoresTermination() async throws { + let root = temporaryDirectory("shell-timeout") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let pidFile = root.appending(path: "pid", directoryHint: .notDirectory) + let shell = try executableScript( + in: root, + name: "hang.sh", + contents: """ + #!/bin/sh + trap '' TERM + printf '%s' "$$" > \(shellQuote(pidFile.path)) + while :; do sleep 1; done + """ + ) + let process = CodexShellProbeProcess( + timeout: 0.5, + maximumOutputBytes: 1_024, + shellOverride: shell + ) + + await #expect(throws: CodexShellProbeProcessError.timeout) { + try await process.run(cwd: root, script: "ignored") + } + + let pidText = try String(contentsOf: pidFile, encoding: .utf8) + let pid = try #require(pid_t(pidText)) + #expect(kill(pid, 0) == -1) + #expect(errno == ESRCH) + } + + @Test func streamingOutputBoundStopsNoisyLoginShell() async throws { + let root = temporaryDirectory("shell-output") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let shell = try executableScript( + in: root, + name: "noisy.sh", + contents: """ + #!/bin/sh + while :; do printf '0123456789abcdef'; done + """ + ) + let process = CodexShellProbeProcess( + timeout: 2, + maximumOutputBytes: 128, + shellOverride: shell + ) + + await #expect(throws: CodexShellProbeProcessError.outputTooLarge) { + try await process.run(cwd: root, script: "ignored") + } + } + + private func executableScript(in root: URL, name: String, contents: String) throws -> URL { + let url = root.appending(path: name, directoryHint: .notDirectory) + try contents.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: url.path) + return url + } + + private func temporaryDirectory(_ name: String) -> URL { + FileManager.default.temporaryDirectory.appending( + path: "prowl-tests-\(name)-\(UUID().uuidString)", + directoryHint: .isDirectory + ) + } + + private func shellQuote(_ value: String) -> String { + "'" + value.replacing("'", with: "'\"'\"'") + "'" + } +} diff --git a/supacodeTests/WorktreeTerminalStateAgentProfileTests.swift b/supacodeTests/WorktreeTerminalStateAgentProfileTests.swift index f1e1aa1e..33a64a24 100644 --- a/supacodeTests/WorktreeTerminalStateAgentProfileTests.swift +++ b/supacodeTests/WorktreeTerminalStateAgentProfileTests.swift @@ -122,6 +122,44 @@ struct WorktreeTerminalStateAgentProfileTests { #expect(state.currentFocusedSurfaceId() == anchor.surfaceID) } + @Test func frozenDynamicProfileContextRejectsFocusAndInheritedCWDDrift() throws { + let state = makeState() + let first = try state.launchAgentProfile( + AgentProfileLaunchRequest( + plan: makePlan(dedicatedHome: nil), + placement: .tab(background: false) + ) + ).get() + _ = try state.launchAgentProfile( + AgentProfileLaunchRequest( + plan: makePlan(dedicatedHome: nil), + placement: .tab(background: false) + ) + ).get() + let request = AgentProfileLaunchRequest( + plan: makePlan(dedicatedHome: nil), + placement: .tab(background: false) + ) + let frozen = try state.freezeAgentProfileLaunchContext(request).get() + #expect(state.isAgentProfileLaunchContextValid(frozen)) + + #expect(state.focusSurface(id: first.surfaceID)) + #expect(!state.isAgentProfileLaunchContextValid(frozen)) + let cwdOnly = FrozenAgentProfileLaunchContext( + request: frozen.request, + inheritedCWD: frozen.inheritedCWD, + anchorSurfaceID: frozen.anchorSurfaceID, + tracksFocusedAnchor: false, + tracksInheritedCWD: true + ) + #expect( + !state.isAgentProfileLaunchContextValid( + cwdOnly, + inheritedCWDOverride: URL(filePath: "/tmp/repo/changed", directoryHint: .isDirectory) + ) + ) + } + @Test func explicitSplitFailureDoesNotFallBackToATab() { let state = makeState() let missingAnchor = UUID() -- 2.51.2 From 55742b56cd825efd98e881fbcb0e81ed0f824cf1 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 24 Aug 2026 04:06:42 +0900 Subject: [PATCH 5/8] fix: enforce bounded hook ingress I/O --- .../Transport/SocketTransportClient.swift | 50 ++++++++++++++--- ProwlCLITests/ProwlCLIIntegrationTests.swift | 54 +++++++++++++++++-- .../007-s3a-action.md | 18 +++++++ .../AgentRuntime/CodexShellProbeProcess.swift | 11 ++-- .../AgentRuntime/StableOwnerFileReader.swift | 2 +- supacodeTests/AgentHookRenderingTests.swift | 19 +++++++ .../CodexShellProbeProcessTests.swift | 6 ++- 7 files changed, 145 insertions(+), 15 deletions(-) diff --git a/ProwlCLI/Transport/SocketTransportClient.swift b/ProwlCLI/Transport/SocketTransportClient.swift index 6e8bfade..29f77014 100644 --- a/ProwlCLI/Transport/SocketTransportClient.swift +++ b/ProwlCLI/Transport/SocketTransportClient.swift @@ -34,6 +34,9 @@ enum SocketTransportClient { ) } defer { close(clientFD) } + let deadline = timeoutMilliseconds.map { + DispatchTime.now().uptimeNanoseconds + UInt64(max(1, $0)) * 1_000_000 + } if let timeoutMilliseconds { try configureTimeout(clientFD, milliseconds: timeoutMilliseconds) } @@ -45,11 +48,15 @@ enum SocketTransportClient { // Send length-prefixed request: 4-byte big-endian length + JSON payload var length = UInt32(requestData.count).bigEndian - try withUnsafeBytes(of: &length) { try fdWrite(fildes: clientFD, buffer: $0) } - try requestData.withUnsafeBytes { try fdWrite(fildes: clientFD, buffer: $0) } + try withUnsafeBytes(of: &length) { + try fdWrite(fildes: clientFD, buffer: $0, deadline: deadline) + } + try requestData.withUnsafeBytes { + try fdWrite(fildes: clientFD, buffer: $0, deadline: deadline) + } // Read length-prefixed response - let responseLengthData = try fdRead(fildes: clientFD, count: 4) + let responseLengthData = try fdRead(fildes: clientFD, count: 4, deadline: deadline) let responseLength = responseLengthData.withUnsafeBytes { UInt32(bigEndian: $0.load(as: UInt32.self)) } @@ -61,7 +68,7 @@ enum SocketTransportClient { ) } - return try fdRead(fildes: clientFD, count: Int(responseLength)) + return try fdRead(fildes: clientFD, count: Int(responseLength), deadline: deadline) } // MARK: - Low-level I/O using Darwin/Glibc read/write @@ -84,9 +91,14 @@ enum SocketTransportClient { } } - private static func fdWrite(fildes: Int32, buffer: UnsafeRawBufferPointer) throws { + private static func fdWrite( + fildes: Int32, + buffer: UnsafeRawBufferPointer, + deadline: UInt64? + ) throws { var offset = 0 while offset < buffer.count { + try waitUntilReady(fildes, events: Int16(POLLOUT), deadline: deadline) let written = Darwin.write(fildes, buffer.baseAddress!.advanced(by: offset), buffer.count - offset) guard written > 0 else { throw ExitError(code: CLIErrorCode.transportFailed, message: socketWriteFailureMessage(bytesWritten: written)) @@ -95,13 +107,18 @@ enum SocketTransportClient { } } - private static func fdRead(fildes: Int32, count: Int) throws -> Data { + private static func fdRead( + fildes: Int32, + count: Int, + deadline: UInt64? + ) throws -> Data { var data = Data(capacity: count) var remaining = count let bufferSize = min(count, 65536) let buffer = UnsafeMutableRawPointer.allocate(byteCount: bufferSize, alignment: 1) defer { buffer.deallocate() } while remaining > 0 { + try waitUntilReady(fildes, events: Int16(POLLIN), deadline: deadline) let toRead = min(remaining, bufferSize) let bytesRead = Darwin.read(fildes, buffer, toRead) guard bytesRead > 0 else { @@ -113,6 +130,27 @@ enum SocketTransportClient { return data } + private static func waitUntilReady( + _ descriptor: Int32, + events: Int16, + deadline: UInt64? + ) throws { + guard let deadline else { return } + let now = DispatchTime.now().uptimeNanoseconds + guard now < deadline else { throw deadlineError() } + let remainingMilliseconds = max(1, Int((deadline - now + 999_999) / 1_000_000)) + var pollDescriptor = pollfd(fd: descriptor, events: events, revents: 0) + let result = poll(&pollDescriptor, 1, Int32(min(remainingMilliseconds, Int(Int32.max)))) + guard result > 0 else { throw deadlineError() } + } + + private static func deadlineError() -> ExitError { + ExitError( + code: CLIErrorCode.transportFailed, + message: "Prowl hook transport exceeded its total deadline." + ) + } + private static func socketWriteFailureMessage(bytesWritten: Int) -> String { if bytesWritten == 0 { return "Socket write failed: wrote 0 bytes before the request was complete." diff --git a/ProwlCLITests/ProwlCLIIntegrationTests.swift b/ProwlCLITests/ProwlCLIIntegrationTests.swift index bdb47bb4..3dd427f6 100644 --- a/ProwlCLITests/ProwlCLIIntegrationTests.swift +++ b/ProwlCLITests/ProwlCLIIntegrationTests.swift @@ -65,6 +65,36 @@ final class ProwlCLIIntegrationTests: XCTestCase { XCTAssertEqual(result.stderr, "") } + func testNativeHookBridgeEnforcesATotalDeadlineAgainstDripResponse() throws { + let socketPath = temporarySocketPath(suffix: "hook-drip-deadline") + let server = try MockSocketServer( + socketPath: socketPath, + responseData: Data("abcde".utf8), + responseByteDelayMicroseconds: 200_000 + ) + try server.start() + defer { server.stop() } + let payload = Data( + #"{"hook_event_name":"Stop","session_id":"session-1","cwd":"/tmp/project"}"#.utf8 + ) + let clock = ContinuousClock() + let start = clock.now + + let result = try runProwl( + args: ["agents", "_hook", "claude", "Stop"], + environment: [ + AgentNativeHookInput.tokenEnvironmentKey: "token-1", + ProwlSocket.environmentKey: socketPath, + ], + stdinData: payload + ) + + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(result.stdout, "") + XCTAssertEqual(result.stderr, "") + XCTAssertLessThan(start.duration(to: clock.now), .milliseconds(700)) + } + func testCodexHookForwardsExactPayloadOnTransportLossAndScrubsInternalEnvironment() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent( "prowl-hook-forward-\(UUID().uuidString)", @@ -3444,15 +3474,21 @@ private struct CommandResult { private final class MockSocketServer: @unchecked Sendable { private let socketPath: String private let responseData: Data + private let responseByteDelayMicroseconds: useconds_t private var serverFD: Int32 = -1 private var receivedRequestData: Data? private let lock = NSLock() private let requestSemaphore = DispatchSemaphore(value: 0) - init(socketPath: String, responseData: Data) throws { + init( + socketPath: String, + responseData: Data, + responseByteDelayMicroseconds: useconds_t = 0 + ) throws { self.socketPath = socketPath self.responseData = responseData + self.responseByteDelayMicroseconds = responseByteDelayMicroseconds } deinit { stop() } @@ -3510,6 +3546,10 @@ private final class MockSocketServer: @unchecked Sendable { let clientFD = accept(self.serverFD, nil, nil) guard clientFD >= 0 else { return } defer { close(clientFD) } + var noSigPipe: Int32 = 1 + _ = withUnsafePointer(to: &noSigPipe) { + setsockopt(clientFD, SOL_SOCKET, SO_NOSIGPIPE, $0, socklen_t(MemoryLayout.size)) + } do { let lengthData = try self.readExact(fd: clientFD, count: 4) @@ -3527,8 +3567,16 @@ private final class MockSocketServer: @unchecked Sendable { try withUnsafeBytes(of: &responseLength) { lengthBytes in try self.writeAll(fd: clientFD, bytes: lengthBytes) } - try self.responseData.withUnsafeBytes { bytes in - try self.writeAll(fd: clientFD, bytes: bytes) + if self.responseByteDelayMicroseconds == 0 { + try self.responseData.withUnsafeBytes { bytes in + try self.writeAll(fd: clientFD, bytes: bytes) + } + } else { + for byte in self.responseData { + var value = byte + try withUnsafeBytes(of: &value) { try self.writeAll(fd: clientFD, bytes: $0) } + usleep(self.responseByteDelayMicroseconds) + } } } catch { self.requestSemaphore.signal() diff --git a/docs-ai/064-agent-completion-signals/007-s3a-action.md b/docs-ai/064-agent-completion-signals/007-s3a-action.md index b5f59aef..86d24698 100644 --- a/docs-ai/064-agent-completion-signals/007-s3a-action.md +++ b/docs-ai/064-agent-completion-signals/007-s3a-action.md @@ -160,6 +160,24 @@ A follow-up hardening discovered while verifying the first finding also carries `PATH` override into login-shell executable resolution; the prepared runtime invocation then uses the attested absolute executable. Focused round-2 validation passed 77 tests plus `make check`. +### Round 3 — final full-diff blocker review + +The final review reproduced two remaining boundedness failures with temporary-only probes: + +- Opening a user-selected FIFO with read-only/no-follow flags blocked before type validation. The + shared stable owner-file reader now adds `O_NONBLOCK | O_CLOEXEC`; a FIFO regression returns + `.unreadable` within 200 ms without a writer. +- `SO_RCVTIMEO`/`SO_SNDTIMEO` were per-syscall rather than a total hook deadline. CLI transport now + carries one monotonic deadline across write and both response reads, using `poll` with the + remaining interval. A five-byte response dripped every 200 ms now exits silently within the + bounded threshold, while existing listener-loss and Codex forwarding tests retain fail-open + behavior. + +Both were valid P1 findings and are fixed with focused tests. The final repository gate rerun after +round 2 verified 2520 app tests, 95 CLI integration tests, 34 script tests, strict format/lint, and +the Debug build; round-3 focused regressions also pass. No P0/P1/P2 code finding remains. The only +residual is the separately documented visible-GUI acceptance limitation. + ## Deferred scope S3b owns Copilot/Droid/Qoder adapters. S3c owns Pi/OMP/OpenCode adapters and the Active Agents exact diff --git a/supacode/Domain/AgentRuntime/CodexShellProbeProcess.swift b/supacode/Domain/AgentRuntime/CodexShellProbeProcess.swift index 233328e6..362f1ce6 100644 --- a/supacode/Domain/AgentRuntime/CodexShellProbeProcess.swift +++ b/supacode/Domain/AgentRuntime/CodexShellProbeProcess.swift @@ -18,6 +18,7 @@ nonisolated struct CodexShellProbeProcess: Sendable { let timeout: TimeInterval let maximumOutputBytes: Int let shellOverride: URL? + let shellOverrideArguments: [String] } private final class ProcessBox: @unchecked Sendable { @@ -38,15 +39,18 @@ nonisolated struct CodexShellProbeProcess: Sendable { let timeout: TimeInterval let maximumOutputBytes: Int let shellOverride: URL? + let shellOverrideArguments: [String] init( timeout: TimeInterval = 1, maximumOutputBytes: Int = 16 * 1_024, - shellOverride: URL? = nil + shellOverride: URL? = nil, + shellOverrideArguments: [String] = [] ) { self.timeout = max(0.05, timeout) self.maximumOutputBytes = max(1, maximumOutputBytes) self.shellOverride = shellOverride + self.shellOverrideArguments = shellOverrideArguments } func run(cwd: URL, script: String) async throws -> ShellOutput { @@ -58,7 +62,8 @@ nonisolated struct CodexShellProbeProcess: Sendable { options: RunOptions( timeout: timeout, maximumOutputBytes: maximumOutputBytes, - shellOverride: shellOverride + shellOverride: shellOverride, + shellOverrideArguments: shellOverrideArguments ), processBox: processBox ) @@ -80,7 +85,7 @@ nonisolated struct CodexShellProbeProcess: Sendable { let process = Process() if let shellOverride = options.shellOverride { process.executableURL = shellOverride - process.arguments = [] + process.arguments = options.shellOverrideArguments } else { let invocation = ShellClient.loginShellInvocation(userShell: defaultShellURL()) process.executableURL = invocation.shell diff --git a/supacode/Domain/AgentRuntime/StableOwnerFileReader.swift b/supacode/Domain/AgentRuntime/StableOwnerFileReader.swift index 747de675..084b6b9b 100644 --- a/supacode/Domain/AgentRuntime/StableOwnerFileReader.swift +++ b/supacode/Domain/AgentRuntime/StableOwnerFileReader.swift @@ -20,7 +20,7 @@ nonisolated enum StableOwnerFileReader { afterRead: () -> Void = {} ) -> StableOwnerFileReadResult { let path = url.path(percentEncoded: false) - let descriptor = Darwin.open(path, O_RDONLY | O_NOFOLLOW) + let descriptor = Darwin.open(path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC) guard descriptor >= 0 else { return .unreadable } defer { Darwin.close(descriptor) } var before = stat() diff --git a/supacodeTests/AgentHookRenderingTests.swift b/supacodeTests/AgentHookRenderingTests.swift index 24340e0a..944e4529 100644 --- a/supacodeTests/AgentHookRenderingTests.swift +++ b/supacodeTests/AgentHookRenderingTests.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation import Testing @@ -96,6 +97,24 @@ struct AgentHookRenderingTests { #expect(stop.count == 1) } + @Test func stableReaderRejectsFIFOWithoutBlockingAtOpen() throws { + let directory = FileManager.default.temporaryDirectory.appending( + path: "prowl-settings-fifo-\(UUID().uuidString)", + directoryHint: .isDirectory + ) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let fifo = directory.appending(path: "settings.json", directoryHint: .notDirectory) + #expect(mkfifo(fifo.path(percentEncoded: false), 0o600) == 0) + let clock = ContinuousClock() + let start = clock.now + + let result = ClaudeSettingsStableReader.read(fifo, maximumBytes: 1_024) + + #expect(result == .unreadable) + #expect(start.duration(to: clock.now) < .milliseconds(200)) + } + @Test func stableReaderRejectsAtomicPathReplacement() throws { let directory = FileManager.default.temporaryDirectory.appending( path: "prowl-settings-replacement-\(UUID().uuidString)", diff --git a/supacodeTests/CodexShellProbeProcessTests.swift b/supacodeTests/CodexShellProbeProcessTests.swift index c890f21a..8a6ef748 100644 --- a/supacodeTests/CodexShellProbeProcessTests.swift +++ b/supacodeTests/CodexShellProbeProcessTests.swift @@ -23,7 +23,8 @@ struct CodexShellProbeProcessTests { let process = CodexShellProbeProcess( timeout: 0.5, maximumOutputBytes: 1_024, - shellOverride: shell + shellOverride: URL(filePath: "/bin/sh"), + shellOverrideArguments: [shell.path(percentEncoded: false)] ) await #expect(throws: CodexShellProbeProcessError.timeout) { @@ -51,7 +52,8 @@ struct CodexShellProbeProcessTests { let process = CodexShellProbeProcess( timeout: 2, maximumOutputBytes: 128, - shellOverride: shell + shellOverride: URL(filePath: "/bin/sh"), + shellOverrideArguments: [shell.path(percentEncoded: false)] ) await #expect(throws: CodexShellProbeProcessError.outputTooLarge) { -- 2.51.2 From 6470cd295b7f0af28198003feff2a6f0520a06fd Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 24 Aug 2026 04:07:28 +0900 Subject: [PATCH 6/8] docs(ai): finalize S3a review record --- docs-ai/064-agent-completion-signals/007-s3a-action.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs-ai/064-agent-completion-signals/007-s3a-action.md b/docs-ai/064-agent-completion-signals/007-s3a-action.md index 86d24698..f9db49aa 100644 --- a/docs-ai/064-agent-completion-signals/007-s3a-action.md +++ b/docs-ai/064-agent-completion-signals/007-s3a-action.md @@ -96,8 +96,8 @@ exact notifier forwarding with empty/Unicode argv, unchanged payload, scrubbed i and original notifier exit status. Repository gates passed on the implementation branch: `make build-cli`, `make test-cli-smoke`, -`make test-cli-integration` (95 integration tests), `make check` (including 34 script tests), -`make test` (xcresult verified 2506 tests, zero failures), and `make build-app`. The enabled live +`make test-cli-integration` (96 integration tests), `make check` (including 34 script tests), +`make test` (xcresult verified 2521 tests, zero failures), and `make build-app`. The enabled live Codex 0.149 scratch contract also passed. The full Debug GUI matrix was attempted with a freshly embedded bundle CLI, custom socket, @@ -174,7 +174,7 @@ The final review reproduced two remaining boundedness failures with temporary-on behavior. Both were valid P1 findings and are fixed with focused tests. The final repository gate rerun after -round 2 verified 2520 app tests, 95 CLI integration tests, 34 script tests, strict format/lint, and +round 3 verified 2521 app tests, 96 CLI integration tests, 34 script tests, strict format/lint, and the Debug build; round-3 focused regressions also pass. No P0/P1/P2 code finding remains. The only residual is the separately documented visible-GUI acceptance limitation. -- 2.51.2 From a62b798e654b2890b7c159e1b9db0385b21b1bf6 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 24 Aug 2026 04:20:53 +0900 Subject: [PATCH 7/8] test: make surface creation failure deterministic --- .../Models/WorktreeTerminalState+Surfaces.swift | 1 + .../Terminal/Models/WorktreeTerminalState.swift | 5 ++++- .../Infrastructure/Ghostty/GhosttySurfaceView.swift | 7 +++++++ .../WorktreeTerminalStateAgentProfileTests.swift | 11 ++++++++--- 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift index 4eff8472..e6dd1e3b 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift @@ -374,6 +374,7 @@ extension WorktreeTerminalState { context: context, environment: worktree.scriptEnvironment.merging(additionalEnvironment) { _, patched in patched }, skipsSurfaceCreationForTesting: skipsSurfaceCreationForTesting, + failsSurfaceCreationForTesting: failsSurfaceCreationForTesting, defersSurfaceCreation: defersSurfaceCreation ) // Sending a no-op font size action marks the Ghostty surface as diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 17cbfefd..eff0d9c2 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -96,6 +96,7 @@ final class WorktreeTerminalState { let worktree: Worktree private let targetHandleRegistry: TerminalTargetHandleRegistry let skipsSurfaceCreationForTesting: Bool + let failsSurfaceCreationForTesting: Bool @ObservationIgnored @SharedReader private var repositorySettings: RepositorySettings var trees: [TerminalTabID: SplitTree] = [:] @@ -287,12 +288,14 @@ final class WorktreeTerminalState { defaultFontSize: Float32? = nil, targetHandleRegistry: TerminalTargetHandleRegistry? = nil, titleFlushClock: any Clock = ContinuousClock(), - skipsSurfaceCreationForTesting: Bool = false + skipsSurfaceCreationForTesting: Bool = false, + failsSurfaceCreationForTesting: Bool = false ) { self.runtime = runtime self.worktree = worktree self.targetHandleRegistry = targetHandleRegistry ?? TerminalTargetHandleRegistry() self.skipsSurfaceCreationForTesting = skipsSurfaceCreationForTesting + self.failsSurfaceCreationForTesting = failsSurfaceCreationForTesting self.pendingSetupScript = runSetupScript self.defaultFontSize = defaultFontSize self.tabManager = TerminalTabManager(titleFlushClock: titleFlushClock) diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift index 32834130..35d3b30d 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift @@ -145,6 +145,7 @@ final class GhosttySurfaceView: NSView, Identifiable { context } private let skipsSurfaceCreationForTesting: Bool + private let failsSurfaceCreationForTesting: Bool private(set) var surfaceCreationArmed = false private var trackingArea: NSTrackingArea? private var lastBackingSize: CGSize = .zero @@ -269,6 +270,7 @@ final class GhosttySurfaceView: NSView, Identifiable { context: ghostty_surface_context_e, environment: [String: String] = [:], skipsSurfaceCreationForTesting: Bool = false, + failsSurfaceCreationForTesting: Bool = false, defersSurfaceCreation: Bool = false ) { let id = UUID() @@ -278,6 +280,7 @@ final class GhosttySurfaceView: NSView, Identifiable { self.fontSize = fontSize ?? 0 self.context = context self.skipsSurfaceCreationForTesting = skipsSurfaceCreationForTesting + self.failsSurfaceCreationForTesting = failsSurfaceCreationForTesting if let workingDirectory { let path = Self.normalizedWorkingDirectoryPath( workingDirectory.path(percentEncoded: false) @@ -369,6 +372,10 @@ final class GhosttySurfaceView: NSView, Identifiable { guard !surfaceCreationArmed else { return true } surfaceCreationArmed = true guard !skipsSurfaceCreationForTesting else { return true } + guard !failsSurfaceCreationForTesting else { + surfaceCreationArmed = false + return false + } createSurface() guard let surface else { surfaceCreationArmed = false diff --git a/supacodeTests/WorktreeTerminalStateAgentProfileTests.swift b/supacodeTests/WorktreeTerminalStateAgentProfileTests.swift index 33a64a24..386bea58 100644 --- a/supacodeTests/WorktreeTerminalStateAgentProfileTests.swift +++ b/supacodeTests/WorktreeTerminalStateAgentProfileTests.swift @@ -216,7 +216,10 @@ struct WorktreeTerminalStateAgentProfileTests { } @Test func deferredGhosttyCreationFailureRollsBackRegistrationAndSurface() { - let state = makeState(skipsSurfaceCreationForTesting: false) + let state = makeState( + skipsSurfaceCreationForTesting: false, + failsSurfaceCreationForTesting: true + ) var registeredSurface: UUID? var closedSurface: UUID? state.onAgentProfileSurfacePrepared = { surfaceID, _ in @@ -374,7 +377,8 @@ struct WorktreeTerminalStateAgentProfileTests { } private func makeState( - skipsSurfaceCreationForTesting: Bool = true + skipsSurfaceCreationForTesting: Bool = true, + failsSurfaceCreationForTesting: Bool = false ) -> WorktreeTerminalState { WorktreeTerminalState( runtime: GhosttyRuntime(), @@ -385,7 +389,8 @@ struct WorktreeTerminalStateAgentProfileTests { workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-1"), repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") ), - skipsSurfaceCreationForTesting: skipsSurfaceCreationForTesting + skipsSurfaceCreationForTesting: skipsSurfaceCreationForTesting, + failsSurfaceCreationForTesting: failsSurfaceCreationForTesting ) } -- 2.51.2 From 05eef4e91305e28f72078647e1d42df2701d8686 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 24 Aug 2026 04:32:00 +0900 Subject: [PATCH 8/8] test: let config probe fixture exit after response --- supacodeTests/CodexConfigReadProcessTests.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/supacodeTests/CodexConfigReadProcessTests.swift b/supacodeTests/CodexConfigReadProcessTests.swift index 02782bc4..27f710dc 100644 --- a/supacodeTests/CodexConfigReadProcessTests.swift +++ b/supacodeTests/CodexConfigReadProcessTests.swift @@ -97,7 +97,6 @@ struct CodexConfigReadProcessTests { "config_mode": stat.S_IMODE(config.stat().st_mode) if config.exists() else None })) print(json.dumps({"jsonrpc":"2.0","id":2,"result":{"config":{"notify":notify}}}), flush=True) - time.sleep(30) """ try script.write(to: executable, atomically: true, encoding: .utf8) try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: executable.path)