diff --git a/docs-ai/053-agent-profiles/000-plan.md b/docs-ai/053-agent-profiles/000-plan.md index 9da43daf..68e133c3 100644 --- a/docs-ai/053-agent-profiles/000-plan.md +++ b/docs-ai/053-agent-profiles/000-plan.md @@ -331,6 +331,13 @@ agent 列表不变。 ## Amendments +- Updated 2026-07-31: **环境补丁语义从 surface-scoped 改为 launch-scoped** — onevcat + 定位出"Agents 启动 → agent 退出 → 手动 codex 继承 profile env"的串号链,环境补丁 + 改为随 `env` 前缀只作用于 launched 进程(home 内联、override 值经 `PROWL_ENV_*` + carrier 引用,不进 history),launch identity 随 launched 进程退出而清除;本文 + 「账号绑定与 runtime home」节的 surface 附加语义由此作废 — see + [006-launch-scoped-environment.md](006-launch-scoped-environment.md). + - Updated 2026-07-31: V1 综合审计(双评审交叉核对)与首轮边界补强 — see [005-v1-audit.md](005-v1-audit.md)。修复:`HOME` 入 env 保留名单、launch 结果 事件化(记忆后置 + 失败 toast)、profile 名按 runtime 门控、可用性判定共享且 diff --git a/docs-ai/053-agent-profiles/006-launch-scoped-environment.md b/docs-ai/053-agent-profiles/006-launch-scoped-environment.md new file mode 100644 index 00000000..68e732a5 --- /dev/null +++ b/docs-ai/053-agent-profiles/006-launch-scoped-environment.md @@ -0,0 +1,80 @@ +# 053.006 — 环境补丁改为 launch-scoped + +| | | +| --- | --- | +| **日期** | 2026-07-31 | +| **状态** | Implemented(与 005 同分支/PR) | +| **关联** | [000-plan](000-plan.md) · [004-environment-overrides](004-environment-overrides.md) · [005-v1-audit](005-v1-audit.md) | + +## 背景:onevcat 发现的串 env + +原设计把环境补丁(dedicated home 变量 + 用户 env overrides)在 **surface spawn 时注入 +shell 环境**(000-plan「环境 patch 是叠加而非替换」)。onevcat 在 005 审计讨论中指出 +这与产品语义冲突,并定位出具体串号链: + +``` +Agents button → shell 带 CODEX_HOME/OPENAI_API_KEY 出生 → agent 退出(Ctrl+C) +→ env 残留在 shell → 用户手动敲 codex → 继承 profile 的 home/key +→ 手动启动跑在了 profile 账号 / override 端点上 +``` + +产品承诺被澄清为:**profile 属于启动动作,不属于 pane**。经 Prowl 的 Agent Profile +入口(Agents 菜单、Command Palette)拉起的 agent 带 profile;用户手动启动的 +agent 必须走自己的默认环境与账号。surface-scoped 注入违反该承诺,且 pane 里后续 +任何进程(npm script、curl…)都会静默继承 API key。 + +## 决策:全部 launch-scoped + +三个候选(全 launch-scoped / home 留 surface 的混合 / 维持现状 + 可见化)中, +onevcat 选定**全部 launch-scoped**。已知且接受的行为代价:绑定 pane 内 agent 退出后, +手动 `codex` / `codex resume` 走用户默认账号,profile home 里的 session 手动恢复不到 +——回到 profile 上下文的唯一方式是再经 Agents 入口启动。这正是所选语义的直接推论。 + +## 机制 + +打入 pane 的命令从 `'codex' …` 变为: + +``` +env CODEX_HOME='/Users/x/.prowl/agent-profiles/' OPENAI_API_KEY="$PROWL_ENV_OPENAI_API_KEY" 'codex' … +``` + +- **home 路径内联**:非 secret,UUID 派生无空格,经既有 `AgentInvocation.shellQuote` + 单引号渲染;preview 里可见即自文档。 +- **override 值经 `PROWL_ENV_` carrier 间接引用**:真实值仍走 Ghostty spawn env, + 但顶着 Prowl 保留前缀——没有工具会读它;命令文本、shell history、scrollback 里只有 + 引用,永不出现值。name 经 POSIX 校验,引用 token 不含任何用户 shell 文本,注入安全 + 不变。`PROWL_` 保留名单保证用户行无法与 carrier 碰撞。 +- `env(1)` 是外部二进制,zsh/bash/fish 语法一致(fish 不可靠支持 `VAR=x cmd` 裸前缀, + 故不用裸前缀);`"$VAR"` 展开三家皆同。 +- **launch identity 与 launched 进程同生命周期**:`removeAgentEntryIfNeeded`(detection + 层感知 agent 退出的缝)同时清除 `launchProfilesBySurface`——之后手动启动的同 runtime + agent 不再顶着 profile 名,rooted session 检测的 config root 一并复原。 +- Launch Preview 即 `terminalInput` 本身(所打即所见);004 的名字启发式脱敏机制删除 + ——secret 已不在预览里,无需脱敏。 + +## 连带简化 + +- 005 曾把「split 继承 / layout restore 重放 env」列为下一步推荐;launch-scoped 语义下 + 这两条**不再需要**(surface 上只剩 carrier 变量,不继承恰是正确行为),从 follow-up + 中撤销。 +- restore/手动启动"不重放 override"从 known limitation 升格为规则本身。 +- resume(handoff 波次)携带环境的缺口不变:结构化 resume 需要把同一组 + token/carrier 语义带过去,仍归 handoff 波次。 + +## 已知边界 + +- agent 退出与手动重启发生在同一 detection tick 内时,identity 存在极短的残留窗口 + (presence hold 粒度);可接受,detection 按进程存亡收敛。 +- 用户可 `echo $PROWL_ENV_X` 查看 carrier 值:同用户主动检视,与 0600 JSON 同级, + 非泄露面。 +- 从 shell history 重放 launch 命令会得到空的 carrier 展开(auth 显式失败,可见), + 这与"手动启动不带 profile env"的语义一致。 + +## 验证 + +- Planner 测试:home token 内联渲染、override 排序 token + carrier 映射、reserved 行 + (含 `CODEX_HOME` 伪造)零 token、preview == terminalInput 且不含任何 override 值、 + 空值 override 语义保留。 +- 终端层测试:agent 退出清除 launch identity。 +- `make check` / 全量 `make test` / `make build-app` 全绿;文案与 docs + (`docs/components/agent-profiles.md`)同步改写为 launch-scoped 语义。 diff --git a/docs/components/agent-profiles.md b/docs/components/agent-profiles.md index 09138a03..1fb93fd6 100644 --- a/docs/components/agent-profiles.md +++ b/docs/components/agent-profiles.md @@ -39,8 +39,10 @@ A launch creates a **new** tab (or split, per placement) in the current worktree, running the agent interactively with no initial prompt. Prowl never types into an existing shell. The new pane records its profile identity at creation: the Active Agents rows and the capsule show the profile's display -name (frozen at launch — later renames don't relabel live panes; a *different* -agent started manually in the same pane shows its own name, not the profile's). +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. A launch that fails before its surface exists (e.g. home provisioning) shows a warning toast, and only a successful launch updates the per-repo "last launched" memory behind the Recommended resolution. @@ -72,11 +74,15 @@ existing, enabled profile. ## Environment variables The **Environment Variables** table (Advanced, below Extra Arguments) adds -per-profile environment overrides to the launched surface — they are applied -at spawn, so the new pane's shell and everything started in it (the agent, its -subprocesses, later manual commands in that pane) see them, on top of the -shell's normal environment — e.g. `OPENAI_BASE_URL` + `OPENAI_API_KEY` to get -a "Codex but using DeepSeek" profile. Rules: +per-profile environment overrides — e.g. `OPENAI_BASE_URL` + `OPENAI_API_KEY` +to get a "Codex but using DeepSeek" profile. The whole patch is +**launch-scoped**: it applies to the launched agent process (and its +subprocesses) only. The pane's shell keeps your normal environment, so after +the agent exits, a manual `codex` / `claude` — or any other command — in that +pane runs with your own account and environment. Mechanically, the launch +command carries `env NAME="$PROWL_ENV_NAME" …` references while the values +ride in hidden `PROWL_ENV_*` surface variables, so no override value ever +appears in the typed command, shell history, or scrollback. Rules: - Names must be valid POSIX names (`[A-Za-z_][A-Za-z0-9_]*`). An empty value legitimately sets the variable to the empty string. @@ -87,21 +93,26 @@ a "Codex but using DeepSeek" profile. Rules: through **Use Dedicated Home**, which always wins over a same-named row. - Later duplicate names win (shell-export semantics). - Values are stored in plaintext in `~/.prowl/global.onevcat.json` (kept - owner-only, `0600`). The Launch Preview masks secret-looking values - (names containing `KEY`/`TOKEN`/`SECRET`/`PASSWORD`). -- Overrides apply to profile launches only; resumed or restored panes do not - re-apply them (same limitation as dedicated homes). + owner-only, `0600`); the Launch Preview shows only the `$PROWL_ENV_*` + references, never the values. +- Launch-scoped by design: manual launches, resumed sessions, and restored + panes intentionally run with your default environment. Re-launch through + the Agents menu to get the profile's environment again. ## Dedicated home (separate account) Toggling **Use Dedicated Home** (Advanced) gives the profile its own runtime -home under `~/.prowl/agent-profiles//`, attached to the new surface via -`CLAUDE_CONFIG_DIR` / `CODEX_HOME`. That relocates the runtime's *entire* -home: separate login and usage, but also separate skills, global instructions +home under `~/.prowl/agent-profiles//`, attached to the launched agent +via a `CLAUDE_CONFIG_DIR` / `CODEX_HOME` assignment on the launch command +(launch-scoped, like overrides). That relocates the runtime's *entire* home: +separate login and usage, but also separate skills, global instructions (`CLAUDE.md` / `AGENTS.md`), and session history. The first launch is the sign-in moment — the agent's own TUI walks through login and the credentials land inside the profile home. Prowl never reads or copies them; use **Reveal -Profile Files** to manage skills and instruction files there yourself. +Profile Files** to manage skills and instruction files there yourself. After +the launched agent exits, a manually started agent in the same pane uses your +default home and account — re-enter the profile's account by launching from +the Agents menu again. Removing any profile asks first. Pure presets have no file operations. A bound profile offers **Remove Profile** to keep its folder on disk and **Remove and @@ -134,10 +145,12 @@ effort, execution mode, placement). ## Gotchas for agents -- Session detection follows the relocated home for Prowl-launched bound panes - (resume and handoff artifacts resolve against the profile's config root). - An agent you start manually with your own `CLAUDE_CONFIG_DIR`/`CODEX_HOME` - is still detected, but without session identity. +- Session detection follows the relocated home while the launched bound agent + is running (resume and handoff artifacts resolve against the profile's + config root); once it exits, the pane's config root reverts with the + identity. An agent you start manually with your own + `CLAUDE_CONFIG_DIR`/`CODEX_HOME` is still detected, but without session + identity. - Availability is judged in two tiers. Ground truth is a background login-shell probe (`command -v`, the same PATH resolution a launch uses): once it answers, "not found" warns "… is not on your shell's PATH" and diff --git a/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift b/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift index cb1f2419..fab618f7 100644 --- a/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift +++ b/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift @@ -8,39 +8,31 @@ nonisolated struct AgentProfileLaunchPlan: Equatable, Sendable { let profileName: String let runtime: AgentProfileRuntime let invocation: AgentInvocation + /// `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 + /// environment, so a manual `codex`/`claude` after the agent exits runs + /// with the user's own account. Home paths are inlined (not secret); + /// override values are referenced as `"$PROWL_ENV_"` so no user + /// value ever appears in the typed command, shell history, or scrollback. + let commandEnvironmentTokens: [String] let placement: AgentProfilePlacement let splitDirection: UserCustomSplitDirection - /// Environment patch for the new surface: the profile's user overrides - /// plus, for account-bound profiles, the dedicated-home variable. Applied at - /// surface spawn, so the shell and everything started in it see it; additive - /// over the shell's normal environment, never a scrub. - let environment: [String: String] + /// Carrier variables for the reference tokens, injected at surface spawn: + /// `PROWL_ENV_` → verbatim value. The namespace is Prowl-reserved, so + /// nothing but the launch command reads them, and the real variable names + /// never exist in the pane's shell. + let surfaceEnvironment: [String: String] /// Dedicated home to provision before launch; nil for pure presets. let dedicatedHome: URL? - var terminalInput: String { invocation.terminalInput } - - /// Human-readable launch preview: env prefix plus the exact rendered - /// invocation. The prefix is an equivalent shell rendering — the real patch - /// goes through Ghostty's spawn env array, never a shell — so values are - /// quoted for faithfulness and secret-looking values are masked rather than - /// displayed (docs-ai 053/004). - var previewText: String { - let prefix = environment.sorted { $0.key < $1.key }.map { pair in - "\(pair.key)=\(Self.previewValue(name: pair.key, value: pair.value))" - } - return (prefix + [invocation.terminalInput]).joined(separator: " ") + /// The exact line typed into the new pane; doubles as the launch preview. + var terminalInput: String { + guard !commandEnvironmentTokens.isEmpty else { return invocation.terminalInput } + return (["env"] + commandEnvironmentTokens + [invocation.terminalInput]).joined(separator: " ") } - private static let secretNameFragments = ["KEY", "TOKEN", "SECRET", "PASSWORD"] - - private static func previewValue(name: String, value: String) -> String { - let upper = name.uppercased() - if secretNameFragments.contains(where: { upper.contains($0) }) { - return "•••" - } - return "'" + value.replacing("'", with: "'\"'\"'") + "'" - } + var previewText: String { terminalInput } } /// Which env variable names a profile override may set, shared by the planner @@ -99,6 +91,14 @@ nonisolated enum AgentProfileEnvironmentPolicy { return nil } + /// Carrier variable in the surface environment holding an override's value. + /// The `PROWL_` namespace is already reserved, so user rows can't collide + /// with a carrier, and the validated POSIX name keeps the reference token + /// (`NAME="$PROWL_ENV_NAME"`) free of any user-authored shell text. + static func carrierName(for name: String) -> String { + "PROWL_ENV_\(name)" + } + /// The applied subset: trimmed names, invalid/reserved rows dropped, later /// duplicates win (shell-export semantics). Values stay verbatim — an empty /// value legitimately sets the variable to the empty string. @@ -178,10 +178,12 @@ nonisolated enum AgentProfileLaunchPlanner { AgentStartRequest(agent: profile.runtime.agent, intent: .interactive, configuration: configuration) ) - // User overrides first; the dedicated-home variable is written after, so - // an account binding always beats a same-named user row — the account - // isolation reasoning must stay provable (docs-ai 053/004). - var environment = AgentProfileEnvironmentPolicy.effectiveOverrides(profile.environmentOverrides) + // The whole patch renders as launch-scoped `env` tokens (docs-ai + // 053/006). The home token leads: it is the launch's identity, and the + // reserved-name policy already guarantees no user row can carry the same + // name — the account isolation reasoning stays provable. + var tokens: [String] = [] + var surfaceEnvironment: [String: String] = [:] var dedicatedHome: URL? if profile.bindsDedicatedHome { guard let variable = adapter.accountHomeEnvironmentVariable else { @@ -191,18 +193,27 @@ nonisolated enum AgentProfileLaunchPlanner { guard isContained(home, in: homeBaseDirectory) else { throw AgentProfileLaunchPlanError.homeEscapesBase(home) } - environment[variable] = pathString(home) + // Inlined literal: the UUID-derived path is not a secret, and seeing it + // in the preview documents which home the launch binds. + tokens.append("\(variable)=\(AgentInvocation.shellQuote(pathString(home)))") dedicatedHome = home } + let overrides = AgentProfileEnvironmentPolicy.effectiveOverrides(profile.environmentOverrides) + for name in overrides.keys.sorted() { + let carrier = AgentProfileEnvironmentPolicy.carrierName(for: name) + tokens.append("\(name)=\"$\(carrier)\"") + surfaceEnvironment[carrier] = overrides[name] + } return AgentProfileLaunchPlan( profileID: profile.id, profileName: profile.name, runtime: profile.runtime, invocation: invocation, + commandEnvironmentTokens: tokens, placement: profile.placement, splitDirection: profile.splitDirection, - environment: environment, + surfaceEnvironment: surfaceEnvironment, dedicatedHome: dedicatedHome ) } diff --git a/supacode/Domain/AgentRuntime/AgentRuntimeAdapter.swift b/supacode/Domain/AgentRuntime/AgentRuntimeAdapter.swift index 2849519a..be3f01e7 100644 --- a/supacode/Domain/AgentRuntime/AgentRuntimeAdapter.swift +++ b/supacode/Domain/AgentRuntime/AgentRuntimeAdapter.swift @@ -152,7 +152,9 @@ nonisolated struct AgentInvocation: Equatable, Sendable { ([executable] + arguments).map(Self.shellQuote).joined(separator: " ") } - private static func shellQuote(_ argument: String) -> String { + /// The single reviewed quoting rule for anything typed into a pane's shell; + /// the profile launch planner reuses it for inlined `env` assignments. + static func shellQuote(_ argument: String) -> String { "'" + argument.replacing("'", with: "'\"'\"'") + "'" } } diff --git a/supacode/Features/Settings/Views/AgentProfileEditorView.swift b/supacode/Features/Settings/Views/AgentProfileEditorView.swift index 4a7d0269..9ef85c9f 100644 --- a/supacode/Features/Settings/Views/AgentProfileEditorView.swift +++ b/supacode/Features/Settings/Views/AgentProfileEditorView.swift @@ -159,7 +159,7 @@ struct AgentProfileEditorView: View { .help("Add an environment variable override for this profile's launches") } label: { Text("Environment Variables") - Text("Applied to the new terminal surface — its shell and everything started in it.") + Text("Applied only to the launched agent — the pane's shell keeps your normal environment.") } } @@ -204,9 +204,12 @@ struct AgentProfileEditorView: View { private var launchPreviewSection: some View { Section("Launch Preview") { - Text("Prowl will execute this command. Secret-looking values are hidden here.") - .font(.caption) - .foregroundStyle(.secondary) + Text( + "Prowl types this command into the new pane. " + + "Override values travel in hidden PROWL_ENV variables, never in the command text." + ) + .font(.caption) + .foregroundStyle(.secondary) Text(previewText) .font(.callout.monospaced()) .foregroundStyle(.secondary) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index e0ee0e7a..4006f6f0 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -228,6 +228,11 @@ extension WorktreeTerminalState { surfaceAgentStates[surfaceID] = PaneAgentState(lastChangedAt: Date()) lastWorkingAtBySurface.removeValue(forKey: surfaceID) lastEmittedAgentEntriesBySurface.removeValue(forKey: surfaceID) + // The launch identity lives exactly as long as the launched agent + // (docs-ai 053/006): once the pane is a bare shell again, a manually + // started agent is the user's own — default home, default account — and + // must not wear the profile's name or config root. + launchProfilesBySurface.removeValue(forKey: surfaceID) onAgentEntryRemoved?(surfaceID) if let tabId = tabId(containing: surfaceID) { updateTabAgentBusyState(for: tabId) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 0b9b444d..ea133b34 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -436,7 +436,7 @@ final class WorktreeTerminalState { let surfaceID = createSplitOnFocusedSurface( direction: plan.splitDirection, initialInput: plan.terminalInput, - additionalEnvironment: plan.environment + additionalEnvironment: plan.surfaceEnvironment ) { launchProfilesBySurface[surfaceID] = identity @@ -452,7 +452,7 @@ final class WorktreeTerminalState { inheritingFromSurfaceId: currentFocusedSurfaceId(), context: GHOSTTY_SURFACE_CONTEXT_TAB, workingDirectoryOverride: nil, - additionalEnvironment: plan.environment + additionalEnvironment: plan.surfaceEnvironment ) ) guard let tabId, let surfaceID = trees[tabId]?.root?.leftmostLeaf().id else { return nil } diff --git a/supacodeTests/AgentProfileTests.swift b/supacodeTests/AgentProfileTests.swift index 0dd9a6c7..d69fc5a1 100644 --- a/supacodeTests/AgentProfileTests.swift +++ b/supacodeTests/AgentProfileTests.swift @@ -169,7 +169,8 @@ struct AgentProfileTests { homeBaseDirectory: URL(fileURLWithPath: "/base", isDirectory: true) ) - #expect(plan.environment.isEmpty) + #expect(plan.surfaceEnvironment.isEmpty) + #expect(plan.commandEnvironmentTokens.isEmpty) #expect(plan.dedicatedHome == nil) #expect(plan.invocation.executable == "codex") #expect( @@ -193,8 +194,13 @@ struct AgentProfileTests { #expect( AgentProfileLaunchPlanner.pathString(home) == "/base/agent-profiles/\(bound.id.uuidString)" ) - #expect(plan.environment == ["CODEX_HOME": "/base/agent-profiles/\(bound.id.uuidString)"]) - #expect(plan.previewText.hasPrefix("CODEX_HOME='/base/agent-profiles/")) + // The home rides the launch command, not the pane's shell environment: a + // manual launch after the agent exits uses the default account. + #expect( + plan.commandEnvironmentTokens == ["CODEX_HOME='/base/agent-profiles/\(bound.id.uuidString)'"] + ) + #expect(plan.surfaceEnvironment.isEmpty) + #expect(plan.previewText.hasPrefix("env CODEX_HOME='/base/agent-profiles/")) #expect(AgentProfileLaunchPlanner.isContained(home, in: base)) } @@ -207,7 +213,7 @@ struct AgentProfileTests { homeBaseDirectory: URL(fileURLWithPath: "/base", isDirectory: true) ) - #expect(plan.environment.keys.contains("CLAUDE_CONFIG_DIR")) + #expect(plan.commandEnvironmentTokens.first?.hasPrefix("CLAUDE_CONFIG_DIR=") == true) } // MARK: - Environment overrides @@ -225,12 +231,20 @@ struct AgentProfileTests { homeBaseDirectory: URL(fileURLWithPath: "/base", isDirectory: true) ) + // Values ride in namespaced carriers; the command only references them. #expect( - plan.environment == [ - "OPENAI_BASE_URL": "https://api.deepseek.com/beta", - "EMPTY_VALUE": "", + plan.surfaceEnvironment == [ + "PROWL_ENV_OPENAI_BASE_URL": "https://api.deepseek.com/beta", + "PROWL_ENV_EMPTY_VALUE": "", ] ) + #expect( + plan.commandEnvironmentTokens == [ + "EMPTY_VALUE=\"$PROWL_ENV_EMPTY_VALUE\"", + "OPENAI_BASE_URL=\"$PROWL_ENV_OPENAI_BASE_URL\"", + ] + ) + #expect(plan.terminalInput.hasPrefix("env EMPTY_VALUE=")) } @Test func planDropsInvalidReservedAndInProgressOverrideRows() throws { @@ -251,7 +265,8 @@ struct AgentProfileTests { homeBaseDirectory: URL(fileURLWithPath: "/base", isDirectory: true) ) - #expect(plan.environment.isEmpty) + #expect(plan.surfaceEnvironment.isEmpty) + #expect(plan.commandEnvironmentTokens.isEmpty) } @Test func dedicatedHomeBindingBeatsSameNamedUserOverride() throws { @@ -264,10 +279,15 @@ struct AgentProfileTests { let plan = try AgentProfileLaunchPlanner.plan(for: bound, homeBaseDirectory: base) - #expect(plan.environment == ["CODEX_HOME": "/base/agent-profiles/\(bound.id.uuidString)"]) + // The reserved-name policy drops the user row, so the launch command + // carries exactly one CODEX_HOME assignment — the derived home. + #expect( + plan.commandEnvironmentTokens == ["CODEX_HOME='/base/agent-profiles/\(bound.id.uuidString)'"] + ) + #expect(plan.surfaceEnvironment.isEmpty) } - @Test func previewQuotesOverrideValuesAndMasksSecretLookingNames() throws { + @Test func commandAndPreviewCarryReferencesButNeverOverrideValues() throws { var preset = profile(name: "Codex · DeepSeek") preset.environmentOverrides = [ AgentProfileEnvironmentOverride(name: "OPENAI_BASE_URL", value: "https://a.example/v1 beta"), @@ -279,10 +299,14 @@ struct AgentProfileTests { homeBaseDirectory: URL(fileURLWithPath: "/base", isDirectory: true) ) - #expect(plan.previewText.contains("OPENAI_BASE_URL='https://a.example/v1 beta'")) - #expect(plan.previewText.contains("OPENAI_API_KEY=•••")) + // The typed command (== preview) references the carriers; the values ride + // the spawn environment only — never shell history or scrollback. + #expect(plan.previewText == plan.terminalInput) + #expect(plan.previewText.contains("OPENAI_API_KEY=\"$PROWL_ENV_OPENAI_API_KEY\"")) + #expect(plan.previewText.contains("OPENAI_BASE_URL=\"$PROWL_ENV_OPENAI_BASE_URL\"")) #expect(!plan.previewText.contains("sk-secret-123")) - #expect(plan.environment["OPENAI_API_KEY"] == "sk-secret-123") + #expect(!plan.previewText.contains("a.example")) + #expect(plan.surfaceEnvironment["PROWL_ENV_OPENAI_API_KEY"] == "sk-secret-123") } @Test func environmentPolicyReportsRowIssuesForEditorDiagnostics() { diff --git a/supacodeTests/WorktreeTerminalStateAgentProfileTests.swift b/supacodeTests/WorktreeTerminalStateAgentProfileTests.swift index afe31788..c88921c7 100644 --- a/supacodeTests/WorktreeTerminalStateAgentProfileTests.swift +++ b/supacodeTests/WorktreeTerminalStateAgentProfileTests.swift @@ -39,6 +39,24 @@ struct WorktreeTerminalStateAgentProfileTests { #expect(state.launchProfileName(surfaceID: UUID(), detected: .codex) == nil) } + @Test func launchIdentityClearsWhenTheLaunchedAgentExits() { + let state = makeState() + let surfaceID = UUID() + state.launchProfilesBySurface[surfaceID] = WorktreeTerminalState.SurfaceLaunchProfile( + profileID: UUID(), + name: "Codex · Work", + runtime: .codex, + dedicatedHome: nil + ) + state.surfaceAgentStates[surfaceID] = PaneAgentState(detectedAgent: .codex) + + state.removeAgentEntryIfNeeded(surfaceID: surfaceID) + + // The identity lives exactly as long as the launched agent: a manually + // started agent afterwards is the user's own (docs-ai 053/006). + #expect(state.launchProfilesBySurface[surfaceID] == nil) + } + private func makeState() -> WorktreeTerminalState { WorktreeTerminalState( runtime: GhosttyRuntime(), @@ -58,9 +76,10 @@ struct WorktreeTerminalStateAgentProfileTests { profileName: "Codex · Bound", runtime: .codex, invocation: AgentInvocation(executable: "codex", arguments: []), + commandEnvironmentTokens: [], placement: .tab, splitDirection: .right, - environment: [:], + surfaceEnvironment: [:], dedicatedHome: dedicatedHome ) }