diff --git a/amadeus/spaces/default/intents/260804-live-e2e-phase2/amadeus-state.md b/amadeus/spaces/default/intents/260804-live-e2e-phase2/amadeus-state.md index 59f9b6a6c..e17e3119c 100644 --- a/amadeus/spaces/default/intents/260804-live-e2e-phase2/amadeus-state.md +++ b/amadeus/spaces/default/intents/260804-live-e2e-phase2/amadeus-state.md @@ -6,14 +6,15 @@ - **Scope**: self-feature - **Start Date**: 2026-08-04T08:24:31Z - **State Version**: 7 -- **Active Agent**: amadeus-architect-agent +- **Active Agent**: amadeus-developer-agent - **Harness**: codex - **Harness Version**: {"state":"unavailable","reason":"native-harness-version-not-exposed"} - **Model**: {"state":"unavailable","reason":"native-model-not-exposed"} -- **Worktree Path**: +- **Worktree Path**: /Users/j5ik2o/Sources/j5ik2o.github.com/amadeus-dlc/amadeus/.amadeus/worktrees/bolt-kiro-tui-live-e2e - **Bolt Refs**: - **Practices Affirmed Timestamp**: +- **Merge-Held**: false ## Scope Configuration - **Stages to Execute**: 0.1, 0.2, 0.3, 1.1, 1.4, 2.1, 2.3, 2.6, 2.7, 2.8, 3.1, 3.3, 3.5, 3.6 - **Stages to Skip**: 1.2 (market-research), 1.3 (feasibility), 1.5 (team-formation), 1.6 (rough-mockups), 1.7 (approval-handoff), 2.2 (practices-discovery), 2.4 (user-stories), 2.5 (refined-mockups), 3.2 (nfr-requirements), 3.4 (infrastructure-design), 3.7 (ci-pipeline), 3.8 (formal-model-check), 4.1 (deployment-pipeline), 4.2 (environment-provisioning), 4.3 (deployment-execution), 4.4 (observability-setup), 4.5 (incident-response), 4.6 (performance-validation), 4.7 (feedback-optimization) @@ -28,8 +29,8 @@ ## Execution Plan Summary - **Total Stages**: 14 -- **Completed**: 11 -- **In Progress**: nfr-design +- **Completed**: 12 +- **In Progress**: code-generation ## Runtime State - **Revision Count**: 0 @@ -38,8 +39,6 @@ - **Mirror Initial Create Receipt**: completed - **Mirror Boundary Receipts**: {"ideation":"completed","inception":"completed"} - **Skeleton Stance**: on -- **Parked**: 2026-08-04T14:23:20Z -- **Parked At Stage**: nfr-design ## Phase Progress @@ -80,9 +79,9 @@ Per unit: [TBD] - [x] functional-design — EXECUTE - [ ] nfr-requirements — SKIP -- [-] nfr-design — EXECUTE +- [x] nfr-design — EXECUTE - [ ] infrastructure-design — SKIP -- [ ] code-generation — EXECUTE +- [-] code-generation — EXECUTE - [ ] build-and-test — EXECUTE - [ ] ci-pipeline — SKIP - [ ] formal-model-check — SKIP @@ -98,15 +97,15 @@ Per unit: [TBD] ## Current Status - **Lifecycle Phase**: CONSTRUCTION -- **Current Stage**: nfr-design -- **Next Stage**: code-generation +- **Current Stage**: code-generation +- **Next Stage**: build-and-test - **Status**: Running - **Construction Autonomy Mode**: gated -- **Last Updated**: 2026-08-04T14:23:20Z +- **Last Updated**: 2026-08-04T23:52:14Z ## Session Resume Point -- **Last Completed Stage**: functional-design -- **Next Action**: Execute Nfr Design +- **Last Completed Stage**: nfr-design +- **Next Action**: Execute Code Generation - **Pending Artifacts**: none diff --git a/amadeus/spaces/default/intents/260804-live-e2e-phase2/audit/j5ik2o-mac-studio-lan-da10ea4c23f8.jsonl b/amadeus/spaces/default/intents/260804-live-e2e-phase2/audit/j5ik2o-mac-studio-lan-da10ea4c23f8.jsonl new file mode 100644 index 000000000..ce9abba81 --- /dev/null +++ b/amadeus/spaces/default/intents/260804-live-e2e-phase2/audit/j5ik2o-mac-studio-lan-da10ea4c23f8.jsonl @@ -0,0 +1,15 @@ +{"schemaVersion":2,"eventId":"80b0d669-bcc2-462d-afc7-55b9267e8535","seq":1,"timestamp":"2026-08-04T23:44:55Z","eventName":"amadeus.workflow.unparked","attributes":{"Event":"WORKFLOW_UNPARKED","Timestamp":"2026-08-04T23:44:55Z"},"intentId":"260804-live-e2e-phase2","space":"default","cloneId":"da10ea4c23f8","traceId":null,"spanId":null,"traceFlags":0,"idempotencyKey":"9a5b3ed4-1349-410f-a847-76e2a7dbfdbe","canonical":true} +{"schemaVersion":2,"eventId":"4c54eb38-579c-4f09-b64d-3c6876c1870d","seq":2,"timestamp":"2026-08-04T23:45:13Z","eventName":"amadeus.human.turn","attributes":{"Event":"HUMAN_TURN"},"intentId":"260804-live-e2e-phase2","space":"default","cloneId":"da10ea4c23f8","traceId":null,"spanId":null,"traceFlags":0,"idempotencyKey":"a8d70884-9c67-4f4c-af82-19d580d9f73d","canonical":true} +{"schemaVersion":2,"eventId":"efbbe720-52df-4076-bc15-043d5b7339c1","seq":3,"timestamp":"2026-08-04T23:45:19Z","eventName":"amadeus.operation.failed","attributes":{"Command":"report --result answered --user-input Resume from last checkpoint","Error":"Unknown --result \"answered\". report commits forward transitions only; accepted outcomes: approved, completed, complete, done.","Event":"ERROR_LOGGED","Tool":"amadeus-orchestrate"},"intentId":"260804-live-e2e-phase2","space":"default","cloneId":"da10ea4c23f8","traceId":null,"spanId":null,"traceFlags":0,"idempotencyKey":"f698c999-892a-405a-a1f0-00ac23f119d8","canonical":true} +{"schemaVersion":2,"eventId":"b7b5b5c8-e8c9-4ca6-9ced-2b98609b56f4","seq":4,"timestamp":"2026-08-04T23:49:14Z","eventName":"amadeus.subagent.completed","attributes":{"Agent ID":"a4551e227c45e54e9","Agent Type":"amadeus-architecture-reviewer-agent","Event":"SUBAGENT_COMPLETED","Message":"Reviewer: amadeus-architecture-reviewer-agent\nInvocation: 4078e600-985f-4ab5-99da-30f795b1f523\nIteration: 1\nVerdict: READY\nSummary: The security-design.md artifact for the phase2-live-e2e-evidence spe"},"intentId":"260804-live-e2e-phase2","space":"default","cloneId":"da10ea4c23f8","traceId":null,"spanId":null,"traceFlags":0,"idempotencyKey":"e879179f-3f44-4f75-baa6-b9243395590a","canonical":true} +{"schemaVersion":2,"eventId":"96f13c2a-696d-432d-9fd9-0ec16a7a72b0","seq":5,"timestamp":"2026-08-04T23:50:11Z","eventName":"amadeus.stage.awaiting.approval","attributes":{"Event":"STAGE_AWAITING_APPROVAL","Stage":"nfr-design"},"intentId":"260804-live-e2e-phase2","space":"default","cloneId":"da10ea4c23f8","traceId":null,"spanId":null,"traceFlags":0,"idempotencyKey":"b96b8799-9550-4db0-a5b5-8962bb58c8d0","canonical":true} +{"schemaVersion":2,"eventId":"33fe906d-9bfa-4045-9203-827c000d7354","seq":6,"timestamp":"2026-08-04T23:52:11Z","eventName":"amadeus.human.turn","attributes":{"Event":"HUMAN_TURN"},"intentId":"260804-live-e2e-phase2","space":"default","cloneId":"da10ea4c23f8","traceId":null,"spanId":null,"traceFlags":0,"idempotencyKey":"ef40cceb-0a65-4ef4-a155-c3ce9b6b9dbe","canonical":true} +{"schemaVersion":2,"eventId":"28db498c-7ce8-41ec-87e1-6db4114f2700","seq":7,"timestamp":"2026-08-04T23:52:14Z","eventName":"amadeus.gate.approved","attributes":{"Event":"GATE_APPROVED","Stage":"nfr-design","User Input":"Approve"},"intentId":"260804-live-e2e-phase2","space":"default","cloneId":"da10ea4c23f8","traceId":null,"spanId":null,"traceFlags":0,"idempotencyKey":"af329b23-fd5c-463f-a6c7-107c1248cb86","canonical":true} +{"schemaVersion":2,"eventId":"77ecaa95-1e9a-45ce-af4d-59cf9d0d588e","seq":8,"timestamp":"2026-08-04T23:52:14Z","eventName":"amadeus.stage.completed","attributes":{"Details":"Stage Nfr Design approved by gate","Event":"STAGE_COMPLETED","Stage":"nfr-design"},"intentId":"260804-live-e2e-phase2","space":"default","cloneId":"da10ea4c23f8","traceId":null,"spanId":null,"traceFlags":0,"idempotencyKey":"f608c56f-2ae6-45fb-b03e-6776b4794280","canonical":true} +{"schemaVersion":2,"eventId":"ae123cfa-0c2a-4a7e-a84b-1af09aab1a39","seq":9,"timestamp":"2026-08-04T23:52:14Z","eventName":"amadeus.stage.started","attributes":{"Agent":"amadeus-developer-agent","Event":"STAGE_STARTED","Stage":"code-generation"},"intentId":"260804-live-e2e-phase2","space":"default","cloneId":"da10ea4c23f8","traceId":null,"spanId":null,"traceFlags":0,"idempotencyKey":"f43460a7-a0e8-4d07-a110-a09cf6741392","canonical":true} +{"schemaVersion":2,"eventId":"97e87717-728e-485e-90f3-d05c2e303fa4","seq":10,"timestamp":"2026-08-04T23:52:42Z","eventName":"amadeus.operation.failed","attributes":{"Command":"amadeus-bolt status","Error":"Unknown subcommand: status. Valid: start, complete, fail, abort, set-autonomy, approve-batch, dispatch-event, hold-merge, release-merge","Event":"ERROR_LOGGED","Tool":"amadeus-bolt"},"intentId":"260804-live-e2e-phase2","space":"default","cloneId":"da10ea4c23f8","traceId":null,"spanId":null,"traceFlags":0,"idempotencyKey":"9792b1b9-4bdc-41b4-8a6e-61e647561ade","canonical":true} +{"schemaVersion":2,"eventId":"76f4d1b7-03bb-437c-91b7-98e310c959cd","seq":11,"timestamp":"2026-08-04T23:53:13Z","eventName":"amadeus.operation.failed","attributes":{"Command":"amadeus-worktree --project-dir /Users/j5ik2o/Sources/j5ik2o.github.com/amadeus-dlc/amadeus/.claude/worktrees/sub3 create --slug kiro-tui-live-e2e --base main","Error":"[slug=kiro-tui-live-e2e] Local base branch \"main\" differs from origin/main: local SHA 8a7af9aa85a6177abe224f72fd79a435f36b55c9, remote SHA 5fb23ec2aac348f0d6517a24d1f9ec109ab7b6fb. Run git fetch origin and fast-forward \"main\", or rerun with --allow-stale to intentionally use the local SHA.","Event":"ERROR_LOGGED","Tool":"amadeus-worktree"},"intentId":"260804-live-e2e-phase2","space":"default","cloneId":"da10ea4c23f8","traceId":null,"spanId":null,"traceFlags":0,"idempotencyKey":"5052f4db-7785-4f7e-9fd9-1c3ddc8ebe5e","canonical":true} +{"schemaVersion":2,"eventId":"7fb87003-39f0-47aa-87c8-6ffa72d7c7b9","seq":12,"timestamp":"2026-08-04T23:53:19Z","eventName":"amadeus.worktree.created","attributes":{"Base branch":"main","Bolt slug":"kiro-tui-live-e2e","Branch name":"bolt-kiro-tui-live-e2e","Event":"WORKTREE_CREATED","Worktree path":"/Users/j5ik2o/Sources/j5ik2o.github.com/amadeus-dlc/amadeus/.amadeus/worktrees/bolt-kiro-tui-live-e2e"},"intentId":"260804-live-e2e-phase2","space":"default","cloneId":"da10ea4c23f8","traceId":null,"spanId":null,"traceFlags":0,"idempotencyKey":"f323d6ae-9faa-42f7-9085-8b340969002f","canonical":true} +{"schemaVersion":2,"eventId":"31c10903-fc25-40cd-a705-a39b827c4311","seq":13,"timestamp":"2026-08-04T23:53:20Z","eventName":"amadeus.bolt.started","attributes":{"Batch number":"1","Bolt names":"kiro-tui-live-e2e","Bolt slug":"kiro-tui-live-e2e","Event":"BOLT_STARTED","Walking skeleton":"false"},"intentId":"260804-live-e2e-phase2","space":"default","cloneId":"da10ea4c23f8","traceId":null,"spanId":null,"traceFlags":0,"idempotencyKey":"94632e0e-7c1e-48b5-a7b6-ec92088288e7","canonical":true} +{"schemaVersion":2,"eventId":"a3c4a197-7a7e-43c7-b7f0-aaab8ec4a714","seq":14,"timestamp":"2026-08-04T23:53:21Z","eventName":"amadeus.state.forked","attributes":{"Bolt slug":"kiro-tui-live-e2e","Event":"STATE_FORKED","Source state hash":"b28fee8782fc1ce940e65c48e033bea502f25c05b2b027a82dccd6f6546c8346","Target state hash":"b28fee8782fc1ce940e65c48e033bea502f25c05b2b027a82dccd6f6546c8346","Worktree path":"/Users/j5ik2o/Sources/j5ik2o.github.com/amadeus-dlc/amadeus/.amadeus/worktrees/bolt-kiro-tui-live-e2e"},"intentId":"260804-live-e2e-phase2","space":"default","cloneId":"da10ea4c23f8","traceId":null,"spanId":null,"traceFlags":0,"idempotencyKey":"722f404a-44e5-4f94-a428-a74ac7315cb8","canonical":true} +{"schemaVersion":2,"eventId":"5305596d-d048-45cf-a5d5-f5371a345bb7","seq":15,"timestamp":"2026-08-04T23:53:21Z","eventName":"amadeus.audit.forked","attributes":{"Bolt slug":"kiro-tui-live-e2e","Event":"AUDIT_FORKED","Fork Boundary":"14","Source Audit Hash":"3011e28bb4672adaf9f5003e27e71a9ce22619869581ecb0b865d657cc597f21"},"intentId":"260804-live-e2e-phase2","space":"default","cloneId":"da10ea4c23f8","traceId":null,"spanId":null,"traceFlags":0,"idempotencyKey":"65aedc44-2ffd-4026-80e2-a08ea55c1ffa","canonical":true} diff --git a/docs/harness-engineering/live-e2e.md b/docs/harness-engineering/live-e2e.md index e960106e7..655585f59 100644 --- a/docs/harness-engineering/live-e2e.md +++ b/docs/harness-engineering/live-e2e.md @@ -8,6 +8,8 @@ Live journeys never run on GitHub Actions, even if an opt-in variable is set. Ea The `codex-exec` adapter requires `AMADEUS_CODEX_EXEC_LIVE=1`, Codex CLI 0.139.0 or newer, `dist/codex`, and an `OPENAI_API_KEY` credential lease. The `claude-print` adapter requires `AMADEUS_CLAUDE_PRINT_LIVE=1`; the `claude-tui` adapter requires `AMADEUS_TUI_LIVE=1` and tmux. The `claude-print` and `claude-tui` adapters both require Claude Code 2.1.220 or newer with their measured flags and `dist/claude`. The `claude-sdk` adapter separately requires `AMADEUS_CLAUDE_SDK_LIVE=1`, Claude Agent SDK 0.3.158 or newer, and `dist/claude`. Claude SDK and TUI authentication require a short-lived `ANTHROPIC_API_KEY` binding; Claude print may instead use a verified native keychain login. Source `HOME`, `CLAUDE_CONFIG_DIR`, and user/local settings are never forwarded in either case. The SDK adapter owns the SDK client and stream in an isolated worker group, transfers an environment credential through one length-prefixed stdin frame, and escalates abort to TERM and KILL before cleanup. +The `kiro-tui` adapter requires `AMADEUS_KIRO_TUI_LIVE=1`, tmux, Kiro CLI 2.6.0 or newer, and `dist/kiro`. Kiro keeps its authentication in an on-disk database under the user's home and re-executes its chat runtime from a per-home path, so no environment credential lease exists for it. The scratch home instead binds those two source entries by reference: the credential bytes never leave the user's home, nothing is copied into scratch, and the adapter never writes to, edits, or deletes anything under the source home. Removing the scratch tree removes the entire binding. Source `HOME`, `XDG_DATA_HOME`, `KIRO_HOME`, and ambient AWS credentials are never forwarded to the child. + Cleanup is a barrier, not another recorded outcome. A cleanup error, leak finding, or retained resource returns `cleanup-barrier-failed` and suppresses ledger append even when execution/assertion succeeded. Only the sequence `executed/asserted → cleanup-barrier-closed → ledger-appended|already-present → closure-committed` can release a PASS receipt or a supported matrix projection. ## Running a live journey @@ -48,6 +50,15 @@ AMADEUS_TUI_LIVE=1 ANTHROPIC_API_KEY='…' \ The TUI runner starts Claude with project-only settings and scratch-confined `acceptEdits` permission inside a run-private `tmux -S` server and session. It waits for a painted pane, sends one prompt bound to the 128-bit run ID, verifies the exact current-run file anchor, retains only bounded pane digests, and then closes session, server, credential, and scratch resources before the ledger can be called. It never lists, attaches to, or kills a session on the default tmux server. +Run the serial Kiro TUI journey only from a clean local worktree: + +```bash +AMADEUS_KIRO_TUI_LIVE=1 \ + bun test --timeout 240000 tests/e2e/t-kiro-tui-kernel.serial.test.ts +``` + +The runner copies `dist/kiro` into a fresh Git project, sets a fresh `HOME` and `TMPDIR`, binds the source auth database and chat runtime into the scratch home, and starts `kiro-cli chat --agent kiro_default --trust-all-tools` inside a run-private `tmux -S` server and session. The built-in agent is pinned deliberately: this journey measures the TUI transport, not the shipped conductor, whose own workflow journeys live in `tests/e2e/t-tui-kiro-*`. The run-private socket is created in the system temp directory under a short run-identified name, because a socket nested inside the scratch root would exceed the platform UNIX domain socket path limit; cleanup unlinks it. It clears the trust-all confirmation picker once, waits for the painted input footer, sends one prompt bound to the 128-bit run ID, verifies the exact current-run file anchor, retains only bounded pane digests, and then closes session, server, binding, and scratch resources before the ledger can be called. It never lists, attaches to, or kills a session on the default tmux server. Authentication is probed by presence only; run `kiro-cli login` first, and expect the journey to spend real Kiro credits on one short turn. + ## Ledger and matrix Recorded runs append atomically to `tests/harness/live-e2e/runs.jsonl`. A recorded receipt contains adapter/version/SHA/time/result and bounded digests, never raw credentials, absolute source paths, prompts, or full output. A pending durability marker is not green evidence; recover the identical receipt before projecting it. @@ -69,6 +80,7 @@ The live test updates only the ledger. Maintainers explicitly run `update`, revi | claude-sdk | claude | agent-sdk | `AMADEUS_CLAUDE_SDK_LIVE` | hard deny | SDK-owned worker group; one-shot credential pipe; project settings only | schema, tool, state, audit | UNVERIFIED | 0.3.158 / 0.3.158 | — | | claude-tui | claude | tui | `AMADEUS_TUI_LIVE` | hard deny | fresh project/home; project settings only; run-private tmux socket and session | file, state | UNVERIFIED | 2.1.220 / 2.1.220 | — | | codex-exec | codex | exec | `AMADEUS_CODEX_EXEC_LIVE` | hard deny | fresh project/home; env credential lease; no source config or hooks | exit, schema, file | UNVERIFIED | 0.139.0 / 0.146.0 | — | +| kiro-tui | kiro | tui | `AMADEUS_KIRO_TUI_LIVE` | hard deny | fresh project/home; source auth bound by reference, never copied; run-private tmux socket and session | file, state | UNVERIFIED | 2.6.0 / 2.13.0 | — | ## Distribution change trigger diff --git a/tests/e2e/t-kiro-tui-kernel.serial.test.ts b/tests/e2e/t-kiro-tui-kernel.serial.test.ts new file mode 100644 index 000000000..4c05aa050 --- /dev/null +++ b/tests/e2e/t-kiro-tui-kernel.serial.test.ts @@ -0,0 +1,75 @@ +// Local-only Kiro CLI rendered TUI live journey. The adapter owns a fresh +// project/home and a run-private tmux socket/session. Kiro authentication is +// on disk under the user's home, so the scratch home binds it by reference — +// nothing is copied into scratch, and the source home is never written to, +// edited, or deleted by the adapter. + +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { kiroTuiLiveRequirementsSkipReason } from "../harness/kiro-tui-live.ts"; +import { createKiroTuiJourney } from "../harness/live-e2e/journey.ts"; +import { + defaultKiroSourceHome, + KiroHomeCredentialSource, + KiroScratchAllocator, +} from "../harness/live-e2e/kiro.ts"; +import { KiroTuiAdapter } from "../harness/live-e2e/kiro-tui.ts"; +import { runLiveJourney } from "../harness/live-e2e/lifecycle.ts"; +import { + currentGitSha, + LIVE_E2E_LEDGER, + liveScratchLeakCheck, +} from "../harness/live-e2e/testing/live-kernel.ts"; +import { REPO_ROOT } from "../harness/fixtures.ts"; + +const KIRO_BIN = process.env.AMADEUS_KIRO_BIN ?? "kiro-cli"; +const TMUX_BIN = process.env.AMADEUS_TMUX_BIN ?? "tmux"; +const KIRO_DIST = join(REPO_ROOT, "dist", "kiro"); +const SOURCE_HOME = defaultKiroSourceHome(process.env); +const SKIP_REASON = kiroTuiLiveRequirementsSkipReason({ + env: process.env, + kiroBin: KIRO_BIN, + tmuxBin: TMUX_BIN, + distributionDir: KIRO_DIST, + sourceHome: SOURCE_HOME, +}); + +describe("Kiro TUI live E2E kernel", () => { + test.skipIf(SKIP_REASON !== null)( + `records a real kiro-tui anchor journey${SKIP_REASON ? ` [SKIP: ${SKIP_REASON}]` : ""}`, + async () => { + const result = await runLiveJourney( + new KiroTuiAdapter({ + kiroBin: KIRO_BIN, + tmuxBin: TMUX_BIN, + distributionDir: KIRO_DIST, + sourceHome: SOURCE_HOME, + parentEnv: process.env, + }), + createKiroTuiJourney(), + { + env: process.env, + gitSha: currentGitSha(), + now: () => new Date(), + ledgerPath: LIVE_E2E_LEDGER, + durability: "file-and-directory", + credentialSource: new KiroHomeCredentialSource({ sourceHome: SOURCE_HOME, env: process.env }), + allocator: new KiroScratchAllocator({ + prefix: "amadeus-kiro-tui-live-", + distributionDir: KIRO_DIST, + }), + leakCheck: liveScratchLeakCheck, + }, + ); + expect(result).toMatchObject({ + ok: true, + value: { + kind: "recorded", + adapterId: "kiro-tui", + outcome: { code: "AMADEUS_LIVE_E2E:PASS:SUCCESS" }, + }, + }); + }, + 240_000, + ); +}); diff --git a/tests/harness/kiro-tui-live.ts b/tests/harness/kiro-tui-live.ts new file mode 100644 index 000000000..625f8bc42 --- /dev/null +++ b/tests/harness/kiro-tui-live.ts @@ -0,0 +1,54 @@ +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { defaultKiroSourceHome, kiroHomeLayout } from "./live-e2e/kiro.ts"; +import { buildChildEnvironment, evaluateLiveGate } from "./live-e2e/policy.ts"; +import { requireCapability } from "./live-e2e/registry.ts"; +import { parseVersion, versionAtLeast } from "./live-e2e/version.ts"; + +const CAPABILITY = requireCapability("kiro-tui"); + +export function kiroTuiLiveSkipReason( + env: Readonly>, +): string | null { + const gate = evaluateLiveGate(env, CAPABILITY); + return gate.kind === "skip" ? gate.diagnostic : null; +} + +export interface KiroTuiLiveRequirements { + readonly env: Readonly>; + readonly kiroBin: string; + readonly tmuxBin: string; + readonly distributionDir: string; + /** Home owning the Kiro auth database and chat runtime. */ + readonly sourceHome?: string; +} + +export function kiroTuiLiveRequirementsSkipReason({ + env, + kiroBin, + tmuxBin, + distributionDir, + sourceHome, +}: KiroTuiLiveRequirements): string | null { + const gateReason = kiroTuiLiveSkipReason(env); + if (gateReason !== null) return gateReason; + const isolated = buildChildEnvironment(env, CAPABILITY.environment); + if (!isolated.ok) return `Kiro child environment rejected ${isolated.error.key}`; + const spawnOptions = { encoding: "utf8", env: isolated.value, timeout: 15_000 } as const; + const tmux = spawnSync(tmuxBin, ["-V"], { ...spawnOptions, maxBuffer: 64 * 1024 }); + if (tmux.status !== 0 || !/tmux\s+\d+\.\d+/i.test(tmux.stdout)) return "tmux capability is unavailable"; + const minimumVersion = parseVersion(CAPABILITY.minimumVersion); + if (minimumVersion === null) throw new Error("invalid kiro-tui minimum version"); + const kiro = spawnSync(kiroBin, ["--version"], { ...spawnOptions, maxBuffer: 64 * 1024 }); + const parsed = kiro.status === 0 ? parseVersion(kiro.stdout) : null; + if (parsed === null || !versionAtLeast(parsed, minimumVersion)) { + return `kiro-cli >= ${CAPABILITY.minimumVersion} not found (AMADEUS_KIRO_BIN=${kiroBin})`; + } + if (!existsSync(distributionDir)) return `distributable missing: ${distributionDir}`; + // Kiro authentication is on disk under the source home, so it is probed by + // presence only — the adapter never reads or copies the database itself. + const layout = kiroHomeLayout(sourceHome ?? defaultKiroSourceHome(env), process.platform, env); + if (!existsSync(layout.authFile)) return "Kiro CLI is not authenticated (run `kiro-cli login`)"; + if (!existsSync(layout.chatBinary)) return "Kiro CLI chat runtime is unavailable"; + return null; +} diff --git a/tests/harness/live-e2e/claude-tui.ts b/tests/harness/live-e2e/claude-tui.ts index c20fb3033..fe079152e 100644 --- a/tests/harness/live-e2e/claude-tui.ts +++ b/tests/harness/live-e2e/claude-tui.ts @@ -20,6 +20,24 @@ import { digest, type Result, sanitizeText } from "./contract.ts"; import { buildChildEnvironment } from "./policy.ts"; import { capabilityById } from "./registry.ts"; import { cleanupReceiptFromRegistrar, type ResourceRegistrar } from "./resources.ts"; +import { + absentPrivateServer, + commandFailed, + MAX_PANE_LINES, + paneLimitIssue, + shellQuote, + SpawnSyncTmuxCommandPort, + type TmuxCommandOptions, + type TmuxCommandPort, + type TmuxCommandResult, +} from "./tmux.ts"; + +export { + SpawnSyncTmuxCommandPort, + type TmuxCommandOptions, + type TmuxCommandPort, + type TmuxCommandResult, +} from "./tmux.ts"; export const CLAUDE_TUI_ANCHOR_FILE = ".amadeus-live-tui-anchor.json"; export const CLAUDE_TUI_PROMPT = @@ -33,48 +51,8 @@ const CAPABILITY = (() => { return resolved.value; })(); const CREDENTIAL_DECLARATION: CredentialDeclaration = { childKey: "ANTHROPIC_API_KEY" }; -const MAX_PANE_BYTES = 1_048_576; -const MAX_PANE_LINES = 16_384; -const MAX_PANE_LINE_BYTES = 65_536; const READY_PROMPT_PATTERN = /^\s*❯\s*$/mu; -export interface TmuxCommandResult { - readonly exitCode: number | null; - readonly stdout: string; - readonly stderr: string; -} - -export interface TmuxCommandOptions { - readonly cwd?: string; - readonly env?: Readonly>; -} - -export interface TmuxCommandPort { - run(args: readonly string[], options?: TmuxCommandOptions): TmuxCommandResult; -} - -export class SpawnSyncTmuxCommandPort implements TmuxCommandPort { - readonly #tmuxBin: string; - - constructor(tmuxBin: string) { - this.#tmuxBin = tmuxBin; - } - - run(args: readonly string[], options: TmuxCommandOptions = {}): TmuxCommandResult { - const result = spawnSync(this.#tmuxBin, [...args], { - cwd: options.cwd, - env: options.env, - encoding: "utf8", - maxBuffer: MAX_PANE_BYTES + 1, - }); - return { - exitCode: result.status, - stdout: result.stdout ?? "", - stderr: result.stderr ?? String(result.error ?? ""), - }; - } -} - interface PrivateTmuxIdentity { readonly runId: string; readonly socketPath: string; @@ -94,19 +72,6 @@ export interface ClaudeTuiAdapterOptions { readonly readyTimeoutMs?: number; } -function shellQuote(value: string): string { - return `'${value.replaceAll("'", `'\\''`)}'`; -} - -function paneLimitIssue(pane: string): string | null { - if (Buffer.byteLength(pane) > MAX_PANE_BYTES) return "pane exceeded byte limit"; - const lines = pane.split("\n"); - if (lines.length > MAX_PANE_LINES) return "pane exceeded line limit"; - return lines.some((line) => Buffer.byteLength(line) > MAX_PANE_LINE_BYTES) - ? "pane exceeded single-line limit" - : null; -} - function readCurrentAnchor(projectDir: string, runId: string): boolean { const path = join(projectDir, CLAUDE_TUI_ANCHOR_FILE); if (!existsSync(path)) return false; @@ -118,15 +83,6 @@ function readCurrentAnchor(projectDir: string, runId: string): boolean { } } -function commandFailed(result: TmuxCommandResult): boolean { - return result.exitCode !== 0; -} - -function absentPrivateServer(result: TmuxCommandResult): boolean { - const diagnostic = `${result.stdout}\n${result.stderr}`; - return /no server running|can't find session|no sessions/i.test(diagnostic); -} - export class ClaudeTuiAdapter implements LiveAdapter { readonly capability = CAPABILITY; readonly #options: ClaudeTuiAdapterOptions; diff --git a/tests/harness/live-e2e/claude.ts b/tests/harness/live-e2e/claude.ts index 0aaec4487..e0af3f19a 100644 --- a/tests/harness/live-e2e/claude.ts +++ b/tests/harness/live-e2e/claude.ts @@ -22,6 +22,7 @@ import { type Result, sanitizeText } from "./contract.ts"; import { buildChildEnvironment } from "./policy.ts"; import { capabilityById } from "./registry.ts"; import { cleanupReceiptFromRegistrar, type ResourceRegistrar } from "./resources.ts"; +import { initializeScratchGit } from "./scratch.ts"; import { collectBounded } from "./stream.ts"; import { parseVersion, type Version, versionAtLeast } from "./version.ts"; @@ -150,7 +151,7 @@ export class ClaudeScratchAllocator implements ScratchAllocator { mkdirSync(homeDir, { recursive: true }); mkdirSync(join(root, "tmp"), { recursive: true }); (this.#options.family ?? createClaudeFamilyContext()).writeProjectSettings(projectDir); - initializeGit(projectDir, homeDir, process.env); + initializeScratchGit(projectDir, homeDir, process.env, CAPABILITY.environment); return { root, projectDir, homeDir, state: "ready" }; } catch (error) { rmSync(root, { recursive: true, force: true }); @@ -159,35 +160,6 @@ export class ClaudeScratchAllocator implements ScratchAllocator { } } -function initializeGit( - projectDir: string, - homeDir: string, - parentEnv: Readonly>, -): void { - const base = buildChildEnvironment(parentEnv, CAPABILITY.environment); - if (!base.ok) throw new Error(`git environment rejected ${base.error.key}`); - const env = { - ...base.value, - HOME: homeDir, - GIT_CONFIG_GLOBAL: "/dev/null", - GIT_CONFIG_SYSTEM: "/dev/null", - }; - for (const args of [ - ["init", "-q"], - ["add", "-A"], - [ - "-c", "user.email=live@example.invalid", - "-c", "user.name=Amadeus Live", - "-c", "commit.gpgsign=false", - "-c", "core.hooksPath=", - "commit", "-qm", "install", - ], - ]) { - const result = spawnSync("git", args, { cwd: projectDir, encoding: "utf8", env, timeout: 30_000 }); - if (result.status !== 0) throw new Error(`git ${args[0]} failed: ${sanitizeText(result.stderr)}`); - } -} - export interface ClaudePrintAdapterOptions { readonly claudeBin: string; readonly distributionDir: string; diff --git a/tests/harness/live-e2e/journey.ts b/tests/harness/live-e2e/journey.ts index 87a7de388..41aca1323 100644 --- a/tests/harness/live-e2e/journey.ts +++ b/tests/harness/live-e2e/journey.ts @@ -5,6 +5,7 @@ import { CLAUDE_SDK_PROMPT, type ClaudeSdkWorkerEvent } from "./claude-sdk.ts"; import { CLAUDE_TUI_PROMPT } from "./claude-tui.ts"; import { CLAUDE_PRINT_PROMPT } from "./claude.ts"; import { digest } from "./contract.ts"; +import { KIRO_TUI_PROMPT } from "./kiro-tui.ts"; export interface CodexAnchorJourneyOptions { readonly prompt?: string; @@ -103,11 +104,25 @@ export function createClaudeSdkJourney(timeoutMs = 90_000): LiveJourney { }; } -export function createClaudeTuiJourney(): LiveJourney { +interface TuiAnchorJourneySpec { + readonly id: string; + readonly prompt: string; + readonly timeoutMs: number; + readonly evidenceKind: string; + readonly passedDiagnostic: string; + readonly failedDiagnostic: string; +} + +/** + * Rendered-TUI adapters share one anchor contract — private session, verified + * file anchor, single input, bounded pane digests — so the assertion lives in + * one factory and per-harness journeys differ only in identity and wording. + */ +function createTuiAnchorJourney(spec: TuiAnchorJourneySpec): LiveJourney { return { - id: "claude-tui-anchor-v1", - prompt: CLAUDE_TUI_PROMPT, - timeoutMs: 120_000, + id: spec.id, + prompt: spec.prompt, + timeoutMs: spec.timeoutMs, retryPolicy: { maxAttempts: 1 }, assert: (execution) => { const passed = execution.exitCode === 0 && @@ -117,11 +132,9 @@ export function createClaudeTuiJourney(): LiveJourney { typeof execution.structured.sessionDigest === "string"; return { passed, - diagnostic: passed - ? "private TUI session, current-run file anchor, and bounded pane evidence passed" - : "Claude TUI anchor mismatch", + diagnostic: passed ? spec.passedDiagnostic : spec.failedDiagnostic, evidence: [{ - kind: "claude-tui-anchor", + kind: spec.evidenceKind, value: digest( `${execution.exitCode}:${execution.structured?.anchorVerified}:${execution.structured?.inputCount}:${execution.structured?.paneDigest}`, ), @@ -132,6 +145,28 @@ export function createClaudeTuiJourney(): LiveJourney { }; } +export function createClaudeTuiJourney(): LiveJourney { + return createTuiAnchorJourney({ + id: "claude-tui-anchor-v1", + prompt: CLAUDE_TUI_PROMPT, + timeoutMs: 120_000, + evidenceKind: "claude-tui-anchor", + passedDiagnostic: "private TUI session, current-run file anchor, and bounded pane evidence passed", + failedDiagnostic: "Claude TUI anchor mismatch", + }); +} + +export function createKiroTuiJourney(): LiveJourney { + return createTuiAnchorJourney({ + id: "kiro-tui-anchor-v1", + prompt: KIRO_TUI_PROMPT, + timeoutMs: 180_000, + evidenceKind: "kiro-tui-anchor", + passedDiagnostic: "private Kiro TUI session, current-run file anchor, and bounded pane evidence passed", + failedDiagnostic: "Kiro TUI anchor mismatch", + }); +} + export function createCodexAnchorJourney(options: CodexAnchorJourneyOptions = {}): LiveJourney { const prompt = options.prompt ?? 'Create .amadeus-live-anchor.json containing exactly {"status":"ok"}, then respond briefly.'; diff --git a/tests/harness/live-e2e/kiro-tui.ts b/tests/harness/live-e2e/kiro-tui.ts new file mode 100644 index 000000000..a0a7fa0f9 --- /dev/null +++ b/tests/harness/live-e2e/kiro-tui.ts @@ -0,0 +1,525 @@ +import { randomBytes } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { + AdapterExecution, + CleanupReceipt, + CleanupTarget, + CredentialBinding, + CredentialDeclaration, + LiveAdapter, + PreflightContext, + PreflightFinding, + PreflightResult, + PrepareContext, + PreparedRun, +} from "./adapter.ts"; +import { digest, type Result, sanitizeText } from "./contract.ts"; +import { + bindKiroScratchHome, + defaultKiroSourceHome, + KIRO_HOME_BINDING_KEY, + kiroHomeLayout, +} from "./kiro.ts"; +import { buildChildEnvironment } from "./policy.ts"; +import { requireCapability } from "./registry.ts"; +import { cleanupReceiptFromRegistrar, type ResourceRegistrar } from "./resources.ts"; +import { + absentPrivateServer, + commandFailed, + MAX_PANE_LINES, + paneLimitIssue, + shellQuote, + SpawnSyncTmuxCommandPort, + type TmuxCommandOptions, + type TmuxCommandPort, + type TmuxCommandResult, +} from "./tmux.ts"; +import { parseVersion, versionAtLeast } from "./version.ts"; + +export const KIRO_TUI_ANCHOR_FILE = ".amadeus-live-kiro-tui-anchor.json"; +export const KIRO_TUI_PROMPT = + "Create .amadeus-live-kiro-tui-anchor.json containing exactly " + + '{"status":"ok","runId":"{{RUN_ID}}"}, then reply briefly.'; + +const CAPABILITY = requireCapability("kiro-tui"); +const CREDENTIAL_DECLARATION: CredentialDeclaration = { childKey: KIRO_HOME_BINDING_KEY }; +const RESOURCE_BINDING = "kiro-tui-home-binding"; +const RESOURCE_SERVER = "kiro-tui-server"; +const RESOURCE_SESSION = "kiro-tui-session"; +const PANE_WIDTH = "200"; +const PANE_HEIGHT = "50"; +/** + * The journey measures the TUI transport — private tmux, scratch isolation, + * anchor, bounded evidence — so it pins Kiro's built-in agent rather than + * riding whichever agent the installed distribution makes the workspace + * default. The shipped conductor is a workflow surface with its own journeys; + * letting it answer here would make the transport result depend on workflow + * behaviour. This mirrors the Claude TUI adapter running with project-only + * settings and no hooks. + */ +const KIRO_TRANSPORT_AGENT = "kiro_default"; +/** + * A UNIX domain socket path is capped near 104 bytes on macOS and 108 on Linux. + * A scratch root under the system temp directory is already long enough that a + * socket nested inside it overflows, so the run-private socket lives directly + * in the temp directory under a short run-identified name and is unlinked by + * cleanup. The guard keeps an unusually long temp directory a clear prepare + * failure rather than a confusing connect error at execute time. + */ +export const MAX_SOCKET_PATH_BYTES = 100; + +/** The idle input footer Kiro paints once a turn has finished. */ +const IDLE_PROMPT_PATTERN = /ask a question or describe a task/i; +/** The trust-all confirmation picker Kiro shows on launch; "Yes, I accept" is one Down away. */ +const TRUST_PROMPT_PATTERN = /Yes, I accept/i; + +/** One readiness observation: the pane is idle, the run failed, or it is still settling. */ +type ReadinessObservation = + | Readonly<{ kind: "ready" }> + | Readonly<{ kind: "failed"; execution: AdapterExecution }> + | Readonly<{ kind: "waiting"; trustCleared: boolean; pane: string }>; + +interface PrivateTmuxIdentity { + readonly runId: string; + readonly socketPath: string; + readonly sessionName: string; + readonly target: string; +} + +export interface KiroTuiAdapterOptions { + readonly kiroBin: string; + readonly tmuxBin?: string; + readonly distributionDir: string; + readonly parentEnv: Readonly>; + /** Home owning the Kiro auth database and chat runtime; defaults to the user's home. */ + readonly sourceHome?: string; + readonly tmux?: TmuxCommandPort; + readonly createRunId?: () => string; + readonly pollIntervalMs?: number; + readonly readyTimeoutMs?: number; + readonly reapTimeoutMs?: number; +} + +/** + * tmux runs a pane command through a shell whose startup files may rewrite + * PATH, so a bare binary name can resolve in the parent and still be missing in + * the pane. Resolving against the child PATH up front keeps the launch bound to + * the executable preflight actually measured. + */ +function resolveExecutable( + binary: string, + childEnv: Readonly>, +): string | null { + if (binary.includes("/")) return existsSync(binary) ? binary : null; + return Bun.which(binary, { PATH: childEnv.PATH ?? "" }); +} + +function readCurrentAnchor(projectDir: string, runId: string): boolean { + const path = join(projectDir, KIRO_TUI_ANCHOR_FILE); + if (!existsSync(path)) return false; + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as { status?: unknown; runId?: unknown }; + return parsed.status === "ok" && parsed.runId === runId; + } catch { + return false; + } +} + +export class KiroTuiAdapter implements LiveAdapter { + readonly capability = CAPABILITY; + readonly #options: KiroTuiAdapterOptions; + readonly #tmux: TmuxCommandPort; + readonly #sourceHome: string; + #binding: CredentialBinding | undefined; + #identity: PrivateTmuxIdentity | undefined; + #registrar: ResourceRegistrar | undefined; + + constructor(options: KiroTuiAdapterOptions) { + this.#options = options; + this.#tmux = options.tmux ?? new SpawnSyncTmuxCommandPort(options.tmuxBin ?? "tmux"); + this.#sourceHome = options.sourceHome ?? defaultKiroSourceHome(options.parentEnv); + } + + async preflight(context: PreflightContext): Promise { + const kiro = this.#probeKiro(); + const findings = [...kiro.findings, ...this.#probeTmux()]; + if (!existsSync(this.#options.distributionDir)) { + findings.push({ + code: "AMADEUS_LIVE_E2E:SKIP:DIST_MISSING", + diagnostic: "Kiro distribution is missing", + }); + } + if (!(await context.credentialSource.canLease(CREDENTIAL_DECLARATION))) { + findings.push({ + code: "AMADEUS_LIVE_E2E:SKIP:AUTH_UNAVAILABLE", + diagnostic: "Kiro CLI source authentication is unavailable", + }); + } + if (!existsSync(kiroHomeLayout(this.#sourceHome, process.platform, this.#options.parentEnv).chatBinary)) { + findings.push({ + code: "AMADEUS_LIVE_E2E:SKIP:CAPABILITY_UNSUPPORTED", + diagnostic: "Kiro CLI chat runtime is unavailable", + }); + } + return findings.length === 0 + ? { kind: "ready", measuredVersion: kiro.measuredVersion ?? "unknown", findings } + : { kind: "skip", measuredVersion: kiro.measuredVersion, findings }; + } + + #probeKiro(): Readonly<{ measuredVersion?: string; findings: readonly PreflightFinding[] }> { + const base = buildChildEnvironment(this.#options.parentEnv, this.capability.environment); + if (!base.ok) { + return { findings: [{ + code: "AMADEUS_LIVE_E2E:SKIP:CAPABILITY_UNSUPPORTED", + diagnostic: `Kiro environment policy rejected ${base.error.key}`, + }] }; + } + const version = spawnSync(this.#options.kiroBin, ["--version"], { + encoding: "utf8", + env: base.value, + maxBuffer: 64 * 1024, + timeout: 15_000, + }); + if (version.status !== 0) { + return { findings: [{ + code: "AMADEUS_LIVE_E2E:SKIP:BINARY_MISSING", + diagnostic: "Kiro CLI executable is unavailable", + }] }; + } + const parsed = parseVersion(version.stdout); + const minimum = parseVersion(CAPABILITY.minimumVersion); + if (minimum === null) throw new Error(`invalid minimumVersion: ${CAPABILITY.minimumVersion}`); + if (parsed === null || !versionAtLeast(parsed, minimum)) { + return { + measuredVersion: parsed?.join("."), + findings: [{ + code: "AMADEUS_LIVE_E2E:SKIP:VERSION_UNSUPPORTED", + diagnostic: `Kiro CLI version is below ${CAPABILITY.minimumVersion}`, + }], + }; + } + return { measuredVersion: parsed.join("."), findings: [] }; + } + + #probeTmux(): readonly PreflightFinding[] { + const base = buildChildEnvironment(this.#options.parentEnv, this.capability.environment); + const tmuxVersion = this.#tmux.run(["-V"], base.ok ? { env: base.value } : undefined); + if (commandFailed(tmuxVersion)) { + return [{ + code: "AMADEUS_LIVE_E2E:SKIP:BINARY_MISSING", + diagnostic: "tmux executable is unavailable", + }]; + } + return /tmux\s+\d+\.\d+/i.test(tmuxVersion.stdout) + ? [] + : [{ + code: "AMADEUS_LIVE_E2E:SKIP:CAPABILITY_UNSUPPORTED", + diagnostic: "tmux version output is unsupported", + }]; + } + + async prepare( + context: PrepareContext, + ): Promise>> { + const runId = (this.#options.createRunId ?? (() => randomBytes(16).toString("hex")))(); + if (!/^[a-f0-9]{32}$/.test(runId)) { + return { ok: false, error: { kind: "prepare-failed", diagnostic: "run ID must be 128-bit lowercase hex" } }; + } + const socketPath = join(tmpdir(), `amadeus-kiro-${runId.slice(0, 16)}.sock`); + if (Buffer.byteLength(socketPath) > MAX_SOCKET_PATH_BYTES) { + return { + ok: false, + error: { kind: "prepare-failed", diagnostic: "run-private tmux socket path is too long" }, + }; + } + this.#identity = { + runId, + socketPath, + sessionName: `amadeus-kiro-${runId.slice(0, 16)}`, + target: `amadeus-kiro-${runId.slice(0, 16)}:0.0`, + }; + this.#registrar = context.registrar; + for (const resource of [ + { id: RESOURCE_BINDING, kind: "credential-binding", locator: "scratch-home:kiro", credentialBearing: true }, + { id: RESOURCE_SERVER, kind: "tmux-server", locator: "private-tmux-socket", credentialBearing: true }, + { id: RESOURCE_SESSION, kind: "tmux-session", locator: "private-tmux-session", credentialBearing: true }, + ] as const) { + context.registrar.registerPlanned(resource); + } + try { + this.#binding = await context.credentialSource.lease(CREDENTIAL_DECLARATION); + // The source locator is consumed here and never travels further: the child + // environment below is built from the allow-list alone. + bindKiroScratchHome( + context.scratch.homeDir, + this.#binding.expose(), + process.platform, + this.#options.parentEnv, + ); + context.registrar.markCreated(RESOURCE_BINDING); + const base = buildChildEnvironment(this.#options.parentEnv, this.capability.environment); + if (!base.ok) { + return { ok: false, error: { kind: "prepare-failed", diagnostic: `environment policy rejected ${base.error.key}` } }; + } + const environment = { + ...base.value, + HOME: context.scratch.homeDir, + TMPDIR: join(context.scratch.root, "tmp"), + }; + const executable = resolveExecutable(this.#options.kiroBin, environment); + if (executable === null) { + return { ok: false, error: { kind: "prepare-failed", diagnostic: "Kiro CLI executable is unresolvable" } }; + } + return { + ok: true, + value: { + cwd: context.scratch.projectDir, + executable, + args: ["chat", "--agent", KIRO_TRANSPORT_AGENT, "--trust-all-tools"], + environmentKeys: Object.keys(environment), + resolveEnvironment: () => ({ ...environment }), + registeredResourceIds: [RESOURCE_BINDING, RESOURCE_SERVER, RESOURCE_SESSION], + }, + }; + } catch (error) { + return { ok: false, error: { kind: "prepare-failed", diagnostic: sanitizeText(String(error)) } }; + } + } + + async execute(run: PreparedRun, signal: AbortSignal): Promise { + const identity = this.#identity; + if (identity === undefined) return this.#failedExecution("private tmux identity is missing"); + const command = [run.executable, ...run.args].map(shellQuote).join(" "); + const started = this.#privateCommand( + [ + "new-session", + "-d", + "-s", + identity.sessionName, + "-x", + PANE_WIDTH, + "-y", + PANE_HEIGHT, + "-c", + run.cwd, + command, + ], + { cwd: run.cwd, env: run.resolveEnvironment() }, + ); + if (commandFailed(started)) return this.#failedExecution(started.stderr); + this.#registrar?.markCreated(RESOURCE_SERVER); + this.#registrar?.markCreated(RESOURCE_SESSION); + + const ready = await this.#waitForReady(signal); + if (!ready.ok) return ready.execution; + + const prompt = (run.prompt ?? KIRO_TUI_PROMPT).replaceAll("{{RUN_ID}}", identity.runId); + const sent = this.#privateCommand(["send-keys", "-t", identity.target, "-l", prompt]); + if (commandFailed(sent)) return this.#failedExecution(sent.stderr); + const entered = this.#privateCommand(["send-keys", "-t", identity.target, "Enter"]); + if (commandFailed(entered)) return this.#failedExecution(entered.stderr); + + for (;;) { + if (signal.aborted) { + return { ...this.#failedExecution("journey aborted"), timedOut: true, aborted: true }; + } + const captured = this.#capturePane(`-${MAX_PANE_LINES}`); + if (!captured.ok) return captured.execution; + if (readCurrentAnchor(run.cwd, identity.runId)) { + return { + exitCode: 0, + timedOut: false, + aborted: false, + stdoutDigest: digest(captured.pane), + stderrDigest: digest(""), + structured: { + anchorVerified: true, + inputCount: 1, + paneDigest: digest(captured.pane), + sessionDigest: digest(identity.sessionName), + }, + }; + } + await this.#sleep(); + } + } + + async cleanup(target: CleanupTarget): Promise { + const failures: string[] = []; + const identity = this.#identity; + if (identity !== undefined) { + this.#killPrivateResource(["kill-session", "-t", identity.sessionName], RESOURCE_SESSION, failures); + this.#killPrivateResource(["kill-server"], RESOURCE_SERVER, failures); + const unreaped = await this.#reapPrivateServer(); + if (unreaped !== null) failures.push(unreaped); + // The socket lives outside the scratch root, so removing the scratch tree + // cannot reclaim it. tmux normally unlinks it on kill; force covers the + // case where the server was never started. + try { + rmSync(identity.socketPath, { force: true }); + } catch (error) { + failures.push(sanitizeText(String(error))); + } + } + if (this.#binding !== undefined) { + try { + await this.#binding.release(); + this.#registrar?.markReleased(RESOURCE_BINDING); + } catch (error) { + failures.push(sanitizeText(String(error))); + } finally { + this.#binding = undefined; + } + } + // Removing the scratch root removes the scratch-side binding links. The + // source auth database and chat runtime they pointed at are untouched. + const scratchFailure = await this.#removeScratch(target.scratch.root); + if (scratchFailure === null) { + this.#registrar?.markReleased("scratch-root"); + } else { + failures.push(scratchFailure); + } + this.#identity = undefined; + const receipt = cleanupReceiptFromRegistrar(this.#registrar, target, failures); + this.#registrar = undefined; + return receipt; + } + + #privateCommand(args: readonly string[], options?: TmuxCommandOptions): TmuxCommandResult { + const identity = this.#identity; + if (identity === undefined) return { exitCode: 1, stdout: "", stderr: "private tmux identity is missing" }; + return this.#tmux.run(["-S", identity.socketPath, ...args], options); + } + + #capturePane( + since?: string, + ): Readonly<{ ok: true; pane: string }> | Readonly<{ ok: false; execution: AdapterExecution }> { + const args = ["capture-pane", "-t", this.#identity?.target ?? "", "-p", "-J", "-e"]; + if (since !== undefined) args.push("-S", since); + const captured = this.#privateCommand(args); + if (commandFailed(captured)) return { ok: false, execution: this.#failedExecution(captured.stderr) }; + const issue = paneLimitIssue(captured.stdout); + return issue === null + ? { ok: true, pane: captured.stdout } + : { ok: false, execution: this.#failedExecution(issue, captured.stdout) }; + } + + /** + * Kiro launches into a trust-all confirmation picker before it paints its + * input footer. Clear the picker once, then wait for the footer — the footer + * is what proves the TUI is accepting a prompt. + */ + async #waitForReady( + signal: AbortSignal, + ): Promise | Readonly<{ ok: false; execution: AdapterExecution }>> { + const identity = this.#identity; + if (identity === undefined) { + return { ok: false, execution: this.#failedExecution("private tmux identity is missing") }; + } + const deadline = Date.now() + (this.#options.readyTimeoutMs ?? 60_000); + let trustCleared = false; + for (;;) { + if (signal.aborted) { + return { + ok: false, + execution: { ...this.#failedExecution("journey aborted"), timedOut: true, aborted: true }, + }; + } + const step = this.#readinessStep(identity.target, trustCleared); + if (step.kind === "ready") return { ok: true }; + if (step.kind === "failed") return { ok: false, execution: step.execution }; + trustCleared = step.trustCleared; + if (Date.now() >= deadline) { + return { ok: false, execution: this.#failedExecution("Kiro TUI readiness timed out", step.pane) }; + } + await this.#sleep(); + } + } + + /** Read the pane, and clear the trust picker the first time it appears. */ + #readinessStep(target: string, trustCleared: boolean): ReadinessObservation { + const captured = this.#capturePane(); + if (!captured.ok) return { kind: "failed", execution: captured.execution }; + if (IDLE_PROMPT_PATTERN.test(captured.pane)) return { kind: "ready" }; + if (trustCleared || !TRUST_PROMPT_PATTERN.test(captured.pane)) { + return { kind: "waiting", trustCleared, pane: captured.pane }; + } + // Move the caret off the default "No, exit" and accept. + for (const key of ["Down", "Enter"]) { + const result = this.#privateCommand(["send-keys", "-t", target, key]); + if (commandFailed(result)) { + return { kind: "failed", execution: this.#failedExecution(result.stderr) }; + } + } + return { kind: "waiting", trustCleared: true, pane: captured.pane }; + } + + /** + * Wait for the killed server to actually go away before the scratch tree is + * removed. tmux leaves the socket file behind, so liveness is asked of the + * server itself: once it reports absent it has already hung up its panes. + * Without this barrier a dying child can recreate files under its scratch + * HOME after removal, leaving a resource the leak check no longer sees. + * Returns a diagnostic when the server outlives the barrier, or null. + */ + async #reapPrivateServer(): Promise { + const deadline = Date.now() + (this.#options.reapTimeoutMs ?? 10_000); + for (;;) { + const listed = this.#privateCommand(["list-sessions"]); + if (commandFailed(listed)) { + if (absentPrivateServer(listed)) return null; + // Any other failure — socket permission, missing binary, timeout — + // says nothing about server liveness, so report it verbatim instead + // of polling the same failure until the deadline. + return sanitizeText(listed.stderr || listed.stdout || "private tmux server liveness is unknown"); + } + if (Date.now() >= deadline) return "private tmux server was not reaped"; + await this.#sleep(); + } + } + + /** + * Remove the scratch tree and prove it stayed removed. A child still shutting + * down can recreate files under its scratch HOME immediately after the first + * removal, so the tree is re-checked and re-removed before the barrier + * accepts closure. + */ + async #removeScratch(root: string): Promise { + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + rmSync(root, { recursive: true, force: true }); + } catch (error) { + return sanitizeText(String(error)); + } + await this.#sleep(); + if (!existsSync(root)) return null; + } + return "scratch root reappeared after removal"; + } + + #killPrivateResource(args: readonly string[], resourceId: string, failures: string[]): void { + const result = this.#privateCommand(args); + if (commandFailed(result) && !absentPrivateServer(result)) { + failures.push(sanitizeText(result.stderr || result.stdout)); + return; + } + this.#registrar?.markReleased(resourceId); + } + + #sleep(): Promise { + return new Promise((resolve) => setTimeout(resolve, this.#options.pollIntervalMs ?? 250)); + } + + #failedExecution(diagnostic: string, pane = ""): AdapterExecution { + return { + exitCode: 1, + timedOut: false, + aborted: false, + stdoutDigest: digest(pane), + stderrDigest: digest(sanitizeText(diagnostic)), + }; + } +} diff --git a/tests/harness/live-e2e/kiro.ts b/tests/harness/live-e2e/kiro.ts new file mode 100644 index 000000000..7b7c6c6aa --- /dev/null +++ b/tests/harness/live-e2e/kiro.ts @@ -0,0 +1,196 @@ +import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import type { + CredentialBinding, + CredentialDeclaration, + CredentialSourcePort, + ScratchAllocator, + ScratchReceipt, +} from "./adapter.ts"; +import { requireCapability } from "./registry.ts"; +import type { ResourceRegistrar } from "./resources.ts"; +import { initializeScratchGit } from "./scratch.ts"; + +/** + * Kiro CLI keeps its authentication in an on-disk database under the user's + * home, and re-executes `kiro-cli chat` from a per-home runtime path. Neither + * is reachable through an environment variable, so a scratch home is + * unauthenticated and cannot even launch the TUI. The seam this module owns is + * therefore a scratch-side BINDING: the scratch home links the source auth + * database and chat runtime by reference. The credential bytes never leave the + * user's home, nothing is copied into scratch, and the adapter never writes to, + * edits, or deletes anything under the source home — removing the scratch tree + * removes the whole binding. + */ + +export const KIRO_HOME_BINDING_KEY = "KIRO_CLI_HOME_BINDING"; +export const KIRO_AUTH_FILE = "data.sqlite3"; +export const KIRO_CHAT_BINARY = "kiro-cli-chat"; + +/** + * Entries linked into the scratch data directory: the auth database plus the + * runtime assets `kiro-cli chat` executes. Everything else the CLI wants + * (history, sessions, caches) is created scratch-local and dies with it. + */ +export const KIRO_BOUND_DATA_ENTRIES = [ + KIRO_AUTH_FILE, + "bun", + "bun.sha256", + "tui.js", + "tui.js.sha256", + "shell", +] as const; + +export interface KiroHomeLayout { + readonly home: string; + readonly dataDir: string; + readonly authFile: string; + readonly chatBinary: string; +} + +/** + * Resolve the Kiro paths a home owns. Paths are fixed offsets from the home, + * except that a source home on Linux honours `XDG_DATA_HOME` — the registry + * declares that key as a source path, and kiro-cli stores its data there when + * it is set. Scratch homes never pass an environment, so their layout stays a + * pure offset of the scratch root. + */ +export function kiroHomeLayout( + home: string, + platform: string = process.platform, + env: Readonly> = {}, +): KiroHomeLayout { + const xdgDataHome = platform === "darwin" ? undefined : env.XDG_DATA_HOME; + const dataDir = platform === "darwin" + ? join(home, "Library", "Application Support", "kiro-cli") + : xdgDataHome !== undefined && xdgDataHome !== "" + ? join(xdgDataHome, "kiro-cli") + : join(home, ".local", "share", "kiro-cli"); + return { + home, + dataDir, + authFile: join(dataDir, KIRO_AUTH_FILE), + chatBinary: join(home, ".local", "bin", KIRO_CHAT_BINARY), + }; +} + +export function defaultKiroSourceHome(env: Readonly> = process.env): string { + return env.AMADEUS_KIRO_SOURCE_HOME ?? homedir(); +} + +/** + * Link the source auth database and chat runtime into a scratch home. Returns + * the scratch-side paths that were created — the only resources this binding + * owns. + */ +export function bindKiroScratchHome( + scratchHome: string, + sourceHome: string, + platform: string = process.platform, + sourceEnv: Readonly> = {}, +): readonly string[] { + const source = kiroHomeLayout(sourceHome, platform, sourceEnv); + const scratch = kiroHomeLayout(scratchHome, platform); + const bound: string[] = []; + mkdirSync(scratch.dataDir, { recursive: true }); + for (const entry of KIRO_BOUND_DATA_ENTRIES) { + const target = join(source.dataDir, entry); + if (!existsSync(target)) continue; + const link = join(scratch.dataDir, entry); + symlinkSync(target, link); + bound.push(link); + } + mkdirSync(join(scratchHome, ".local", "bin"), { recursive: true }); + if (existsSync(source.chatBinary)) { + symlinkSync(source.chatBinary, scratch.chatBinary); + bound.push(scratch.chatBinary); + } + return bound; +} + +export interface KiroHomeCredentialSourceOptions { + readonly sourceHome?: string; + readonly platform?: string; + /** Source-side environment consulted for `XDG_DATA_HOME`; defaults to none. */ + readonly env?: Readonly>; +} + +/** + * The source-side auth boundary. `expose()` yields the source home locator that + * `bindKiroScratchHome` needs and nothing else — it is never placed in the + * child environment, an argument vector, a diagnostic, or a receipt. + */ +export class KiroHomeCredentialSource implements CredentialSourcePort { + readonly #sourceHome: string; + readonly #platform: string; + readonly #env: Readonly>; + + constructor(options: KiroHomeCredentialSourceOptions = {}) { + this.#sourceHome = options.sourceHome ?? defaultKiroSourceHome(); + this.#platform = options.platform ?? process.platform; + this.#env = options.env ?? {}; + } + + async canLease(declaration: CredentialDeclaration): Promise { + return declaration.childKey === KIRO_HOME_BINDING_KEY && + existsSync(kiroHomeLayout(this.#sourceHome, this.#platform, this.#env).authFile); + } + + async lease(declaration: CredentialDeclaration): Promise { + if (!(await this.canLease(declaration))) { + throw new Error("Kiro CLI source authentication is unavailable"); + } + let value: string | undefined = this.#sourceHome; + let active = true; + return { + key: declaration.childKey, + expose: () => { + if (!active) throw new Error("credential binding was released"); + return value ?? ""; + }, + release: async () => { + value = undefined; + active = false; + }, + }; + } +} + +export interface KiroScratchAllocatorOptions { + readonly prefix: string; + readonly distributionDir: string; +} + +export class KiroScratchAllocator implements ScratchAllocator { + readonly #options: KiroScratchAllocatorOptions; + allocationCount = 0; + + constructor(options: KiroScratchAllocatorOptions) { + this.#options = options; + } + + async allocate(registrar: ResourceRegistrar): Promise { + this.allocationCount += 1; + registrar.registerPlanned({ + id: "scratch-root", + kind: "scratch-root", + locator: "temporary-directory", + credentialBearing: false, + }); + const root = mkdtempSync(join(tmpdir(), this.#options.prefix)); + registrar.markCreated("scratch-root"); + const projectDir = join(root, "project"); + const homeDir = join(root, "home"); + try { + cpSync(this.#options.distributionDir, projectDir, { recursive: true }); + mkdirSync(homeDir, { recursive: true }); + mkdirSync(join(root, "tmp"), { recursive: true }); + initializeScratchGit(projectDir, homeDir, process.env, requireCapability("kiro-tui").environment); + return { root, projectDir, homeDir, state: "ready" }; + } catch (error) { + rmSync(root, { recursive: true, force: true }); + throw error; + } + } +} diff --git a/tests/harness/live-e2e/registry.ts b/tests/harness/live-e2e/registry.ts index d36a6832b..878455df2 100644 --- a/tests/harness/live-e2e/registry.ts +++ b/tests/harness/live-e2e/registry.ts @@ -1,7 +1,12 @@ import type { EnvironmentDeclaration } from "./policy.ts"; import type { Result } from "./contract.ts"; -export type LiveAdapterId = "codex-exec" | "claude-print" | "claude-sdk" | "claude-tui"; +export type LiveAdapterId = + | "codex-exec" + | "claude-print" + | "claude-sdk" + | "claude-tui" + | "kiro-tui"; export type CapabilityStatus = "supported" | "unsupported" | "unverified"; export interface LiveCapability { @@ -89,6 +94,23 @@ export const LIVE_CAPABILITIES = [ }, isolationSummary: "fresh project/home; project settings only; run-private tmux socket and session", }, + { + id: "kiro-tui", + harness: "kiro", + transport: "tui", + optInKey: "AMADEUS_KIRO_TUI_LIVE", + minimumVersion: "2.6.0", + measuredVersion: "2.13.0", + status: "supported", + anchorKinds: ["file", "state"], + environment: { + allowedKeys: ["PATH", "LANG", "LC_ALL", "NO_COLOR", "TERM"], + sensitiveKeys: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN"], + sourcePathKeys: ["HOME", "XDG_DATA_HOME", "KIRO_HOME"], + }, + isolationSummary: + "fresh project/home; source auth bound by reference, never copied; run-private tmux socket and session", + }, ] as const satisfies readonly LiveCapability[]; export function validateCapabilityRegistry( diff --git a/tests/harness/live-e2e/scratch.ts b/tests/harness/live-e2e/scratch.ts new file mode 100644 index 000000000..d07138df6 --- /dev/null +++ b/tests/harness/live-e2e/scratch.ts @@ -0,0 +1,43 @@ +import { spawnSync } from "node:child_process"; +import { sanitizeText } from "./contract.ts"; +import { buildChildEnvironment, type EnvironmentDeclaration } from "./policy.ts"; + +/** + * Commit the freshly installed distribution inside a scratch project so a live + * journey starts from a clean tree. Global and system Git configuration are + * pinned away from the developer's own so the scratch project can never inherit + * user identity, hooks, or signing keys. + */ +export function initializeScratchGit( + projectDir: string, + homeDir: string, + parentEnv: Readonly>, + declaration: EnvironmentDeclaration, +): void { + const base = buildChildEnvironment(parentEnv, declaration); + if (!base.ok) throw new Error(`git environment rejected ${base.error.key}`); + const env = { + ...base.value, + HOME: homeDir, + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_SYSTEM: "/dev/null", + }; + const steps: readonly { verb: string; args: readonly string[] }[] = [ + { verb: "init", args: ["init", "-q"] }, + { verb: "add", args: ["add", "-A"] }, + { + verb: "commit", + args: [ + "-c", "user.email=live@example.invalid", + "-c", "user.name=Amadeus Live", + "-c", "commit.gpgsign=false", + "-c", "core.hooksPath=", + "commit", "-qm", "install", + ], + }, + ]; + for (const step of steps) { + const result = spawnSync("git", [...step.args], { cwd: projectDir, encoding: "utf8", env, timeout: 30_000 }); + if (result.status !== 0) throw new Error(`git ${step.verb} failed: ${sanitizeText(result.stderr)}`); + } +} diff --git a/tests/harness/live-e2e/tmux.ts b/tests/harness/live-e2e/tmux.ts new file mode 100644 index 000000000..eb664c5b8 --- /dev/null +++ b/tests/harness/live-e2e/tmux.ts @@ -0,0 +1,79 @@ +import { spawnSync } from "node:child_process"; + +/** + * Run-private tmux mechanics shared by every rendered-TUI adapter. The port + * only executes what the caller names: socket selection stays with the adapter + * so no default-server discovery can happen here. + */ + +export const MAX_PANE_BYTES = 1_048_576; +export const MAX_PANE_LINES = 16_384; +export const MAX_PANE_LINE_BYTES = 65_536; + +export interface TmuxCommandResult { + readonly exitCode: number | null; + readonly stdout: string; + readonly stderr: string; +} + +export interface TmuxCommandOptions { + readonly cwd?: string; + readonly env?: Readonly>; + readonly timeoutMs?: number; +} + +/** + * A stalled tmux client would block the synchronous caller past every journey + * deadline, so each command carries its own bound. + */ +export const DEFAULT_TMUX_COMMAND_TIMEOUT_MS = 15_000; + +export interface TmuxCommandPort { + run(args: readonly string[], options?: TmuxCommandOptions): TmuxCommandResult; +} + +export class SpawnSyncTmuxCommandPort implements TmuxCommandPort { + readonly #tmuxBin: string; + + constructor(tmuxBin: string) { + this.#tmuxBin = tmuxBin; + } + + run(args: readonly string[], options: TmuxCommandOptions = {}): TmuxCommandResult { + const result = spawnSync(this.#tmuxBin, [...args], { + cwd: options.cwd, + env: options.env, + encoding: "utf8", + maxBuffer: MAX_PANE_BYTES + 1, + timeout: options.timeoutMs ?? DEFAULT_TMUX_COMMAND_TIMEOUT_MS, + }); + return { + exitCode: result.status, + stdout: result.stdout ?? "", + stderr: result.stderr ?? String(result.error ?? ""), + }; + } +} + +export function commandFailed(result: TmuxCommandResult): boolean { + return result.exitCode !== 0; +} + +/** A kill against a server that is already gone is a closed resource, not a failure. */ +export function absentPrivateServer(result: TmuxCommandResult): boolean { + const diagnostic = `${result.stdout}\n${result.stderr}`; + return /no server running|can't find session|no sessions/i.test(diagnostic); +} + +export function paneLimitIssue(pane: string): string | null { + if (Buffer.byteLength(pane) > MAX_PANE_BYTES) return "pane exceeded byte limit"; + const lines = pane.split("\n"); + if (lines.length > MAX_PANE_LINES) return "pane exceeded line limit"; + return lines.some((line) => Buffer.byteLength(line) > MAX_PANE_LINE_BYTES) + ? "pane exceeded single-line limit" + : null; +} + +export function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'`; +} diff --git a/tests/integration/t-kiro-tui-live-gate.integration.test.ts b/tests/integration/t-kiro-tui-live-gate.integration.test.ts new file mode 100644 index 000000000..7024ef6f1 --- /dev/null +++ b/tests/integration/t-kiro-tui-live-gate.integration.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test } from "bun:test"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { + kiroTuiLiveRequirementsSkipReason, + kiroTuiLiveSkipReason, +} from "../harness/kiro-tui-live.ts"; +import { createKiroTuiJourney } from "../harness/live-e2e/journey.ts"; +import { + defaultKiroSourceHome, + KIRO_HOME_BINDING_KEY, + kiroHomeLayout, +} from "../harness/live-e2e/kiro.ts"; +import { capabilityById } from "../harness/live-e2e/registry.ts"; + +function writeExecutable(path: string, body: string): void { + writeFileSync(path, `#!/bin/sh\n${body}\n`); + chmodSync(path, 0o755); +} + +describe("Kiro TUI live contract", () => { + test("GHA hard deny takes precedence over probing invalid binaries", () => { + expect(kiroTuiLiveRequirementsSkipReason({ + env: { GITHUB_ACTIONS: "true", AMADEUS_KIRO_TUI_LIVE: "1" }, + kiroBin: "/not/kiro-cli", + tmuxBin: "/not/tmux", + distributionDir: "/not/dist", + sourceHome: "/not/home", + })).toContain("forbidden on GitHub Actions"); + }); + + test.each([undefined, "", "0", "true", " 1", "1 "])( + "only exact one enables Kiro TUI (%s is denied)", + (value) => { + expect(kiroTuiLiveSkipReason({ AMADEUS_KIRO_TUI_LIVE: value })).toContain( + "AMADEUS_KIRO_TUI_LIVE=1", + ); + }, + ); + + test("requirements probe checks tmux, Kiro version, distribution, and the home auth seam", () => { + const root = mkdtempSync(join(tmpdir(), "kiro-tui-gate-")); + const tmuxBin = join(root, "tmux"); + const kiroBin = join(root, "kiro-cli"); + const distributionDir = join(root, "dist"); + const sourceHome = join(root, "home"); + const layout = kiroHomeLayout(sourceHome); + const env = { AMADEUS_KIRO_TUI_LIVE: "1", PATH: process.env.PATH }; + try { + expect(kiroTuiLiveRequirementsSkipReason({ env, kiroBin, tmuxBin, distributionDir, sourceHome })) + .toBe("tmux capability is unavailable"); + writeExecutable(tmuxBin, "printf '%s\\n' 'tmux 3.4'"); + writeExecutable(kiroBin, "printf '%s\\n' 'kiro-cli 2.5.9'"); + expect(kiroTuiLiveRequirementsSkipReason({ env, kiroBin, tmuxBin, distributionDir, sourceHome })) + .toContain("kiro-cli >= 2.6.0 not found"); + writeExecutable(kiroBin, "printf '%s\\n' 'kiro-cli 2.13.0'"); + expect(kiroTuiLiveRequirementsSkipReason({ env, kiroBin, tmuxBin, distributionDir, sourceHome })) + .toBe(`distributable missing: ${distributionDir}`); + mkdirSync(distributionDir); + expect(kiroTuiLiveRequirementsSkipReason({ env, kiroBin, tmuxBin, distributionDir, sourceHome })) + .toBe("Kiro CLI is not authenticated (run `kiro-cli login`)"); + mkdirSync(layout.dataDir, { recursive: true }); + writeFileSync(layout.authFile, ""); + expect(kiroTuiLiveRequirementsSkipReason({ env, kiroBin, tmuxBin, distributionDir, sourceHome })) + .toBe("Kiro CLI chat runtime is unavailable"); + mkdirSync(join(sourceHome, ".local", "bin"), { recursive: true }); + writeExecutable(layout.chatBinary, "exit 0"); + expect(kiroTuiLiveRequirementsSkipReason({ env, kiroBin, tmuxBin, distributionDir, sourceHome })) + .toBeNull(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("home layout keeps the auth seam relative to the owning home", () => { + const layout = kiroHomeLayout("/source/home"); + expect(layout.dataDir.startsWith("/source/home/")).toBe(true); + expect(layout.authFile).toBe(join(layout.dataDir, "data.sqlite3")); + expect(layout.chatBinary).toBe(join("/source/home", ".local", "bin", "kiro-cli-chat")); + expect(kiroHomeLayout("/scratch/home").dataDir.slice("/scratch/home".length)) + .toBe(layout.dataDir.slice("/source/home".length)); + }); + + test("the default source home prefers AMADEUS_KIRO_SOURCE_HOME over the process home", () => { + expect(defaultKiroSourceHome({ AMADEUS_KIRO_SOURCE_HOME: "/pinned/home" })).toBe("/pinned/home"); + expect(defaultKiroSourceHome({})).toBe(homedir()); + }); + + test("a source home on Linux honours XDG_DATA_HOME; scratch and macOS layouts do not", () => { + expect(kiroHomeLayout("/source/home", "linux", { XDG_DATA_HOME: "/xdg/data" }).dataDir) + .toBe("/xdg/data/kiro-cli"); + expect(kiroHomeLayout("/source/home", "linux", { XDG_DATA_HOME: "" }).dataDir) + .toBe(join("/source/home", ".local", "share", "kiro-cli")); + expect(kiroHomeLayout("/source/home", "darwin", { XDG_DATA_HOME: "/xdg/data" }).dataDir) + .toBe(join("/source/home", "Library", "Application Support", "kiro-cli")); + // No environment means the pure home offset — the scratch-side contract. + expect(kiroHomeLayout("/scratch/home", "linux").dataDir) + .toBe(join("/scratch/home", ".local", "share", "kiro-cli")); + }); + + test("registry and journey expose the closed Kiro TUI contract", async () => { + expect(capabilityById("kiro-tui")).toMatchObject({ + ok: true, + value: { + harness: "kiro", + minimumVersion: "2.6.0", + transport: "tui", + optInKey: "AMADEUS_KIRO_TUI_LIVE", + anchorKinds: ["file", "state"], + }, + }); + expect(KIRO_HOME_BINDING_KEY).toBe("KIRO_CLI_HOME_BINDING"); + const execution = { + exitCode: 0, + timedOut: false, + aborted: false, + stdoutDigest: "stdout", + stderrDigest: "stderr", + structured: { + anchorVerified: true, + inputCount: 1, + paneDigest: "pane", + sessionDigest: "session", + }, + } as const; + const scratch = { root: "scratch", homeDir: "home", projectDir: "project", state: "ready" } as const; + expect(await createKiroTuiJourney().assert(execution, scratch)).toMatchObject({ passed: true }); + // One negation per term of the passed predicate, so dropping any term + // from the journey assert turns at least one of these green-to-red. + for (const failing of [ + { ...execution, exitCode: 1 }, + { ...execution, structured: { ...execution.structured, anchorVerified: false } }, + { ...execution, structured: { ...execution.structured, inputCount: 2 } }, + { ...execution, structured: { ...execution.structured, paneDigest: 7 } }, + { ...execution, structured: { ...execution.structured, sessionDigest: undefined } }, + ]) { + expect(await createKiroTuiJourney().assert(failing, scratch)) + .toMatchObject({ passed: false }); + } + }); +}); diff --git a/tests/integration/t-live-e2e-kiro-tui.integration.test.ts b/tests/integration/t-live-e2e-kiro-tui.integration.test.ts new file mode 100644 index 000000000..c8c82b35e --- /dev/null +++ b/tests/integration/t-live-e2e-kiro-tui.integration.test.ts @@ -0,0 +1,440 @@ +import { describe, expect, test } from "bun:test"; +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createKiroTuiJourney } from "../harness/live-e2e/journey.ts"; +import { + KiroHomeCredentialSource, + kiroHomeLayout, + KiroScratchAllocator, +} from "../harness/live-e2e/kiro.ts"; +import { KiroTuiAdapter, MAX_SOCKET_PATH_BYTES } from "../harness/live-e2e/kiro-tui.ts"; +import { runLiveJourney } from "../harness/live-e2e/lifecycle.ts"; +import type { + TmuxCommandOptions, + TmuxCommandPort, + TmuxCommandResult, +} from "../harness/live-e2e/tmux.ts"; + +const RUN_ID = "0123456789abcdef0123456789abcdef"; +const ANCHOR_FILE = ".amadeus-live-kiro-tui-anchor.json"; +const IDLE_PANE = "kiro_default · auto\nask a question or describe a task ↵\n"; +const TRUST_PANE = "By proceeding, you confirm that you understand the risks.\n❯ No, exit\n Yes, I accept\n"; + +interface TmuxCall { + readonly args: readonly string[]; + readonly environmentKeys: readonly string[]; +} + +interface FakeTmuxOptions { + readonly failKillServer?: boolean; + /** Never leave the trust picker, so readiness must hit its deadline. */ + readonly stuckOnTrustPrompt?: boolean; + /** Keep answering as a live server, so the cleanup reap barrier must fail. */ + readonly serverNeverReaps?: boolean; + /** Fail list-sessions for a reason other than an absent server. */ + readonly denyListSessions?: boolean; +} + +/** + * A private tmux server whose pane walks the real Kiro launch sequence: the + * trust-all confirmation picker first, then the idle input footer, then the + * anchor the prompt asks the agent to write. + */ +class FakePrivateTmux implements TmuxCommandPort { + readonly calls: TmuxCall[] = []; + readonly #options: FakeTmuxOptions; + #projectDir: string | undefined; + #prompt = ""; + #trustCleared = false; + + constructor(options: FakeTmuxOptions = {}) { + this.#options = options; + } + + run(args: readonly string[], options: TmuxCommandOptions = {}): TmuxCommandResult { + this.calls.push({ args: [...args], environmentKeys: Object.keys(options.env ?? {}).sort() }); + if (args[0] === "-V") return { exitCode: 0, stdout: "tmux 3.5a\n", stderr: "" }; + const command = args[2]; + if (command === "new-session") { + const cwdIndex = args.indexOf("-c"); + this.#projectDir = cwdIndex < 0 ? undefined : args[cwdIndex + 1]; + } + if (command === "send-keys") this.#handleSendKeys(args); + if (command === "kill-server" && this.#options.failKillServer === true) { + return { exitCode: 1, stdout: "", stderr: "injected server cleanup failure" }; + } + if (command === "list-sessions") { + if (this.#options.denyListSessions === true) { + return { exitCode: 1, stdout: "", stderr: "error connecting to socket: Permission denied" }; + } + return this.#options.serverNeverReaps === true + ? { exitCode: 0, stdout: "amadeus-kiro: 1 windows\n", stderr: "" } + : { exitCode: 1, stdout: "", stderr: "no server running on the private socket" }; + } + const pane = command === "capture-pane" ? (this.#trustCleared ? IDLE_PANE : TRUST_PANE) : ""; + return { exitCode: 0, stdout: pane, stderr: "" }; + } + + #handleSendKeys(args: readonly string[]): void { + if (args.includes("-l")) { + this.#prompt = args.at(-1) ?? ""; + return; + } + if (args.at(-1) !== "Enter") return; + if (!this.#trustCleared) { + this.#trustCleared = this.#options.stuckOnTrustPrompt !== true; + return; + } + this.#writeAnchor(); + } + + #writeAnchor(): void { + const runId = this.#prompt.match(/"runId":"([a-f0-9]{32})"/)?.[1]; + if (this.#projectDir === undefined || runId === undefined) return; + writeFileSync(join(this.#projectDir, ANCHOR_FILE), JSON.stringify({ status: "ok", runId })); + } +} + +interface Fixture { + readonly root: string; + readonly kiroBin: string; + readonly distribution: string; + readonly sourceHome: string; +} + +/** A source home carrying the auth database and chat runtime the adapter binds. */ +function fixture(): Fixture { + const root = mkdtempSync(join(tmpdir(), "amadeus-live-kiro-tui-")); + const kiroBin = join(root, "fake-kiro-cli"); + const distribution = join(root, "dist", "kiro"); + const sourceHome = join(root, "source-home"); + const layout = kiroHomeLayout(sourceHome); + mkdirSync(join(distribution, ".kiro"), { recursive: true }); + writeFileSync(join(distribution, "AGENTS.md"), "# Fixture\n"); + mkdirSync(layout.dataDir, { recursive: true }); + writeFileSync(layout.authFile, "source-auth-fixture"); + writeFileSync(join(layout.dataDir, "tui.js"), "// fixture runtime\n"); + mkdirSync(join(sourceHome, ".local", "bin"), { recursive: true }); + writeFileSync(layout.chatBinary, "#!/bin/sh\nexit 0\n"); + chmodSync(layout.chatBinary, 0o755); + writeFileSync(kiroBin, "#!/bin/sh\nprintf '%s\\n' 'kiro-cli 2.13.0'\n"); + chmodSync(kiroBin, 0o755); + return { root, kiroBin, distribution, sourceHome }; +} + +function runFixture(item: Fixture, tmux: FakePrivateTmux, readyTimeoutMs?: number) { + return runLiveJourney( + new KiroTuiAdapter({ + kiroBin: item.kiroBin, + distributionDir: item.distribution, + sourceHome: item.sourceHome, + parentEnv: { + PATH: process.env.PATH, + LANG: "C.UTF-8", + HOME: "/source/home", + KIRO_HOME: "/source/kiro", + AWS_SESSION_TOKEN: "must-not-leak", + }, + tmux, + createRunId: () => RUN_ID, + pollIntervalMs: 1, + readyTimeoutMs, + }), + createKiroTuiJourney(), + { + env: { AMADEUS_KIRO_TUI_LIVE: "1" }, + gitSha: "d".repeat(40), + now: () => new Date("2026-08-05T00:00:00.000Z"), + ledgerPath: join(item.root, "runs.jsonl"), + durability: "file-only", + credentialSource: new KiroHomeCredentialSource({ sourceHome: item.sourceHome }), + allocator: new KiroScratchAllocator({ + prefix: "kiro-tui-fixture-", + distributionDir: item.distribution, + }), + leakCheck: async () => [], + }, + ); +} + +describe("Kiro TUI live adapter", () => { + test("private socket journey closes cleanup before ledger-backed success", async () => { + const item = fixture(); + const tmux = new FakePrivateTmux(); + try { + const result = await runFixture(item, tmux); + expect(result).toMatchObject({ + ok: true, + value: { + kind: "recorded", + adapterId: "kiro-tui", + measuredVersion: "2.13.0", + outcome: { code: "AMADEUS_LIVE_E2E:PASS:SUCCESS" }, + cleanup: { failures: [], retainedResourceIds: [] }, + }, + }); + const serverCalls = tmux.calls.filter((call) => call.args[0] !== "-V"); + expect(serverCalls.length).toBeGreaterThan(5); + // Every command is bound to the run-private socket, and the socket path + // stays inside the portable UNIX domain socket length limit. + expect(serverCalls.every((call) => + call.args[0] === "-S" && + call.args[1]?.includes(RUN_ID.slice(0, 16)) === true && + Buffer.byteLength(call.args[1] ?? "") <= MAX_SOCKET_PATH_BYTES + )).toBe(true); + expect(serverCalls.some((call) => call.args[2] === "kill-session")).toBe(true); + expect(serverCalls.some((call) => call.args[2] === "kill-server")).toBe(true); + const launch = serverCalls.find((call) => call.args[2] === "new-session"); + // The transport journey pins Kiro's built-in agent so the result does not + // depend on whichever agent the distribution makes the workspace default. + expect(launch?.args.at(-1)).toContain("'chat' '--agent' 'kiro_default' '--trust-all-tools'"); + // The child environment is rebuilt from the allow-list: no ambient + // credential, no source home, and no source config path reaches it. + expect(launch?.environmentKeys).toContain("HOME"); + expect(launch?.environmentKeys).not.toContain("KIRO_HOME"); + expect(launch?.environmentKeys).not.toContain("AWS_SESSION_TOKEN"); + const serialized = JSON.stringify(result); + expect(serialized).not.toContain("must-not-leak"); + expect(serialized).not.toContain("source-auth-fixture"); + expect(serialized).not.toContain(item.sourceHome); + } finally { + rmSync(item.root, { recursive: true, force: true }); + } + }); + + test("the scratch home binds source auth by reference and never copies or mutates it", async () => { + const item = fixture(); + const layout = kiroHomeLayout(item.sourceHome); + const before = readFileSync(layout.authFile, "utf8"); + const allocator = new KiroScratchAllocator({ + prefix: "kiro-tui-binding-", + distributionDir: item.distribution, + }); + const adapter = new KiroTuiAdapter({ + kiroBin: item.kiroBin, + distributionDir: item.distribution, + sourceHome: item.sourceHome, + parentEnv: { PATH: process.env.PATH }, + tmux: new FakePrivateTmux(), + createRunId: () => RUN_ID, + }); + const { ResourceRegistrar } = await import("../harness/live-e2e/resources.ts"); + const registrar = new ResourceRegistrar(); + try { + const scratch = await allocator.allocate(registrar); + const prepared = await adapter.prepare({ + scratch, + registrar, + credentialSource: new KiroHomeCredentialSource({ sourceHome: item.sourceHome }), + }); + expect(prepared.ok).toBe(true); + const scratchLayout = kiroHomeLayout(scratch.homeDir); + // Bound by symlink: the credential bytes never enter the scratch tree. + expect(lstatSync(scratchLayout.authFile).isSymbolicLink()).toBe(true); + expect(lstatSync(scratchLayout.chatBinary).isSymbolicLink()).toBe(true); + expect(existsSync(join(scratchLayout.dataDir, "tui.js"))).toBe(true); + await adapter.cleanup({ scratch, registeredResources: registrar.snapshot() }); + expect(existsSync(scratch.root)).toBe(false); + expect(readFileSync(layout.authFile, "utf8")).toBe(before); + expect(existsSync(layout.chatBinary)).toBe(true); + } finally { + rmSync(item.root, { recursive: true, force: true }); + } + }); + + test("a server that outlives the kill fails the cleanup barrier", async () => { + const item = fixture(); + const allocator = new KiroScratchAllocator({ + prefix: "kiro-tui-reap-", + distributionDir: item.distribution, + }); + const adapter = new KiroTuiAdapter({ + kiroBin: item.kiroBin, + distributionDir: item.distribution, + sourceHome: item.sourceHome, + parentEnv: { PATH: process.env.PATH }, + tmux: new FakePrivateTmux({ serverNeverReaps: true }), + createRunId: () => RUN_ID, + pollIntervalMs: 1, + reapTimeoutMs: 0, + }); + const { ResourceRegistrar } = await import("../harness/live-e2e/resources.ts"); + const registrar = new ResourceRegistrar(); + try { + const scratch = await allocator.allocate(registrar); + const prepared = await adapter.prepare({ + scratch, + registrar, + credentialSource: new KiroHomeCredentialSource({ sourceHome: item.sourceHome }), + }); + expect(prepared.ok).toBe(true); + // The fake server answers list-sessions as if it were still alive, so the + // barrier must report it rather than call the run closed. + const receipt = await adapter.cleanup({ scratch, registeredResources: registrar.snapshot() }); + expect(receipt.failures).toContain("private tmux server was not reaped"); + } finally { + rmSync(item.root, { recursive: true, force: true }); + } + }); + + test("a liveness probe that fails for another reason reports it instead of polling to the deadline", async () => { + const item = fixture(); + const allocator = new KiroScratchAllocator({ + prefix: "kiro-tui-reap-denied-", + distributionDir: item.distribution, + }); + const adapter = new KiroTuiAdapter({ + kiroBin: item.kiroBin, + distributionDir: item.distribution, + sourceHome: item.sourceHome, + parentEnv: { PATH: process.env.PATH }, + tmux: new FakePrivateTmux({ denyListSessions: true }), + createRunId: () => RUN_ID, + pollIntervalMs: 1, + // A generous deadline: the barrier must not spend it polling a failure + // that says nothing about server liveness. + reapTimeoutMs: 60_000, + }); + const { ResourceRegistrar } = await import("../harness/live-e2e/resources.ts"); + const registrar = new ResourceRegistrar(); + try { + const scratch = await allocator.allocate(registrar); + const prepared = await adapter.prepare({ + scratch, + registrar, + credentialSource: new KiroHomeCredentialSource({ sourceHome: item.sourceHome }), + }); + expect(prepared.ok).toBe(true); + const started = Date.now(); + const receipt = await adapter.cleanup({ scratch, registeredResources: registrar.snapshot() }); + expect(receipt.failures).toContain("error connecting to socket: Permission denied"); + expect(receipt.failures).not.toContain("private tmux server was not reaped"); + expect(Date.now() - started).toBeLessThan(30_000); + } finally { + rmSync(item.root, { recursive: true, force: true }); + } + }); + + test("cleanup barrier failure never invokes the ledger", async () => { + const item = fixture(); + try { + const result = await runFixture(item, new FakePrivateTmux({ failKillServer: true })); + expect(result).toMatchObject({ + ok: false, + error: { + kind: "cleanup-barrier-failed", + originalOutcome: { code: "AMADEUS_LIVE_E2E:PASS:SUCCESS" }, + }, + }); + expect(existsSync(join(item.root, "runs.jsonl"))).toBe(false); + } finally { + rmSync(item.root, { recursive: true, force: true }); + } + }); + + test("a trust picker that never clears reaches the readiness deadline", async () => { + const item = fixture(); + const tmux = new FakePrivateTmux({ stuckOnTrustPrompt: true }); + try { + const result = await runFixture(item, tmux, 0); + expect(result).toMatchObject({ + ok: true, + value: { kind: "recorded", outcome: { code: "AMADEUS_LIVE_E2E:FAIL:EXECUTION_FAILED" } }, + }); + // EXECUTION_FAILED alone does not prove the readiness path: pin that the + // trust picker was answered but the prompt itself was never delivered. + expect(tmux.calls.some((call) => call.args.at(-1) === "Down")).toBe(true); + expect(tmux.calls.some((call) => call.args.includes("-l"))).toBe(false); + } finally { + rmSync(item.root, { recursive: true, force: true }); + } + }); + + test("preflight skips before any spawn when the source auth seam is absent", async () => { + const item = fixture(); + const unauthenticated = join(item.root, "empty-home"); + mkdirSync(unauthenticated, { recursive: true }); + const tmux = new FakePrivateTmux(); + try { + const result = await runLiveJourney( + new KiroTuiAdapter({ + kiroBin: item.kiroBin, + distributionDir: item.distribution, + sourceHome: unauthenticated, + parentEnv: { PATH: process.env.PATH }, + tmux, + }), + createKiroTuiJourney(), + { + env: { AMADEUS_KIRO_TUI_LIVE: "1" }, + gitSha: "d".repeat(40), + now: () => new Date("2026-08-05T00:00:00.000Z"), + ledgerPath: join(item.root, "skip.jsonl"), + durability: "file-only", + credentialSource: new KiroHomeCredentialSource({ sourceHome: unauthenticated }), + allocator: new KiroScratchAllocator({ + prefix: "kiro-tui-skip-", + distributionDir: item.distribution, + }), + leakCheck: async () => [], + }, + ); + expect(result).toMatchObject({ + ok: true, + value: { kind: "skipped", outcome: { code: "AMADEUS_LIVE_E2E:SKIP:AUTH_UNAVAILABLE" } }, + }); + expect(tmux.calls.some((call) => call.args.includes("new-session"))).toBe(false); + } finally { + rmSync(item.root, { recursive: true, force: true }); + } + }); + + test("the gate denies before any tmux probe or scratch allocation", async () => { + const item = fixture(); + const tmux = new FakePrivateTmux(); + const allocator = new KiroScratchAllocator({ + prefix: "kiro-tui-gate-", + distributionDir: item.distribution, + }); + try { + for (const env of [{}, { GITHUB_ACTIONS: "true", AMADEUS_KIRO_TUI_LIVE: "1" }]) { + const result = await runLiveJourney( + new KiroTuiAdapter({ + kiroBin: item.kiroBin, + distributionDir: item.distribution, + sourceHome: item.sourceHome, + parentEnv: { PATH: process.env.PATH }, + tmux, + }), + createKiroTuiJourney(), + { + env, + gitSha: "d".repeat(40), + now: () => new Date("2026-08-05T00:00:00.000Z"), + ledgerPath: join(item.root, "gate.jsonl"), + durability: "file-only", + credentialSource: new KiroHomeCredentialSource({ sourceHome: item.sourceHome }), + allocator, + leakCheck: async () => [], + }, + ); + expect(result).toMatchObject({ ok: true, value: { kind: "skipped" } }); + } + expect(tmux.calls).toHaveLength(0); + expect(allocator.allocationCount).toBe(0); + expect(existsSync(join(item.root, "gate.jsonl"))).toBe(false); + } finally { + rmSync(item.root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/no-silent-drop/baseline.json b/tests/no-silent-drop/baseline.json index b3253d11e..3d45903aa 100644 --- a/tests/no-silent-drop/baseline.json +++ b/tests/no-silent-drop/baseline.json @@ -5,7 +5,7 @@ "revision": "2e990c45a4cf034c9b4c6a68b1cafed0bea48fcd", "censusDigest": "b6bf74ffc7810e2a619424bf3992eeeab321b511f9fe8cce9876808b5ea34c2c", "approvalDigest": "0e92854570e9dacf4664fd19ff7074b8538b32fd59283d1d48252925015ca6df", - "previousDigest": "5bb81500ba1ddd99d19669a4f34e5abe23d13beb158e0ae32add48a0f2af5c7e" + "previousDigest": "5651c2e331f4ce387121892ee33db5e676fe99ed8fca4a8f120e3a208f0ec6bf" }, "entries": [ { diff --git a/tests/no-silent-drop/exemptions.json b/tests/no-silent-drop/exemptions.json index ddcc0755b..c1fae57d7 100644 --- a/tests/no-silent-drop/exemptions.json +++ b/tests/no-silent-drop/exemptions.json @@ -1,5 +1,5 @@ { "schemaVersion": 1, - "previousDigest": "2eb99bb2e67a4bcc8b4bbc421ffbc13191f1ef5bf4821a6e17ceae6e06ab783b", + "previousDigest": "917062645fdf08da66da978f44f4e56571abf8080810bb62748926d6bce1069b", "entries": [] }