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 + + + + +