diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..b4e5f9c
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,16 @@
+# Everything the image regenerates from source, plus host cruft that must never
+# cross into a Linux image. node_modules is the critical one: the host's is
+# built for the host platform, and copying it in would shadow the `npm ci` the
+# Dockerfile runs to build the right one.
+node_modules
+**/node_modules
+dist
+web/styled-system
+.git
+.github
+.playwright-mcp
+*.log
+.DS_Store
+bun.lock
+bun.lockb
+profiles.png
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 91836e7..676e295 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -73,6 +73,16 @@ jobs:
- name: Test
run: npm test
+ # The end-to-end harness (Tier A): run the REAL bin/swisscode.js against a
+ # recorder and assert on the process it actually launched — the execve
+ # handoff, the argv it forwarded, and the stale env it removed at the OS
+ # level. Hermetic (no network, no credential, no real CLI), so it belongs
+ # on every PR. Runs in every matrix cell, which is how both supported Node
+ # versions exercise it. `npm test` already built dist/, but the script
+ # rebuilds to stay self-contained if this step is ever reordered.
+ - name: End-to-end (hermetic)
+ run: npm run test:e2e
+
# What users actually download. Shipping the web UI means everyone gets a
# React bundle they may never open; the budget keeps that a decision that
# is re-made on every PR rather than one that drifts. One cell is enough —
diff --git a/.github/workflows/e2e-real.yml b/.github/workflows/e2e-real.yml
new file mode 100644
index 0000000..81e79fb
--- /dev/null
+++ b/.github/workflows/e2e-real.yml
@@ -0,0 +1,35 @@
+name: E2E (real CLIs)
+
+# Tier B: swisscode against the actual @anthropic-ai/claude-code, @kilocode/cli
+# and opencode-ai. Manual dispatch ONLY — building on every PR would tie the
+# merge gate to three third-party packages being installable, and the value
+# (catching an upstream flag rename) does not need per-PR frequency. Run it when
+# verifying against a new CLI release, or on a whim.
+#
+# Non-billable: the suite forwards only `--version`, which every CLI answers
+# before auth. No secret is needed and none is referenced.
+
+on:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ real:
+ name: real CLI handoff
+ runs-on: ubuntu-latest
+ steps:
+ # SHA-pinned, matching ci.yml and publish.yml, so Dependabot bumps all
+ # three in one reviewable PR.
+ - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
+
+ # The whole point is the image: it installs the three real CLIs at pinned
+ # versions and runs the handoff suite as its default command. Building and
+ # running it here is a faithful copy of the documented local command, so a
+ # green run means the documented command works.
+ - name: Build the real-CLI image
+ run: docker build -f test/e2e/Dockerfile -t swisscode-e2e .
+
+ - name: Run the real-CLI handoff suite
+ run: docker run --rm swisscode-e2e
diff --git a/assets/hero.png b/assets/hero.png
index d469b65..2dd12af 100644
Binary files a/assets/hero.png and b/assets/hero.png differ
diff --git a/assets/hero.svg b/assets/hero.svg
index 0ede983..6258e35 100644
--- a/assets/hero.svg
+++ b/assets/hero.svg
@@ -14,10 +14,12 @@
-
+
-
-
+
+
+
+
diff --git a/assets/social.png b/assets/social.png
index 00c6894..f62d8c9 100644
Binary files a/assets/social.png and b/assets/social.png differ
diff --git a/assets/social.svg b/assets/social.svg
index b7cc3d2..2136248 100644
--- a/assets/social.svg
+++ b/assets/social.svg
@@ -16,8 +16,10 @@
-
-
+
+
+
+ swisscode
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 184a180..b23891f 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -13,6 +13,9 @@ npm run typecheck # tsc --noEmit over src/ and test/
node --test "test/core/**/*.test.ts" # ~0.3s, no build needed
node --test test/golden.test.ts # one file
node --test --test-name-pattern "binding walk" "test/**/*.test.ts"
+
+npm run test:e2e # Tier A: the REAL binary vs a recorder
+npm run test:e2e:real # Tier B: vs the REAL CLIs (Docker; see below)
```
**Most suites need no build.** Node executes `.ts` directly, and `dist/` is
@@ -38,6 +41,8 @@ affecting anything else.
| `test/registry.test.ts` | Invariant | Descriptor rules every provider must satisfy. |
| `test/ui.test.ts`, `picker`, `profiles-ui` | End-to-end | The Ink wizard, driven with synthetic keystrokes. |
| `test/config-commands.test.ts`, `launch-overrides.test.ts` | Integration | Subcommand dispatch and the override pipeline through a composition root. |
+| `test/e2e/*.e2e.ts` | End-to-end (hermetic) | The **real** `bin/swisscode.js`, launched against a recorder. See below. |
+| `test/e2e/*.real.ts` | End-to-end (real CLIs) | The real binary vs the real coding CLIs, in Docker. See below. |
| `test/support/fixtures.ts` | Helper | Typed constructors for deliberately incomplete domain objects. |
### The four that are unusual
@@ -154,22 +159,70 @@ a temp directory; never let a test touch the real `~/.config/swisscode`.
property: `an unknown positional falls through, an unknown flag does not`,
`no launch inherits a stale ANTHROPIC_API_KEY it did not ask for`.
+## The end-to-end harness
+
+Everything above asserts on the launch **plan** — the in-memory object
+`planLaunch` / `buildEnvPlan` produce. Nothing there runs the real binary and
+observes what it launches. The e2e harness closes that gap, in two tiers, both
+under `test/e2e/`.
+
+**Tier A — hermetic (`*.e2e.ts`, `npm run test:e2e`, every PR).** The three
+`SWISSCODE_*_BIN` overrides are pointed at `recorder.mjs` — a tiny fake agent
+that writes down its own argv, env and cwd, then exits. The harness seeds a temp
+config, runs the real `bin/swisscode.js`, and reads that capture. So the whole
+pipeline runs for real — argv parsing, config load, resolution, env lowering,
+the `execve` handoff — with no network, no credential and full determinism.
+
+The assertion a plan test cannot make: for a third-party provider, the stale
+`ANTHROPIC_API_KEY` from the polluted ambient env is **absent from the launched
+child**. Present in `plan.unset` is not the same as gone from the process; only a
+real launch shows the difference. This is `test/golden.test.ts`'s claim, verified
+one layer further out.
+
+The recorder is **copied** into a temp dir, never symlinked or run from inside
+the repo, because swisscode's recursion guard resolves candidates with
+`realpathSync` and rejects anything under its own install directory — a symlink
+resolves back into the repo and is (correctly) refused. A copy under `/tmp` is
+accepted exactly as a real agent would be. The recorder exiting immediately is
+also why this is not the "do not launch for real" hazard `CLAUDE.md` warns
+about: it is the safe launch that warning leaves room for.
+
+**Tier B — real CLIs (`*.real.ts`, `npm run test:e2e:real`, manual only).** A
+Docker image (`test/e2e/Dockerfile`) installs the three actual CLIs at pinned
+versions and runs swisscode against them. It forwards only `--version`, which
+every CLI answers before any auth — non-billable, no secret, no cost. It cannot
+be deterministic (it depends on upstream), so it never gates a PR; it catches an
+upstream flag rename or a rejected env var when someone runs it:
+
+```sh
+docker build -f test/e2e/Dockerfile -t swisscode-e2e .
+docker run --rm swisscode-e2e
+```
+
+The `.real.ts` extension is deliberate: `test:e2e` globs `*.e2e.ts` and
+`npm test` globs `*.test.ts`, so the three sets are disjoint and a PR that has
+not installed the real CLIs stays green.
+
## CI
-`.github/workflows/ci.yml` runs `npm test` on every push to `main` and every pull
-request, across Node 22 and 24 on ubuntu and macOS.
+`.github/workflows/ci.yml` runs `npm test` and then `npm run test:e2e` on every
+push to `main` and every pull request, across Node 22 and 24 on ubuntu and macOS.
+`.github/workflows/e2e-real.yml` runs Tier B, on manual dispatch only.
Node 22 is not there for symmetry — it is the floor `engines` promises, and the
version where the toolchain assumptions have to hold: type stripping is what lets
the suite run straight from `.ts`, and `tsc`'s emit is equivalent to that stripped
program only because `target: es2023` downlevels nothing there.
-One thing it does **not** buy, despite being the version where `process.execve`
-does not exist: extra coverage of the spawn fallback. `spawnFallback` is called
-directly with an injected `SignalHost`, so it is exercised on every version, and
-nothing asserts on the dispatch inside `createNodeProcess().replace()` — taking
-the `execve` branch would replace the test process. The runtime difference is
-real; the test difference is not.
+The `execve` dispatch inside `createNodeProcess().replace()` is now covered — by
+the e2e harness, which runs the real binary in a child process, lets it `execve`
+into the recorder, and reads back what landed. Taking that branch replaces the
+*child*, not the test runner, which is what makes it observable. (Even Node 22.23
+backported `process.execve`, so both supported versions take the `execve` branch;
+the `spawn` fallback fires only where execve is truly absent — Windows and
+Node < 23.11 — and stays unit-tested via `spawnFallback` with an injected
+`SignalHost`. Running the e2e under both Node versions is defence in depth, not
+two different dispatches.)
Windows is deliberately not in the matrix. `execve` does not exist there, the
POSIX mode bits the config store asserts are meaningless, and nobody has verified
diff --git a/package.json b/package.json
index d5e6704..960d138 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,8 @@
"dev": "node build.js && tsc -p tsconfig.build.json --watch --preserveWatchOutput",
"typecheck": "tsc --noEmit",
"test": "tsc --noEmit && npm run typecheck:web && node build.js && node --test \"test/**/*.test.ts\"",
+ "test:e2e": "node build.js && node --test \"test/e2e/**/*.e2e.ts\"",
+ "test:e2e:real": "node --test \"test/e2e/**/*.real.ts\"",
"prepare": "node build.js",
"prepublishOnly": "node build.js",
"size": "node scripts/size-budget.js",
diff --git a/src/adapters/agents/kilo/index.ts b/src/adapters/agents/kilo/index.ts
index 44c4960..8cdf2b7 100644
--- a/src/adapters/agents/kilo/index.ts
+++ b/src/adapters/agents/kilo/index.ts
@@ -19,6 +19,7 @@ import {
ambientUnset,
anthropicOptions,
collapsedTierWarning,
+ compatIgnoredWarning,
sessionUnavailableWarning,
extendedContextWarning,
modelRef,
@@ -57,7 +58,7 @@ export const kilo = {
},
binary,
translate(input: TranslateInput): Translation {
- const { intent, passthrough } = input
+ const { intent, passthrough, profile } = input
const primary = intent.models.opus
const config: Record = {
@@ -82,6 +83,8 @@ export const kilo = {
if (collapse) warnings.push(collapse)
const ext = extendedContextWarning(intent, primary, 'Kilo')
if (ext) warnings.push(ext)
+ const compat = compatIgnoredWarning(profile.compat, 'Kilo')
+ if (compat) warnings.push(compat)
return { plan: { set, unset: ambientUnset(intent) }, args: [...passthrough], warnings }
},
diff --git a/src/adapters/agents/opencode/index.ts b/src/adapters/agents/opencode/index.ts
index 17336f0..0b9976b 100644
--- a/src/adapters/agents/opencode/index.ts
+++ b/src/adapters/agents/opencode/index.ts
@@ -16,6 +16,7 @@ import {
ambientUnset,
anthropicOptions,
collapsedTierWarning,
+ compatIgnoredWarning,
sessionUnavailableWarning,
extendedContextWarning,
modelRef,
@@ -56,7 +57,7 @@ export const opencode = {
},
binary,
translate(input: TranslateInput): Translation {
- const { intent, passthrough } = input
+ const { intent, passthrough, profile } = input
const primary = intent.models.opus
const small = intent.models.haiku
@@ -87,6 +88,8 @@ export const opencode = {
if (collapse) warnings.push(collapse)
const ext = extendedContextWarning(intent, primary, 'OpenCode')
if (ext) warnings.push(ext)
+ const compat = compatIgnoredWarning(profile.compat, 'OpenCode')
+ if (compat) warnings.push(compat)
return { plan: { set, unset: ambientUnset(intent) }, args, warnings }
},
diff --git a/src/adapters/agents/shared.ts b/src/adapters/agents/shared.ts
index a41da85..593dffb 100644
--- a/src/adapters/agents/shared.ts
+++ b/src/adapters/agents/shared.ts
@@ -103,6 +103,35 @@ export function collapsedTierWarning(
}
}
+/**
+ * Warn when a profile carries gateway compatibility flags that this agent does
+ * not consume.
+ *
+ * The compat mechanism is Claude-Code-only (capabilities.compatFlags). A flag
+ * toggled on for a Kilo or OpenCode setup changes nothing and, until this
+ * existed, said so NOWHERE — not at edit, not at launch, not in the doctor.
+ * That is the one capability mismatch with no net, and the
+ * capability-gap-never-silently-dropped rule the tier and session warnings
+ * already follow says it should have one. The flags are kept, not stripped, so
+ * the setup still works when it runs Claude Code again.
+ */
+export function compatIgnoredWarning(
+ compat: Record | undefined,
+ agentLabel: string,
+): EnvWarning | null {
+ const active = Object.entries(compat ?? {})
+ .filter(([, on]) => on)
+ .map(([id]) => id)
+ if (active.length === 0) return null
+ return {
+ severity: 'medium',
+ code: 'compat-ignored',
+ message:
+ `${agentLabel} does not use gateway compatibility flags; these are set but ignored: ` +
+ `${active.join(', ')}.`,
+ }
+}
+
/**
* Warn when a 1M-capable provider is reached without Claude Code's `[1m]`
* signal, which only Claude Code sends. The model still runs; its window is
diff --git a/test/adapters/agents/kilo.test.ts b/test/adapters/agents/kilo.test.ts
index 1ddba9a..5e9eb9f 100644
--- a/test/adapters/agents/kilo.test.ts
+++ b/test/adapters/agents/kilo.test.ts
@@ -68,3 +68,35 @@ test('capabilities describe a single model slot', () => {
assert.equal(kilo.binary.name, 'kilo')
assert.equal(kilo.binary.overrideEnv, 'SWISSCODE_KILO_BIN')
})
+
+test('a gateway compat flag on a Kilo profile is flagged as ignored — the safety net', () => {
+ // The UI now hides the compat section for Kilo, but a hand-edited or
+ // agent-switched config can still carry a flag. Compat is Claude-Code-only,
+ // so Kilo warns rather than silently doing nothing — the one mismatch that
+ // previously had no net at edit, launch, or doctor.
+ const input: TranslateInput = {
+ intent: intent(),
+ profile: makeProfile({ provider: 'zai', compat: { disableAdaptiveThinking: true } }),
+ provider: null,
+ passthrough: [],
+ ambient: {},
+ }
+ const w = kilo.translate(input).warnings.find((x) => x.code === 'compat-ignored')
+ assert.ok(w, 'Kilo should warn that a compat flag is ignored')
+ assert.match(w.message, /disableAdaptiveThinking/)
+ assert.match(w.message, /Kilo/)
+})
+
+test('a Kilo profile with no compat flags emits no compat-ignored warning', () => {
+ const input: TranslateInput = {
+ intent: intent(),
+ profile: makeProfile({ provider: 'zai' }),
+ provider: null,
+ passthrough: [],
+ ambient: {},
+ }
+ assert.equal(
+ kilo.translate(input).warnings.find((x) => x.code === 'compat-ignored'),
+ undefined,
+ )
+})
diff --git a/test/e2e/Dockerfile b/test/e2e/Dockerfile
new file mode 100644
index 0000000..f8758f2
--- /dev/null
+++ b/test/e2e/Dockerfile
@@ -0,0 +1,54 @@
+# Tier B: swisscode against the three REAL coding CLIs.
+#
+# The hermetic e2e suite (test/e2e/*.e2e.ts) proves swisscode BUILDS the right
+# launch against a recorder. It cannot prove the launch still works after an
+# upstream release — a renamed flag, a rejected env var, a binary that moved.
+# This image installs the actual CLIs and runs `test:e2e:real`, which forwards
+# only `--version` (non-billable: no key, no inference, no cost) and checks that
+# swisscode resolves each one and hands off cleanly.
+#
+# Manual-dispatch only (see .github/workflows/e2e-real.yml). Building on every
+# PR would couple the merge gate to three third-party packages' availability.
+#
+# Build and run from the repo root:
+# docker build -f test/e2e/Dockerfile -t swisscode-e2e .
+# docker run --rm swisscode-e2e
+
+FROM node:22-slim
+
+WORKDIR /app
+
+# Install swisscode's own dependencies first, in their own layer, so a source
+# edit does not re-run `npm ci`. `web` is a workspace, so its manifest has to be
+# present before the install resolves.
+#
+# `--ignore-scripts` is REQUIRED here, not an optimisation: the repo's `prepare`
+# lifecycle is `node build.js`, and at this layer the source it needs has not
+# been copied yet. Skipping lifecycle scripts is safe — swisscode has no native
+# dependencies, and esbuild/vite ship their platform binaries via
+# optionalDependencies, which npm resolves without a script. The real build runs
+# explicitly below, once the source is present.
+COPY package.json package-lock.json ./
+COPY web/package.json ./web/package.json
+RUN npm ci --ignore-scripts
+
+# The rest of the source, then the full build — dist/cli.js is what bin/swisscode.js
+# runs, and the e2e harness refuses to start without it.
+COPY . .
+RUN node build.js
+
+# THE POINT OF THIS IMAGE: the three real CLIs, at PINNED versions. Pinning makes
+# an upstream break a deliberate, reviewable bump in this file rather than a
+# nightly mystery — the same discipline the repo already applies to its GitHub
+# Actions (SHA-pinned) and its own lockfile. Bump these when verifying against a
+# newer release on purpose.
+RUN npm install -g \
+ @anthropic-ai/claude-code@2.1.218 \
+ @kilocode/cli@7.4.15 \
+ opencode-ai@1.18.4
+
+# Prove they are all resolvable before the suite runs, so a failed global install
+# is a clear build-time error rather than a confusing skip at test time.
+RUN claude --version && kilo --version && opencode --version
+
+CMD ["npm", "run", "test:e2e:real"]
diff --git a/test/e2e/agents.e2e.ts b/test/e2e/agents.e2e.ts
new file mode 100644
index 0000000..ab84f7c
--- /dev/null
+++ b/test/e2e/agents.e2e.ts
@@ -0,0 +1,70 @@
+// All three coding CLIs, selected and launched for real.
+//
+// This proves two things a plan test states but cannot demonstrate: that
+// swisscode consults the RIGHT `SWISSCODE_*_BIN` for the selected agent (each
+// resolves a differently-named recorder), and that the billing guard — the
+// stale `ANTHROPIC_API_KEY` never surviving into the child — holds across all
+// three agent families, not only Claude Code.
+//
+// The dialects genuinely differ: Claude Code reads `ANTHROPIC_*` environment
+// variables, while Kilo and OpenCode carry a whole provider config as a single
+// JSON blob (`KILO_CONFIG_CONTENT` / `OPENCODE_CONFIG_CONTENT`) with the
+// credential inside it. Both are asserted against the real child here.
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { launch, makeConfig, resolvedAgent } from './harness.ts'
+
+function configForAgent(agent: string) {
+ return makeConfig({
+ providerAccounts: { or: { provider: 'openrouter', apiKey: 'sk-agent-KEY' } },
+ agentProfiles: { main: { agent, models: { opus: 'the-model' } } },
+ profiles: { p: { agentProfile: 'main', accounts: ['or'] } },
+ })
+}
+
+test('claude-code resolves its own binary and speaks the ANTHROPIC_* dialect', () => {
+ const r = launch({ config: configForAgent('claude-code'), argv: ['hi'] })
+ assert.ok(r.capture, r.stderr)
+ const c = r.capture
+ assert.equal(resolvedAgent(c), 'recorder-claude')
+ assert.equal(c.env.ANTHROPIC_AUTH_TOKEN, 'sk-agent-KEY')
+ assert.equal(c.env.ANTHROPIC_BASE_URL, 'https://openrouter.ai/api')
+ // It does not leak the other agents' config vocabulary.
+ assert.ok(!('KILO_CONFIG_CONTENT' in c.env))
+ assert.ok(!('OPENCODE_CONFIG_CONTENT' in c.env))
+})
+
+for (const { agent, recorder, configVar } of [
+ { agent: 'kilo', recorder: 'recorder-kilo', configVar: 'KILO_CONFIG_CONTENT' },
+ { agent: 'opencode', recorder: 'recorder-opencode', configVar: 'OPENCODE_CONFIG_CONTENT' },
+]) {
+ test(`${agent} resolves its own binary and carries a config blob, not ANTHROPIC_* env`, () => {
+ const r = launch({ config: configForAgent(agent), argv: ['hi'] })
+ assert.ok(r.capture, r.stderr)
+ const c = r.capture
+
+ // The right override variable was consulted, proven by the resolved name.
+ assert.equal(resolvedAgent(c), recorder)
+
+ // The provider config is a JSON blob with the credential inside it — this
+ // agent never uses ANTHROPIC_AUTH_TOKEN.
+ const config = JSON.parse(c.env[configVar] ?? '{}')
+ assert.ok(config.provider, `${agent} config blob has no provider`)
+ assert.match(JSON.stringify(config), /sk-agent-KEY/, `${agent} credential missing from config`)
+ assert.ok(!('ANTHROPIC_AUTH_TOKEN' in c.env), `${agent} should not set ANTHROPIC_AUTH_TOKEN`)
+
+ // THE BILLING GUARD, across agent families: the stale ANTHROPIC_API_KEY and
+ // ANTHROPIC_BASE_URL from the environment must not survive, or @ai-sdk/anthropic
+ // would fall back to billing the wrong account.
+ assert.ok(!('ANTHROPIC_API_KEY' in c.env), `${agent}: stale ANTHROPIC_API_KEY survived`)
+ assert.ok(!('ANTHROPIC_BASE_URL' in c.env), `${agent}: stale ANTHROPIC_BASE_URL survived`)
+ })
+}
+
+test('every agent sets the recursion guard in the child', () => {
+ for (const agent of ['claude-code', 'kilo', 'opencode']) {
+ const r = launch({ config: configForAgent(agent) })
+ assert.ok(r.capture, `${agent}: ${r.stderr}`)
+ assert.equal(r.capture.env.SWISSCODE, '1', `${agent} did not set SWISSCODE=1`)
+ }
+})
diff --git a/test/e2e/errors.e2e.ts b/test/e2e/errors.e2e.ts
new file mode 100644
index 0000000..c927236
--- /dev/null
+++ b/test/e2e/errors.e2e.ts
@@ -0,0 +1,57 @@
+// The failure paths, exercised through the real binary.
+//
+// A launcher's exit codes and refusals are a contract — a script wrapping
+// swisscode branches on them — and nothing tested them end to end. Each case
+// here asserts that swisscode exits WITHOUT handing off (no capture), with the
+// documented code, and with a message that names the fix. The absence of a
+// capture is itself the assertion: a refusal that still launched something
+// would be the worst kind of failure.
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { join } from 'node:path'
+import { launch, makeConfig } from './harness.ts'
+
+test('an unknown provider exits 2 and launches nothing', () => {
+ const r = launch({
+ config: makeConfig({
+ providerAccounts: { a: { provider: 'nonesuch', apiKey: 'k' } },
+ profiles: { p: { agentProfile: 'main', accounts: ['a'] } },
+ }),
+ })
+ assert.equal(r.capture, null, 'a broken config must not hand off to an agent')
+ assert.equal(r.exitCode, 2)
+ assert.match(r.stderr, /does not know|provider/)
+})
+
+test('a SWISSCODE_*_BIN pointing at swisscode itself is refused, not recursed into', () => {
+ // The alias/shim loop guard, observed end to end: pointing the override back
+ // at swisscode used to execve in an infinite chain that presents as a hang.
+ const r = launch({
+ config: makeConfig(),
+ overrideBins: { claude: join(process.cwd(), 'bin', 'swisscode.js') },
+ })
+ assert.equal(r.capture, null)
+ assert.notEqual(r.exitCode, 0)
+ assert.match(r.stderr, /points at swisscode itself/)
+})
+
+test('a SWISSCODE_*_BIN pointing at a non-existent file is refused', () => {
+ const r = launch({
+ config: makeConfig(),
+ overrideBins: { claude: '/nonexistent/definitely-not-here' },
+ })
+ assert.equal(r.capture, null)
+ assert.notEqual(r.exitCode, 0)
+ assert.match(r.stderr, /not an executable|could not find|SWISSCODE_CLAUDE_BIN/)
+})
+
+test('a profile naming a missing account launches nothing', () => {
+ const r = launch({
+ config: makeConfig({
+ providerAccounts: { real: { provider: 'openrouter', apiKey: 'k' } },
+ profiles: { p: { agentProfile: 'main', accounts: ['ghost'] } },
+ }),
+ })
+ assert.equal(r.capture, null)
+ assert.notEqual(r.exitCode, 0)
+})
diff --git a/test/e2e/harness.ts b/test/e2e/harness.ts
new file mode 100644
index 0000000..fce00de
--- /dev/null
+++ b/test/e2e/harness.ts
@@ -0,0 +1,239 @@
+// The end-to-end harness: seed a config, run the REAL binary, read what it
+// launched.
+//
+// This is the seam that lets a test observe swisscode's single most
+// consequential act — the execve/spawn handoff — instead of trusting the plan
+// object that describes it. Every helper here exists so a `.e2e.ts` file reads
+// like a unit test while actually spawning `node bin/swisscode.js` end to end.
+//
+// HERMETIC BY CONSTRUCTION. Every run gets its own temp XDG dirs, its own
+// capture file, and a set of symlinks pointing the three SWISSCODE_*_BIN
+// overrides at the recorder. No network, no credential, no keychain, no shared
+// state — the same discipline the unit fs-store tests already follow, one layer
+// out.
+
+import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
+import { spawnSync } from 'node:child_process'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const HERE = dirname(fileURLToPath(import.meta.url))
+const ROOT = join(HERE, '..', '..')
+const BIN = join(ROOT, 'bin', 'swisscode.js')
+const DIST_CLI = join(ROOT, 'dist', 'cli.js')
+const RECORDER = join(HERE, 'recorder.mjs')
+
+/** The recorded child process — what swisscode actually handed the agent. */
+export type Capture = {
+ /** the arguments forwarded to the agent (swisscode's own flags already stripped) */
+ argv: string[]
+ /** the WHOLE child environment; assert by key — an absent key proves an unset */
+ env: Record
+ cwd: string
+ /** the resolved binary path; its basename names which agent was selected */
+ binary: string
+}
+
+export type LaunchResult = {
+ exitCode: number
+ stdout: string
+ stderr: string
+ /** null when swisscode exited before handing off (an error, or `needsSetup`) */
+ capture: Capture | null
+}
+
+/**
+ * The deliberately polluted ambient environment, shared with `test/golden.test.ts`.
+ *
+ * The whole point of these vars is that they are WRONG: a stale key, a stale
+ * gateway, stale tier models. If the launched child still carries any of them,
+ * swisscode's `unset` did not reach the OS — which is exactly the failure a
+ * plan-level test cannot see and this harness exists to catch.
+ */
+export const AMBIENT: Readonly> = Object.freeze({
+ ANTHROPIC_API_KEY: 'sk-ant-STALE',
+ ANTHROPIC_AUTH_TOKEN: 'stale-auth-token',
+ ANTHROPIC_BASE_URL: 'https://stale.gateway.example',
+ ANTHROPIC_DEFAULT_SONNET_MODEL: 'stale-sonnet',
+ ANTHROPIC_DEFAULT_FABLE_MODEL: 'stale-fable',
+ CLAUDE_CODE_SUBAGENT_MODEL: 'stale-subagent',
+})
+
+/** The three override variables, and the symlink name each resolves to. */
+const AGENT_OVERRIDES = {
+ claude: 'SWISSCODE_CLAUDE_BIN',
+ kilo: 'SWISSCODE_KILO_BIN',
+ opencode: 'SWISSCODE_OPENCODE_BIN',
+} as const
+
+let distChecked = false
+function ensureBuilt(): void {
+ if (distChecked) return
+ try {
+ readFileSync(DIST_CLI)
+ distChecked = true
+ } catch {
+ throw new Error(
+ 'e2e: dist/cli.js is missing — the e2e suite runs the built binary. Run `node build.js` first ' +
+ '(the `test:e2e` npm script and CI both build before running).',
+ )
+ }
+}
+
+export type LaunchOptions = {
+ /** the v3 config to seed; use `makeConfig` for the common shape */
+ config: unknown
+ /** everything after `swisscode` on the command line */
+ argv?: string[]
+ /** where the binary is invoked from — drives directory bindings */
+ cwd?: string
+ /**
+ * Extra ambient env, merged over AMBIENT. Set a key to '' to REMOVE it from
+ * the ambient the child inherits (spawnSync has no unset, so the harness
+ * filters empties out of the final env).
+ */
+ env?: Record
+ /**
+ * Point a SWISSCODE_*_BIN at something other than the recorder — used by the
+ * recursion-guard test, which points it at swisscode itself.
+ */
+ overrideBins?: Partial>
+ /**
+ * Tier B: install no recorders and set no overrides, so resolution finds the
+ * REAL agent on PATH. There is no capture — the real binary does not write
+ * one — so a Tier B assertion reads `exitCode`/`stdout` instead. Used only in
+ * the Docker image where the three CLIs are installed.
+ */
+ useRealBinaries?: boolean
+}
+
+/**
+ * Seed a config, run `node bin/swisscode.js `, and return the launch.
+ *
+ * The recorder writes its capture synchronously before exiting, so by the time
+ * spawnSync returns the file is complete; there is no race to poll for.
+ */
+export function launch({
+ config,
+ argv = [],
+ cwd,
+ env = {},
+ overrideBins = {},
+ useRealBinaries = false,
+}: LaunchOptions): LaunchResult {
+ ensureBuilt()
+
+ const work = mkdtempSync(join(tmpdir(), 'swisscode-e2e-'))
+ const configDir = join(work, 'config', 'swisscode')
+ mkdirSync(configDir, { recursive: true, mode: 0o700 })
+ writeFileSync(join(configDir, 'config.json'), JSON.stringify(config), { mode: 0o600 })
+
+ // The recorder is COPIED into the temp dir, not symlinked, and that is not a
+ // detail. swisscode's recursion guard resolves every candidate binary with
+ // `realpathSync` and rejects anything under its own install directory — which
+ // is the repo root when the harness runs the built binary. A symlink resolves
+ // BACK into the repo and gets rejected as "swisscode itself"; a copy under
+ // /tmp resolves outside it and is accepted, exactly as a real agent would be.
+ // (This is the guard working correctly, and the recursion-guard test relies
+ // on it.) One copy per override so a resolved binary's basename tells the test
+ // WHICH override variable swisscode consulted, not just which env it lowered.
+ const capture = join(work, 'capture.json')
+ const overrides: Record = {}
+ if (!useRealBinaries) {
+ for (const [agent, variable] of Object.entries(AGENT_OVERRIDES)) {
+ const explicit = overrideBins[agent as keyof typeof AGENT_OVERRIDES]
+ if (explicit) {
+ overrides[variable] = explicit
+ continue
+ }
+ const copy = join(work, `recorder-${agent}`)
+ copyFileSync(RECORDER, copy)
+ chmodSync(copy, 0o755)
+ overrides[variable] = copy
+ }
+ }
+
+ // AMBIENT + caller env, with empty strings meaning "not present" so a test can
+ // model an unpolluted shell for a specific variable.
+ const merged: Record = {
+ // A minimal real shell: the child needs PATH to find `node` for the shebang,
+ // and HOME because the default-config-dir logic reads it.
+ PATH: process.env.PATH ?? '/usr/bin:/bin',
+ HOME: process.env.HOME ?? work,
+ ...AMBIENT,
+ ...env,
+ ...overrides,
+ XDG_CONFIG_HOME: join(work, 'config'),
+ XDG_STATE_HOME: join(work, 'state'),
+ SWISSCODE_E2E_CAPTURE: capture,
+ }
+ const childEnv = Object.fromEntries(Object.entries(merged).filter(([, v]) => v !== ''))
+
+ // `process.execPath`, not the string 'node', on purpose: the child swisscode
+ // runs under the SAME Node as the suite. This is what finally exercises the
+ // `execve` dispatch of replace() end to end — the handoff `ci.yml` admits
+ // nothing covered. (The `spawn` fallback fires only where execve is absent,
+ // i.e. Windows and Node < 23.11 — note even Node 22.23 backported execve — so
+ // it stays unit-tested via spawnFallback + an injected SignalHost, and this
+ // e2e does not reach it. Running the matrix under both supported Node versions
+ // is defence in depth, not two different dispatches.)
+ const run = spawnSync(process.execPath, [BIN, ...argv], {
+ env: childEnv,
+ ...(cwd ? { cwd } : {}),
+ encoding: 'utf8',
+ })
+
+ let recorded: Capture | null = null
+ try {
+ recorded = JSON.parse(readFileSync(capture, 'utf8')) as Capture
+ } catch {
+ // No capture: swisscode exited before handing off (an error path, or it
+ // needed setup). That is a legitimate, asserted-on outcome.
+ }
+
+ return {
+ exitCode: run.status ?? 1,
+ stdout: run.stdout ?? '',
+ stderr: run.stderr ?? '',
+ capture: recorded,
+ }
+}
+
+// ── config fixtures ──
+
+type AccountSpec = { provider: string; apiKey?: string; apiKeyFromEnv?: string; configDir?: string; baseUrl?: string }
+type ProfileSpec = { agentProfile: string; accounts: string[]; strategy?: string }
+
+/**
+ * A v3 config, spelled at the density a test needs.
+ *
+ * Defaults to one openrouter key account, one claude-code agent profile pinning
+ * a model, and one profile pairing them as the default — the smallest config
+ * that launches. Any piece can be overridden.
+ */
+export function makeConfig(over: {
+ providerAccounts?: Record
+ agentProfiles?: Record }>
+ profiles?: Record
+ defaultProfile?: string | null
+ bindings?: Record
+ settings?: Record
+ providers?: Record
+} = {}): unknown {
+ return {
+ version: 3,
+ providerAccounts: over.providerAccounts ?? { or: { provider: 'openrouter', apiKey: 'sk-e2e-or' } },
+ agentProfiles: over.agentProfiles ?? { main: { agent: 'claude-code', models: { opus: 'openrouter/fusion' } } },
+ profiles: over.profiles ?? { p: { agentProfile: 'main', accounts: ['or'] } },
+ defaultProfile: 'defaultProfile' in over ? over.defaultProfile : 'p',
+ bindings: over.bindings ?? {},
+ settings: over.settings ?? {},
+ ...(over.providers ? { providers: over.providers } : {}),
+ }
+}
+
+/** basename of a resolved binary path, for `recorder-` assertions. */
+export function resolvedAgent(capture: Capture): string {
+ return capture.binary.split('/').pop() ?? capture.binary
+}
diff --git a/test/e2e/launch.e2e.ts b/test/e2e/launch.e2e.ts
new file mode 100644
index 0000000..7492f9f
--- /dev/null
+++ b/test/e2e/launch.e2e.ts
@@ -0,0 +1,114 @@
+// The end-to-end counterpart of test/golden.test.ts.
+//
+// golden asserts that `buildEnvPlan` PRODUCES the right environment — an
+// in-memory object. This asserts that the environment REACHES THE CHILD: it
+// runs the real `bin/swisscode.js`, which resolves the recorder and execve's
+// into it, and reads back the actual OS-level process environment. The two
+// cover different halves of the same claim. golden proves the plan is correct;
+// this proves the handoff is faithful, which nothing else tests — `ci.yml` says
+// so in as many words.
+//
+// The headline assertion is the one a plan-level test cannot make: for every
+// third-party provider, the deliberately-stale `ANTHROPIC_API_KEY` in AMBIENT is
+// ABSENT from the launched child. Present-in-`plan.unset` is not the same as
+// gone-from-the-process; only a real launch shows the difference.
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { launch, makeConfig, resolvedAgent } from './harness.ts'
+import { PROVIDERS, byId } from '../../src/adapters/providers/registry.ts'
+
+/** A key-mode account for a provider, plus a claude-code profile pinning a model. */
+function configFor(providerId: string) {
+ const provider = byId(providerId)!
+ const account: { provider: string; apiKey: string; baseUrl?: string } = {
+ provider: providerId,
+ apiKey: 'sk-e2e-KEY',
+ }
+ // `custom` has no baseUrl of its own; an account must supply one.
+ if (provider.askBaseUrl) account.baseUrl = 'https://custom.example'
+ return makeConfig({
+ providerAccounts: { acct: account },
+ agentProfiles: { main: { agent: 'claude-code', models: { opus: 'test-opus' } } },
+ profiles: { p: { agentProfile: 'main', accounts: ['acct'] } },
+ })
+}
+
+// Every shipped provider, launched for real through Claude Code.
+for (const provider of PROVIDERS) {
+ test(`${provider.id}: the plan reaches the child, and stale env does not`, () => {
+ const r = launch({ config: configFor(provider.id), argv: [`prompt for ${provider.id}`] })
+ assert.equal(r.exitCode, 0, r.stderr)
+ const c = r.capture
+ assert.ok(c, `${provider.id} handed off but recorded nothing`)
+
+ // swisscode resolved the CLAUDE override specifically — not kilo, not opencode.
+ assert.equal(resolvedAgent(c), 'recorder-claude')
+ // The recursion guard was set in the child, proving buildEnv ran.
+ assert.equal(c.env.SWISSCODE, '1')
+ // The prompt was forwarded verbatim, swisscode's own flags stripped.
+ assert.deepEqual(c.argv, [`prompt for ${provider.id}`])
+
+ const credentialEnv = provider.credentialEnv
+ // The key landed in the variable this provider authenticates with.
+ assert.equal(c.env[credentialEnv], 'sk-e2e-KEY', `${provider.id} credential env`)
+
+ if (credentialEnv !== 'ANTHROPIC_API_KEY') {
+ // THE LOAD-BEARING ASSERTION. Every third-party provider authenticates via
+ // ANTHROPIC_AUTH_TOKEN, so the stale ANTHROPIC_API_KEY must be gone from
+ // the process — not merely listed in plan.unset. This is the billing
+ // guard, observed at the OS level for the first time.
+ assert.ok(
+ !('ANTHROPIC_API_KEY' in c.env),
+ `${provider.id}: stale ANTHROPIC_API_KEY survived into the child`,
+ )
+ } else {
+ // anthropic-direct uses ANTHROPIC_API_KEY, so the stale value is
+ // OVERWRITTEN with the account key rather than removed.
+ assert.equal(c.env.ANTHROPIC_API_KEY, 'sk-e2e-KEY')
+ }
+
+ if (provider.baseUrl === null && !provider.askBaseUrl) {
+ // anthropic-direct: the stale gateway URL is cleared, not overridden.
+ assert.ok(
+ !('ANTHROPIC_BASE_URL' in c.env),
+ `${provider.id}: a stale ANTHROPIC_BASE_URL survived`,
+ )
+ } else {
+ const expected = provider.baseUrl ?? 'https://custom.example'
+ assert.equal(c.env.ANTHROPIC_BASE_URL, expected, `${provider.id} base URL`)
+ }
+
+ // The pinned model reached its tier variable (z.ai appends [1m], hence
+ // `includes` rather than an exact match — the suffix is golden's concern).
+ assert.match(c.env.ANTHROPIC_DEFAULT_OPUS_MODEL ?? '', /test-opus/)
+ })
+}
+
+test('the ambient PATH survives — the child still finds node for the shebang', () => {
+ // A launch that scrubbed PATH would break the recorder's own `#!/usr/bin/env
+ // node`, so this passing at all is part of the proof; assert it explicitly so
+ // a future env-plan change that drops PATH fails here loudly rather than as a
+ // mysterious "recorder did not run".
+ const r = launch({ config: makeConfig() })
+ assert.ok(r.capture)
+ assert.ok(r.capture.env.PATH && r.capture.env.PATH.length > 0)
+})
+
+test('a profile pinning all four tiers writes all four into the child', () => {
+ const r = launch({
+ config: makeConfig({
+ agentProfiles: {
+ main: {
+ agent: 'claude-code',
+ models: { opus: 'm-opus', sonnet: 'm-sonnet', haiku: 'm-haiku', fable: 'm-fable' },
+ },
+ },
+ }),
+ })
+ assert.ok(r.capture)
+ const e = r.capture.env
+ assert.equal(e.ANTHROPIC_DEFAULT_OPUS_MODEL, 'm-opus')
+ assert.equal(e.ANTHROPIC_DEFAULT_SONNET_MODEL, 'm-sonnet')
+ assert.equal(e.ANTHROPIC_DEFAULT_HAIKU_MODEL, 'm-haiku')
+ assert.equal(e.ANTHROPIC_DEFAULT_FABLE_MODEL, 'm-fable')
+})
diff --git a/test/e2e/overrides.e2e.ts b/test/e2e/overrides.e2e.ts
new file mode 100644
index 0000000..07e363a
--- /dev/null
+++ b/test/e2e/overrides.e2e.ts
@@ -0,0 +1,102 @@
+// The `--cc-*` override surface and passthrough, launched for real.
+//
+// The end-to-end counterpart of test/launch-overrides.test.ts, which drives
+// `planLaunch` in-process. This runs the whole binary: the override is parsed
+// from real argv, applied during real resolution, lowered into the real env
+// plan, and observed in the real child. It also proves the half a plan test
+// cannot — that swisscode's own flags are STRIPPED and never forwarded to the
+// agent as if they were prompt text.
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { launch, makeConfig } from './harness.ts'
+
+// A config with two accounts on two providers, so `--cc-provider` has somewhere
+// to borrow a key from and `--cc-model` has a baseline to override.
+function twoProviderConfig() {
+ return makeConfig({
+ providerAccounts: {
+ or: { provider: 'openrouter', apiKey: 'sk-or' },
+ z: { provider: 'zai', apiKey: 'sk-zai' },
+ },
+ agentProfiles: { main: { agent: 'claude-code', models: { opus: 'base-opus', sonnet: 'base-sonnet' } } },
+ profiles: {
+ p: { agentProfile: 'main', accounts: ['or'] },
+ onz: { agentProfile: 'main', accounts: ['z'] },
+ },
+ })
+}
+
+test('--cc-model bare resets every tier to the one value', () => {
+ const r = launch({ config: twoProviderConfig(), argv: ['--cc-model', 'override-all'] })
+ assert.ok(r.capture, r.stderr)
+ const e = r.capture.env
+ for (const tier of ['OPUS', 'SONNET', 'HAIKU', 'FABLE']) {
+ assert.equal(e[`ANTHROPIC_DEFAULT_${tier}_MODEL`], 'override-all', `${tier} not overridden`)
+ }
+})
+
+test('--cc-model opus=X refines one tier and leaves the profile pin on the rest', () => {
+ const r = launch({ config: twoProviderConfig(), argv: ['--cc-model', 'opus=just-opus'] })
+ assert.ok(r.capture, r.stderr)
+ const e = r.capture.env
+ assert.equal(e.ANTHROPIC_DEFAULT_OPUS_MODEL, 'just-opus')
+ // The profile pinned sonnet; a scoped opus override must not disturb it.
+ assert.equal(e.ANTHROPIC_DEFAULT_SONNET_MODEL, 'base-sonnet')
+})
+
+test('--cc-base-url redirects the endpoint for this launch only', () => {
+ const r = launch({ config: twoProviderConfig(), argv: ['--cc-base-url', 'https://elsewhere.example'] })
+ assert.ok(r.capture, r.stderr)
+ assert.equal(r.capture.env.ANTHROPIC_BASE_URL, 'https://elsewhere.example')
+})
+
+test('--cc-env sets a variable, and an empty value UNSETS one', () => {
+ const r = launch({
+ config: twoProviderConfig(),
+ // A value swisscode would otherwise set, cleared; plus a fresh one.
+ argv: ['--cc-env', 'CLAUDE_CODE_MAX_OUTPUT_TOKENS=4096', '--cc-env', 'ANTHROPIC_DEFAULT_OPUS_MODEL='],
+ })
+ assert.ok(r.capture, r.stderr)
+ const e = r.capture.env
+ assert.equal(e.CLAUDE_CODE_MAX_OUTPUT_TOKENS, '4096')
+ // The empty override wins over the profile's own opus pin and removes it.
+ assert.ok(!('ANTHROPIC_DEFAULT_OPUS_MODEL' in e), 'empty --cc-env should unset')
+})
+
+test('--cc-provider borrows another profile\'s endpoint and key', () => {
+ // Switch the openrouter-default launch onto z.ai. The z.ai base URL and its
+ // credential must both arrive, and openrouter\'s must not linger.
+ const r = launch({ config: twoProviderConfig(), argv: ['--cc-provider', 'zai'] })
+ assert.ok(r.capture, r.stderr)
+ const e = r.capture.env
+ assert.equal(e.ANTHROPIC_BASE_URL, 'https://api.z.ai/api/anthropic')
+ assert.equal(e.ANTHROPIC_AUTH_TOKEN, 'sk-zai')
+})
+
+test('a bare prompt and unknown flags are forwarded to the agent untouched', () => {
+ const r = launch({
+ config: makeConfig(),
+ argv: ['fix the bug', '--resume', '--model', 'ignored-by-swisscode'],
+ })
+ assert.ok(r.capture, r.stderr)
+ // Everything that is not a --cc-* / --safe / --yolo reaches the agent verbatim.
+ assert.deepEqual(r.capture.argv, ['fix the bug', '--resume', '--model', 'ignored-by-swisscode'])
+})
+
+test('--cc-* flags are STRIPPED — they never reach the agent as arguments', () => {
+ const r = launch({ config: twoProviderConfig(), argv: ['--cc-model', 'x', 'the prompt'] })
+ assert.ok(r.capture, r.stderr)
+ // The classic failure this prevents: a reserved flag forwarded to the agent,
+ // where it reads as prompt text while the launch silently uses wrong settings.
+ assert.deepEqual(r.capture.argv, ['the prompt'])
+ assert.ok(!r.capture.argv.includes('--cc-model'))
+})
+
+test('--yolo forwards the skip-permissions flag to the agent', () => {
+ const r = launch({ config: makeConfig(), argv: ['--yolo'] })
+ assert.ok(r.capture, r.stderr)
+ assert.ok(
+ r.capture.argv.includes('--dangerously-skip-permissions'),
+ `--yolo should forward the skip flag; got ${JSON.stringify(r.capture.argv)}`,
+ )
+})
diff --git a/test/e2e/real-handoff.real.ts b/test/e2e/real-handoff.real.ts
new file mode 100644
index 0000000..7b802f7
--- /dev/null
+++ b/test/e2e/real-handoff.real.ts
@@ -0,0 +1,72 @@
+// Tier B: swisscode against the REAL coding CLIs.
+//
+// Tier A (the *.e2e.ts files) proves swisscode builds the right launch by
+// pointing the SWISSCODE_*_BIN overrides at a recorder. That is complete and
+// deterministic, but it can never catch an UPSTREAM change — a renamed flag, a
+// rejected environment variable, a binary that moved. This tier installs the
+// three actual CLIs (`@anthropic-ai/claude-code`, `@kilocode/cli`,
+// `opencode-ai`) and proves swisscode resolves each one and hands off to it.
+//
+// NON-BILLABLE BY CONSTRUCTION. It forwards only `--version`, which every CLI
+// answers before any authentication or inference — no API key, no network to a
+// model, no cost. It is the smallest act that proves resolution + env dialect +
+// the execve handoff against the genuine binary.
+//
+// A DISTINCT `.real.ts` EXTENSION, not `.e2e.ts`, keeps it out of the hermetic
+// pass: `test:e2e` globs `*.e2e.ts` and never sees this, so a PR that has not
+// installed the three CLIs stays green. This runs only via `test:e2e:real`,
+// inside the Docker image, or by hand.
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { spawnSync } from 'node:child_process'
+import { launch, makeConfig } from './harness.ts'
+
+/**
+ * Is this binary on PATH at all?
+ *
+ * Detected by trying to run it and watching for ENOENT rather than shelling out
+ * to `which`, which `node:*-slim` images do not ship. The timeout guards a CLI
+ * whose `--version` might not short-circuit as cleanly as expected — the whole
+ * risk this tier was told to verify.
+ */
+function installed(bin: string): boolean {
+ const r = spawnSync(bin, ['--version'], { encoding: 'utf8', timeout: 20_000 })
+ return !(r.error && (r.error as NodeJS.ErrnoException).code === 'ENOENT')
+}
+
+const AGENTS = [
+ { id: 'claude-code', bin: 'claude', model: 'claude-opus-4-8' },
+ { id: 'kilo', bin: 'kilo', model: 'anthropic/claude-opus-4-8' },
+ { id: 'opencode', bin: 'opencode', model: 'anthropic/claude-opus-4-8' },
+]
+
+for (const agent of AGENTS) {
+ test(`swisscode resolves and hands off to the real ${agent.bin} (--version)`, { skip: !installed(agent.bin) }, () => {
+ const r = launch({
+ config: makeConfig({
+ providerAccounts: { a: { provider: 'openrouter', apiKey: 'sk-e2e-not-used-by-version' } },
+ agentProfiles: { m: { agent: agent.id, models: { opus: agent.model } } },
+ profiles: { p: { agentProfile: 'm', accounts: ['a'] } },
+ }),
+ argv: ['--version'],
+ useRealBinaries: true,
+ })
+
+ // The real binary printed its version and exited cleanly. There is no
+ // capture — the genuine CLI does not write our file — so the exit code and
+ // stdout ARE the assertion.
+ assert.equal(r.exitCode, 0, `stderr: ${r.stderr}`)
+ assert.match(r.stdout + r.stderr, /\d+\.\d+\.\d+/, 'expected a version string from the real CLI')
+ assert.equal(r.capture, null, 'a real handoff writes no recorder capture')
+ })
+}
+
+test('at least one real CLI was actually exercised', () => {
+ // A guard against the whole tier silently no-opping: if the image is built
+ // right, all three are installed. If NONE are, this fails loudly rather than
+ // reporting a reassuring row of skips.
+ assert.ok(
+ AGENTS.some((a) => installed(a.bin)),
+ 'no real coding CLI is installed — Tier B proved nothing. Check the Dockerfile installs.',
+ )
+})
diff --git a/test/e2e/recorder.mjs b/test/e2e/recorder.mjs
new file mode 100755
index 0000000..b36f059
--- /dev/null
+++ b/test/e2e/recorder.mjs
@@ -0,0 +1,52 @@
+#!/usr/bin/env node
+// The fake agent that makes end-to-end launch testing possible.
+//
+// swisscode's whole job is to resolve a binary and hand off to it with a
+// precise environment — and until now nothing tested that the handoff actually
+// happens the way the plan says. Every test asserted on the PLAN (an in-memory
+// object); none observed a real child process. This is the child process.
+//
+// The e2e harness points SWISSCODE_CLAUDE_BIN (and the kilo/opencode
+// equivalents) at this file, then runs the REAL bin/swisscode.js. swisscode
+// parses argv, loads the seeded config, resolves the profile, builds the env
+// plan, and execve's (Node >= 23.11) or spawns (Node 22) into THIS. We write
+// down exactly what we were handed and exit. The harness reads it back and
+// asserts.
+//
+// WHY THIS IS NOT THE "do not launch for real" hazard CLAUDE.md warns about:
+// that warning is about launching a REAL agent — a 250 MB interactive TUI that
+// hangs a tool call and starts a nested session. This exits in milliseconds and
+// touches no network, no credential, no keychain. It is the safe launch the
+// warning leaves room for.
+//
+// It records `process.argv[1]` as `binary`: swisscode execve's the override
+// path verbatim as argv[0], so a symlink named `recorder-claude` tells the test
+// WHICH agent's binary swisscode resolved — proving the override selection, not
+// only the lowered env.
+
+import { writeFileSync } from 'node:fs'
+
+const capturePath = process.env.SWISSCODE_E2E_CAPTURE
+
+// Defensive: if this is ever reached without a capture target, it must still be
+// a well-behaved no-op that exits cleanly rather than a crash that would read as
+// a launch failure. In the harness the variable is always set.
+if (!capturePath) {
+ process.stderr.write('recorder: SWISSCODE_E2E_CAPTURE not set; nothing recorded\n')
+ process.exit(0)
+}
+
+// `argv.slice(2)` is what swisscode forwarded to the agent: argv[0] is node,
+// argv[1] is this script (the resolved binary path), the rest are the agent's
+// arguments. `env` is captured WHOLE and asserted against by key — the point of
+// an e2e is that an absent key proves the plan's `unset` reached the OS, which
+// no plan-level assertion can show.
+const record = {
+ argv: process.argv.slice(2),
+ env: { ...process.env },
+ cwd: process.cwd(),
+ binary: process.argv[1],
+}
+
+writeFileSync(capturePath, JSON.stringify(record))
+process.exit(0)
diff --git a/test/e2e/session.e2e.ts b/test/e2e/session.e2e.ts
new file mode 100644
index 0000000..a9a76ac
--- /dev/null
+++ b/test/e2e/session.e2e.ts
@@ -0,0 +1,53 @@
+// Subscription session mode, launched for real.
+//
+// The single most dangerous bug this whole feature can have is a
+// silently-wrong-account launch, and it lives exactly at the boundary a plan
+// test cannot see: whether the child process actually has `CLAUDE_CONFIG_DIR`
+// set (or unset) and whether both credential variables are actually gone. This
+// asserts it against the real child.
+//
+// The default-directory trap is the subtle one. Claude Code selects its
+// keychain item on WHETHER `CLAUDE_CONFIG_DIR` is set, not its value, so an
+// account pointed at the default `~/.claude` must UNSET the variable — pointing
+// it at that path is a different, empty login. The corrected mechanism is
+// proven here by the variable's ABSENCE from the child.
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { homedir } from 'node:os'
+import { join } from 'node:path'
+import { launch, makeConfig } from './harness.ts'
+
+function sessionConfig(configDir: string, extra: Record = {}) {
+ return makeConfig({
+ providerAccounts: { s: { provider: 'anthropic', configDir, ...extra } },
+ agentProfiles: { m: { agent: 'claude-code', models: { opus: 'x' } } },
+ profiles: { p: { agentProfile: 'm', accounts: ['s'] } },
+ })
+}
+
+test('a custom session directory sets CLAUDE_CONFIG_DIR and clears BOTH credentials', () => {
+ const r = launch({ config: sessionConfig('/tmp/e2e-cc-dir') })
+ assert.ok(r.capture, r.stderr)
+ const e = r.capture.env
+ assert.equal(e.CLAUDE_CONFIG_DIR, '/tmp/e2e-cc-dir')
+ // Both, not one: a stale ANTHROPIC_AUTH_TOKEN left behind would override the
+ // subscription login just as surely as a stale API key.
+ assert.ok(!('ANTHROPIC_API_KEY' in e), 'stale ANTHROPIC_API_KEY survived a session launch')
+ assert.ok(!('ANTHROPIC_AUTH_TOKEN' in e), 'stale ANTHROPIC_AUTH_TOKEN survived a session launch')
+})
+
+test('naming the DEFAULT ~/.claude UNSETS the variable rather than writing it', () => {
+ // The trap. HOME is the harness's HOME, so the default dir is HOME/.claude.
+ const r = launch({ config: sessionConfig(join(process.env.HOME ?? homedir(), '.claude')) })
+ assert.ok(r.capture, r.stderr)
+ assert.ok(
+ !('CLAUDE_CONFIG_DIR' in r.capture.env),
+ 'the default directory must UNSET CLAUDE_CONFIG_DIR — writing the path is a different, empty login',
+ )
+})
+
+test('a session launch resolves the claude binary, not another agent', () => {
+ const r = launch({ config: sessionConfig('/tmp/e2e-cc-dir') })
+ assert.ok(r.capture, r.stderr)
+ assert.equal(r.capture.binary.split('/').pop(), 'recorder-claude')
+})
diff --git a/web/index.html b/web/index.html
index 4b1a57d..b0fb415 100644
--- a/web/index.html
+++ b/web/index.html
@@ -4,6 +4,15 @@
swisscode
+
+
+
+
+