diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9bf9ace..4ea8d29 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,6 +74,23 @@ jobs: - name: Verify exact release runtime if: matrix.node == '24.18.1' run: corepack pnpm@10.34.0 verify + # Drives the released daemon, installed from npm at the locked integrity, + # through the built client: commands, adoption replay, and event resume + # across a daemon restart. Needs the build that the verify step produced. + - name: Drive the released Coven v0.4.7 daemon through the SDK + if: matrix.node == '24.18.1' + env: + COVEN_CLI_DIR: ${{ runner.temp }}/coven-cli-0.4.7 + run: | + set -euo pipefail + mkdir -p "$COVEN_CLI_DIR" + cp conformance/automations-v1-daemon/package.json \ + conformance/automations-v1-daemon/package-lock.json "$COVEN_CLI_DIR/" + npm ci --prefix "$COVEN_CLI_DIR" --ignore-scripts --no-audit --no-fund + npm audit signatures --prefix "$COVEN_CLI_DIR" + corepack pnpm@10.34.0 canary:automations-v1-daemon -- \ + --coven "$COVEN_CLI_DIR/node_modules/@opencoven/cli/bin/coven.js" \ + --expect-version 0.4.7 # The moving major validates supported package behavior without claiming # exact release, conformance, provenance, or environment authority. - name: Verify Node 24 package compatibility diff --git a/README.md b/README.md index 550a9c8..f566396 100644 --- a/README.md +++ b/README.md @@ -295,6 +295,64 @@ tarball remains an external immutable artifact rather than a committed binary: corepack pnpm@10.34.0 verify:automations-v1-evidence ``` +## Automations v1 daemon canary + +The artifact canary checks the contract a release ships. The daemon canary +checks that the released daemon behaves that way when this SDK's built client +drives it. CI installs `@opencoven/cli` from npm at the version and integrity +locked in +[`conformance/automations-v1-daemon/package-lock.json`](conformance/automations-v1-daemon/package-lock.json), +runs `npm audit signatures` over it, and then runs: + +```bash +corepack pnpm@10.34.0 build +corepack pnpm@10.34.0 canary:automations-v1-daemon -- \ + --coven /path/to/node_modules/@opencoven/cli/bin/coven.js \ + --expect-version 0.4.7 +``` + +The canary refuses any binary that does not report the expected version. It +starts `coven daemon serve` in an owned temporary `COVEN_HOME`, with a minimal +environment and no harness, and discovers it the way a consumer would. Then it: + +- creates a draft and requires the daemon's stored `integrity` to equal + `computeDefinitionDigest()`, then resends it under the same adoption key + (`replayed`) and with a changed body (`ADOPTION_REPLAY_MISMATCH`); +- revises it, has a stale revision refused with `REVISION_CONFLICT` and the + current revision, activates and pauses it, and reads it back with `get()`; +- stops the daemon with `SIGTERM`, requires it to remove its socket and + `daemon.json`, starts it again over the same home, and requires the earlier + activation to come back `replayed` under its adoption key; +- disables the routine, resumes `subscribe()` from a checkpoint taken before the + restart, and requires exactly the four later lifecycle events followed by the + final empty page and its checkpoint, then the same tail from a concrete + `after` cursor; +- requires empty occurrence and run history. The schedule is set twelve hours + away from the activation, so nothing fires. + +It prints one line, for example +`Automations v1 daemon verified: covenVersion=0.4.7 daemonStarts=2 commands=9 … peerIdentity=harness-asserted`. +A failure prints the daemon's own output. On success, failure, `SIGINT`, or +`SIGTERM` (exit 130 or 143), the daemon is stopped and its home removed. Unix +only. + +What it does not establish: + +- **Peer identity is asserted, not inspected.** Node has no peer-credential API. + The canary launched the daemon under its own uid in a `0700` home, and checks + that the home and socket belong to that uid before asserting it. Production + callers still need a reviewed provider. +- **No run executes.** Runs, receipts, and runtime authority are out of scope. +- **`get()` is v0.4.7's legacy routine projection**, not the stored rich + definition. +- **`eventDefinitionDigest=differs-from-definition-integrity`** records that + v0.4.7's lifecycle events carry the digest of that routine projection, not + the definition document's `integrity`. Coven's occurrences, runs, and receipts + pin the same projection digest, so passing a definition's `integrity` to + `verifyReceipt()` as the expected `definitionDigest` will not match a v0.4.7 + receipt. [Coven #1054](https://github.com/OpenCoven/coven/issues/1054) tracks + this. + ## Choosing a package | Need | Package | diff --git a/conformance/automations-v1-daemon/package-lock.json b/conformance/automations-v1-daemon/package-lock.json new file mode 100644 index 0000000..ced88d9 --- /dev/null +++ b/conformance/automations-v1-daemon/package-lock.json @@ -0,0 +1,84 @@ +{ + "name": "opencoven-automations-v1-daemon-canary", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "opencoven-automations-v1-daemon-canary", + "license": "MIT", + "dependencies": { + "@opencoven/cli": "0.4.7" + } + }, + "node_modules/@opencoven/cli": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@opencoven/cli/-/cli-0.4.7.tgz", + "integrity": "sha512-8sqcYXvCoaJIU+R2wrc9DER6Cva1MicPUeoRH/14I6vekcEKvqbCAhqBzzY92MNN4+qHia1DAFtgZlQ+6C57jw==", + "license": "MIT", + "bin": { + "coven": "bin/coven.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@opencoven/cli-linux-x64": "0.4.7", + "@opencoven/cli-macos": "0.4.7", + "@opencoven/cli-macos-x64": "0.4.7", + "@opencoven/cli-windows": "0.4.7" + } + }, + "node_modules/@opencoven/cli-linux-x64": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@opencoven/cli-linux-x64/-/cli-linux-x64-0.4.7.tgz", + "integrity": "sha512-apsrPv4YUTjOcAy+t+Kfp6o2q8EmIiETyX/454H2yB2zJM4sN5ngFZIV+MHZvMpg0TixhRsUhBkKvm1mZXO73g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@opencoven/cli-macos": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@opencoven/cli-macos/-/cli-macos-0.4.7.tgz", + "integrity": "sha512-zXDTlar/shiaDnH27hb/spk84ve8IiTW+wXQ5Y3bFoHpflRxNsaD4kMh8oRKlndxQnV0BkFcFDpeXg9HBmCF1g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@opencoven/cli-macos-x64": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@opencoven/cli-macos-x64/-/cli-macos-x64-0.4.7.tgz", + "integrity": "sha512-gcnyXQdhgJj90ShGroqw9OS1575T5CsL2mhI+VxGlDHK+LRAZxeJcO63T6YnjyqKr015ytR/xWaTZGToOeuttw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@opencoven/cli-windows": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@opencoven/cli-windows/-/cli-windows-0.4.7.tgz", + "integrity": "sha512-g2EyCz4GCX2kivcDECpNLtk8AR7mYYIh6fwWbegUkSIq/oZKQ6Nw5hVcMzCuzdy4Yb8P/FING9NByluD8Q8MtQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + } + } +} diff --git a/conformance/automations-v1-daemon/package.json b/conformance/automations-v1-daemon/package.json new file mode 100644 index 0000000..7d849a3 --- /dev/null +++ b/conformance/automations-v1-daemon/package.json @@ -0,0 +1,9 @@ +{ + "name": "opencoven-automations-v1-daemon-canary", + "private": true, + "description": "Pins the released Coven CLI that scripts/verify-automations-v1-daemon.mjs drives.", + "license": "MIT", + "dependencies": { + "@opencoven/cli": "0.4.7" + } +} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c83f91e..909b37f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -319,6 +319,13 @@ authentication remains explicitly unperformed. lock and the exact-runtime CI reproduction from the pre-release `8a796807` artifact to the producer of the released Coven v0.4.7 bundle: `c93a8a93`, 19 files, contract content `ef266d16`. +[SDK #338](https://github.com/OpenCoven/sdk/pull/338) adds a daemon canary. +CI installs the published `@opencoven/cli@0.4.7` at its locked integrity and +drives its daemon through the built client: draft, revise and lifecycle +commands, adoption replay and refusal, and checkpoint resume across a daemon +restart. It found that v0.4.7's lifecycle events, occurrences, runs and +receipts pin the digest of the legacy routine projection rather than the +definition document's `integrity` (reported on Coven #1054). OpenCoven/coven#991 (`d277ade3`) and OpenCoven/coven#999 (`735e2f05`) publish packaged base capability negotiation, durable `CAPABILITY_UNSUPPORTED` outcomes, and exact wire request diff --git a/package.json b/package.json index 433eb8e..071d98e 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "api:baseline:update": "node ./scripts/update-api-baselines.mjs", "build": "corepack pnpm@10.34.0 --recursive --filter './packages/*' build && corepack pnpm@10.34.0 --recursive --filter './examples/*' build", "canary:automations-v1": "node ./scripts/verify-automations-v1-artifact.mjs", + "canary:automations-v1-daemon": "node ./scripts/verify-automations-v1-daemon.mjs", "clean:public-dist": "node ./scripts/clean-public-package-dist.mjs", "changeset": "changeset", "cleanup:merged": "node ./scripts/cleanup-merged-branch.mjs", diff --git a/scripts/verify-automations-v1-daemon.d.mts b/scripts/verify-automations-v1-daemon.d.mts new file mode 100644 index 0000000..18514ab --- /dev/null +++ b/scripts/verify-automations-v1-daemon.d.mts @@ -0,0 +1,67 @@ +export interface DaemonCanaryArguments { + coven: string; + expectVersion: string; +} + +export interface DaemonCanaryCommandContext { + adoptionKey: string; + intent: string; + principalId: string; +} + +export interface DaemonCanaryEventStream { + kind: 'automation' | 'occurrence' | 'run'; + id: string; +} + +export type DaemonCanaryEventQuery = + | { stream: DaemonCanaryEventStream; after?: number } + | { stream: DaemonCanaryEventStream; checkpoint: string }; + +/** The slice of `CovenAutomationsClient` the scenario drives. */ +export interface DaemonCanaryClient { + capabilities(): Promise; + createDraft(definition: Record, context: DaemonCanaryCommandContext): Promise; + revise( + automationId: string, + expectedRevision: number, + definition: Record, + context: DaemonCanaryCommandContext, + ): Promise; + activate(automationId: string, expectedRevision: number, context: DaemonCanaryCommandContext): Promise; + pause(automationId: string, expectedRevision: number, context: DaemonCanaryCommandContext): Promise; + disable(automationId: string, expectedRevision: number, context: DaemonCanaryCommandContext): Promise; + get(automationId: string): Promise; + list(): Promise; + events(query: DaemonCanaryEventQuery): Promise; + subscribe(query: DaemonCanaryEventQuery): AsyncIterable; + occurrenceHistory(automationId: string, query: { limit: number }): Promise; + runHistory(automationId: string, query: { limit: number }): Promise; +} + +export interface DaemonCanarySummary { + commands: number; + replays: number; + rejections: number; + events: number; + subscribePages: number; + eventDefinitionDigest: 'matches-definition-integrity' | 'differs-from-definition-integrity'; +} + +export function parseDaemonCanaryArguments(argv: string[]): DaemonCanaryArguments; +export function quietScheduleHour(now?: Date): number; +export function canaryDefinition(scheduleHour: number): Record; +export function runDaemonScenario(options: { + sdk: { computeDefinitionDigest(definition: unknown): unknown }; + connect: () => Promise; + restartDaemon: () => Promise; + scheduleHour: number; +}): Promise; +export function verifyCovenVersion(coven: string, expectVersion: string, home: string): string; +export function harnessAssertedSecurity( + discovered: unknown, + home: string, +): { platform: 'unix'; peerIdentity: { inspectConnected(socket: unknown): Promise<{ uid: number }> } }; +export function verifyAutomationsDaemon( + options: DaemonCanaryArguments & { signal?: AbortSignal }, +): Promise; diff --git a/scripts/verify-automations-v1-daemon.mjs b/scripts/verify-automations-v1-daemon.mjs new file mode 100644 index 0000000..fcf1ec6 --- /dev/null +++ b/scripts/verify-automations-v1-daemon.mjs @@ -0,0 +1,525 @@ +import { spawn, spawnSync } from 'node:child_process'; +import { existsSync, lstatSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { + cleanupOwnedTempRoot, + createOwnedTempDirectory, +} from './owned-temp-directory.mjs'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const usage = + 'usage: verify-automations-v1-daemon.mjs --coven --expect-version '; +const versionPattern = /^\d+\.\d+\.\d+$/u; +const timestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/u; +const sha256Pattern = /^[0-9a-f]{64}$/u; +// sockaddr_un.sun_path is 104 bytes on macOS and 108 on Linux, including NUL. +const maxSocketPathBytes = 103; +const readinessTimeoutMs = 30_000; +const shutdownTimeoutMs = 15_000; +const logLimitBytes = 64 * 1024; +const automationId = 'sdk-daemon-canary'; +const principalId = 'principal:sdk-daemon-canary'; +const requiredActions = [ + 'coven.automations.command.v1', + 'coven.automations.definition.create.v1', + 'coven.automations.definition.revise.v1', + 'coven.automations.definition.activate.v1', + 'coven.automations.definition.pause.v1', + 'coven.automations.definition.disable.v1', + 'coven.automations.definition.get.v1', + 'coven.automations.definition.list.v1', + 'coven.automations.events.subscribe.v1', + 'coven.automations.occurrence.history.v1', + 'coven.automations.run.history.v1', +]; + +function fail(message) { + throw new Error(message); +} + +export function parseDaemonCanaryArguments(argv) { + const values = new Map(); + for (let index = 0; index < argv.length; index += 1) { + const flag = argv[index]; + const value = argv[index + 1]; + if (flag === '--') continue; + if ((flag !== '--coven' && flag !== '--expect-version') || values.has(flag) || + value === undefined || value.startsWith('--')) { + fail(usage); + } + values.set(flag, value); + index += 1; + } + const coven = values.get('--coven'); + const expectVersion = values.get('--expect-version'); + if (coven === undefined || expectVersion === undefined || !versionPattern.test(expectVersion)) { + fail(usage); + } + return { coven: resolve(coven), expectVersion }; +} + +/** + * A schedule hour twelve hours away, so the brief activation cannot reach a + * slot and occurrence and run history stay empty. + */ +export function quietScheduleHour(now = new Date()) { + return (now.getUTCHours() + 12) % 24; +} + +export function canaryDefinition(scheduleHour) { + return { + schemaVersion: 'coven.automations.v1', + automationId, + display: { name: 'SDK daemon canary', tags: ['canary'] }, + trigger: { + variant: 'schedule', version: 1, + schedule: { rrule: `FREQ=DAILY;BYHOUR=${scheduleHour}`, timezone: 'utc' }, + }, + action: { variant: 'familiarInvocation', version: 1, prompt: 'Report that the canary ran.' }, + binding: { + familiarBindingPolicy: 'exact', familiarId: 'canary', + authority: { approvalPolicyRef: 'policy://authority/familiars/canary' }, + }, + runtimeRequirements: { runtimeId: 'coven-code', capabilities: ['sessions.launch'] }, + policies: { + timeout: { perRunMinutes: 5 }, + retry: { maxAttempts: 1, backoffPolicy: 'none' }, + concurrency: { overlap: 'forbid' }, + misfire: { disposition: 'latest' }, + retention: { occurrenceHistory: { classification: 'standard' } }, + }, + }; +} + +function context(adoptionKey) { + return { adoptionKey, intent: 'Exercise the released daemon from the SDK canary.', principalId }; +} + +function expectCommitted(label, result, command, revision) { + if (result?.outcome !== 'committed' || result.command !== command || result.revision !== revision) { + fail(`${label}: expected ${command} committed at revision ${revision}, received ${describe(result)}.`); + } +} + +function expectReplayed(label, result, command, revision) { + if (result?.outcome !== 'replayed' || result.command !== command || result.revision !== revision || + !timestampPattern.test(result.replay?.firstCommittedAt ?? '')) { + fail(`${label}: expected ${command} replayed at revision ${revision}, received ${describe(result)}.`); + } +} + +function expectRejected(label, result, code, extra = {}) { + if (result?.outcome !== 'rejected' || result.error?.code !== code || result.error.retryable !== false || + Object.entries(extra).some(([key, value]) => result.error[key] !== value)) { + fail(`${label}: expected rejection ${code}, received ${describe(result)}.`); + } +} + +function describe(value) { + try { + return JSON.stringify(value, (key, entry) => (key === 'definition' ? '[definition]' : entry)).slice(0, 400); + } catch { + return String(value); + } +} + +function expectSequences(label, events, expected) { + const actual = events.map((event) => `${event.sequence}:${event.kind}`); + if (actual.join(',') !== expected.join(',')) { + fail(`${label}: expected events [${expected.join(', ')}], received [${actual.join(', ')}].`); + } +} + +const lifecycleKinds = [ + '0:definition.created', + '1:definition.revised', + '2:definition.activated', + '3:definition.paused', + '4:definition.disabled', +]; + +/** + * Drives one isolated daemon through the SDK's public client. `connect` + * returns a client for the daemon as it currently runs; `restartDaemon` stops + * it cleanly and starts it again over the same COVEN_HOME. + */ +export async function runDaemonScenario({ sdk, connect, restartDaemon, scheduleHour }) { + let client = await connect(); + const capabilities = await client.capabilities(); + if (capabilities?.status !== 'available' || !Array.isArray(capabilities.actions)) { + fail(`capabilities: expected coven.automations available, received ${describe(capabilities)}.`); + } + const missing = requiredActions.filter((action) => !capabilities.actions.includes(action)); + if (missing.length > 0) fail(`capabilities: missing ${missing.join(', ')}.`); + + const draft = canaryDefinition(scheduleHour); + const created = await client.createDraft(draft, context('canary:create')); + expectCommitted('createDraft', created, 'definition.create.v1', 1); + const stored = created.result?.definition; + const recomputed = sdk.computeDefinitionDigest(stored); + if (stored?.revision !== 1 || stored.lifecycleState !== 'draft' || recomputed.status !== 'computed' || + recomputed.digest.value !== stored.integrity?.value) { + fail(`createDraft: the stored definition's integrity does not match the SDK digest, received ${describe(stored?.integrity)}.`); + } + expectReplayed('createDraft replay', await client.createDraft(draft, context('canary:create')), + 'definition.create.v1', 1); + expectRejected('createDraft mismatch', await client.createDraft( + { ...draft, display: { name: 'Changed body', tags: ['canary'] } }, context('canary:create'), + ), 'ADOPTION_REPLAY_MISMATCH'); + + const stream = { kind: 'automation', id: automationId }; + const beforeRevise = await client.events({ stream }); + expectSequences('events from the start', beforeRevise.events, lifecycleKinds.slice(0, 1)); + const createdDigest = beforeRevise.events[0].payload.definitionDigest?.value; + if (!sha256Pattern.test(createdDigest ?? '') || + beforeRevise.events[0].causation?.adoptionKey !== 'canary:create') { + fail(`events: definition.created lacks a digest or its adoption key, received ${describe(beforeRevise.events[0])}.`); + } + + const revision = { ...draft, lifecycleState: 'paused', display: { name: 'SDK daemon canary', tags: ['canary', 'revised'] } }; + expectCommitted('revise', await client.revise(automationId, 1, revision, context('canary:revise')), + 'definition.revise.v1', 2); + expectRejected('stale revise', await client.revise( + automationId, 1, { ...revision, display: { name: 'Stale', tags: ['canary'] } }, context('canary:revise-stale'), + ), 'REVISION_CONFLICT', { currentRevision: 2 }); + expectCommitted('activate', await client.activate(automationId, 2, context('canary:activate')), + 'definition.activate.v1', 3); + expectCommitted('pause', await client.pause(automationId, 3, context('canary:pause')), + 'definition.pause.v1', 4); + + const read = await client.get(automationId); + if (read?.revision !== 4 || read.routine?.status !== 'PAUSED' || !read.routine.tags?.includes('revised')) { + fail(`get: expected the paused revision 4 with the revised tags, received ${describe(read)}.`); + } + + await restartDaemon(); + client = await connect(); + + expectReplayed('activate replay after restart', await client.activate(automationId, 2, context('canary:activate')), + 'definition.activate.v1', 3); + expectCommitted('disable', await client.disable(automationId, 4, context('canary:disable')), + 'definition.disable.v1', 5); + + const resumed = []; + let pages = 0; + let last; + for await (const page of client.subscribe({ stream, checkpoint: beforeRevise.checkpoint })) { + pages += 1; + if (pages === 1 && page.after !== 0) { + fail(`subscribe: the checkpoint resumed after ${page.after}, expected 0.`); + } + resumed.push(...page.events); + last = page; + if (pages > 10) fail('subscribe: no end of stream after 10 pages.'); + } + expectSequences('subscribe from the pre-restart checkpoint', resumed, lifecycleKinds.slice(1)); + // The iterator yields one empty page so its checkpoint can be saved, then ends. + if (last?.events?.length !== 0 || last.after !== 4 || last.nextAfter !== 4 || + typeof last.checkpoint !== 'string' || last.checkpoint.length === 0) { + fail(`subscribe: expected a final empty page after sequence 4 with a checkpoint, received ${ + describe(last === undefined ? undefined : { ...last, events: last.events?.length })}.`); + } + const cursor = await client.events({ stream, after: 2 }); + expectSequences('events after sequence 2', cursor.events, lifecycleKinds.slice(3)); + + const listed = await client.list(); + if (listed?.revisionById?.[automationId] !== 5) { + fail(`list: expected revision 5, received ${describe(listed?.revisionById)}.`); + } + const occurrences = await client.occurrenceHistory(automationId, { limit: 5 }); + const runs = await client.runHistory(automationId, { limit: 5 }); + for (const [label, page] of [['occurrenceHistory', occurrences], ['runHistory', runs]]) { + if (page?.automationId !== automationId || page.data?.length !== 0 || page.cursor?.hasMore !== false) { + fail(`${label}: expected an empty final page, received ${describe(page)}.`); + } + } + + return { + commands: 9, + replays: 2, + rejections: 2, + events: lifecycleKinds.length, + subscribePages: pages, + eventDefinitionDigest: createdDigest === stored.integrity.value ? 'matches-definition-integrity' + : 'differs-from-definition-integrity', + }; +} + +function daemonCommand(coven) { + return /\.[cm]?js$/u.test(coven) ? [process.execPath, [coven]] : [coven, []]; +} + +function daemonEnvironment(home) { + return { + HOME: home, + COVEN_HOME: home, + PATH: [dirname(process.execPath), '/usr/bin', '/bin'].join(':'), + NO_COLOR: '1', + }; +} + +export function verifyCovenVersion(coven, expectVersion, home) { + const [command, prefix] = daemonCommand(coven); + const result = spawnSync(command, [...prefix, '--version'], { + env: daemonEnvironment(home), encoding: 'utf8', timeout: 30_000, + }); + const reported = result.stdout?.trim() ?? ''; + if (result.status !== 0 || !reported.startsWith(`coven v${expectVersion} `)) { + fail(`Expected coven v${expectVersion}, ${coven} reported "${reported || result.stderr?.trim() || result.error?.message}".`); + } + return reported; +} + +function delay(ms) { + return new Promise((resolveDelay) => setTimeout(resolveDelay, ms)); +} + +/** Resolves with the exit, or undefined after `ms`, without holding the event loop open. */ +function exitWithin(daemon, ms) { + let timer; + return Promise.race([ + daemon.exited, + new Promise((resolveTimeout) => { timer = setTimeout(resolveTimeout, ms); }), + ]).finally(() => clearTimeout(timer)); +} + +function isSocket(path) { + try { + return lstatSync(path).isSocket(); + } catch { + return false; + } +} + +function startDaemon(coven, home, log) { + const [command, prefix] = daemonCommand(coven); + const child = spawn(command, [...prefix, 'daemon', 'serve'], { + env: daemonEnvironment(home), stdio: ['ignore', 'pipe', 'pipe'], + }); + const exited = new Promise((resolveExit) => { + child.once('exit', (code, signal) => resolveExit({ code, signal })); + child.once('error', (error) => resolveExit({ code: null, signal: null, error })); + }); + const capture = (chunk) => { + if (log.bytes < logLimitBytes) { + log.chunks.push(chunk); + log.bytes += chunk.length; + } + }; + child.stdout.on('data', capture); + child.stderr.on('data', capture); + return { child, exited }; +} + +async function waitForDaemon(daemon, home, signal) { + const deadline = Date.now() + readinessTimeoutMs; + let exit; + void daemon.exited.then((value) => { exit = value; }); + while (Date.now() < deadline) { + if (signal?.aborted === true) fail('Interrupted while the daemon was starting.'); + if (exit !== undefined) { + fail(`coven daemon serve exited before it was ready (${exit.error?.message ?? `code ${exit.code}, signal ${exit.signal}`}).`); + } + if (existsSync(resolve(home, 'daemon.json')) && isSocket(resolve(home, 'coven.sock'))) return; + await delay(100); + } + fail(`coven daemon serve was not ready within ${readinessTimeoutMs} ms.`); +} + +async function stopDaemon(daemon, home) { + daemon.child.kill('SIGTERM'); + const exit = await exitWithin(daemon, shutdownTimeoutMs); + if (exit === undefined) { + fail(`coven daemon serve did not stop within ${shutdownTimeoutMs} ms of SIGTERM.`); + } + if (existsSync(resolve(home, 'daemon.json')) || existsSync(resolve(home, 'coven.sock'))) { + fail('coven daemon serve stopped without removing daemon.json and coven.sock.'); + } +} + +/** + * Last-resort cleanup after a failure. The npm wrapper forwards SIGTERM but + * cannot forward SIGKILL, so the native daemon recorded in this private home + * is killed by its own pid. + */ +async function killDaemon(daemon, home) { + let pid; + try { + pid = JSON.parse(readFileSync(resolve(home, 'daemon.json'), 'utf8')).pid; + } catch { + pid = undefined; + } + daemon.child.kill('SIGTERM'); + const exit = await exitWithin(daemon, shutdownTimeoutMs); + if (exit === undefined) { + daemon.child.kill('SIGKILL'); + await daemon.exited; + } + if (Number.isSafeInteger(pid) && pid > 0 && pid !== daemon.child.pid) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // Already gone. + } + } +} + +/** + * The canary launched this daemon under its own uid in a 0700 home it owns, + * so it asserts that uid for the connected peer instead of inspecting the + * socket. Node exposes no peer-credential API; production callers must + * supply a reviewed provider that does inspect the peer. + */ +export function harnessAssertedSecurity(discovered, home) { + const uid = process.getuid(); + const socketPath = resolve(home, 'coven.sock'); + const homeStats = lstatSync(home); + const socketStats = lstatSync(socketPath); + if (discovered?.endpoint?.kind !== 'unix' || discovered.endpoint.path !== socketPath) { + fail(`Discovery returned ${describe(discovered?.endpoint)}, expected the canary's socket ${socketPath}.`); + } + if (discovered.owner?.uid !== uid || socketStats.uid !== uid || homeStats.uid !== uid) { + fail('The daemon home, socket, or discovered owner is not owned by the canary user.'); + } + if (!homeStats.isDirectory() || (homeStats.mode & 0o077) !== 0 || !socketStats.isSocket()) { + fail('The daemon home must be a private directory holding a Unix socket.'); + } + return { platform: 'unix', peerIdentity: { inspectConnected: () => Promise.resolve({ uid }) } }; +} + +async function loadSdk() { + const entry = resolve(root, 'packages/coven/dist/index.js'); + if (!existsSync(entry)) { + fail(`${entry} is missing; run corepack pnpm@10.34.0 build first.`); + } + return import(pathToFileURL(entry).href); +} + +/** + * Rejects once `signal` aborts. Every step races against it, so an interrupted + * canary reaches the single cleanup path in `verifyAutomationsDaemon` instead + * of waiting for an in-flight request or a restart to finish. + */ +function interruption(signal) { + const interrupted = new Promise((_resolve, reject) => { + if (signal === undefined) return; + const abort = () => reject(new Error(`Interrupted by ${String(signal.reason)}.`)); + if (signal.aborted) abort(); + else signal.addEventListener('abort', abort, { once: true }); + }); + return (promise) => { + // The losing step still settles later; nothing may observe it as unhandled. + promise.catch(() => {}); + return Promise.race([promise, interrupted]); + }; +} + +export async function verifyAutomationsDaemon({ coven, expectVersion, signal }) { + if (process.platform === 'win32') fail('The daemon canary drives the Unix socket transport only.'); + if (!existsSync(coven)) fail(`${coven} does not exist.`); + const sdk = await loadSdk(); + const temp = createOwnedTempDirectory({ prefix: 'cvn', childSegments: ['h'] }); + const home = temp.path; + const log = { chunks: [], bytes: 0 }; + const guarded = interruption(signal); + let daemon; + try { + if (Buffer.byteLength(resolve(home, 'coven.sock')) > maxSocketPathBytes) { + fail(`The socket path under ${home} exceeds ${maxSocketPathBytes} bytes; set TMPDIR to a shorter directory.`); + } + verifyCovenVersion(coven, expectVersion, home); + let starts = 0; + let previousPid; + const start = async () => { + // A restart already under way must not outlive an interruption. + if (signal?.aborted === true) fail('Interrupted; the daemon was not restarted.'); + daemon = startDaemon(coven, home, log); + starts += 1; + await waitForDaemon(daemon, home, signal); + }; + await guarded(start()); + const connect = async () => { + const discovered = await sdk.discoverCovenEndpoint({ env: { COVEN_HOME: home } }); + if (discovered.freshness?.daemonPid === previousPid) { + fail('Discovery after the restart returned the previous daemon instance.'); + } + previousPid = discovered.freshness?.daemonPid; + const transport = sdk.createCovenAutomationsUnixTransport(discovered, { + security: harnessAssertedSecurity(discovered, home), + }); + return sdk.createCovenAutomationsClient({ transport }); + }; + const restartDaemon = async () => { + await stopDaemon(daemon, home); + daemon = undefined; + await start(); + }; + const result = await guarded(runDaemonScenario({ + sdk, connect, restartDaemon, scheduleHour: quietScheduleHour(), + })); + await guarded(stopDaemon(daemon, home)); + daemon = undefined; + return { ...result, covenVersion: expectVersion, daemonStarts: starts }; + } catch (error) { + const output = Buffer.concat(log.chunks).toString('utf8').trim(); + if (output.length > 0 && error instanceof Error) { + error.message += `\n--- coven daemon serve output ---\n${output}`; + } + throw error; + } finally { + if (daemon !== undefined) await killDaemon(daemon, home); + cleanupOwnedTempRoot(temp); + } +} + +const exitSignals = { SIGINT: 2, SIGTERM: 15 }; + +async function main(signal) { + const result = await verifyAutomationsDaemon({ ...parseDaemonCanaryArguments(process.argv.slice(2)), signal }); + process.stdout.write( + [ + 'Automations v1 daemon verified:', + `covenVersion=${result.covenVersion}`, + `daemonStarts=${result.daemonStarts}`, + `commands=${result.commands}`, + `replays=${result.replays}`, + `rejections=${result.rejections}`, + `events=${result.events}`, + `subscribePages=${result.subscribePages}`, + 'definitionIntegrity=matches-sdk', + 'adoptionReplayAcrossRestart=passed', + 'checkpointResumeAcrossRestart=passed', + `eventDefinitionDigest=${result.eventDefinitionDigest}`, + 'peerIdentity=harness-asserted', + ].join(' ') + '\n', + ); +} + +if ( + process.argv[1] !== undefined && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + // Without handlers Node exits on these signals before any cleanup runs. + const controller = new AbortController(); + for (const name of Object.keys(exitSignals)) { + process.on(name, () => { + if (!controller.signal.aborted) controller.abort(name); + }); + } + try { + await main(controller.signal); + } catch (error) { + process.stderr.write( + `Automations v1 daemon canary failed: ${ + error instanceof Error ? error.message : String(error) + }\n`, + ); + process.exitCode = 1; + } + if (controller.signal.aborted) { + // Cleanup is done; do not wait out the interrupted request's own timeout. + process.exit(128 + exitSignals[controller.signal.reason]); + } +} diff --git a/tests/automations-v1-daemon-canary.spec.ts b/tests/automations-v1-daemon-canary.spec.ts new file mode 100644 index 0000000..1606f12 --- /dev/null +++ b/tests/automations-v1-daemon-canary.spec.ts @@ -0,0 +1,438 @@ +import { spawn, spawnSync } from 'node:child_process'; +import { chmodSync, existsSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { createServer, type Server } from 'node:net'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { computeDefinitionDigest } from '@opencoven/coven-client'; +import { afterEach, describe, expect, test } from 'vitest'; + +import { + canaryDefinition, + harnessAssertedSecurity, + parseDaemonCanaryArguments, + quietScheduleHour, + runDaemonScenario, + verifyCovenVersion, +} from '../scripts/verify-automations-v1-daemon.mjs'; +import type { + DaemonCanaryClient, + DaemonCanaryCommandContext, + DaemonCanaryEventQuery, +} from '../scripts/verify-automations-v1-daemon.mjs'; + +const root = resolve(fileURLToPath(new URL('..', import.meta.url))); +const scriptPath = resolve(root, 'scripts/verify-automations-v1-daemon.mjs'); +const scratchRoots: string[] = []; +const servers: Server[] = []; +const uid = process.getuid?.() ?? -1; +const actions = [ + 'coven.automations.command.v1', + 'coven.automations.definition.create.v1', + 'coven.automations.definition.revise.v1', + 'coven.automations.definition.activate.v1', + 'coven.automations.definition.pause.v1', + 'coven.automations.definition.disable.v1', + 'coven.automations.definition.get.v1', + 'coven.automations.definition.list.v1', + 'coven.automations.events.subscribe.v1', + 'coven.automations.occurrence.history.v1', + 'coven.automations.run.history.v1', +]; + +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => new Promise((done) => server.close(done)))); + for (const path of scratchRoots.splice(0)) rmSync(path, { recursive: true, force: true }); +}); + +function scratch(): string { + const path = mkdtempSync(resolve(realpathSync(tmpdir()), 'cvs-')); + chmodSync(path, 0o700); + scratchRoots.push(path); + return path; +} + +interface Flaws { + replayCommits?: boolean; + acceptsMismatch?: boolean; + acceptsStale?: boolean; + integrityDrift?: boolean; + forgetsAdoptionsOnRestart?: boolean; + checkpointRewinds?: boolean; + subscribeOmitsFinalPage?: boolean; + missingAction?: string; + historyNonEmpty?: boolean; + eventDigestMatches?: boolean; +} + +interface FakeEvent { + sequence: number; + kind: string; + causation: { adoptionKey: string }; + payload: { revision: number; definitionDigest: { algorithm: string; canonicalization: string; value: string } }; +} + +type Result = Record; + +/** An in-memory stand-in for the daemon's command, event, and read semantics. */ +function fakeDaemon(flaws: Flaws = {}) { + const automationId = 'sdk-daemon-canary'; + const stream = { kind: 'automation', id: automationId }; + let revision = 0; + let status = 'DRAFT'; + let tags: unknown = []; + let integrity = ''; + let adoptions = new Map(); + const events: FakeEvent[] = []; + + const record = (kind: string, adoptionKey: string) => { + events.push({ + sequence: events.length, kind, causation: { adoptionKey }, + payload: { + revision, + definitionDigest: { + algorithm: 'sha256', canonicalization: 'jcs-rfc8785', + value: flaws.eventDigestMatches === true ? integrity : 'a'.repeat(64), + }, + }, + }); + }; + const rejected = (command: string, adoptionKey: string, error: Result): Result => + ({ outcome: 'rejected', command, adoptionKey, error: { retryable: false, ...error } }); + const command = (name: string, context: DaemonCanaryCommandContext, fingerprint: string, apply: () => Result) => { + const prior = adoptions.get(context.adoptionKey); + if (prior !== undefined) { + if (prior.fingerprint !== fingerprint) { + return Promise.resolve(flaws.acceptsMismatch === true ? prior.result + : rejected(name, context.adoptionKey, { code: 'ADOPTION_REPLAY_MISMATCH' })); + } + return Promise.resolve(flaws.replayCommits === true ? prior.result + : { ...prior.result, outcome: 'replayed', replay: { firstCommittedAt: '2026-10-03T06:00:00.000Z' } }); + } + const result = apply(); + if (result.outcome === 'committed') adoptions.set(context.adoptionKey, { fingerprint, result }); + return Promise.resolve(result); + }; + const transition = ( + name: string, kind: string, expected: number, context: DaemonCanaryCommandContext, next: string, + ) => command(name, context, `${name}:${expected}`, () => { + if (expected !== revision && flaws.acceptsStale !== true) { + return rejected(name, context.adoptionKey, { code: 'REVISION_CONFLICT', currentRevision: revision }); + } + revision += 1; + status = next; + record(kind, context.adoptionKey); + return { + outcome: 'committed', command: name, adoptionKey: context.adoptionKey, revision, + result: { id: automationId, revision, status }, + }; + }); + const page = (after: number | null) => { + const delivered = events.filter((event) => after === null || event.sequence > after); + const last = delivered.at(-1)?.sequence ?? after; + return { + stream, after, events: delivered, nextAfter: last, + checkpoint: `cp:${last ?? -1}`, checkpointExpiresAt: '2026-10-04T06:00:00.000Z', + }; + }; + const read = (query: DaemonCanaryEventQuery) => { + if ('checkpoint' in query) { + const cursor = Number(query.checkpoint.slice(3)); + return page(flaws.checkpointRewinds === true || cursor < 0 ? null : cursor); + } + return page(query.after ?? null); + }; + const history = { automationId, data: flaws.historyNonEmpty === true ? [{}] : [], cursor: { hasMore: false } }; + + const client: DaemonCanaryClient = { + capabilities: () => Promise.resolve({ + status: 'available', actions: actions.filter((action) => action !== flaws.missingAction), + }), + createDraft: (definition, context) => command('definition.create.v1', context, JSON.stringify(definition), () => { + revision = 1; + tags = (definition.display as { tags: unknown }).tags; + const stored = { ...definition, revision: 1, lifecycleState: 'draft' }; + // The digest recipe removes `integrity`, but a document must carry one. + const digest = computeDefinitionDigest({ + ...stored, integrity: { algorithm: 'sha256', canonicalization: 'jcs-rfc8785', value: '0'.repeat(64) }, + }); + integrity = digest.status === 'computed' ? digest.digest.value : ''; + record('definition.created', context.adoptionKey); + return { + outcome: 'committed', command: 'definition.create.v1', adoptionKey: context.adoptionKey, revision, + result: { + revision, + definition: { + ...stored, + integrity: { + algorithm: 'sha256', canonicalization: 'jcs-rfc8785', + value: flaws.integrityDrift === true ? 'b'.repeat(64) : integrity, + }, + }, + }, + }; + }), + revise: (_id, expected, definition, context) => + command('definition.revise.v1', context, `${expected}:${JSON.stringify(definition)}`, () => { + if (expected !== revision && flaws.acceptsStale !== true) { + return rejected('definition.revise.v1', context.adoptionKey, { code: 'REVISION_CONFLICT', currentRevision: revision }); + } + revision += 1; + status = 'PAUSED'; + tags = (definition.display as { tags: unknown }).tags; + record('definition.revised', context.adoptionKey); + return { outcome: 'committed', command: 'definition.revise.v1', adoptionKey: context.adoptionKey, revision }; + }), + activate: (_id, expected, context) => + transition('definition.activate.v1', 'definition.activated', expected, context, 'ACTIVE'), + pause: (_id, expected, context) => + transition('definition.pause.v1', 'definition.paused', expected, context, 'PAUSED'), + disable: (_id, expected, context) => + transition('definition.disable.v1', 'definition.disabled', expected, context, 'DISABLED'), + get: () => Promise.resolve({ routine: { id: automationId, status, tags }, revision, tombstonedAt: null }), + list: () => Promise.resolve({ routines: [], revisionById: { [automationId]: revision }, tombstonedAtById: {} }), + events: (query) => Promise.resolve(read(query)), + subscribe: (query) => ({ + [Symbol.asyncIterator]() { + let next: DaemonCanaryEventQuery | undefined = query; + return { + next() { + if (next === undefined) return Promise.resolve({ done: true as const, value: undefined }); + const current = read(next); + if (current.events.length === 0 && flaws.subscribeOmitsFinalPage === true) { + return Promise.resolve({ done: true as const, value: undefined }); + } + next = current.events.length === 0 ? undefined : { stream: query.stream, checkpoint: current.checkpoint }; + return Promise.resolve({ done: false as const, value: current }); + }, + }; + }, + }), + occurrenceHistory: () => Promise.resolve(history), + runHistory: () => Promise.resolve(history), + }; + const restartDaemon = () => { + if (flaws.forgetsAdoptionsOnRestart === true) adoptions = new Map(); + return Promise.resolve(); + }; + return { connect: () => Promise.resolve(client), restartDaemon }; +} + +function scenario(flaws: Flaws = {}) { + return runDaemonScenario({ sdk: { computeDefinitionDigest }, scheduleHour: 21, ...fakeDaemon(flaws) }); +} + +describe('daemon canary pin', () => { + test('installs exactly the CLI version that CI expects, from the npm registry with integrity', () => { + const directory = resolve(root, 'conformance/automations-v1-daemon'); + const manifest = JSON.parse(readFileSync(resolve(directory, 'package.json'), 'utf8')) as { + dependencies: Record; + }; + const lock = JSON.parse(readFileSync(resolve(directory, 'package-lock.json'), 'utf8')) as { + lockfileVersion: number; + packages: Record; + }; + const workflow = readFileSync(resolve(root, '.github/workflows/ci.yml'), 'utf8'); + const version = manifest.dependencies['@opencoven/cli']; + expect(Object.keys(manifest.dependencies)).toEqual(['@opencoven/cli']); + expect(version).toMatch(/^\d+\.\d+\.\d+$/u); + expect(lock.lockfileVersion).toBe(3); + const installed = Object.entries(lock.packages).filter(([path]) => path !== ''); + expect(installed.map(([path]) => path).sort()).toEqual([ + 'node_modules/@opencoven/cli', + 'node_modules/@opencoven/cli-linux-x64', + 'node_modules/@opencoven/cli-macos', + 'node_modules/@opencoven/cli-macos-x64', + 'node_modules/@opencoven/cli-windows', + ]); + for (const [path, entry] of installed) { + expect(entry.version).toBe(version); + expect(entry.resolved).toBe(`https://registry.npmjs.org/${path.slice('node_modules/'.length)}/-/${path.split('/').at(-1)}-${version}.tgz`); + expect(entry.integrity).toMatch(/^sha512-[A-Za-z0-9+/]{86}==$/u); + } + expect(workflow).toContain(`- name: Drive the released Coven v${version} daemon through the SDK`); + expect(workflow).toContain(`--expect-version ${version}\n`); + expect(workflow).toContain('npm ci --prefix "$COVEN_CLI_DIR" --ignore-scripts --no-audit --no-fund'); + expect(workflow).toContain('npm audit signatures --prefix "$COVEN_CLI_DIR"'); + }); +}); + +describe('daemon canary arguments', () => { + test('accepts a coven path and exact version, with or without the pnpm separator', () => { + expect(parseDaemonCanaryArguments(['--', '--coven', 'bin/coven.js', '--expect-version', '0.4.7'])).toEqual({ + coven: resolve('bin/coven.js'), expectVersion: '0.4.7', + }); + }); + + test.each([ + [[]], + [['--coven', 'coven']], + [['--coven', 'coven', '--expect-version', 'v0.4.7']], + [['--coven', 'coven', '--coven', 'other', '--expect-version', '0.4.7']], + [['--coven', '--expect-version', '0.4.7']], + [['--coven', 'coven', '--expect-version', '0.4.7', '--archive', 'x']], + ])('refuses %j', (argv) => { + expect(() => parseDaemonCanaryArguments(argv)).toThrow(/^usage:/u); + }); + + test('schedules the routine twelve hours from the activation hour', () => { + expect(quietScheduleHour(new Date('2026-10-03T00:30:00Z'))).toBe(12); + expect(quietScheduleHour(new Date('2026-10-03T13:59:00Z'))).toBe(1); + expect(canaryDefinition(1)).toMatchObject({ trigger: { schedule: { rrule: 'FREQ=DAILY;BYHOUR=1' } } }); + }); +}); + +describe('daemon canary scenario', () => { + test('passes against a daemon with the released command and replay semantics', async () => { + await expect(scenario()).resolves.toEqual({ + commands: 9, replays: 2, rejections: 2, events: 5, subscribePages: 2, + eventDefinitionDigest: 'differs-from-definition-integrity', + }); + await expect(scenario({ eventDigestMatches: true })).resolves.toMatchObject({ + eventDefinitionDigest: 'matches-definition-integrity', + }); + }); + + test.each<[string, Flaws, RegExp]>([ + ['a missing advertised action', { missingAction: 'coven.automations.run.history.v1' }, /capabilities: missing coven\.automations\.run\.history\.v1/u], + ['a stored digest the SDK cannot reproduce', { integrityDrift: true }, /integrity does not match the SDK digest/u], + ['a replay that commits again', { replayCommits: true }, /createDraft replay: expected definition\.create\.v1 replayed/u], + ['a changed body accepted under a used key', { acceptsMismatch: true }, /createDraft mismatch: expected rejection ADOPTION_REPLAY_MISMATCH/u], + ['a stale revision accepted', { acceptsStale: true }, /stale revise: expected rejection REVISION_CONFLICT/u], + ['adoption records lost on restart', { forgetsAdoptionsOnRestart: true }, /activate replay after restart/u], + ['a checkpoint that rewinds to the start', { checkpointRewinds: true }, /subscribe: the checkpoint resumed after null, expected 0/u], + ['a subscription that ends without its empty page', { subscribeOmitsFinalPage: true }, /subscribe: expected a final empty page after sequence 4 with a checkpoint/u], + ['history for a routine that never fired', { historyNonEmpty: true }, /occurrenceHistory: expected an empty final page/u], + ])('fails closed on %s', async (_label, flaws, message) => { + await expect(scenario(flaws)).rejects.toThrow(message); + }); +}); + +describe.skipIf(process.platform === 'win32')('harness-asserted peer identity', () => { + async function listening(home: string) { + const server = createServer(); + servers.push(server); + await new Promise((ready) => server.listen(resolve(home, 'coven.sock'), ready)); + } + + function discovered(home: string, owner = uid) { + return { endpoint: { kind: 'unix', path: resolve(home, 'coven.sock') }, owner: { kind: 'unix', uid: owner } }; + } + + test('asserts the canary uid only for its own private socket', async () => { + const home = scratch(); + await listening(home); + const security = harnessAssertedSecurity(discovered(home), home); + expect(security.platform).toBe('unix'); + await expect(security.peerIdentity.inspectConnected({})).resolves.toEqual({ uid }); + }); + + test('refuses another endpoint, another owner, or a shared home', async () => { + const home = scratch(); + await listening(home); + expect(() => harnessAssertedSecurity( + { ...discovered(home), endpoint: { kind: 'unix', path: resolve(home, 'other.sock') } }, home, + )).toThrow(/expected the canary's socket/u); + expect(() => harnessAssertedSecurity(discovered(home, uid + 1), home)).toThrow(/not owned by the canary user/u); + chmodSync(home, 0o755); + expect(() => harnessAssertedSecurity(discovered(home), home)).toThrow(/private directory/u); + }); +}); + +describe.skipIf(process.platform === 'win32')('daemon canary process handling', () => { + function fakeCoven(directory: string, mode: 'serve' | 'hang' | 'exit') { + writeFileSync(resolve(directory, 'mode'), mode); + const path = resolve(directory, 'coven.mjs'); + writeFileSync(path, ` +import { createServer } from 'node:http'; +import { readFileSync, rmSync, writeFileSync } from 'node:fs'; +const [command] = process.argv.slice(2); +if (command === '--version') { + process.stdout.write('coven v0.4.7 (fake)\\n'); + process.exit(0); +} +const mode = readFileSync(new URL('./mode', import.meta.url), 'utf8'); +if (mode === 'exit') { + process.stderr.write('fake daemon refused to start\\n'); + process.exit(3); +} +const home = process.env.COVEN_HOME; +const socket = home + '/coven.sock'; +writeFileSync(new URL('./pid', import.meta.url), String(process.pid)); +const body = JSON.stringify({ capabilities: [] }); +const server = createServer((request, response) => { + if (mode === 'hang') return; + response.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }); + response.end(body); +}); +server.listen(socket, () => { + writeFileSync(home + '/daemon.json', JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString(), socket })); + process.stderr.write('fake daemon listening\\n'); +}); +process.on('SIGTERM', () => { + server.close(); + rmSync(socket, { force: true }); + rmSync(home + '/daemon.json', { force: true }); + process.exit(0); +}); +`); + return path; + } + + function runCanary(coven: string, temp: string) { + return spawnSync(process.execPath, [scriptPath, '--coven', coven, '--expect-version', '0.4.7'], { + env: { ...process.env, TMPDIR: temp }, encoding: 'utf8', timeout: 60_000, + }); + } + + test('reports the version a binary prints and refuses any other', () => { + const directory = scratch(); + const coven = fakeCoven(directory, 'serve'); + expect(verifyCovenVersion(coven, '0.4.7', directory)).toBe('coven v0.4.7 (fake)'); + expect(() => verifyCovenVersion(coven, '0.4.6', directory)).toThrow(/Expected coven v0\.4\.6, .* reported "coven v0\.4\.7 \(fake\)"/u); + }); + + test('stops the daemon and removes its home when the scenario fails', () => { + const directory = scratch(); + const temp = scratch(); + const result = runCanary(fakeCoven(directory, 'serve'), temp); + expect(result.status).toBe(1); + expect(result.stderr).toContain('capabilities: expected coven.automations available'); + expect(result.stderr).toContain('--- coven daemon serve output ---\nfake daemon listening'); + const pid = Number(readFileSync(resolve(directory, 'pid'), 'utf8')); + expect(() => process.kill(pid, 0)).toThrow(/ESRCH/u); + expect(readdirSync(temp)).toEqual([]); + }); + + test.each([['SIGTERM', 143], ['SIGINT', 130]] as const)( + 'stops the daemon and removes its home when %s interrupts a request', async (signal, code) => { + const directory = scratch(); + const temp = scratch(); + const canary = spawn(process.execPath, [scriptPath, '--coven', fakeCoven(directory, 'hang'), '--expect-version', '0.4.7'], { + env: { ...process.env, TMPDIR: temp }, stdio: ['ignore', 'ignore', 'pipe'], + }); + let stderr = ''; + canary.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString(); }); + const exited = new Promise((done) => canary.once('exit', (status) => done(status))); + const pidPath = resolve(directory, 'pid'); + const deadline = Date.now() + 30_000; + while (!existsSync(pidPath) && Date.now() < deadline) await new Promise((wait) => setTimeout(wait, 50)); + const pid = Number(readFileSync(pidPath, 'utf8')); + canary.kill(signal); + expect(await exited).toBe(code); + expect(stderr).toContain(`Interrupted by ${signal}.`); + expect(() => process.kill(pid, 0)).toThrow(/ESRCH/u); + expect(readdirSync(temp)).toEqual([]); + }, + ); + + test('reports a daemon that exits before it is ready', () => { + const temp = scratch(); + const result = runCanary(fakeCoven(scratch(), 'exit'), temp); + expect(result.status).toBe(1); + expect(result.stderr).toContain('coven daemon serve exited before it was ready (code 3, signal null)'); + expect(result.stderr).toContain('fake daemon refused to start'); + expect(readdirSync(temp)).toEqual([]); + }); +});