From 5618ca1fc52174b3f27b810b86fef2d4bf11bf0b Mon Sep 17 00:00:00 2001 From: William Valentin Date: Thu, 26 Feb 2026 19:05:41 -0800 Subject: [PATCH] feat(companion): add optional signing for release bundle artifacts --- README.md | 1 + docs/api/PROTOCOL.md | 4 +- docs/architecture/AGENT_DIAGRAM.md | 1 + .../GATEWAY_SESSIONS_AND_QUEUE.md | 1 + docs/operations/COMPANION_RELEASE_BUNDLE.md | 17 ++++++ ...-personal-assistant-productization-plan.md | 2 +- docs/plans/state.json | 26 +++++++- src/cli/companion.test.ts | 61 ++++++++++++++++++- src/cli/companion.ts | 19 +++++- src/companion/releaseBundle.test.ts | 50 +++++++++++++++ src/companion/releaseBundle.ts | 24 +++++++- 11 files changed, 196 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 17dcfed..ed1d804 100644 --- a/README.md +++ b/README.md @@ -1744,6 +1744,7 @@ Minimal companion CLI: - `flynn companion --once --handoff "summarize my status"` performs one post-registration `agent.send` handoff and prints the `done` content. - `flynn companion --export-bootstrap ./companion.bootstrap.json` writes a resolved bootstrap manifest for desktop/mobile companion app packaging (use `-` for stdout). - `flynn companion --export-release-bundle ./dist/companion-macos` writes a release bundle directory (`companion.bootstrap.json`, `run-companion.sh`, `README.md`, `CHECKSUMS.sha256`) and exits. +- `flynn companion --export-release-bundle ./dist/companion-macos --signing-key ./keys/release-private.pem --signing-key-id team-k1` also writes `CHECKSUMS.sha256.sig` for signed verification workflows. - `flynn companion --platform ios --export-shell-template ./dist/companion-ios-template` writes a platform-native starter template directory (`companion.bootstrap.json`, native starter file, `README.md`) and exits. - `flynn companion --once --platform ios --app-version 1.2.3 --device-name "iPhone" --status-text ready --battery-pct 84 --power-source battery` sends richer initial node status metadata. - `flynn companion --once --latitude 37.3349 --longitude -122.009 --location-source gps` bootstraps node location metadata. diff --git a/docs/api/PROTOCOL.md b/docs/api/PROTOCOL.md index edbc92c..1d3024d 100644 --- a/docs/api/PROTOCOL.md +++ b/docs/api/PROTOCOL.md @@ -23,7 +23,7 @@ The gateway provides: - **HTTP Server**: Serves static dashboard and handles webhook endpoints - **Node Capability Negotiation**: Optional companion-node role/capability registration -Operational note: onboarding (`flynn setup` / `flynn onboard`) now runs post-save live readiness checks (model/channel/memory/automation) and prints a guided first-success task flow. Companion CLI now also supports bootstrap-manifest export (`flynn companion --export-bootstrap `), release-bundle export (`--export-release-bundle `), platform shell-template export (`--export-shell-template `), plus richer shell bootstrap flags for status/location/push (`--app-version`, `--latitude/--longitude`, `--push-token`, etc.) for desktop/mobile app packaging without changing JSON-RPC method/event shapes. +Operational note: onboarding (`flynn setup` / `flynn onboard`) now runs post-save live readiness checks (model/channel/memory/automation) and prints a guided first-success task flow. Companion CLI now also supports bootstrap-manifest export (`flynn companion --export-bootstrap `), release-bundle export (`--export-release-bundle ` with optional `--signing-key`/`--signing-key-id` signature output), platform shell-template export (`--export-shell-template `), plus richer shell bootstrap flags for status/location/push (`--app-version`, `--latitude/--longitude`, `--push-token`, etc.) for desktop/mobile app packaging without changing JSON-RPC method/event shapes. ### Execution Model (Sessions + Per-Session Queue) @@ -1863,5 +1863,5 @@ For more implementation details, see: - Companion runtime client helper: `src/companion/runtimeClient.ts` (node + system + `canvas.*` typed RPC wrappers, optional `autoConnect`/`autoReconnect`, optional reconnect state replay, `sendAgentMessage` handoff helper, connection event subscriptions) - Platform companion wrappers: `src/companion/platformClients.ts` - Companion bootstrap manifest helper: `src/companion/bootstrapManifest.ts` (typed packaging manifest contract used by `flynn companion --export-bootstrap`, including optional initial status/location/push payloads) -- Companion release bundle helper: `src/companion/releaseBundle.ts` (writes bootstrap JSON + launcher script + README + `CHECKSUMS.sha256` for distributable companion shell bundles) +- Companion release bundle helper: `src/companion/releaseBundle.ts` (writes bootstrap JSON + launcher script + README + `CHECKSUMS.sha256`; optional `CHECKSUMS.sha256.sig` when a signing key is provided) - Companion shell template helper: `src/companion/shellTemplate.ts` (writes platform-native starter template files for `macos`, `ios`, and `android` shell scaffolding) diff --git a/docs/architecture/AGENT_DIAGRAM.md b/docs/architecture/AGENT_DIAGRAM.md index 72c375d..23ec231 100644 --- a/docs/architecture/AGENT_DIAGRAM.md +++ b/docs/architecture/AGENT_DIAGRAM.md @@ -157,6 +157,7 @@ Gateway streaming UX signals: - Companion nodes re-register `node.*` capabilities after reconnect; runtime clients can auto-reconnect, optionally replay cached node state (`register/status/location/push`), and surface connection events. - `flynn companion --export-bootstrap ` can emit a resolved companion bootstrap manifest (gateway/node/runtime contract) for desktop/mobile packaging flows without opening a runtime connection. - `flynn companion --export-release-bundle ` can emit a distributable shell bundle (bootstrap JSON + launcher + README + SHA-256 checksums) for desktop/mobile packaging pipelines. +- `flynn companion --export-release-bundle ... --signing-key ` can additionally emit `CHECKSUMS.sha256.sig` for signed artifact verification pipelines. - `flynn companion --export-shell-template ` can emit platform starter shell templates (macOS/iOS/Android native scaffold files + bootstrap JSON) for reference app bootstrapping. - `flynn companion` can bootstrap status/location/push metadata on connect (`node.status.set` + optional `node.location.set`/`node.push_token.set`) so thin companion shells can register operational context in one launch. - Canvas artifacts are persisted by the gateway so session UI surfaces can recover after daemon restarts. diff --git a/docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md b/docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md index 9d7a0b5..60cdfd3 100644 --- a/docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md +++ b/docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md @@ -22,6 +22,7 @@ If you only want the protocol surface, see `docs/api/PROTOCOL.md`. - Companion `node.*` registration is per WebSocket connection; reconnects must re-register capabilities before invoking node RPC methods (or use runtime-client reconnect state replay to re-register/status/location/push automatically). - Companion packaging/bootstrap can be generated offline via `flynn companion --export-bootstrap `, which emits resolved gateway/node/runtime settings without opening a WebSocket session. - Companion release artifacts can be generated via `flynn companion --export-release-bundle `, producing bootstrap JSON + launcher + README + `CHECKSUMS.sha256` for installable shell distribution workflows. +- Companion release-bundle exports can optionally be signed (`--signing-key`, `--signing-key-id`) to emit `CHECKSUMS.sha256.sig` for distribution trust verification. - Companion platform starter scaffolds can be generated via `flynn companion --export-shell-template ` for macOS/iOS/Android reference app bootstrapping. - Companion CLI supports one-shot shell bootstrap metadata for live sessions (`--app-version`/`--status-text`, `--latitude`/`--longitude`, `--push-token`) so desktop/mobile wrappers can initialize node status/location/push in a single launch flow. - Canvas artifacts are persisted per session under the gateway data directory for UI recovery across restarts. diff --git a/docs/operations/COMPANION_RELEASE_BUNDLE.md b/docs/operations/COMPANION_RELEASE_BUNDLE.md index cd163d8..a5eba26 100644 --- a/docs/operations/COMPANION_RELEASE_BUNDLE.md +++ b/docs/operations/COMPANION_RELEASE_BUNDLE.md @@ -21,6 +21,21 @@ Generated files: - `README.md` - `CHECKSUMS.sha256` +Optional signed export: + +```bash +flynn companion \ + --platform macos \ + --node-id companion-macbook \ + --export-release-bundle ./dist/companion-macos \ + --signing-key ./keys/release-private.pem \ + --signing-key-id team-k1 +``` + +Additional file: + +- `CHECKSUMS.sha256.sig` + ## Generate Platform Starter Shell Template For native app bootstrapping (without launcher/checksum artifacts), export a platform template: @@ -51,6 +66,8 @@ Expected result: - all bundle files report `OK` +If signature is present, verify `CHECKSUMS.sha256.sig` with your org signing key policy before launch. + ## Launch ```bash diff --git a/docs/plans/2026-02-26-personal-assistant-productization-plan.md b/docs/plans/2026-02-26-personal-assistant-productization-plan.md index aa3c84c..994203c 100644 --- a/docs/plans/2026-02-26-personal-assistant-productization-plan.md +++ b/docs/plans/2026-02-26-personal-assistant-productization-plan.md @@ -49,7 +49,7 @@ Within 8-10 weeks, ship a stable "Personal Assistant Mode" that supports: 2. Ship a minimal mobile companion shell (iOS + Android) for registration, status, push token, and message handoff. 3. Add signed release artifacts and installation docs. -Status update (2026-02-27): companion bootstrap-manifest export is now available via `flynn companion --export-bootstrap ` as a packaging contract for desktop/mobile shells, `flynn companion --export-release-bundle ` now emits bundle artifacts (bootstrap JSON + launcher + README + `CHECKSUMS.sha256`), `flynn companion --export-shell-template ` now emits macOS/iOS/Android starter shell templates, and `flynn companion` supports one-shot status/location/push bootstrap flags (`--app-version`, `--latitude/--longitude`, `--push-token`) so thin shells can initialize companion metadata in a single run. +Status update (2026-02-27): companion bootstrap-manifest export is now available via `flynn companion --export-bootstrap ` as a packaging contract for desktop/mobile shells, `flynn companion --export-release-bundle ` now emits bundle artifacts (bootstrap JSON + launcher + README + `CHECKSUMS.sha256`, optional `CHECKSUMS.sha256.sig` with `--signing-key`), `flynn companion --export-shell-template ` now emits macOS/iOS/Android starter shell templates, and `flynn companion` supports one-shot status/location/push bootstrap flags (`--app-version`, `--latitude/--longitude`, `--push-token`) so thin shells can initialize companion metadata in a single run. ### Implementation Anchors diff --git a/docs/plans/state.json b/docs/plans/state.json index a694cc2..3f75f66 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -7034,10 +7034,30 @@ "docs/plans/state.json" ], "test_status": "pnpm test:run src/cli/companion.test.ts src/companion/shellTemplate.test.ts + pnpm typecheck passing" + }, + "personal-assistant-productization-phase1-companion-signed-release-artifacts": { + "status": "completed", + "date": "2026-02-27", + "updated": "2026-02-27", + "summary": "Added optional signing for companion release-bundle artifacts. `flynn companion --export-release-bundle ... --signing-key [--signing-key-id ]` now emits `CHECKSUMS.sha256.sig` alongside checksum manifests. Release-bundle helper supports signing and tests verify signature correctness using generated key pairs.", + "files_modified": [ + "src/companion/releaseBundle.ts", + "src/companion/releaseBundle.test.ts", + "src/cli/companion.ts", + "src/cli/companion.test.ts", + "README.md", + "docs/api/PROTOCOL.md", + "docs/architecture/AGENT_DIAGRAM.md", + "docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md", + "docs/operations/COMPANION_RELEASE_BUNDLE.md", + "docs/plans/2026-02-26-personal-assistant-productization-plan.md", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/cli/companion.test.ts src/companion/releaseBundle.test.ts + pnpm typecheck passing" } }, "overall_progress": { - "total_test_count": 2577, + "total_test_count": 2580, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -7052,7 +7072,7 @@ "tier2_completion": "4/4 (100%) — inbound webhooks, vector memory search, Dockerfile, heartbeat monitor", "tier3_completion": "5/5 (100%) — lane queue, credential redaction, web UI token dashboard, xAI (Grok) provider, Voyage AI embeddings", "tier4_completion": "4/4 (100%) — gateway lock, shell completion, Tailscale Serve/Funnel, DM pairing codes", - "feature_gap_scorecard": "rebaselined 2026-02-26 and updated 2026-02-27 (phase 3 + phase 1 + phase 2 + phase 4 slices + companion packaging/bundle tooling + shell bootstrap controls + platform templates) — channel breadth, setup wizard, baseline browser automation, subagent controls, browser workflow reliability primitives (wait/assert/extract/retries/checkpoints/guardrails/budgets), companion reconnect/runtime-handoff foundations, companion packaging primitives (bootstrap export + release-bundle artifacts + checksum manifests), companion install/verification runbook, platform starter shell templates for macOS/iOS/Android, and one-shot status/location/push shell bootstrap controls, voice reliability hardening (talk controls + TTS fallback/health + interruption-safe cancel semantics), and onboarding first-success funnel improvements are implemented; remaining high-impact personal-assistant gaps center on production-grade companion app binaries and signed distribution pipelines.", + "feature_gap_scorecard": "rebaselined 2026-02-26 and updated 2026-02-27 (phase 3 + phase 1 + phase 2 + phase 4 slices + companion packaging/bundle tooling + shell bootstrap controls + platform templates + signed artifacts) — channel breadth, setup wizard, baseline browser automation, subagent controls, browser workflow reliability primitives (wait/assert/extract/retries/checkpoints/guardrails/budgets), companion reconnect/runtime-handoff foundations, companion packaging primitives (bootstrap export + release-bundle artifacts + checksum manifests + optional signatures), companion install/verification runbook, platform starter shell templates for macOS/iOS/Android, and one-shot status/location/push shell bootstrap controls, voice reliability hardening (talk controls + TTS fallback/health + interruption-safe cancel semantics), and onboarding first-success funnel improvements are implemented; remaining high-impact personal-assistant gaps center on production-grade companion app binaries and distribution hardening automation.", "operator_dx_milestone": "Phase 3 (Live Ops Dashboard): 2/2 plans complete — milestone done", "dashboard_observability": "completed — service health graphs + core service log viewer added to web UI via observability RPCs and bounded backend sampling", "gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram", @@ -7085,7 +7105,7 @@ "deeper_surfaces_phase3_companion_canvas_voice": "completed — companion reconnect resilience (auto-reconnect with backoff, pending-wait cancellation on disconnect), canvas artifact persistence (SQLite-backed store, daemon-restart durability), voice TTS fallback coverage (text-only reply on TTS failure, no dropped responses)", "deeper_surfaces_phase4_rollout": "completed — phase 4 rollout and operator readiness plan documented: canary rollout plan by feature flag/surface, explicit rollback playbook, operator docs and architecture/protocol docs synchronized", "post_phase_test_fixes": "completed — fixed 4 test failures introduced by phases 1-3: iOS/Android push listNodes (missing publishHeartbeat before platform-filtered query), server.test agent.send (run_state events now precede done; added sendAndWaitForDone helper), httpBody 413 (req.destroy() closed socket before response could be sent; replaced with Connection: close header on 413 responses)", - "personal_assistant_productization_plan": "in_progress — 8-10 week phased roadmap active; Phase 3 browser workflow reliability shipped, Phase 1 companion runtime reliability includes reconnect state replay + typed handoff support, companion packaging primitives now include bootstrap manifest export, release-bundle artifact generation, checksum manifests, platform starter shell-template generation, and an install/verification runbook, companion shell bootstrap controls cover status/location/push metadata, Phase 2 voice reliability ships talk controls + TTS provider fallback/health + interruption-safe voice cancel mapping, and Phase 4 onboarding includes Personal Assistant Mode preset + live readiness checks + first-success guidance. Remaining phase focus: production-ready companion app surfaces and signed release artifacts.", + "personal_assistant_productization_plan": "in_progress — 8-10 week phased roadmap active; Phase 3 browser workflow reliability shipped, Phase 1 companion runtime reliability includes reconnect state replay + typed handoff support, companion packaging primitives now include bootstrap manifest export, release-bundle artifact generation, checksum manifests, optional signature emission, platform starter shell-template generation, and an install/verification runbook, companion shell bootstrap controls cover status/location/push metadata, Phase 2 voice reliability ships talk controls + TTS provider fallback/health + interruption-safe voice cancel mapping, and Phase 4 onboarding includes Personal Assistant Mode preset + live readiness checks + first-success guidance. Remaining phase focus: production-ready companion app surfaces and distribution automation polish.", "subagents_support": "completed — subagent phases 1-3 shipped with `subagent.spawn/send/list/cancel/delete/summary`, per-child queue mode (`followup|interrupt`), budgets (`max_turns`, `max_total_tokens`, `turn_timeout_ms`), tool-profile overrides, trace-linked audit events, `/subagents` inspection commands, and focused regression tests." }, "soul_md_and_cron_create": { diff --git a/src/cli/companion.test.ts b/src/cli/companion.test.ts index d633936..4fc464b 100644 --- a/src/cli/companion.test.ts +++ b/src/cli/companion.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { Command } from 'commander'; @@ -90,12 +90,15 @@ const { const writeCompanionReleaseBundle = vi.fn(async (input: { outputDir: string; manifest: unknown; + signingKeyPem?: string; + signingKeyId?: string; }) => ({ outputDir: input.outputDir, manifestPath: `${input.outputDir}/companion.bootstrap.json`, launcherPath: `${input.outputDir}/run-companion.sh`, readmePath: `${input.outputDir}/README.md`, checksumsPath: `${input.outputDir}/CHECKSUMS.sha256`, + signaturePath: input.signingKeyPem ? `${input.outputDir}/CHECKSUMS.sha256.sig` : undefined, })); const writeCompanionShellTemplate = vi.fn(async (input: { outputDir: string; @@ -416,6 +419,8 @@ describe('companion command', () => { node: expect.objectContaining({ platform: 'android' }), push: expect.objectContaining({ provider: 'fcm' }), }), + signingKeyPem: undefined, + signingKeyId: undefined, }); expect(mockRuntimeCtorArgs).toEqual([]); expect(mockRuntimeInstances).toEqual([]); @@ -425,6 +430,39 @@ describe('companion command', () => { errSpy.mockRestore(); }); + it('passes signing key material to release bundle export', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'flynn-signing-key-')); + const keyPath = join(tempDir, 'release-signing-key.pem'); + await writeFile(keyPath, '---test-private-key---\n', 'utf8'); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const program = new Command(); + const { registerCompanionCommand } = await import('./companion.js'); + registerCompanionCommand(program); + + await program.parseAsync([ + 'node', + 'test', + 'companion', + '--export-release-bundle', + '/tmp/flynn-companion-bundle', + '--signing-key', + keyPath, + '--signing-key-id', + 'test-key', + ]); + + expect(mockWriteCompanionReleaseBundle).toHaveBeenCalledWith(expect.objectContaining({ + signingKeyPem: '---test-private-key---\n', + signingKeyId: 'test-key', + })); + expect(errSpy).not.toHaveBeenCalled(); + + await rm(tempDir, { recursive: true, force: true }); + logSpy.mockRestore(); + errSpy.mockRestore(); + }); + it('exports a platform shell template and exits without runtime connection', async () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); @@ -565,4 +603,25 @@ describe('companion command', () => { errSpy.mockRestore(); }); + + it('sets process exit code when signing-key is provided without release bundle export', async () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const program = new Command(); + const { registerCompanionCommand } = await import('./companion.js'); + registerCompanionCommand(program); + + await program.parseAsync([ + 'node', + 'test', + 'companion', + '--once', + '--signing-key', + '/tmp/flynn-signing-key.pem', + ]); + + expect(errSpy).toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + + errSpy.mockRestore(); + }); }); diff --git a/src/cli/companion.ts b/src/cli/companion.ts index 2cc9cde..765a55e 100644 --- a/src/cli/companion.ts +++ b/src/cli/companion.ts @@ -1,6 +1,6 @@ import { hostname } from 'node:os'; import { randomUUID } from 'node:crypto'; -import { writeFile } from 'node:fs/promises'; +import { readFile, writeFile } from 'node:fs/promises'; import type { Command } from 'commander'; import { CompanionRuntimeClient } from '../companion/index.js'; import type { SetNodeLocationInput, SetNodePushTokenInput, SetNodeStatusInput } from '../companion/index.js'; @@ -42,6 +42,8 @@ interface CompanionCommandOptions { exportBootstrap?: string; exportReleaseBundle?: string; exportShellTemplate?: string; + signingKey?: string; + signingKeyId?: string; once?: boolean; } @@ -323,12 +325,17 @@ export async function runCompanionSession(options: CompanionCommandOptions): Pro const exportBootstrapPath = options.exportBootstrap?.trim(); const exportReleaseBundleDir = options.exportReleaseBundle?.trim(); const exportShellTemplateDir = options.exportShellTemplate?.trim(); + const signingKeyPath = options.signingKey?.trim(); + const signingKeyId = options.signingKeyId?.trim(); const exportCount = [exportBootstrapPath, exportReleaseBundleDir, exportShellTemplateDir] .filter((value) => Boolean(value)).length; if (exportCount > 1) { throw new Error('export-bootstrap, export-release-bundle, and export-shell-template are mutually exclusive'); } + if (signingKeyPath && !exportReleaseBundleDir) { + throw new Error('signing-key requires --export-release-bundle'); + } const manifest = createCompanionBootstrapManifest({ gatewayUrl, @@ -357,15 +364,23 @@ export async function runCompanionSession(options: CompanionCommandOptions): Pro } if (exportReleaseBundleDir) { + const signingKeyPem = signingKeyPath + ? await readFile(signingKeyPath, 'utf8') + : undefined; const result = await writeCompanionReleaseBundle({ outputDir: exportReleaseBundleDir, manifest, + signingKeyPem, + signingKeyId: signingKeyId && signingKeyId.length > 0 ? signingKeyId : undefined, }); console.log(`Wrote companion release bundle to ${result.outputDir}`); console.log(`- Manifest: ${result.manifestPath}`); console.log(`- Launcher: ${result.launcherPath}`); console.log(`- README: ${result.readmePath}`); console.log(`- Checksums: ${result.checksumsPath}`); + if (result.signaturePath) { + console.log(`- Signature: ${result.signaturePath}`); + } return; } @@ -571,6 +586,8 @@ export function registerCompanionCommand(program: Command): void { '--export-release-bundle ', 'Write a companion release bundle (manifest + launcher + README) and exit', ) + .option('--signing-key ', 'Optional private-key PEM path for release-bundle signature') + .option('--signing-key-id ', 'Optional signing key identifier to embed in signature metadata') .option( '--export-shell-template ', 'Write a platform shell template (bootstrap + native starter file + README) and exit', diff --git a/src/companion/releaseBundle.test.ts b/src/companion/releaseBundle.test.ts index 91cf883..1ae5d0f 100644 --- a/src/companion/releaseBundle.test.ts +++ b/src/companion/releaseBundle.test.ts @@ -1,6 +1,7 @@ import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { generateKeyPairSync, verify, createPublicKey } from 'node:crypto'; import { describe, expect, it } from 'vitest'; import { writeCompanionReleaseBundle } from './releaseBundle.js'; @@ -66,4 +67,53 @@ describe('writeCompanionReleaseBundle', () => { await rm(tempDir, { recursive: true, force: true }); }); + + it('writes signature artifact when signing key is provided', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'flynn-companion-release-sign-')); + const outputDir = join(tempDir, 'bundle'); + const keyPair = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const privatePem = keyPair.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(); + const publicPem = keyPair.publicKey.export({ type: 'spki', format: 'pem' }).toString(); + + const result = await writeCompanionReleaseBundle({ + outputDir, + signingKeyPem: privatePem, + signingKeyId: 'test-key', + manifest: { + schemaVersion: 1, + generatedAt: '2026-02-27T00:00:00.000Z', + gateway: { url: 'ws://127.0.0.1:18800' }, + node: { + nodeId: 'macos-node', + role: 'companion', + platform: 'macos', + capabilities: ['ui.canvas'], + }, + runtime: { + heartbeatSeconds: 30, + handoffTimeoutMs: 120000, + autoReconnect: true, + }, + }, + }); + + expect(result.signaturePath).toBe(`${outputDir}/CHECKSUMS.sha256.sig`); + + const checksumsRaw = await readFile(result.checksumsPath, 'utf8'); + const signatureRaw = await readFile(result.signaturePath!, 'utf8'); + const signatureLine = signatureRaw.split('\n').find((line) => line.startsWith('signature=')); + expect(signatureLine).toBeTruthy(); + expect(signatureRaw).toContain('key_id=test-key'); + + const signature = Buffer.from(String(signatureLine).replace('signature=', ''), 'base64'); + const verified = verify( + 'sha256', + Buffer.from(checksumsRaw, 'utf8'), + createPublicKey(publicPem), + signature, + ); + expect(verified).toBe(true); + + await rm(tempDir, { recursive: true, force: true }); + }); }); diff --git a/src/companion/releaseBundle.ts b/src/companion/releaseBundle.ts index a22ef14..c06704b 100644 --- a/src/companion/releaseBundle.ts +++ b/src/companion/releaseBundle.ts @@ -1,10 +1,12 @@ import { chmod, mkdir, writeFile } from 'node:fs/promises'; -import { createHash } from 'node:crypto'; +import { createHash, createPrivateKey, sign } from 'node:crypto'; import type { CompanionBootstrapManifest } from './bootstrapManifest.js'; export interface WriteCompanionReleaseBundleInput { outputDir: string; manifest: CompanionBootstrapManifest; + signingKeyPem?: string; + signingKeyId?: string; } export interface WriteCompanionReleaseBundleResult { @@ -13,6 +15,7 @@ export interface WriteCompanionReleaseBundleResult { launcherPath: string; readmePath: string; checksumsPath: string; + signaturePath?: string; } function shSingleQuote(value: string): string { @@ -158,7 +161,23 @@ export async function writeCompanionReleaseBundle( [createHash('sha256').update(launcherBody, 'utf8').digest('hex'), 'run-companion.sh'], [createHash('sha256').update(readmeBody, 'utf8').digest('hex'), 'README.md'], ].map(([hash, name]) => `${hash} ${name}`).join('\n'); - await writeFile(checksumsPath, `${checksums}\n`, 'utf8'); + const checksumsPayload = `${checksums}\n`; + await writeFile(checksumsPath, checksumsPayload, 'utf8'); + let signaturePath: string | undefined; + if (input.signingKeyPem && input.signingKeyPem.trim().length > 0) { + const privateKey = createPrivateKey(input.signingKeyPem); + const signature = sign('sha256', Buffer.from(checksumsPayload, 'utf8'), privateKey).toString('base64'); + signaturePath = `${input.outputDir}/CHECKSUMS.sha256.sig`; + const keyIdLine = input.signingKeyId ? `key_id=${input.signingKeyId}\n` : ''; + const signatureBody = [ + '# Flynn companion release signature', + keyIdLine.trim(), + `algorithm=sha256`, + `encoding=base64`, + `signature=${signature}`, + ].filter(Boolean).join('\n'); + await writeFile(signaturePath, `${signatureBody}\n`, 'utf8'); + } return { outputDir: input.outputDir, @@ -166,5 +185,6 @@ export async function writeCompanionReleaseBundle( launcherPath, readmePath, checksumsPath, + signaturePath, }; }