From bcee75381fef426313d4826c7acb0a73b47c34c3 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Sun, 27 Sep 2026 16:47:23 +0900 Subject: [PATCH 1/2] feat(experiment): add read-only Node CLI conformance prototype --- .github/workflows/node-cli-experiment.yml | 43 +++++ experiments/node-cli/.gitignore | 3 + experiments/node-cli/LICENSE | 21 +++ experiments/node-cli/README.md | 117 +++++++++++++ experiments/node-cli/package-lock.json | 54 ++++++ experiments/node-cli/package.json | 20 +++ experiments/node-cli/src/cli.ts | 160 ++++++++++++++++++ .../node-cli/test/conformance.test.mjs | 155 +++++++++++++++++ experiments/node-cli/test/package-smoke.mjs | 46 +++++ experiments/node-cli/tsconfig.json | 13 ++ 10 files changed, 632 insertions(+) create mode 100644 .github/workflows/node-cli-experiment.yml create mode 100644 experiments/node-cli/.gitignore create mode 100644 experiments/node-cli/LICENSE create mode 100644 experiments/node-cli/README.md create mode 100644 experiments/node-cli/package-lock.json create mode 100644 experiments/node-cli/package.json create mode 100644 experiments/node-cli/src/cli.ts create mode 100644 experiments/node-cli/test/conformance.test.mjs create mode 100644 experiments/node-cli/test/package-smoke.mjs create mode 100644 experiments/node-cli/tsconfig.json diff --git a/.github/workflows/node-cli-experiment.yml b/.github/workflows/node-cli-experiment.yml new file mode 100644 index 00000000..41ccbd3b --- /dev/null +++ b/.github/workflows/node-cli-experiment.yml @@ -0,0 +1,43 @@ +name: node-cli-experiment +on: + pull_request: + paths: + - 'experiments/node-cli/**' + - 'session_peer.py' + - 'tests/fixtures/compatibility-v1.json' + - '.github/workflows/node-cli-experiment.yml' + push: + branches: [main] + paths: + - 'experiments/node-cli/**' + - 'session_peer.py' + - 'tests/fixtures/compatibility-v1.json' + - '.github/workflows/node-cli-experiment.yml' +permissions: + contents: read +jobs: + conformance: + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + os: [macos-latest, ubuntu-latest] + node: ['22', '24'] + runs-on: ${{ matrix.os }} + defaults: + run: + working-directory: experiments/node-cli + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ matrix.node }} + cache: npm + cache-dependency-path: experiments/node-cli/package-lock.json + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.13' + - run: npm ci --ignore-scripts + - run: npm test + - run: npm audit --omit=dev + - run: npm run test:package diff --git a/experiments/node-cli/.gitignore b/experiments/node-cli/.gitignore new file mode 100644 index 00000000..1ab415f5 --- /dev/null +++ b/experiments/node-cli/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tgz diff --git a/experiments/node-cli/LICENSE b/experiments/node-cli/LICENSE new file mode 100644 index 00000000..770cc8f3 --- /dev/null +++ b/experiments/node-cli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 abruption + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/experiments/node-cli/README.md b/experiments/node-cli/README.md new file mode 100644 index 00000000..bef3fc93 --- /dev/null +++ b/experiments/node-cli/README.md @@ -0,0 +1,117 @@ +# Node CLI experiment β€” issue #186 + +**Unpublished, read-only prototype. Not a replacement for session-peer.** +The stable Python CLI and hosted Relay remain unchanged. This directory is +excluded from the Python source distribution (`/experiments` in `pyproject.toml`). +No separate repository, npm name reservation or public release is made here. + +## Architecture decision + +| Option | Benefit | Cost / decision | +| --- | --- | --- | +| npm adapter calling Python | Reuses mature messaging and safety implementation | Still requires an explicitly installed Python CLI; not Python-free. Not selected for this experiment. | +| Independent TypeScript CLI | A Node-only installation can serve npm users | Must independently preserve identity, queue, transport and uncertain-outcome contracts. Selected **only for a bounded discovery prototype**. | +| No npm package | No second runtime to secure and maintain | Remains the default distribution decision until the gates below pass. | + +The long-term candidate is a **client CLI**, never a TypeScript replacement for +the Relay server or Control service. Relay **client** support is also absent in +this first slice; it is not silently routed through Python. + +This deliberately starts narrower than a messaging MVP. Codex submission needs +kernel advisory-lock probing, process owner/start-time evidence, cross-home +ambiguity checks and revalidation immediately before queueing. `lsof`, a PID, +or a saved SQLite row alone is not equivalent. No such shortcut is implemented. +Even `send --dry-run` fails explicitly until those checks exist. + +## Run locally + +Prerequisites: Node **22.13+ within 22.x, or 24.x**, macOS/Linux. The Node +`node:sqlite` API may print an experimental warning on stderr; stdout remains +one JSON result. Windows is explicitly rejected because Claude PID creation-time +and named-pipe handling have not been ported. Linux is a CI target, not a claim +of live-agent interoperability. No platform has a production support promise. + +```sh +cd experiments/node-cli +npm ci --ignore-scripts +npm run build +node dist/cli.js list --agent claude --json +node dist/cli.js list --agent codex --codex-home "$HOME/.codex" --json +``` + +- Only `list`, `--agent claude|codex`, `--json` (or `--output-format json`), + `--all`, and the applicable `--codex-home` are supported. `--help`, `--version` + and `--no-update-notice` are available. Updates are never checked or installed. +- Agent selection is mandatory; Codex also requires an explicit home. Implicit + default/Orca/configured multi-home inventory is deferred, not partially emulated. +- Claude reads the same config-directory precedence and actual socket path in + each record. `reachable` is only PID/socket metadata, **not** a connection, + target validation, message consumption or ACK. +- Codex opens `state_5.sqlite` read-only/query-only and discovers saved threads. + It does not read rollout contents, touch writer locks, or claim execution state. + SQLite WAL readers can participate in SQLite shared-memory bookkeeping; this + is not a snapshot reader and does not copy or modify application DB contents. +- `send`, reply, wake, SSH, paired devices, Relay, MCP, doctor, update, human text + output and Antigravity are unsupported. Unknown options are rejected before + discovery. No network or child process is used by the CLI. +- A refusal always has `submitted:false`, `consumptionConfirmed:false`, exit 2 + for usage/unsupported operations or exit 1 for discovery failures. There is no + retry path. Exception strings and unknown argument values are not echoed. +- Successful discovery naturally includes local names, paths and thread IDs, + just like Python; do not publish raw inventory. Failure diagnostics use fixed + codes and never include raw database contents or message arguments. + +The prototype package version (`0.0.0`) is independent from its Python contract +reference (`1.0.2`). JSON uses schema version 1; it does **not** advertise itself +as the full Python 1.0.2 implementation. `private:true` prevents npm publication; +the nonconflicting bin name is `session-peer-ts-prototype`. No lifecycle installer, +postinstall hook, Python fallback, skills installation or service setup exists. + +## Contract tests and packaging + +```sh +npm test # Python 3.9+ needed only for the test oracle +npm run test:package # repeatable pack, clean install and uninstall +npm pack --ignore-scripts # build first; never publish this prototype +``` + +Tests consume `tests/fixtures/compatibility-v1.json` and invoke the repository's +generated Python CLI against the **same temporary fixtures**, with update checks +disabled. They compare exit codes, the v1 envelope, session rows, ordering, +Unicode truncation, archived filtering and successful Codex discovery metadata. +Error wording and package version are deliberately not byte-identical. The +prototype also rejects unsupported row types conservatively. + +An actual Unix socket proves metadata discovery without making a connection; +real SQLite fixtures prove the read path and preserve the DB bytes. Malformed +DB/schema/rows, missing files and unsupported operations fail closed. No real +user messages, credential reads, agent queue submissions or ACKs occur. **These +are not real Claude inbox/Codex queue interoperability tests.** Python-free +execution is tested with an empty PATH. CI covers Node 22/24 on macOS/Linux. + +The package contains compiled JS plus this README, MIT license and package metadata. Build +dependencies are exact-version locked; there are no runtime npm dependencies. +The package smoke test compares two tarball SHA-256 hashes, installs into a fresh +temporary prefix, tests the installed bin with an empty PATH, then uninstalls it. +A release decision still needs cross-builder reproducibility/provenance review +and an approved final package name. + +## Next gates (keep #186 open) + +1. Review this architecture/scope and decide if a second runtime is worth owning. +2. Add full discovery inventory and cross-runtime error/capability contracts. +3. Port target resolution and dry-run with native lock/owner identity evidence; + test ambiguous homes, PID reuse, races, permissions and unsupported platforms. +4. Port local submission with envelope/reply URI validation, uncertain-outcome + handling and no automatic resend. Run explicitly authorized real native + inbox/queue tests; queued must never become consumption/ACK confirmation. +5. Choose SSH prerequisites explicitly: Python currently streams the standalone + script to remote Python. A Node implementation must not silently require + remote Python or download remote Node. Relay client/MCP are separate gates. +6. Only after contract/platform gates pass: decide repo split, npm ownership, + version mapping, supported Node matrix, publication/provenance and maintenance. + +References: [#186](https://github.com/abruption/session-peer/issues/186), +[adapter architecture](../../docs/architecture/agent-transports.md), +[v1 compatibility](../../docs/compatibility-v1.md), +[Node SQLite](https://nodejs.org/docs/latest-v22.x/api/sqlite.html). diff --git a/experiments/node-cli/package-lock.json b/experiments/node-cli/package-lock.json new file mode 100644 index 00000000..e6e7ceed --- /dev/null +++ b/experiments/node-cli/package-lock.json @@ -0,0 +1,54 @@ +{ + "name": "session-peer-ts-prototype", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "session-peer-ts-prototype", + "version": "0.0.0", + "license": "MIT", + "bin": { + "session-peer-ts-prototype": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "22.13.10", + "typescript": "5.9.3" + }, + "engines": { + "node": "^22.13.0 || ^24.0.0" + } + }, + "node_modules/@types/node": { + "version": "22.13.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz", + "integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/experiments/node-cli/package.json b/experiments/node-cli/package.json new file mode 100644 index 00000000..95192f16 --- /dev/null +++ b/experiments/node-cli/package.json @@ -0,0 +1,20 @@ +{ + "name": "session-peer-ts-prototype", + "version": "0.0.0", + "private": true, + "description": "Unpublished, read-only Node CLI compatibility experiment for issue 186", + "type": "module", + "license": "MIT", + "engines": { "node": "^22.13.0 || ^24.0.0" }, + "bin": { "session-peer-ts-prototype": "dist/cli.js" }, + "files": ["dist", "README.md"], + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "npm run build && node --test test/*.test.mjs", + "test:package": "npm run build && node test/package-smoke.mjs" + }, + "devDependencies": { + "@types/node": "22.13.10", + "typescript": "5.9.3" + } +} diff --git a/experiments/node-cli/src/cli.ts b/experiments/node-cli/src/cli.ts new file mode 100644 index 00000000..41f6e4d0 --- /dev/null +++ b/experiments/node-cli/src/cli.ts @@ -0,0 +1,160 @@ +#!/usr/bin/env node +// Deliberately read-only: no subprocess, network, socket connection or DB writes. +import { readFileSync, readdirSync, realpathSync, statSync } from 'node:fs'; +import { hostname, homedir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; + +type Row = Record; +class Refusal extends Error { + constructor(readonly code: string, readonly exitCode = 2) { super(code); } +} +const version = '0.0.0'; +const referenceVersion = '1.0.2'; +const supportedCommands = new Set(['list', 'send', 'doctor', 'device', 'update', 'reply']); + +function missing(error: unknown): boolean { + return ['ENOENT', 'ENOTDIR'].includes((error as NodeJS.ErrnoException).code ?? ''); +} +function canonical(path: string): string { + if (path === '~' || path.startsWith('~/')) path = join(homedir(), path.slice(2)); + else if (path.startsWith('~')) throw new Refusal('unsupported_home_expansion'); + const absolute = resolve(path); + try { return realpathSync(absolute); } + catch (error) { + if (!missing(error)) throw new Refusal('home_resolution_failed', 1); + const parent = dirname(absolute); + return parent === absolute ? absolute : join(canonical(parent), absolute.slice(parent.length)); + } +} +function alive(pid: number): boolean { + if (pid <= 1) return false; + try { process.kill(pid, 0); return true; } + catch (error) { return (error as NodeJS.ErrnoException).code === 'EPERM'; } +} +function claude(all: boolean): Row { + const directory = join(process.env.CLAUDE_CONFIG_DIR || process.env.ANTHROPIC_CONFIG_DIR || join(homedir(), '.claude'), 'sessions'); + let entries: string[]; + try { entries = readdirSync(directory).sort(); } + catch (error) { + if (missing(error)) entries = []; + else throw new Refusal('claude_discovery_failed', 1); + } + const sessions: Row[] = []; + for (const entry of entries) { + if (!/^[0-9]+\.json$/.test(entry)) continue; + let record: Row; + try { + const value: unknown = JSON.parse(readFileSync(join(directory, entry), 'utf8')); + if (!value || typeof value !== 'object' || Array.isArray(value)) continue; + record = value as Row; + } catch { continue; } + const pid = record.pid; + if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid > 2147483647) continue; + const matches = pid === Number(entry.slice(0, -5)); + const live = matches && alive(pid); + const socket = typeof record.messagingSocketPath === 'string' ? record.messagingSocketPath : ''; + let inbox = false; + if (socket) { + try { inbox = statSync(socket).isSocket(); } catch { /* Inaccessible is not reachable. */ } + } + const row: Row = { agent: 'claude', pid, socket, alive: live, reachable: live && inbox }; + for (const key of ['name', 'status', 'cwd', 'kind', 'version', 'tmux']) row[key] = record[key] ?? null; + if (!matches) row.staleReason = 'record_pid_mismatch'; + if (all || row.reachable) sessions.push(row); + } + return { sessions, discovery: { claude: { status: 'ok' } } }; +} + +async function codex(path: string, all: boolean): Promise { + const home = canonical(path); + const file = join(home, 'state_5.sqlite'); + const item: Row = { codexHome: home, stateDb: file, sources: ['argument'] }; + let sessions: Row[] = []; + let code: string | undefined; + try { + if (!statSync(file).isFile()) code = 'state_db_not_regular'; + } catch (error) { code = missing(error) ? 'state_db_missing' : 'state_db_read_failed'; } + if (!code) { + try { + // Lazy import: Claude-only commands don't initialize experimental SQLite. + const { DatabaseSync } = await import('node:sqlite'); + const db = new DatabaseSync(file, { readOnly: true }); + try { + db.exec('PRAGMA query_only=ON; PRAGMA trusted_schema=OFF; PRAGMA busy_timeout=3000'); + const columns = new Set((db.prepare('PRAGMA table_info(threads)').all() as Row[]).map(row => row.name)); + const required = ['id', 'title', 'cwd', 'updated_at', 'archived', 'rollout_path']; + if (!required.every(name => columns.has(name))) throw new Error('unsupported_schema'); + const name = columns.has('name') ? "COALESCE(NULLIF(name, ''), NULLIF(title, ''), id)" : "COALESCE(NULLIF(title, ''), id)"; + const rows = db.prepare(`SELECT id, ${name} AS display_name, cwd, updated_at, archived FROM threads ${all ? '' : 'WHERE archived = 0'} ORDER BY updated_at DESC, id ASC`).all() as Row[]; + sessions = rows.map(row => { + if (typeof row.id !== 'string' || !row.id || typeof row.display_name !== 'string' || + typeof row.updated_at !== 'number' || !Number.isSafeInteger(row.updated_at) || + typeof row.archived !== 'number' || (row.cwd !== null && typeof row.cwd !== 'string')) { + throw new Error('unsupported_row'); + } + // Python slicing counts Unicode code points, not JS UTF-16 units. + const firstLine = row.display_name.split(/[\n\r\v\f\x1c-\x1e\x85\u2028\u2029]/u)[0] ?? ''; + return { agent: 'codex', id: row.id, name: [...firstLine].slice(0, 120).join(''), + cwd: row.cwd, updatedAt: row.updated_at, archived: Boolean(row.archived), + codexHome: home, stateDb: file }; + }); + } finally { db.close(); } + } catch { code = 'state_db_read_failed'; sessions = []; } + } + const failed = Boolean(code); + Object.assign(item, failed ? { status: 'error', code, error: code } : { status: 'ok', sessionCount: sessions.length }); + const discovery: Row = { status: failed ? 'error' : 'ok', homes: [item] }; + if (failed) discovery.error = 'Codex home discovery is incomplete; inspect homes and errors'; + return { sessions, codexHome: home, discovery: { codex: discovery }, ok: !failed, + ...(failed ? { error: 'Discovery failed for: codex' } : {}) }; +} + +function parse(args: string[]): { agent: string; all: boolean; home?: string } { + if (args[0] !== 'list') throw new Refusal('unsupported_command'); + const values = new Map(); + const flags = new Set(); + for (let index = 1; index < args.length; index++) { + const arg = args[index]!; + if (['--all', '--json', '--no-update-notice'].includes(arg)) { + if (flags.has(arg)) throw new Refusal('duplicate_option'); + flags.add(arg); + } else if (['--agent', '--codex-home', '--output-format'].includes(arg)) { + const value = args[++index]; + if (!value || value.startsWith('-') || values.has(arg)) throw new Refusal('invalid_option'); + values.set(arg, value); + } else throw new Refusal('unsupported_option'); + } + const agent = values.get('--agent'); + if (agent !== 'claude' && agent !== 'codex') throw new Refusal('explicit_supported_agent_required'); + if (!flags.has('--json') && values.get('--output-format') !== 'json') throw new Refusal('json_output_required'); + if (values.has('--output-format') && values.get('--output-format') !== 'json') throw new Refusal('unsupported_output_format'); + if (agent === 'codex' && !values.get('--codex-home')) throw new Refusal('explicit_codex_home_required'); + if (agent !== 'codex' && values.has('--codex-home')) throw new Refusal('inapplicable_option'); + return { agent, all: flags.has('--all'), home: values.get('--codex-home') }; +} + +const args = process.argv.slice(2); +const command = supportedCommands.has(args[0] ?? '') ? args[0]! : 'unknown'; +const envelope = { schemaVersion: 1, host: hostname(), command }; +try { + const [major, minor] = process.versions.node.split('.').map(Number); + if (!((major === 22 && minor! >= 13) || major === 24)) throw new Refusal('unsupported_node_version'); + if (args.length === 1 && args[0] === '--version') { + console.log(`session-peer-ts-prototype ${version} (Python contract ${referenceVersion}; read-only)`); + } else if (args.length === 1 && ['--help', '-h'].includes(args[0]!)) { + console.log('Unpublished read-only prototype.\nlist --agent claude --json [--all]\nlist --agent codex --codex-home PATH --json [--all]\nNo send, dry-run, SSH, Relay, MCP, wake, update or implicit multi-agent discovery.'); + } else { + if (!['darwin', 'linux'].includes(process.platform)) throw new Refusal('unsupported_platform'); + const options = parse(args); + const result = options.agent === 'claude' ? claude(options.all) : await codex(options.home!, options.all); + const ok = result.ok !== false; + console.log(JSON.stringify({ ...envelope, ok, version, referenceVersion, ...result })); + process.exitCode = ok ? 0 : 1; + } +} catch (error) { + const failure = error instanceof Refusal ? error : new Refusal('discovery_failed', 1); + // Never echo argv, malformed database contents, exception strings or message text. + console.log(JSON.stringify({ ...envelope, ok: false, error: failure.code, + submitted: false, consumptionConfirmed: false })); + process.exitCode = failure.exitCode; +} diff --git a/experiments/node-cli/test/conformance.test.mjs b/experiments/node-cli/test/conformance.test.mjs new file mode 100644 index 00000000..74e67f14 --- /dev/null +++ b/experiments/node-cli/test/conformance.test.mjs @@ -0,0 +1,155 @@ +// Contract first: the Python v1 fixture and production CLI are the oracle. +// Only temporary records/SQLite databases/Unix sockets; never real agent homes. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, readdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createServer } from 'node:net'; + +const root = fileURLToPath(new URL('../../../', import.meta.url)); +const cli = fileURLToPath(new URL('../dist/cli.js', import.meta.url)); +const fixture = JSON.parse(readFileSync(join(root, 'tests/fixtures/compatibility-v1.json'))); +const python = process.env.PYTHON ?? 'python3'; + +function sandbox(t) { + const path = mkdtempSync(join(process.env.TASK_TEMP ?? tmpdir(), 'codex-node-cli-')); + mkdirSync(join(path, '.claude/sessions'), { recursive: true }); + const env = { ...process.env, HOME: path, USERPROFILE: path, + CLAUDE_CONFIG_DIR: join(path, '.claude'), ANTHROPIC_CONFIG_DIR: '', + CODEX_HOME: '', SESSION_PEER_CODEX_HOMES: '', PYTHONDONTWRITEBYTECODE: '1' }; + t.after(() => rmSync(path, { recursive: true })); + return { path, env }; +} +function invoke(binary, args, env) { + const result = spawnSync(binary, args, { env, encoding: 'utf8', timeout: 10000 }); + assert.ifError(result.error); + assert.equal(result.signal, null, result.stderr); + const value = JSON.parse(result.stdout); + for (const [key, type] of Object.entries(fixture.jsonResult.required)) { + assert.equal(typeof value[key], { int: 'number', bool: 'boolean', str: 'string' }[type]); + } + assert.equal(value.schemaVersion, fixture.schemaVersion); + return { status: result.status, value, stderr: result.stderr }; +} +function compare(args, env) { + const node = invoke(process.execPath, [cli, ...args, '--json'], env); + const reference = invoke(python, [join(root, 'session_peer.py'), ...args, '--json', '--no-update-notice'], env); + assert.equal(node.status, reference.status); + // Package version is independent; only the explicitly supported projection is compared. + assert.equal(node.value.ok, reference.value.ok); + assert.equal(node.value.command, reference.value.command); + assert.equal(node.value.host, reference.value.host); + assert.deepEqual(node.value.sessions, reference.value.sessions); + return { node, reference }; +} + +test('Claude empty and stale records match Python, without creating homes', t => { + const { path, env } = sandbox(t); + compare(['list', '--agent', 'claude'], env); + const records = join(path, '.claude/sessions'); + writeFileSync(join(records, '1.json'), JSON.stringify({ pid: 1, name: 'stale' })); + writeFileSync(join(records, '2.json'), JSON.stringify({ pid: process.pid, name: 'mismatched' })); + writeFileSync(join(records, '3.json'), '{broken'); + writeFileSync(join(records, 'not-a-pid.json'), '{}'); + compare(['list', '--agent', 'claude'], env); + compare(['list', '--agent', 'claude', '--all'], env); + assert.deepEqual(readdirSync(path), ['.claude']); +}); + +test('actual Unix socket metadata matches Python but no connection is made', async t => { + const { path, env } = sandbox(t); + const socketDir = mkdtempSync(join(process.env.TASK_TEMP ?? tmpdir(), 'codex-socket-')); + const socket = join(socketDir, 's'); + let connections = 0; + const server = createServer(s => { connections++; s.destroy(); }); + await new Promise((ok, fail) => { server.once('error', fail); server.listen(socket, ok); }); + t.after(async () => { await new Promise(ok => server.close(ok)); rmSync(socketDir, { recursive: true }); }); + writeFileSync(join(path, '.claude/sessions', `${process.pid}.json`), JSON.stringify({ + pid: process.pid, name: 'ν•œκΈ€ πŸš€', messagingSocketPath: socket, cwd: '/fixture', status: 'idle' + })); + const { node } = compare(['list', '--agent', 'claude'], env); + assert.equal(node.value.sessions[0].reachable, true); + await new Promise(ok => setImmediate(ok)); + assert.equal(connections, 0); +}); + +function makeDatabase(home, withName = true) { + mkdirSync(home); + const code = `import sqlite3,sys\np=sys.argv[1]\nc=sqlite3.connect(p)\nc.execute("CREATE TABLE threads(id TEXT,title TEXT,cwd TEXT,updated_at INTEGER,archived INTEGER,rollout_path TEXT${withName ? ',name TEXT' : ''})")\nrows=[('b','title\\nsecond','/fixture',9,0,'unused'${withName ? ",''" : ''}),('a','πŸš€'*130,'/fixture',9,0,'unused'${withName ? ",None" : ''}),('z','archived','/fixture',10,1,'unused'${withName ? ",'named'" : ''})]\nc.executemany("INSERT INTO threads VALUES (${withName ? '?,?,?,?,?,?,?' : '?,?,?,?,?,?'})",rows)\nc.commit()\nc.close()`; + const result = spawnSync(python, ['-c', code, join(home, 'state_5.sqlite')]); + assert.equal(result.status, 0, result.stderr.toString()); +} +for (const withName of [true, false]) { + test(`Codex read-only SQLite rows match Python (name column=${withName})`, t => { + const { path, env } = sandbox(t); + const home = join(path, 'codex #?% home'); + makeDatabase(home, withName); + const before = readFileSync(join(home, 'state_5.sqlite')); + for (const all of [[], ['--all']]) { + const { node, reference } = compare(['list', '--agent', 'codex', '--codex-home', home, ...all], env); + assert.deepEqual(node.value.discovery, reference.value.discovery); + assert.equal(node.value.codexHome, reference.value.codexHome); + assert.equal('alive' in node.value.sessions[0], false); + assert.equal('reachable' in node.value.sessions[0], false); + } + assert.deepEqual(readFileSync(join(home, 'state_5.sqlite')), before); + assert.deepEqual(readdirSync(home), ['state_5.sqlite']); + }); +} + +test('missing, invalid and unsupported databases fail without raw content in errors', t => { + const { path, env } = sandbox(t); + const home = join(path, 'codex'); + for (const state of ['missing', 'corrupt', 'directory']) { + if (state === 'corrupt') { mkdirSync(home); writeFileSync(join(home, 'state_5.sqlite'), 'SECRET-SENTINEL'); } + if (state === 'directory') { rmSync(join(home, 'state_5.sqlite')); mkdirSync(join(home, 'state_5.sqlite')); } + const result = compare(['list', '--agent', 'codex', '--codex-home', home], env).node; + assert.equal(result.status, fixture.exitCodes.error); + assert.equal(JSON.stringify(result).includes('SECRET-SENTINEL'), false); + } +}); + +test('unsupported SQLite schema and row types cannot become partial success', t => { + const { path, env } = sandbox(t); + const home = join(path, 'codex'); + makeDatabase(home); + for (const statement of ["UPDATE threads SET updated_at='SECRET-SENTINEL'", 'DROP TABLE threads']) { + const changed = spawnSync(python, ['-c', + 'import sqlite3,sys\nc=sqlite3.connect(sys.argv[1]); c.execute(sys.argv[2]); c.commit(); c.close()', + join(home, 'state_5.sqlite'), statement]); + assert.equal(changed.status, 0); + const result = compare(['list', '--agent', 'codex', '--codex-home', home], env).node; + assert.equal(result.status, 1); + assert.deepEqual(result.value.sessions, []); + assert.equal(JSON.stringify(result).includes('SECRET-SENTINEL'), false); + } +}); + +test('unsupported commands/transports fail before inspecting state or sending anything', t => { + const { env } = sandbox(t); + for (const args of [ + ['send', '--to', 'SECRET-SENTINEL', '--message', 'SECRET-SENTINEL'], + ['send', '--dry-run'], ['device'], ['doctor'], ['update'], ['reply'], + ['list', '--agent', 'claude', '--host', 'SECRET-SENTINEL'], + ['list', '--agent', 'claude', '--device', 'SECRET-SENTINEL'], + ['list', '--agent', 'antigravity'], ['list'], ['list', '--agent', 'codex'], + ['list', '--agent', 'claude', '--all=false'], ['list', '--agent', 'claude', '--wake'], + ]) { + const result = invoke(process.execPath, [cli, ...args, '--json'], env); + assert.equal(result.status, fixture.exitCodes.noTargetOrUsage); + assert.equal(result.value.ok, false); + assert.equal(result.value.submitted, false); + assert.equal(result.value.consumptionConfirmed, false); + assert.equal(JSON.stringify(result).includes('SECRET-SENTINEL'), false); + } +}); + +test('runtime works without executables on PATH', t => { + const { env } = sandbox(t); + const result = invoke(process.execPath, [cli, 'list', '--agent', 'claude', '--json'], { ...env, PATH: '' }); + assert.equal(result.status, 0); + assert.deepEqual(result.value.sessions, []); +}); diff --git a/experiments/node-cli/test/package-smoke.mjs b/experiments/node-cli/test/package-smoke.mjs new file mode 100644 index 00000000..7ab4e054 --- /dev/null +++ b/experiments/node-cli/test/package-smoke.mjs @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createHash } from 'node:crypto'; + +const task = mkdtempSync(join(process.env.TASK_TEMP ?? tmpdir(), 'codex-node-pack-')); +const npm = process.env.npm_execpath; +assert.ok(npm, 'invoke through npm run test:package'); +const runNpm = args => execFileSync(process.execPath, [npm, ...args], { encoding: 'utf8', timeout: 60000 }); +try { + const metadata = JSON.parse(readFileSync('package.json')); + assert.equal(metadata.private, true); + assert.equal(metadata.dependencies, undefined); + for (const hook of ['preinstall', 'install', 'postinstall', 'prepare', 'prepack']) { + assert.equal(metadata.scripts[hook], undefined); + } + assert.deepEqual(readFileSync('LICENSE'), readFileSync('../../LICENSE')); + let hash; + for (let index = 0; index < 2; index++) { + const [packed] = JSON.parse(runNpm(['pack', '--ignore-scripts', '--json', '--pack-destination', task])); + assert.deepEqual(packed.files.map(file => file.path).sort(), ['LICENSE', 'README.md', 'dist/cli.js', 'package.json']); + const next = createHash('sha256').update(readFileSync(join(task, packed.filename))).digest('hex'); + if (hash) assert.equal(next, hash, 'same build must produce identical tarball'); + hash = next; + } + const prefix = join(task, 'install'); + const home = join(task, 'empty-home'); + mkdirSync(home); + runNpm(['install', '--prefix', prefix, '--ignore-scripts', '--no-audit', '--no-fund', join(task, 'session-peer-ts-prototype-0.0.0.tgz')]); + const binary = join(prefix, 'node_modules/.bin/session-peer-ts-prototype'); + assert.ok(statSync(binary).mode & 0o111); + const result = JSON.parse(execFileSync(process.execPath, [binary, 'list', '--agent', 'claude', '--json'], { + encoding: 'utf8', timeout: 10000, + env: { ...process.env, PATH: '', HOME: home, CLAUDE_CONFIG_DIR: join(home, '.claude'), ANTHROPIC_CONFIG_DIR: '' } + })); + assert.equal(result.ok, true); + assert.deepEqual(result.sessions, []); + runNpm(['uninstall', '--prefix', prefix, '--ignore-scripts', '--no-audit', '--no-fund', metadata.name]); + assert.equal(existsSync(binary), false); + assert.equal(existsSync(join(prefix, 'node_modules', metadata.name)), false); + console.log(JSON.stringify({ packageSmoke: 'pass', reproducibleSha256: hash, cleanInstall: true, uninstall: true })); +} finally { + rmSync(task, { recursive: true }); +} diff --git a/experiments/node-cli/tsconfig.json b/experiments/node-cli/tsconfig.json new file mode 100644 index 00000000..f9344ee1 --- /dev/null +++ b/experiments/node-cli/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} From d1f9567f777bb399f94923639038809d2f62c5ed Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Sun, 27 Sep 2026 17:21:36 +0900 Subject: [PATCH 2/2] feat(cli): implement guarded local and SSH messaging preview --- experiments/node-cli/.github/workflows/ci.yml | 33 +++ experiments/node-cli/.gitignore | 1 + experiments/node-cli/README.md | 212 +++++++------- experiments/node-cli/VALIDATION.md | 63 +++++ experiments/node-cli/package-lock.json | 36 ++- experiments/node-cli/package.json | 24 +- experiments/node-cli/src/cli.ts | 260 ++++++++---------- experiments/node-cli/src/discovery.ts | 106 +++++++ experiments/node-cli/src/process.ts | 38 +++ experiments/node-cli/src/protocol.ts | 34 +++ experiments/node-cli/src/send.ts | 74 +++++ experiments/node-cli/src/writer.ts | 108 ++++++++ .../node-cli/test/conformance.test.mjs | 7 +- .../test/fixtures/compatibility-v1.json | 31 +++ experiments/node-cli/test/package-smoke.mjs | 11 +- experiments/node-cli/test/transport.test.mjs | 122 ++++++++ 16 files changed, 893 insertions(+), 267 deletions(-) create mode 100644 experiments/node-cli/.github/workflows/ci.yml create mode 100644 experiments/node-cli/VALIDATION.md create mode 100644 experiments/node-cli/src/discovery.ts create mode 100644 experiments/node-cli/src/process.ts create mode 100644 experiments/node-cli/src/protocol.ts create mode 100644 experiments/node-cli/src/send.ts create mode 100644 experiments/node-cli/src/writer.ts create mode 100644 experiments/node-cli/test/fixtures/compatibility-v1.json create mode 100644 experiments/node-cli/test/transport.test.mjs diff --git a/experiments/node-cli/.github/workflows/ci.yml b/experiments/node-cli/.github/workflows/ci.yml new file mode 100644 index 00000000..c165b9ff --- /dev/null +++ b/experiments/node-cli/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: cli +on: [push, pull_request] +permissions: + contents: read +jobs: + contract: + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + os: [macos-latest, ubuntu-latest] + node: ['22', '24'] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: abruption/session-peer + ref: 47c23713d0a2a3c11ebde6186afd8c43489b8b65 + path: .reference-python + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ matrix.node }} + cache: npm + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.13' + - run: npm ci --ignore-scripts + - run: npm test + env: + SESSION_PEER_PYTHON_ROOT: ${{ github.workspace }}/.reference-python + - run: npm run test:package + - run: npm audit diff --git a/experiments/node-cli/.gitignore b/experiments/node-cli/.gitignore index 1ab415f5..67ec36b3 100644 --- a/experiments/node-cli/.gitignore +++ b/experiments/node-cli/.gitignore @@ -1,3 +1,4 @@ node_modules/ dist/ *.tgz +.reference-python/ diff --git a/experiments/node-cli/README.md b/experiments/node-cli/README.md index bef3fc93..23b8c4c0 100644 --- a/experiments/node-cli/README.md +++ b/experiments/node-cli/README.md @@ -1,117 +1,119 @@ -# Node CLI experiment β€” issue #186 - -**Unpublished, read-only prototype. Not a replacement for session-peer.** -The stable Python CLI and hosted Relay remain unchanged. This directory is -excluded from the Python source distribution (`/experiments` in `pyproject.toml`). -No separate repository, npm name reservation or public release is made here. - -## Architecture decision - -| Option | Benefit | Cost / decision | -| --- | --- | --- | -| npm adapter calling Python | Reuses mature messaging and safety implementation | Still requires an explicitly installed Python CLI; not Python-free. Not selected for this experiment. | -| Independent TypeScript CLI | A Node-only installation can serve npm users | Must independently preserve identity, queue, transport and uncertain-outcome contracts. Selected **only for a bounded discovery prototype**. | -| No npm package | No second runtime to secure and maintain | Remains the default distribution decision until the gates below pass. | - -The long-term candidate is a **client CLI**, never a TypeScript replacement for -the Relay server or Control service. Relay **client** support is also absent in -this first slice; it is not silently routed through Python. - -This deliberately starts narrower than a messaging MVP. Codex submission needs -kernel advisory-lock probing, process owner/start-time evidence, cross-home -ambiguity checks and revalidation immediately before queueing. `lsof`, a PID, -or a saved SQLite row alone is not equivalent. No such shortcut is implemented. -Even `send --dry-run` fails explicitly until those checks exist. - -## Run locally - -Prerequisites: Node **22.13+ within 22.x, or 24.x**, macOS/Linux. The Node -`node:sqlite` API may print an experimental warning on stderr; stdout remains -one JSON result. Windows is explicitly rejected because Claude PID creation-time -and named-pipe handling have not been ported. Linux is a CI target, not a claim -of live-agent interoperability. No platform has a production support promise. +# session-peer-ts + +Python-free **client CLI preview** for local and SSH messaging to Claude Code and +Codex. This is not a Relay server or a full replacement for the Python product. +Public npm publication remains disabled (`private: true`). + +## Scope and prerequisites + +- macOS/Linux, Node 22.13+ within 22.x or Node 24.x. Windows and Node 26 are not + supported. A native flock dependency provides prebuilt binaries; x64/arm64 + installation requires a matching binary. It never falls back to checking only + that a lock file exists. +- Claude: a running native TUI with a local inbox. Codex: a saved thread with one + stable live writer, native `codex queue`, `lsof` and `ps`. Saved rows alone do + not authorize delivery. Explicit home selection is mandatory. +- SSH: OpenSSH and this **same preview version installed explicitly on the + remote host**, with supported Node. No Python fallback, source streaming, + remote runtime download or automatic installation. +- Unsupported: Relay client/server, Control, MCP, wake, inactive queue opt-in, + Antigravity, updates and implicit multi-agent discovery. Unsupported options + fail explicitly; existing Python installs and hosted services are unchanged. + +The architecture evaluation in Python +[#186](https://github.com/abruption/session-peer/issues/186) compared an explicit +Python wrapper, an independent Node client, and no npm distribution. This selects +an independent client: Python is only needed by development conformance tests. +A public release still needs a maintenance and publication decision. + +## Build and use ```sh -cd experiments/node-cli npm ci --ignore-scripts npm run build node dist/cli.js list --agent claude --json node dist/cli.js list --agent codex --codex-home "$HOME/.codex" --json +node dist/cli.js send --to CLAUDE_PID --message 'hello' --dry-run --json +node dist/cli.js send --to codex:UUID --codex-home "$HOME/.codex" --message 'hello' --dry-run --json +``` + +Remove `--dry-run` only when delivery is intended. Omit `--message`, or use +`--message -`, for UTF-8 stdin. Output is JSON only (`--json` or +`--output-format json`). Claude targets may be PIDs, `claude:PID`, or unambiguous +ASCII names (case-insensitive). Use a PID for Unicode names; full Unicode +casefold parity is deferred. `--codex-bin` selects an executable, never shell +text. `--all` is listing-only. The bin is `session-peer-ts`, not `session-peer`. + +```sh +node dist/cli.js send --host user@machine --remote-bin /absolute/path/session-peer-ts \ + --to CLAUDE_PID --message 'hello' --dry-run --json ``` -- Only `list`, `--agent claude|codex`, `--json` (or `--output-format json`), - `--all`, and the applicable `--codex-home` are supported. `--help`, `--version` - and `--no-update-notice` are available. Updates are never checked or installed. -- Agent selection is mandatory; Codex also requires an explicit home. Implicit - default/Orca/configured multi-home inventory is deferred, not partially emulated. -- Claude reads the same config-directory precedence and actual socket path in - each record. `reachable` is only PID/socket metadata, **not** a connection, - target validation, message consumption or ACK. -- Codex opens `state_5.sqlite` read-only/query-only and discovers saved threads. - It does not read rollout contents, touch writer locks, or claim execution state. - SQLite WAL readers can participate in SQLite shared-memory bookkeeping; this - is not a snapshot reader and does not copy or modify application DB contents. -- `send`, reply, wake, SSH, paired devices, Relay, MCP, doctor, update, human text - output and Antigravity are unsupported. Unknown options are rejected before - discovery. No network or child process is used by the CLI. -- A refusal always has `submitted:false`, `consumptionConfirmed:false`, exit 2 - for usage/unsupported operations or exit 1 for discovery failures. There is no - retry path. Exception strings and unknown argument values are not echoed. -- Successful discovery naturally includes local names, paths and thread IDs, - just like Python; do not publish raw inventory. Failure diagnostics use fixed - codes and never include raw database contents or message arguments. - -The prototype package version (`0.0.0`) is independent from its Python contract -reference (`1.0.2`). JSON uses schema version 1; it does **not** advertise itself -as the full Python 1.0.2 implementation. `private:true` prevents npm publication; -the nonconflicting bin name is `session-peer-ts-prototype`. No lifecycle installer, -postinstall hook, Python fallback, skills installation or service setup exists. - -## Contract tests and packaging +The default remote command is `session-peer-ts` on remote PATH. An absolute +`--remote-bin` may name an operator-owned wrapper selecting a supported Node. +SSH uses existing configuration/keys with BatchMode and StrictHostKeyChecking; +it never approves new host keys. No arbitrary `--ssh-opt` is accepted. IPv6 +literals and Tailscale canonical-name enrichment are deferred; use an SSH +alias/hostname. Remote version is checked before one JSON request is sent over +stdin. Message text never becomes remote shell command arguments. + +## Delivery and reply safety + +- Claude re-resolves one reachable target before one native JSON-line write. + `posted` is write completion, not ACK. PID/socket metadata is not consumption. +- Codex checks selected/default/Orca/configured homes, actual nonblocking OS + flock, file identity, same-user owner and process start time across two + samples. Unknown evidence, multiple live writers and inactive-only copies are + refused. Inventory and ownership are revalidated immediately before queueing. + No transcript is read, lock deleted or owner process signaled. +- `queued` is queue acceptance only; `consumptionConfirmed` is always false. + Response loss, timeout or native nonzero exit after spawn is conservatively + `status:unknown, submitted:null, retryAllowed:false`. Never automatically resend. +- Refusal before submission has `submitted:false`. Dry-run is `validated`. + Success/error/usage exit codes are 0/1/2 and JSON uses the v1 envelope. Failure + diagnostics are fixed codes; no raw exceptions, native stderr or messages. +- Actual ACK must be checked separately in the receiving TUI, not inferred from + queued/submitted or by polling transcripts. + +`--to 'session-peer://v1/reply?...'` accepts strict local/SSH Claude/Codex reply +URIs as data. Unknown/duplicate fields, malformed encoding, unsafe hosts and +conflicting explicit routes are refused. `--reply-address URI` adds an explicit +structured return address; it does not establish reverse SSH access. No route +is invented. `--no-reply-to` omits a new address. A valid CODEX_THREAD_ID or +CODEX_SESSION_ID adds an informational From header unless `--no-from` is given. +Unknown senders are not invented; peer headers are metadata, not authority. +No executable Reply command is synthesized. + +## Tests + +Point `SESSION_PEER_PYTHON_ROOT` at a checkout of Python v1.0.2, commit +`47c23713d0a2a3c11ebde6186afd8c43489b8b65`. CI pins this commit and compares the +vendored compatibility fixture to it. Development tests require Python 3.9+, +a C compiler and lsof. None is an implicit runtime installer. ```sh -npm test # Python 3.9+ needed only for the test oracle -npm run test:package # repeatable pack, clean install and uninstall -npm pack --ignore-scripts # build first; never publish this prototype +SESSION_PEER_PYTHON_ROOT=/path/to/python-reference npm test +npm run test:package +npm audit ``` -Tests consume `tests/fixtures/compatibility-v1.json` and invoke the repository's -generated Python CLI against the **same temporary fixtures**, with update checks -disabled. They compare exit codes, the v1 envelope, session rows, ordering, -Unicode truncation, archived filtering and successful Codex discovery metadata. -Error wording and package version are deliberately not byte-identical. The -prototype also rejects unsupported row types conservatively. - -An actual Unix socket proves metadata discovery without making a connection; -real SQLite fixtures prove the read path and preserve the DB bytes. Malformed -DB/schema/rows, missing files and unsupported operations fail closed. No real -user messages, credential reads, agent queue submissions or ACKs occur. **These -are not real Claude inbox/Codex queue interoperability tests.** Python-free -execution is tested with an empty PATH. CI covers Node 22/24 on macOS/Linux. - -The package contains compiled JS plus this README, MIT license and package metadata. Build -dependencies are exact-version locked; there are no runtime npm dependencies. -The package smoke test compares two tarball SHA-256 hashes, installs into a fresh -temporary prefix, tests the installed bin with an empty PATH, then uninstalls it. -A release decision still needs cross-builder reproducibility/provenance review -and an approved final package name. - -## Next gates (keep #186 open) - -1. Review this architecture/scope and decide if a second runtime is worth owning. -2. Add full discovery inventory and cross-runtime error/capability contracts. -3. Port target resolution and dry-run with native lock/owner identity evidence; - test ambiguous homes, PID reuse, races, permissions and unsupported platforms. -4. Port local submission with envelope/reply URI validation, uncertain-outcome - handling and no automatic resend. Run explicitly authorized real native - inbox/queue tests; queued must never become consumption/ACK confirmation. -5. Choose SSH prerequisites explicitly: Python currently streams the standalone - script to remote Python. A Node implementation must not silently require - remote Python or download remote Node. Relay client/MCP are separate gates. -6. Only after contract/platform gates pass: decide repo split, npm ownership, - version mapping, supported Node matrix, publication/provenance and maintenance. - -References: [#186](https://github.com/abruption/session-peer/issues/186), -[adapter architecture](../../docs/architecture/agent-transports.md), -[v1 compatibility](../../docs/compatibility-v1.md), -[Node SQLite](https://nodejs.org/docs/latest-v22.x/api/sqlite.html). +Fixtures cover Python discovery/schema/exit contracts, actual temporary SQLite +DBs, Unix inbox sockets and native-process kernel locks. Native fixture evidence +is separate from real TUI/SSH observations in VALIDATION.md. Tests check one-shot +submission, ambiguous writers, wrong owners, dry-run, response loss and unsafe +reply rejection without production state. Set TASK_TEMP for temporary files. + +Package tests allowlist tarball files, compare repeat-pack SHA-256, clean-install +into a temporary prefix, run empty-PATH discovery and uninstall. The native +dependency normally has an install script/fallback compilation; the verified +prebuilt path uses `--ignore-scripts`. This is not a pure-JavaScript package. +SQLite reads are read-only/query-only, but WAL readers can participate in shared +memory bookkeeping; this is not a snapshot reader. + +## Release boundaries + +Preview versioning is independent of Python's 1.0.2 contract reference. This is +not complete Python parity. Windows, wider architectures, full discovery, +Unicode names, implicit reply identity and optional transports require separate +work. Public npm naming, provenance, release automation and long-term ownership +must be decided before publication. Python and Relay retain independent releases. diff --git a/experiments/node-cli/VALIDATION.md b/experiments/node-cli/VALIDATION.md new file mode 100644 index 00000000..972b45f1 --- /dev/null +++ b/experiments/node-cli/VALIDATION.md @@ -0,0 +1,63 @@ +# Validation record β€” 2026-09-27 KST + +This is preview evidence, not an npm release or a claim of complete Python parity. +Python reference: v1.0.2, commit `47c23713d0a2a3c11ebde6186afd8c43489b8b65`. + +## Automated checks + +- 14 tests pass locally on macOS arm64 / Node 22.14.0. +- Real fixture SQLite DBs, Unix inbox sockets and separate C processes holding + actual advisory locks; Python is the discovery oracle, not the implementation. +- Refused inactive/ambiguous/wrong-owner cases; one native fixture queue call; + native nonzero after spawn and SSH response loss remain unknown with no retry. +- Package file allowlist, same-build repeat SHA-256, clean prefix installation, + empty-PATH discovery and uninstall pass. npm audit reports 0 vulnerabilities. +- CI defines macOS/Linux Γ— Node 22/24; see the repository Actions results for + those runs rather than treating this local check as their substitute. + +## Real TUI and SSH checks + +Dedicated, newly created Claude/Codex test TUIs only. The user approved trusting +the two otherwise empty test folders. Agent permissions were not changed. +Each route passed discovery and no-delivery dry-run before exactly one send. +ACKs below were observed as separate **assistant response lines in the owned +live tmux panes**, not copied from submitted prompts, queues or transcript files. + +| Route | Native target | Submission | Actual response | +| --- | --- | --- | --- | +| Mac mini local | Claude Code 2.1.281 | posted | `ACK TS186-LOCAL-CLAUDE-8fc721d0` | +| Mac mini local | Codex CLI 0.157.1 | queued | `ACK TS186-LOCAL-CODEX-cb042ac9` | +| Mac mini β†’ MacBook SSH | Claude Code 2.1.281 | posted | `ACK TS186-SSH-CLAUDE-f0275ade` | +| Mac mini β†’ MacBook SSH, structured Reply-To URI as destination | Codex CLI 0.157.1 | queued | `ACK TS186-SSH-CODEX-a771e96c` | + +Local Node: 22.14.0. Remote MacBook: 24.20.0, selected by a task-owned wrapper +because its normal PATH selected unsupported Node 26. KR Linux Node 24.16.0 +also passed real SSH version negotiation and empty Claude discovery. KR native +agent delivery was not tested. No service was restarted or global CLI replaced. + +The CLI itself still reports `consumptionConfirmed:false`; external ACK review +does not change that contract. This is a one-shot functional test, not a soak. +Local self-SSH was denied by the existing SSH authentication configuration; no +credentials or host-key bypass were introduced. Reverse SSH was not asserted. + +## Exact runtime build checked on both Macs + +Compiled module SHA-256 values matched between the local build and the isolated +MacBook installation used for the SSH ACK tests: + +```text +0b708f1211330ff489b6a405e0ebb71d2b3b392acdb19c216dba18e7b23016c7 cli.js +1d8c41dfc174d950e2196c64f93b324f09befa388a5b303439c390a526ffda48 discovery.js +c3187c99c46364730e46e35ef23d8f3a37e541593b2a6f9c3945dce979e7e99c process.js +b716fed8fe6c8c04047f8485a6e3f982d6e64e5638df570b3b8c2a128f88c81e protocol.js +dd946024a61ce8721c273d47e911494c0c8facd62facd9b791a451b90cb702bf send.js +c441567e83c64c7d5735708936e9a42b68dd9ea0cdf0ef95538da7919d550496 writer.js +``` + +## Not covered / not released + +Windows, native x64 agent interaction, IPv6 literals, Relay/MCP/wake, full Python +CLI parity, automatic reverse-route detection, npm registry publishing and +provenance are outside this evidence. Keep `private:true` until a separate +publication decision. Native dependency prebuild availability must be tested +on each architecture claimed by a future release. diff --git a/experiments/node-cli/package-lock.json b/experiments/node-cli/package-lock.json index e6e7ceed..e198d115 100644 --- a/experiments/node-cli/package-lock.json +++ b/experiments/node-cli/package-lock.json @@ -1,15 +1,22 @@ { - "name": "session-peer-ts-prototype", - "version": "0.0.0", + "name": "session-peer-ts", + "version": "0.1.0-preview.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "session-peer-ts-prototype", - "version": "0.0.0", + "name": "session-peer-ts", + "version": "0.1.0-preview.0", "license": "MIT", + "os": [ + "darwin", + "linux" + ], + "dependencies": { + "fs-ext-extra-prebuilt": "2.2.14" + }, "bin": { - "session-peer-ts-prototype": "dist/cli.js" + "session-peer-ts": "dist/cli.js" }, "devDependencies": { "@types/node": "22.13.10", @@ -29,6 +36,25 @@ "undici-types": "~6.20.0" } }, + "node_modules/fs-ext-extra-prebuilt": { + "version": "2.2.14", + "resolved": "https://registry.npmjs.org/fs-ext-extra-prebuilt/-/fs-ext-extra-prebuilt-2.2.14.tgz", + "integrity": "sha512-V8yXXh7tTQMf9vDdO+z0DIleooF1gj/DslZMTSu5HoJWRQFkK3gxkIE7RmhJrsv0B4pv1mvwJHez3qpf9pJ48w==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "nan": "^2.27.0" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/nan": { + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.29.0.tgz", + "integrity": "sha512-GlGk3HIvitbvs+LT3g6XUP1kpirKNvmDFwF/bmo6XNWSb/eYEs/O4bfgIEIXCZ+lIOTS5xNwDvSGMw6FJdAhtA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/experiments/node-cli/package.json b/experiments/node-cli/package.json index 95192f16..49076bf8 100644 --- a/experiments/node-cli/package.json +++ b/experiments/node-cli/package.json @@ -1,13 +1,22 @@ { - "name": "session-peer-ts-prototype", - "version": "0.0.0", + "name": "session-peer-ts", + "version": "0.1.0-preview.0", "private": true, - "description": "Unpublished, read-only Node CLI compatibility experiment for issue 186", + "repository": { "type": "git", "url": "git+https://github.com/abruption/session-peer-ts.git" }, + "os": ["darwin", "linux"], + "description": "Python-free local and SSH client for Claude Code and Codex sessions (preview)", "type": "module", "license": "MIT", - "engines": { "node": "^22.13.0 || ^24.0.0" }, - "bin": { "session-peer-ts-prototype": "dist/cli.js" }, - "files": ["dist", "README.md"], + "engines": { + "node": "^22.13.0 || ^24.0.0" + }, + "bin": { + "session-peer-ts": "dist/cli.js" + }, + "files": [ + "dist", + "README.md" + ], "scripts": { "build": "tsc -p tsconfig.json", "test": "npm run build && node --test test/*.test.mjs", @@ -16,5 +25,8 @@ "devDependencies": { "@types/node": "22.13.10", "typescript": "5.9.3" + }, + "dependencies": { + "fs-ext-extra-prebuilt": "2.2.14" } } diff --git a/experiments/node-cli/src/cli.ts b/experiments/node-cli/src/cli.ts index 41f6e4d0..724b27b6 100644 --- a/experiments/node-cli/src/cli.ts +++ b/experiments/node-cli/src/cli.ts @@ -1,160 +1,134 @@ #!/usr/bin/env node -// Deliberately read-only: no subprocess, network, socket connection or DB writes. -import { readFileSync, readdirSync, realpathSync, statSync } from 'node:fs'; -import { hostname, homedir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; +import { hostname } from 'node:os'; +import { claude, codex, Refusal } from './discovery.js'; +import { executable, run, UnknownOutcome } from './process.js'; +import { checkMessage, send } from './send.js'; +import { envelope, host, reply, VERSION } from './protocol.js'; -type Row = Record; -class Refusal extends Error { - constructor(readonly code: string, readonly exitCode = 2) { super(code); } -} -const version = '0.0.0'; -const referenceVersion = '1.0.2'; -const supportedCommands = new Set(['list', 'send', 'doctor', 'device', 'update', 'reply']); - -function missing(error: unknown): boolean { - return ['ENOENT', 'ENOTDIR'].includes((error as NodeJS.ErrnoException).code ?? ''); -} -function canonical(path: string): string { - if (path === '~' || path.startsWith('~/')) path = join(homedir(), path.slice(2)); - else if (path.startsWith('~')) throw new Refusal('unsupported_home_expansion'); - const absolute = resolve(path); - try { return realpathSync(absolute); } - catch (error) { - if (!missing(error)) throw new Refusal('home_resolution_failed', 1); - const parent = dirname(absolute); - return parent === absolute ? absolute : join(canonical(parent), absolute.slice(parent.length)); - } -} -function alive(pid: number): boolean { - if (pid <= 1) return false; - try { process.kill(pid, 0); return true; } - catch (error) { return (error as NodeJS.ErrnoException).code === 'EPERM'; } -} -function claude(all: boolean): Row { - const directory = join(process.env.CLAUDE_CONFIG_DIR || process.env.ANTHROPIC_CONFIG_DIR || join(homedir(), '.claude'), 'sessions'); - let entries: string[]; - try { entries = readdirSync(directory).sort(); } - catch (error) { - if (missing(error)) entries = []; - else throw new Refusal('claude_discovery_failed', 1); +type Options = { command: 'list' | 'send'; values: Map; flags: Set }; +export function parse(args: string[]): Options { + const command = args[0]; + if (command !== 'list' && command !== 'send') throw new Refusal('unsupported_command'); + const values = new Map(), flags = new Set(); + for (let i = 1; i < args.length; i++) { + const token = args[i]!; + const split = token.indexOf('='); + const key = split > 0 ? token.slice(0, split) : token; + if (['--json', '--all', '--dry-run', '--no-from', '--no-reply-to', '--no-update-notice'].includes(key)) { + if (flags.has(key) || split > 0) throw new Refusal('invalid_option'); + flags.add(key); + } else if (['--agent', '--codex-home', '--codex-bin', '--output-format', '--to', '--message', '-m', '--host', '--remote-bin', '--reply-address'].includes(key)) { + const name = key === '-m' ? '--message' : key; + const value = split > 0 ? token.slice(split + 1) : args[++i]; + if (value === undefined || values.has(name)) throw new Refusal('invalid_option'); + values.set(name, value); + } else throw new Refusal('unsupported_option'); } - const sessions: Row[] = []; - for (const entry of entries) { - if (!/^[0-9]+\.json$/.test(entry)) continue; - let record: Row; - try { - const value: unknown = JSON.parse(readFileSync(join(directory, entry), 'utf8')); - if (!value || typeof value !== 'object' || Array.isArray(value)) continue; - record = value as Row; - } catch { continue; } - const pid = record.pid; - if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid > 2147483647) continue; - const matches = pid === Number(entry.slice(0, -5)); - const live = matches && alive(pid); - const socket = typeof record.messagingSocketPath === 'string' ? record.messagingSocketPath : ''; - let inbox = false; - if (socket) { - try { inbox = statSync(socket).isSocket(); } catch { /* Inaccessible is not reachable. */ } + if (!flags.has('--json') && values.get('--output-format') !== 'json') throw new Refusal('json_output_required'); + if (values.has('--output-format') && values.get('--output-format') !== 'json') throw new Refusal('unsupported_output_format'); + if (command === 'list') { + if (!['claude', 'codex'].includes(values.get('--agent') ?? '')) throw new Refusal('explicit_supported_agent_required'); + if (values.get('--agent') === 'codex' && !values.get('--codex-home')) throw new Refusal('explicit_codex_home_required'); + if (values.get('--agent') !== 'codex' && values.has('--codex-home')) throw new Refusal('inapplicable_option'); + if (['--to', '--message', '--codex-bin', '--reply-address'].some(k => values.has(k)) || ['--dry-run', '--no-from', '--no-reply-to'].some(k => flags.has(k))) throw new Refusal('inapplicable_option'); + } else { + if (!values.get('--to') || values.has('--agent') || flags.has('--all')) throw new Refusal('invalid_send_options'); + if (flags.has('--no-reply-to') && values.has('--reply-address')) throw new Refusal('conflicting_reply_options'); + if (values.get('--to')!.startsWith('session-peer:')) { + const route = reply(values.get('--to')!); + for (const [key, value] of [['--host', route.host], ['--codex-home', route.home]] as const) { + if (values.has(key) && values.get(key) !== value) throw new Refusal('reply_route_conflict'); + if (value !== undefined) values.set(key, value); + } + values.set('--to', route.to); } - const row: Row = { agent: 'claude', pid, socket, alive: live, reachable: live && inbox }; - for (const key of ['name', 'status', 'cwd', 'kind', 'version', 'tmux']) row[key] = record[key] ?? null; - if (!matches) row.staleReason = 'record_pid_mismatch'; - if (all || row.reachable) sessions.push(row); } - return { sessions, discovery: { claude: { status: 'ok' } } }; -} - -async function codex(path: string, all: boolean): Promise { - const home = canonical(path); - const file = join(home, 'state_5.sqlite'); - const item: Row = { codexHome: home, stateDb: file, sources: ['argument'] }; - let sessions: Row[] = []; - let code: string | undefined; - try { - if (!statSync(file).isFile()) code = 'state_db_not_regular'; - } catch (error) { code = missing(error) ? 'state_db_missing' : 'state_db_read_failed'; } - if (!code) { - try { - // Lazy import: Claude-only commands don't initialize experimental SQLite. - const { DatabaseSync } = await import('node:sqlite'); - const db = new DatabaseSync(file, { readOnly: true }); - try { - db.exec('PRAGMA query_only=ON; PRAGMA trusted_schema=OFF; PRAGMA busy_timeout=3000'); - const columns = new Set((db.prepare('PRAGMA table_info(threads)').all() as Row[]).map(row => row.name)); - const required = ['id', 'title', 'cwd', 'updated_at', 'archived', 'rollout_path']; - if (!required.every(name => columns.has(name))) throw new Error('unsupported_schema'); - const name = columns.has('name') ? "COALESCE(NULLIF(name, ''), NULLIF(title, ''), id)" : "COALESCE(NULLIF(title, ''), id)"; - const rows = db.prepare(`SELECT id, ${name} AS display_name, cwd, updated_at, archived FROM threads ${all ? '' : 'WHERE archived = 0'} ORDER BY updated_at DESC, id ASC`).all() as Row[]; - sessions = rows.map(row => { - if (typeof row.id !== 'string' || !row.id || typeof row.display_name !== 'string' || - typeof row.updated_at !== 'number' || !Number.isSafeInteger(row.updated_at) || - typeof row.archived !== 'number' || (row.cwd !== null && typeof row.cwd !== 'string')) { - throw new Error('unsupported_row'); - } - // Python slicing counts Unicode code points, not JS UTF-16 units. - const firstLine = row.display_name.split(/[\n\r\v\f\x1c-\x1e\x85\u2028\u2029]/u)[0] ?? ''; - return { agent: 'codex', id: row.id, name: [...firstLine].slice(0, 120).join(''), - cwd: row.cwd, updatedAt: row.updated_at, archived: Boolean(row.archived), - codexHome: home, stateDb: file }; - }); - } finally { db.close(); } - } catch { code = 'state_db_read_failed'; sessions = []; } + if (values.has('--host')) host(values.get('--host')!); + if (values.has('--remote-bin')) { + if (!values.has('--host') || !/^\/[\x20-\x7e]+$/.test(values.get('--remote-bin')!)) throw new Refusal('invalid_remote_bin'); } - const failed = Boolean(code); - Object.assign(item, failed ? { status: 'error', code, error: code } : { status: 'ok', sessionCount: sessions.length }); - const discovery: Row = { status: failed ? 'error' : 'ok', homes: [item] }; - if (failed) discovery.error = 'Codex home discovery is incomplete; inspect homes and errors'; - return { sessions, codexHome: home, discovery: { codex: discovery }, ok: !failed, - ...(failed ? { error: 'Discovery failed for: codex' } : {}) }; + return { command, values, flags }; } - -function parse(args: string[]): { agent: string; all: boolean; home?: string } { - if (args[0] !== 'list') throw new Refusal('unsupported_command'); - const values = new Map(); - const flags = new Set(); - for (let index = 1; index < args.length; index++) { - const arg = args[index]!; - if (['--all', '--json', '--no-update-notice'].includes(arg)) { - if (flags.has(arg)) throw new Refusal('duplicate_option'); - flags.add(arg); - } else if (['--agent', '--codex-home', '--output-format'].includes(arg)) { - const value = args[++index]; - if (!value || value.startsWith('-') || values.has(arg)) throw new Refusal('invalid_option'); - values.set(arg, value); - } else throw new Refusal('unsupported_option'); +async function input(): Promise { + const chunks: Buffer[] = []; + let bytes = 0; + for await (const chunk of process.stdin) { + const part = Buffer.from(chunk); bytes += part.length; + if (bytes > 4_100_000) throw new Refusal('input_too_large'); + chunks.push(part); } - const agent = values.get('--agent'); - if (agent !== 'claude' && agent !== 'codex') throw new Refusal('explicit_supported_agent_required'); - if (!flags.has('--json') && values.get('--output-format') !== 'json') throw new Refusal('json_output_required'); - if (values.has('--output-format') && values.get('--output-format') !== 'json') throw new Refusal('unsupported_output_format'); - if (agent === 'codex' && !values.get('--codex-home')) throw new Refusal('explicit_codex_home_required'); - if (agent !== 'codex' && values.has('--codex-home')) throw new Refusal('inapplicable_option'); - return { agent, all: flags.has('--all'), home: values.get('--codex-home') }; + try { return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)); } + catch { throw new Refusal('invalid_utf8'); } +} +const quote = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'"; +async function remote(options: Options, message?: string): Promise> { + const { values, flags, command } = options; + const target = host(values.get('--host')!); + const binary = values.get('--remote-bin') ?? 'session-peer-ts'; + const ssh = executable('ssh'); + const base = ['-T', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=10', '--', target]; + const preflight = await run(ssh, [...base, `${quote(binary)} --version`], { timeout: 15000 }); + if (preflight.interrupted || preflight.code !== 0 || preflight.stdout.trim() !== `session-peer-ts ${VERSION}`) throw new Refusal('remote_version_unverified', 1); + const args = [command, '--json']; + for (const [key, value] of values) if (['--agent', '--codex-home', '--codex-bin', '--to'].includes(key)) args.push(key, value); + if (flags.has('--all')) args.push('--all'); + if (flags.has('--dry-run')) args.push('--dry-run'); + if (command === 'send') args.push('--message', message!, '--no-from', '--no-reply-to'); + const done = await run(ssh, [...base, `${quote(binary)} --stdio-request`], { + timeout: 90000, input: JSON.stringify({ schemaVersion: 1, args }) + }); + const uncertain = () => { if (command === 'send' && !flags.has('--dry-run') && done.spawned) throw new UnknownOutcome(); throw new Refusal('remote_response_unverified', 1); }; + if (done.interrupted || !done.spawned) return uncertain(); + let value: Record; + try { value = JSON.parse(done.stdout); } catch { return uncertain(); } + if (!value || value.schemaVersion !== 1 || value.command !== command || typeof value.ok !== 'boolean' || typeof value.host !== 'string' || + ![0, 1, 2].includes(done.code ?? -1) || (value.ok !== (done.code === 0))) return uncertain(); + const expectedStatus = flags.has('--dry-run') ? 'validated' : values.get('--to')?.startsWith('codex:') ? 'queued' : 'posted'; + if (command === 'send' && (value.consumptionConfirmed !== false || + (value.ok && (value.submitted !== !flags.has('--dry-run') || value.status !== expectedStatus)) || + (!value.ok && !((value.submitted === false && value.status === 'refused') || (value.submitted === null && value.status === 'unknown'))))) return uncertain(); + return { ...value, host: target, sshHost: target }; } -const args = process.argv.slice(2); -const command = supportedCommands.has(args[0] ?? '') ? args[0]! : 'unknown'; -const envelope = { schemaVersion: 1, host: hostname(), command }; +let command = 'unknown'; try { const [major, minor] = process.versions.node.split('.').map(Number); if (!((major === 22 && minor! >= 13) || major === 24)) throw new Refusal('unsupported_node_version'); - if (args.length === 1 && args[0] === '--version') { - console.log(`session-peer-ts-prototype ${version} (Python contract ${referenceVersion}; read-only)`); - } else if (args.length === 1 && ['--help', '-h'].includes(args[0]!)) { - console.log('Unpublished read-only prototype.\nlist --agent claude --json [--all]\nlist --agent codex --codex-home PATH --json [--all]\nNo send, dry-run, SSH, Relay, MCP, wake, update or implicit multi-agent discovery.'); - } else { - if (!['darwin', 'linux'].includes(process.platform)) throw new Refusal('unsupported_platform'); + if (!['darwin', 'linux'].includes(process.platform)) throw new Refusal('unsupported_platform'); + let args = process.argv.slice(2); + if (args.length === 1 && args[0] === '--version') console.log(`session-peer-ts ${VERSION}`); + else if (args.length === 1 && ['--help', '-h'].includes(args[0]!)) console.log('session-peer-ts preview: list --agent claude|codex --json; send --to TARGET --message TEXT --json [--dry-run] [--host HOST] [--remote-bin ABSOLUTE_PATH]. Codex requires --codex-home. Relay/MCP/wake/Windows unsupported.'); + else { + const wire = args.length === 1 && args[0] === '--stdio-request'; + if (wire) { + let request: unknown; + try { request = JSON.parse(await input()); } catch { throw new Refusal('invalid_remote_request'); } + const record = request as { schemaVersion?: unknown; args?: unknown }; + if (!record || record.schemaVersion !== 1 || !Array.isArray(record.args) || record.args.some(a => typeof a !== 'string')) throw new Refusal('invalid_remote_request'); + args = record.args; + } + command = ['list', 'send'].includes(args[0] ?? '') ? args[0]! : 'unknown'; const options = parse(args); - const result = options.agent === 'claude' ? claude(options.all) : await codex(options.home!, options.all); - const ok = result.ok !== false; - console.log(JSON.stringify({ ...envelope, ok, version, referenceVersion, ...result })); - process.exitCode = ok ? 0 : 1; + if (wire && (options.values.has('--host') || options.values.has('--remote-bin'))) throw new Refusal('nested_transport_forbidden'); + let message: string | undefined; + if (command === 'send') { + message = options.values.get('--message'); + if (message === undefined || message === '-') { if (wire) throw new Refusal('remote_message_required'); message = await input(); } + message = envelope(message, options.flags.has('--no-from'), options.values.get('--reply-address')); + checkMessage(message, options.values.get('--to')!.startsWith('codex:')); + } + let result: Record; + if (options.values.has('--host')) result = await remote(options, message); + else if (command === 'send') result = await send({ to: options.values.get('--to')!, home: options.values.get('--codex-home'), codexBin: options.values.get('--codex-bin'), message: message!, dryRun: options.flags.has('--dry-run') }); + else result = options.values.get('--agent') === 'claude' ? claude(options.flags.has('--all')) : await codex(options.values.get('--codex-home')!, options.flags.has('--all')); + console.log(JSON.stringify({ schemaVersion: 1, host: hostname(), command, ok: result.ok !== false, version: VERSION, referenceVersion: '1.0.2', ...result })); + process.exitCode = result.ok === false ? 1 : 0; } } catch (error) { - const failure = error instanceof Refusal ? error : new Refusal('discovery_failed', 1); - // Never echo argv, malformed database contents, exception strings or message text. - console.log(JSON.stringify({ ...envelope, ok: false, error: failure.code, - submitted: false, consumptionConfirmed: false })); - process.exitCode = failure.exitCode; + const unknown = error instanceof UnknownOutcome; + const failure = error instanceof Refusal ? error : new Refusal('operation_failed', 1); + console.log(JSON.stringify({ schemaVersion: 1, host: hostname(), command, ok: false, + error: unknown ? 'outcome_unknown' : failure.code, status: unknown ? 'unknown' : 'refused', + submitted: unknown ? null : false, consumptionConfirmed: false, retryAllowed: false })); + process.exitCode = unknown ? 1 : failure.exitCode; } diff --git a/experiments/node-cli/src/discovery.ts b/experiments/node-cli/src/discovery.ts new file mode 100644 index 00000000..6261911b --- /dev/null +++ b/experiments/node-cli/src/discovery.ts @@ -0,0 +1,106 @@ +// Deliberately read-only: no subprocess, network, socket connection or DB writes. +import { readFileSync, readdirSync, realpathSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; + +type Row = Record; +export class Refusal extends Error { + constructor(readonly code: string, readonly exitCode = 2) { super(code); } +} + +function missing(error: unknown): boolean { + return ['ENOENT', 'ENOTDIR'].includes((error as NodeJS.ErrnoException).code ?? ''); +} +export function canonical(path: string): string { + if (path === '~' || path.startsWith('~/')) path = join(homedir(), path.slice(2)); + else if (path.startsWith('~')) throw new Refusal('unsupported_home_expansion'); + const absolute = resolve(path); + try { return realpathSync(absolute); } + catch (error) { + if (!missing(error)) throw new Refusal('home_resolution_failed', 1); + const parent = dirname(absolute); + return parent === absolute ? absolute : join(canonical(parent), absolute.slice(parent.length)); + } +} +function alive(pid: number): boolean { + if (pid <= 1) return false; + try { process.kill(pid, 0); return true; } + catch (error) { return (error as NodeJS.ErrnoException).code === 'EPERM'; } +} +export function claude(all: boolean): Row { + const directory = join(process.env.CLAUDE_CONFIG_DIR || process.env.ANTHROPIC_CONFIG_DIR || join(homedir(), '.claude'), 'sessions'); + let entries: string[]; + try { entries = readdirSync(directory).sort(); } + catch (error) { + if (missing(error)) entries = []; + else throw new Refusal('claude_discovery_failed', 1); + } + const sessions: Row[] = []; + for (const entry of entries) { + if (!/^[0-9]+\.json$/.test(entry)) continue; + let record: Row; + try { + const value: unknown = JSON.parse(readFileSync(join(directory, entry), 'utf8')); + if (!value || typeof value !== 'object' || Array.isArray(value)) continue; + record = value as Row; + } catch { continue; } + const pid = record.pid; + if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid > 2147483647) continue; + const matches = pid === Number(entry.slice(0, -5)); + const live = matches && alive(pid); + const socket = typeof record.messagingSocketPath === 'string' ? record.messagingSocketPath : ''; + let inbox = false; + if (socket) { + try { inbox = statSync(socket).isSocket(); } catch { /* Inaccessible is not reachable. */ } + } + const row: Row = { agent: 'claude', pid, socket, alive: live, reachable: live && inbox }; + for (const key of ['name', 'status', 'cwd', 'kind', 'version', 'tmux']) row[key] = record[key] ?? null; + if (!matches) row.staleReason = 'record_pid_mismatch'; + if (all || row.reachable) sessions.push(row); + } + return { sessions, discovery: { claude: { status: 'ok' } } }; +} + +export async function codex(path: string, all: boolean): Promise { + const home = canonical(path); + const file = join(home, 'state_5.sqlite'); + const item: Row = { codexHome: home, stateDb: file, sources: ['argument'] }; + let sessions: Row[] = []; + let code: string | undefined; + try { + if (!statSync(file).isFile()) code = 'state_db_not_regular'; + } catch (error) { code = missing(error) ? 'state_db_missing' : 'state_db_read_failed'; } + if (!code) { + try { + // Lazy import: Claude-only commands don't initialize experimental SQLite. + const { DatabaseSync } = await import('node:sqlite'); + const db = new DatabaseSync(file, { readOnly: true }); + try { + db.exec('PRAGMA query_only=ON; PRAGMA trusted_schema=OFF; PRAGMA busy_timeout=3000'); + const columns = new Set((db.prepare('PRAGMA table_info(threads)').all() as Row[]).map(row => row.name)); + const required = ['id', 'title', 'cwd', 'updated_at', 'archived', 'rollout_path']; + if (!required.every(name => columns.has(name))) throw new Error('unsupported_schema'); + const name = columns.has('name') ? "COALESCE(NULLIF(name, ''), NULLIF(title, ''), id)" : "COALESCE(NULLIF(title, ''), id)"; + const rows = db.prepare(`SELECT id, ${name} AS display_name, cwd, updated_at, archived FROM threads ${all ? '' : 'WHERE archived = 0'} ORDER BY updated_at DESC, id ASC`).all() as Row[]; + sessions = rows.map(row => { + if (typeof row.id !== 'string' || !row.id || typeof row.display_name !== 'string' || + typeof row.updated_at !== 'number' || !Number.isSafeInteger(row.updated_at) || + typeof row.archived !== 'number' || (row.cwd !== null && typeof row.cwd !== 'string')) { + throw new Error('unsupported_row'); + } + // Python slicing counts Unicode code points, not JS UTF-16 units. + const firstLine = row.display_name.split(/[\n\r\v\f\x1c-\x1e\x85\u2028\u2029]/u)[0] ?? ''; + return { agent: 'codex', id: row.id, name: [...firstLine].slice(0, 120).join(''), + cwd: row.cwd, updatedAt: row.updated_at, archived: Boolean(row.archived), + codexHome: home, stateDb: file }; + }); + } finally { db.close(); } + } catch { code = 'state_db_read_failed'; sessions = []; } + } + const failed = Boolean(code); + Object.assign(item, failed ? { status: 'error', code, error: code } : { status: 'ok', sessionCount: sessions.length }); + const discovery: Row = { status: failed ? 'error' : 'ok', homes: [item] }; + if (failed) discovery.error = 'Codex home discovery is incomplete; inspect homes and errors'; + return { sessions, codexHome: home, discovery: { codex: discovery }, ok: !failed, + ...(failed ? { error: 'Discovery failed for: codex' } : {}) }; +} diff --git a/experiments/node-cli/src/process.ts b/experiments/node-cli/src/process.ts new file mode 100644 index 00000000..d5b81a56 --- /dev/null +++ b/experiments/node-cli/src/process.ts @@ -0,0 +1,38 @@ +import { spawn } from 'node:child_process'; +import { accessSync, constants, realpathSync, statSync } from 'node:fs'; +import { delimiter, isAbsolute, join } from 'node:path'; +import { Refusal } from './discovery.js'; + +export class UnknownOutcome extends Error {} +export function executable(name: string): string { + const paths = isAbsolute(name) ? [name] : name.includes('/') ? [] : + (process.env.PATH ?? '').split(delimiter).filter(Boolean).map(p => join(p, name)); + for (const path of paths) { + try { accessSync(path, constants.X_OK); if (statSync(path).isFile()) return realpathSync(path); } catch {} + } + throw new Refusal('executable_unavailable', 1); +} +export type Done = { code: number | null; stdout: string; spawned: boolean; interrupted: boolean }; +export function run(binary: string, args: string[], options: { + env?: NodeJS.ProcessEnv; input?: string; timeout?: number; limit?: number; +} = {}): Promise { + return new Promise(resolve => { + let stdout = '', bytes = 0, spawned = false, interrupted = false; + const child = spawn(binary, args, { shell: false, env: options.env ?? process.env, stdio: ['pipe', 'pipe', 'pipe'] }); + const stop = () => { interrupted = true; child.kill('SIGKILL'); }; + const timer = setTimeout(stop, options.timeout ?? 3000); + child.once('spawn', () => { spawned = true; }); + child.stdin.on('error', () => { /* Close/exit determines outcome, never resend. */ }); + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (part: string) => { + bytes += Buffer.byteLength(part); + if (bytes > (options.limit ?? 1024 * 1024)) stop(); + else stdout += part; + }); + // Drain but never report native stderr (it may contain message text/credentials). + child.stderr.on('data', (part: Buffer) => { bytes += part.length; if (bytes > (options.limit ?? 1024 * 1024)) stop(); }); + child.once('error', () => { interrupted = true; }); + child.once('close', code => { clearTimeout(timer); resolve({ code, stdout, spawned, interrupted }); }); + child.stdin.end(options.input); + }); +} diff --git a/experiments/node-cli/src/protocol.ts b/experiments/node-cli/src/protocol.ts new file mode 100644 index 00000000..fbb371c7 --- /dev/null +++ b/experiments/node-cli/src/protocol.ts @@ -0,0 +1,34 @@ +import { Refusal } from './discovery.js'; +import { uuid } from './writer.js'; +export const VERSION = '0.1.0-preview.0'; +export function host(value: string): string { + if (value.length > 255 || !/^(?:[A-Za-z0-9_][A-Za-z0-9_.-]*@)?[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(value)) throw new Refusal('invalid_ssh_host'); + return value; +} +export function reply(value: string): { to: string; host?: string; home?: string } { + if (value.length > 4096 || /[\x00-\x20\x7f]/.test(value) || /%(?![\da-f]{2})/i.test(value)) throw new Refusal('invalid_reply_uri'); + let url: URL; + try { url = new URL(value); } catch { throw new Refusal('invalid_reply_uri'); } + if (url.protocol !== 'session-peer:' || url.host !== 'v1' || url.pathname !== '/reply' || url.hash || url.username || url.password) throw new Refusal('invalid_reply_uri'); + const fields = new Map(); + for (const [key, item] of url.searchParams) { + if (!['agent', 'session', 'transport', 'host', 'codexHome'].includes(key) || fields.has(key) || !item || /[\x00-\x1f\x7f\ufffd]/.test(item)) throw new Refusal('invalid_reply_uri'); + fields.set(key, item); + } + const agent = fields.get('agent'), target = fields.get('session'), transport = fields.get('transport'); + if (!target || !['claude', 'codex'].includes(agent ?? '') || !['local', 'ssh'].includes(transport ?? '')) throw new Refusal('invalid_reply_uri'); + if (agent === 'codex' && !uuid(target)) throw new Refusal('invalid_reply_uri'); + if (agent !== 'codex' && fields.has('codexHome')) throw new Refusal('invalid_reply_uri'); + if (transport === 'local' && fields.has('host')) throw new Refusal('invalid_reply_uri'); + if (transport === 'ssh' && !fields.has('host')) throw new Refusal('invalid_reply_uri'); + return { to: agent === 'codex' ? `codex:${target}` : target, + ...(transport === 'ssh' ? { host: host(fields.get('host')!) } : {}), + ...(fields.has('codexHome') ? { home: fields.get('codexHome')! } : {}) }; +} +export function envelope(text: string, noFrom: boolean, address?: string): string { + const sender = process.env.CODEX_THREAD_ID || process.env.CODEX_SESSION_ID; + const from = !noFrom && sender && uuid(sender) ? `From: codex:${sender}\n\n` : ''; + if (address) reply(address); + // No inferred identity/reverse route and no executable Reply command. + return from + text.replace(/^\n+|\n+$/g, '') + (address ? `\n\n---\nReply-To: ${address}` : ''); +} diff --git a/experiments/node-cli/src/send.ts b/experiments/node-cli/src/send.ts new file mode 100644 index 00000000..a29029c8 --- /dev/null +++ b/experiments/node-cli/src/send.ts @@ -0,0 +1,74 @@ +import { createConnection } from 'node:net'; +import { canonical, claude, Refusal } from './discovery.js'; +import { executable, run, UnknownOutcome } from './process.js'; +import { resolveWriter, uuid } from './writer.js'; + +export type SendOptions = { to: string; home?: string; codexBin?: string; message: string; dryRun?: boolean }; +export function checkMessage(text: string, codex = false): void { + if (!text.trim() || text.includes('\0') || [...text].length > 1_000_000 || + (codex && Buffer.byteLength(text, 'utf8') > 32768)) throw new Refusal('invalid_message', 2); +} +export async function postSocket(path: string, text: string): Promise { + return new Promise((resolve, reject) => { + let attempted = false, finished = false; + const socket = createConnection(path); + const timer = setTimeout(() => finish(new Error('timeout')), 10000); + const finish = (error?: Error) => { + if (finished) return; + finished = true; clearTimeout(timer); socket.destroy(); + if (error) reject(attempted ? new UnknownOutcome() : new Refusal('inbox_unreachable', 1)); + else resolve(); + }; + socket.on('error', error => finish(error)); + socket.once('connect', () => { + attempted = true; + socket.end(JSON.stringify({ type: 'user', message: { role: 'user', content: text } }) + '\n', () => { + // Write completion is submission only, never consumption. Match Python's drain window. + clearTimeout(timer); + const drain = setTimeout(() => finish(), 2000); + socket.once('close', () => { clearTimeout(drain); finish(); }); + socket.once('data', () => { clearTimeout(drain); finish(); }); + }); + }); + }); +} +export async function send(options: SendOptions): Promise> { + checkMessage(options.message, options.to.startsWith('codex:')); + const base = { ok: true, submitted: !options.dryRun, consumptionConfirmed: false, + dryRun: Boolean(options.dryRun), chars: [...options.message].length }; + if (options.to.startsWith('codex:')) { + const id = options.to.slice(6).toLowerCase(); + if (!uuid(id) || !options.home) throw new Refusal('codex_uuid_and_explicit_home_required'); + const home = canonical(options.home); + const binary = executable(options.codexBin ?? 'codex'); + const evidence = await resolveWriter(home, id); + const result = { ...base, target: { agent: 'codex', id }, codexHome: home, + status: options.dryRun ? 'validated' : 'queued' }; + if (options.dryRun) return result; + if (await resolveWriter(home, id) !== evidence) throw new Refusal('writer_evidence_changed_before_queue', 1); + const done = await run(binary, ['queue', '--thread', id, '--message', options.message], + { env: { ...process.env, CODEX_HOME: home }, timeout: 30000 }); + if (!done.spawned) throw new Refusal('native_spawn_failed', 1); + if (done.interrupted || done.code !== 0) throw new UnknownOutcome(); + return result; + } + if (options.to.includes(':') && !options.to.startsWith('claude:')) throw new Refusal('unsupported_agent'); + if (options.home || options.codexBin) throw new Refusal('inapplicable_option'); + const target = options.to.replace(/^claude:/, ''); + // ASCII name folding is explicit; Unicode names must use a PID until full casefold parity exists. + if (!target || /[^\x20-\x7e]/.test(target)) throw new Refusal('use_pid_for_unicode_name'); + const select = () => { + const rows = claude(false).sessions as Record[]; + const matches = rows.filter(r => /^\d+$/.test(target) ? r.pid === Number(target) : + typeof r.name === 'string' && r.name.toLowerCase() === target.toLowerCase()); + if (matches.length !== 1) throw new Refusal(matches.length ? 'ambiguous_target' : 'no_reachable_target', 2); + return matches[0]!; + }; + const row = select(); + const result = { ...base, target: { agent: 'claude', pid: row.pid, name: row.name }, status: options.dryRun ? 'validated' : 'posted' }; + if (options.dryRun) return result; + const rechecked = select(); + if (row.pid !== rechecked.pid || row.socket !== rechecked.socket) throw new Refusal('target_changed', 1); + await postSocket(String(row.socket), options.message); + return result; +} diff --git a/experiments/node-cli/src/writer.ts b/experiments/node-cli/src/writer.ts new file mode 100644 index 00000000..90f73c8c --- /dev/null +++ b/experiments/node-cli/src/writer.ts @@ -0,0 +1,108 @@ +import { closeSync, constants, fstatSync, lstatSync, openSync, readdirSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { isAbsolute, join } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { canonical, Refusal } from './discovery.js'; +import { executable, run } from './process.js'; + +const missing = (error: unknown) => ['ENOENT', 'ENOTDIR'].includes((error as NodeJS.ErrnoException).code ?? ''); +export const uuid = (value: string) => /^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/i.test(value); +function snapshot(path: string): string { + const s = lstatSync(path, { bigint: true }); + return [s.dev, s.ino, s.size, s.mtimeNs].join(':'); +} +export async function probeLock(path: string): Promise<'free' | 'held' | 'absent' | 'unknown'> { + let fd: number | undefined; + try { + const before = lstatSync(path); + if (!before.isFile() || before.isSymbolicLink()) return 'unknown'; + const { flockSync } = await import('fs-ext-extra-prebuilt'); + fd = openSync(path, constants.O_RDWR | constants.O_NOFOLLOW); + const after = fstatSync(fd); + if (before.dev !== after.dev || before.ino !== after.ino) return 'unknown'; + try { flockSync(fd, 'exnb'); } + catch (error) { return ['EAGAIN', 'EACCES', 'EWOULDBLOCK'].includes((error as NodeJS.ErrnoException).code ?? '') ? 'held' : 'unknown'; } + flockSync(fd, 'un'); + return 'free'; + } catch (error) { return missing(error) ? 'absent' : 'unknown'; } + finally { if (fd !== undefined) closeSync(fd); } +} +type Owner = { pid: number; uid?: number; command?: string; start?: string }; +async function sample(path: string) { + const state = await probeLock(path); + if (state !== 'held') return { state, fingerprint: '', owners: [] as Owner[], valid: true }; + const fingerprint = snapshot(path); + const listed = await run(executable('lsof'), ['-nP', '-F0pcu', '--', path]); + const owners: Owner[] = []; + let current: Owner | undefined; + for (const token of listed.stdout.split(/[\0\n]/)) { + const value = token.slice(1).trim(); + if (token[0] === 'p') { current = { pid: Number(value) }; owners.push(current); } + if (current && token[0] === 'u') current.uid = Number(value); + if (current && token[0] === 'c') current.command = value; + } + for (const owner of owners) { + if (!Number.isSafeInteger(owner.pid) || owner.pid <= 1) continue; + const result = await run(executable('ps'), ['-p', String(owner.pid), '-o', 'lstart='], { env: { ...process.env, LC_ALL: 'C' } }); + if (result.code === 0 && !result.interrupted) owner.start = result.stdout.trim(); + } + return { state, fingerprint, owners, valid: listed.code === 0 && !listed.interrupted }; +} +export async function inspectWriter(home: string, id: string) { + const path = join(home, 'thread-writer-locks', `${id}.lock`); + const before = await sample(path); + if (before.state === 'free' || before.state === 'absent') return { activity: 'inactive', identity: '' }; + if (before.state !== 'held') throw new Refusal('active_writer_unverified', 1); + await delay(250); + const after = await sample(path); + const owner = after.owners[0]; + if (!before.valid || !after.valid || before.state !== after.state || before.fingerprint !== after.fingerprint || + before.owners.length !== 1 || after.owners.length !== 1 || JSON.stringify(before.owners) !== JSON.stringify(after.owners) || + !owner?.start || owner.uid !== process.getuid?.() || !/^codex(?:-|$)/i.test(owner.command ?? '')) { + throw new Refusal('active_writer_unverified', 1); + } + return { activity: 'live_writer', identity: JSON.stringify({ fingerprint: after.fingerprint, ...owner }) }; +} +export function homes(selected: string): string[] { + const result = new Set([canonical(selected)]); + const addExisting = (home: string) => { + try { if (!statSync(join(home, 'state_5.sqlite')).isFile()) throw new Refusal('home_inventory_unreadable', 1); result.add(canonical(home)); } + catch (error) { if (!missing(error)) throw new Refusal('home_inventory_unreadable', 1); } + }; + addExisting(join(homedir(), '.codex')); + if (process.env.CODEX_HOME) addExisting(process.env.CODEX_HOME); + if (process.platform === 'darwin') { + const directory = join(homedir(), 'Library/Application Support/orca/codex-accounts'); + let entries: string[] = []; + try { entries = readdirSync(directory).sort(); } + catch (error) { if (!missing(error)) throw new Refusal('home_inventory_unreadable', 1); } + for (const entry of entries) addExisting(join(directory, entry, 'home')); + } + if (process.env.SESSION_PEER_CODEX_HOMES) { + let paths: unknown; + try { paths = JSON.parse(process.env.SESSION_PEER_CODEX_HOMES); } catch { throw new Refusal('invalid_home_configuration', 1); } + if (!Array.isArray(paths) || paths.some(p => typeof p !== 'string' || (!isAbsolute(p) && !p.startsWith('~/')))) throw new Refusal('invalid_home_configuration', 1); + for (const path of paths) result.add(canonical(path)); + } + return [...result].sort(); +} +export async function resolveWriter(selected: string, id: string): Promise { + const { DatabaseSync } = await import('node:sqlite'); + const candidates = homes(selected); + const matches: { home: string; activity: string; identity: string }[] = []; + for (const home of candidates) { + let saved = false; + try { + const db = new DatabaseSync(join(home, 'state_5.sqlite'), { readOnly: true }); + try { db.exec('PRAGMA query_only=ON; PRAGMA trusted_schema=OFF; PRAGMA busy_timeout=3000'); saved = Boolean(db.prepare('SELECT 1 FROM threads WHERE id=? LIMIT 1').get(id)); } + finally { db.close(); } + } catch { throw new Refusal('home_inventory_unreadable', 1); } + if (saved) matches.push({ home, ...await inspectWriter(home, id) }); + } + const live = matches.filter(c => c.activity === 'live_writer'); + if (!matches.length) throw new Refusal('thread_not_saved_in_known_homes', 2); + if (live.length !== 1) throw new Refusal(live.length ? 'multiple_live_writers' : 'inactive_writer', 1); + if (live[0]!.home !== canonical(selected)) throw new Refusal('explicit_home_conflicts_with_live_writer', 1); + // Include inactive competitors and inventory in the pre-submit revalidation token. + return JSON.stringify({ candidates, matches }); +} diff --git a/experiments/node-cli/test/conformance.test.mjs b/experiments/node-cli/test/conformance.test.mjs index 74e67f14..d3787cc7 100644 --- a/experiments/node-cli/test/conformance.test.mjs +++ b/experiments/node-cli/test/conformance.test.mjs @@ -9,9 +9,10 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { createServer } from 'node:net'; -const root = fileURLToPath(new URL('../../../', import.meta.url)); +const root = process.env.SESSION_PEER_PYTHON_ROOT ?? fileURLToPath(new URL('../../../', import.meta.url)); const cli = fileURLToPath(new URL('../dist/cli.js', import.meta.url)); -const fixture = JSON.parse(readFileSync(join(root, 'tests/fixtures/compatibility-v1.json'))); +const fixture = JSON.parse(readFileSync(new URL('./fixtures/compatibility-v1.json', import.meta.url))); +assert.deepEqual(fixture, JSON.parse(readFileSync(join(root, 'tests/fixtures/compatibility-v1.json'))), 'vendored contract must match the pinned Python reference'); const python = process.env.PYTHON ?? 'python3'; function sandbox(t) { @@ -133,7 +134,7 @@ test('unsupported commands/transports fail before inspecting state or sending an for (const args of [ ['send', '--to', 'SECRET-SENTINEL', '--message', 'SECRET-SENTINEL'], ['send', '--dry-run'], ['device'], ['doctor'], ['update'], ['reply'], - ['list', '--agent', 'claude', '--host', 'SECRET-SENTINEL'], + ['list', '--agent', 'claude', '--host', '-SECRET-SENTINEL'], ['list', '--agent', 'claude', '--device', 'SECRET-SENTINEL'], ['list', '--agent', 'antigravity'], ['list'], ['list', '--agent', 'codex'], ['list', '--agent', 'claude', '--all=false'], ['list', '--agent', 'claude', '--wake'], diff --git a/experiments/node-cli/test/fixtures/compatibility-v1.json b/experiments/node-cli/test/fixtures/compatibility-v1.json new file mode 100644 index 00000000..a5a74555 --- /dev/null +++ b/experiments/node-cli/test/fixtures/compatibility-v1.json @@ -0,0 +1,31 @@ +{ + "schemaVersion": 1, + "exitCodes": {"success": 0, "error": 1, "noTargetOrUsage": 2}, + "jsonResult": { + "required": {"schemaVersion": "int", "ok": "bool", "host": "str", "command": "str"}, + "example": {"schemaVersion": 1, "ok": true, "host": "fixture", "command": "list", "sessions": []} + }, + "replyUri": "session-peer://v1/reply?agent=claude&session=worker&transport=local", + "mcpPolicy": { + "schemaVersion": 1, + "destinations": { + "fixture": {"agents": ["claude"], "capabilities": ["list", "send"]} + } + }, + "relayPolicy": { + "targets": {"review": {"agent": "claude", "target": "worker"}}, + "peers": {"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": {"capabilities": ["list", "send"], "targets": ["review"]}} + }, + "backupManifest": { + "schemaVersion": 1, + "required": ["createdAt", "device", "generation", "keyFingerprint", "requestCount", "files"] + }, + "operationReceipt": { + "operationId": "11111111-1111-4111-8111-111111111111", + "committed": true, + "principal": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "keyFingerprint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "keyGeneration": 0 + }, + "relayProtocol": {"alpn": "session-peer-device-v1", "publicStateSchemaVersion": 1, "replayStateSchemaVersion": 1} +} diff --git a/experiments/node-cli/test/package-smoke.mjs b/experiments/node-cli/test/package-smoke.mjs index 7ab4e054..a0284fc3 100644 --- a/experiments/node-cli/test/package-smoke.mjs +++ b/experiments/node-cli/test/package-smoke.mjs @@ -12,15 +12,16 @@ const runNpm = args => execFileSync(process.execPath, [npm, ...args], { encoding try { const metadata = JSON.parse(readFileSync('package.json')); assert.equal(metadata.private, true); - assert.equal(metadata.dependencies, undefined); + assert.deepEqual(metadata.dependencies, { 'fs-ext-extra-prebuilt': '2.2.14' }); for (const hook of ['preinstall', 'install', 'postinstall', 'prepare', 'prepack']) { assert.equal(metadata.scripts[hook], undefined); } - assert.deepEqual(readFileSync('LICENSE'), readFileSync('../../LICENSE')); + assert.match(readFileSync('LICENSE', 'utf8'), /MIT License/); + assert.equal(execFileSync(process.execPath, ['dist/cli.js', '--version'], { encoding: 'utf8' }).trim(), `${metadata.name} ${metadata.version}`); let hash; for (let index = 0; index < 2; index++) { const [packed] = JSON.parse(runNpm(['pack', '--ignore-scripts', '--json', '--pack-destination', task])); - assert.deepEqual(packed.files.map(file => file.path).sort(), ['LICENSE', 'README.md', 'dist/cli.js', 'package.json']); + assert.deepEqual(packed.files.map(file => file.path).sort(), ['LICENSE', 'README.md', 'dist/cli.js', 'dist/discovery.js', 'dist/process.js', 'dist/protocol.js', 'dist/send.js', 'dist/writer.js', 'package.json']); const next = createHash('sha256').update(readFileSync(join(task, packed.filename))).digest('hex'); if (hash) assert.equal(next, hash, 'same build must produce identical tarball'); hash = next; @@ -28,8 +29,8 @@ try { const prefix = join(task, 'install'); const home = join(task, 'empty-home'); mkdirSync(home); - runNpm(['install', '--prefix', prefix, '--ignore-scripts', '--no-audit', '--no-fund', join(task, 'session-peer-ts-prototype-0.0.0.tgz')]); - const binary = join(prefix, 'node_modules/.bin/session-peer-ts-prototype'); + runNpm(['install', '--prefix', prefix, '--ignore-scripts', '--no-audit', '--no-fund', join(task, `${metadata.name}-${metadata.version}.tgz`)]); + const binary = join(prefix, 'node_modules/.bin/session-peer-ts'); assert.ok(statSync(binary).mode & 0o111); const result = JSON.parse(execFileSync(process.execPath, [binary, 'list', '--agent', 'claude', '--json'], { encoding: 'utf8', timeout: 10000, diff --git a/experiments/node-cli/test/transport.test.mjs b/experiments/node-cli/test/transport.test.mjs new file mode 100644 index 00000000..64ffb909 --- /dev/null +++ b/experiments/node-cli/test/transport.test.mjs @@ -0,0 +1,122 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, symlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, delimiter } from 'node:path'; +import { createServer } from 'node:net'; +import { once } from 'node:events'; +import { fileURLToPath } from 'node:url'; +import { probeLock, inspectWriter } from '../dist/writer.js'; +import { reply, envelope } from '../dist/protocol.js'; + +const cli = fileURLToPath(new URL('../dist/cli.js', import.meta.url)); +const python = process.env.PYTHON ?? 'python3'; +const id = '11111111-1111-4111-8111-111111111111'; +function setup(t) { + const path = mkdtempSync(join(process.env.TASK_TEMP ?? tmpdir(), 'codex-send-')); + mkdirSync(join(path, '.claude/sessions'), { recursive: true }); + const env = { ...process.env, HOME: path, USERPROFILE: path, CLAUDE_CONFIG_DIR: join(path, '.claude'), CODEX_HOME: '', SESSION_PEER_CODEX_HOMES: '', CODEX_THREAD_ID: '', CODEX_SESSION_ID: '' }; + t.after(() => rmSync(path, { recursive: true })); + return { path, env }; +} +async function invoke(args, env) { + const child = spawn(process.execPath, [cli, ...args, '--json'], { env, stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; child.stdout.on('data', x => { stdout += x; }); child.stderr.resume(); + const timer = setTimeout(() => child.kill('SIGKILL'), 15000); + const [code, signal] = await once(child, 'close'); clearTimeout(timer); + assert.equal(signal, null); + return { code, ...JSON.parse(stdout) }; +} +async function inbox(t, path) { + const messages = []; + const socket = join(path, 'sock'); + const server = createServer(s => { + let data = ''; s.on('data', chunk => { data += chunk; }); + s.on('end', () => { messages.push(JSON.parse(data)); s.end(); }); + }); + server.listen(socket); await once(server, 'listening'); + t.after(() => new Promise(ok => server.close(ok))); + writeFileSync(join(path, '.claude/sessions', `${process.pid}.json`), JSON.stringify({ pid: process.pid, name: 'fixture', messagingSocketPath: socket })); + return messages; +} +test('real Unix inbox: dry-run does not write, send writes exactly one framed message', async t => { + const { path, env } = setup(t), messages = await inbox(t, path); + const args = ['send', '--to', String(process.pid), '--message', 'ν•œκΈ€\nsecond', '--no-from', '--no-reply-to']; + const dry = await invoke([...args, '--dry-run'], env); + assert.equal(dry.status, 'validated'); assert.equal(dry.submitted, false); assert.equal(messages.length, 0); + const sent = await invoke(args, env); + assert.equal(sent.status, 'posted'); assert.equal(sent.consumptionConfirmed, false); + assert.deepEqual(messages, [{ type: 'user', message: { role: 'user', content: 'ν•œκΈ€\nsecond' } }]); +}); +test('structured replies are inert data and reject conflicting/unsafe fields', () => { + assert.deepEqual(reply('session-peer://v1/reply?agent=claude&session=worker&transport=local'), { to: 'worker' }); + assert.equal(reply(`session-peer://v1/reply?agent=codex&session=${id}&transport=ssh&host=user%40host&codexHome=%2Ftmp%2Fhome`).host, 'user@host'); + for (const suffix of ['&agent=claude', '&host=bad', '&extra=x', '&codexHome=/tmp', '&x=%ZZ']) { + assert.throws(() => reply('session-peer://v1/reply?agent=claude&session=worker&transport=local' + suffix)); + } + assert.throws(() => reply('session-peer://v1/reply?agent=claude&session=x&transport=ssh&host=-oProxyCommand%3Did')); + assert.equal(envelope('hello', true), 'hello'); +}); + +function database(home) { + mkdirSync(join(home, 'thread-writer-locks'), { recursive: true }); + const result = spawnSync(python, ['-c', + 'import sqlite3,sys\nc=sqlite3.connect(sys.argv[1]); c.execute("CREATE TABLE threads(id TEXT)"); c.execute("INSERT INTO threads VALUES (?)",(sys.argv[2],)); c.commit(); c.close()', + join(home, 'state_5.sqlite'), id]); + assert.equal(result.status, 0, result.stderr.toString()); +} +async function holder(t, path, home, name = 'codex-fixture') { + // Actual OS flock held by a separate native process, not a mocked lock probe. + const source = join(path, 'holder.c'), binary = join(path, name); + writeFileSync(source, '#include \n#include \n#include \n#include \nint main(int argc,char**argv){int f=open(argv[1],O_RDWR|O_CREAT,0600); if(f<0||flock(f,LOCK_EX|LOCK_NB))return 1;puts("ready");fflush(stdout); sleep(60);return 0;}\n'); + const built = spawnSync('cc', [source, '-o', binary]); + assert.equal(built.status, 0, built.stderr.toString()); + const child = spawn(binary, [join(home, 'thread-writer-locks', `${id}.lock`)], { stdio: ['ignore', 'pipe', 'inherit'] }); + t.after(async () => { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await once(child, 'close'); } }); + await once(child.stdout, 'data'); + return child; +} +test('actual kernel lock + owner correlation; symlink and non-Codex owner fail closed', async t => { + const { path } = setup(t), home = join(path, 'home'); database(home); + const lock = join(home, 'thread-writer-locks', `${id}.lock`); + assert.equal(await probeLock(lock), 'absent'); + writeFileSync(lock, ''); assert.equal(await probeLock(lock), 'free'); + symlinkSync(lock, join(path, 'alias')); assert.equal(await probeLock(join(path, 'alias')), 'unknown'); + await holder(t, path, home, 'not-codex'); + assert.equal(await probeLock(lock), 'held'); + await assert.rejects(inspectWriter(home, id), /active_writer_unverified/); +}); +test('Codex guarded queue runs once, refuses inactive or competing live writers', async t => { + const { path, env } = setup(t), home = join(path, 'home'); database(home); + const queue = join(path, 'queue'), count = join(path, 'count'); + writeFileSync(queue, `#!${process.execPath}\nrequire('node:fs').appendFileSync(${JSON.stringify(count)},'1'); console.log('queued');`, { mode: 0o700 }); + const args = ['send', '--to', `codex:${id}`, '--codex-home', home, '--codex-bin', queue, '--message', 'fixture', '--no-from']; + assert.equal((await invoke(args, env)).error, 'inactive_writer'); + await holder(t, path, home); + const dry = await invoke([...args, '--dry-run'], env); assert.equal(dry.status, 'validated', JSON.stringify(dry)); + const sent = await invoke(args, env); assert.equal(sent.status, 'queued', JSON.stringify(sent)); + assert.equal(readFileSync(count, 'utf8'), '1'); assert.equal(sent.consumptionConfirmed, false); + const second = join(path, 'second'); database(second); + await holder(t, path, second, 'codex-second'); + const ambiguous = await invoke(args, { ...env, SESSION_PEER_CODEX_HOMES: JSON.stringify([second]) }); + assert.equal(ambiguous.error, 'multiple_live_writers'); assert.equal(readFileSync(count, 'utf8'), '1'); +}); +test('queue failure after spawn is unknown, never retried or leaked', async t => { + const { path, env } = setup(t), home = join(path, 'home'); database(home); await holder(t, path, home); + const queue = join(path, 'queue'), count = join(path, 'count'); + writeFileSync(queue, `#!${process.execPath}\nrequire('node:fs').appendFileSync(${JSON.stringify(count)},'1'); console.error('SECRET-SENTINEL');process.exit(1);`, { mode: 0o700 }); + const result = await invoke(['send', '--to', `codex:${id}`, '--codex-home', home, '--codex-bin', queue, '--message', 'SECRET-SENTINEL'], env); + assert.equal(result.status, 'unknown'); assert.equal(result.submitted, null); assert.equal(result.retryAllowed, false); + assert.equal(JSON.stringify(result).includes('SECRET-SENTINEL'), false); assert.equal(readFileSync(count, 'utf8'), '1'); +}); +test('SSH request framing: no message in remote command; response loss never retries', async t => { + const { path, env } = setup(t), messages = await inbox(t, path); + const ssh = join(path, 'ssh'); + writeFileSync(ssh, `#!${process.execPath}\nconst {spawnSync}=require('node:child_process'); const a=process.argv.slice(2);if(a.at(-1).endsWith('--version')){console.log('session-peer-ts 0.1.0-preview.0');process.exit(0);}if(a.at(-1).includes('SECRET-SENTINEL'))process.exit(99);const input=require('node:fs').readFileSync(0,'utf8');const r=spawnSync(${JSON.stringify(process.execPath)},[${JSON.stringify(cli)},'--stdio-request'],{input,encoding:'utf8',env:process.env});process.stdout.write(r.stdout);process.exit(r.status);`, { mode: 0o700 }); + const result = await invoke(['send', '--host', 'fixture', '--to', String(process.pid), '--message', 'SECRET-SENTINEL', '--no-from'], { ...env, PATH: path + delimiter + env.PATH }); + assert.equal(result.status, 'posted'); assert.equal(messages.length, 1); + writeFileSync(ssh, `#!${process.execPath}\nif(process.argv.at(-1).endsWith('--version'))console.log('session-peer-ts 0.1.0-preview.0');else process.exit(255);`, { mode: 0o700 }); + const lost = await invoke(['send', '--host', 'fixture', '--to', 'fixture', '--message', 'lost'], { ...env, PATH: path + delimiter + env.PATH }); + assert.equal(lost.status, 'unknown'); assert.equal(lost.retryAllowed, false); assert.equal(messages.length, 1); +});