diff --git a/README.md b/README.md index 94311a6..154edb4 100644 --- a/README.md +++ b/README.md @@ -325,6 +325,8 @@ active version. Candidate build runs the complete local acceptance, lint, and syntax gates before writing its receipt. Git checkout staging is bound to a clean exact commit, copies tracked source plus its local dependency tree, excludes unrelated untracked files, and refuses escaping symbolic links: +Activation maintains an installation-owned stable `torch` launcher pointing +through the atomic active-version link; it refuses an unrelated existing path. ```bash node bin/torch.mjs candidate plan --source . --json diff --git a/bin/torch.mjs b/bin/torch.mjs old mode 100644 new mode 100755 diff --git a/docs/PORTABLE_AGENT_FLEET_SPEC.md b/docs/PORTABLE_AGENT_FLEET_SPEC.md index 4a05776..3ab4cdc 100644 --- a/docs/PORTABLE_AGENT_FLEET_SPEC.md +++ b/docs/PORTABLE_AGENT_FLEET_SPEC.md @@ -1371,6 +1371,12 @@ active pointer. Running processes may finish on the old version. Rollback restores the previous pointer without rewriting project state. State migrations MUST declare forward and rollback compatibility. +Activation also maintains a stable executable launcher at +`~/.local/bin/torch` (or an explicitly configured installation bin directory) +that resolves through the atomic active-version pointer. Candidate validation +hashes executable modes as well as bytes, rejects a non-executable TORCH binary, +and refuses to replace a launcher path not owned by that installation. + ## 27. User interfaces ### 27.1 CLI diff --git a/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml b/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml index b23982f..73b96df 100644 --- a/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml +++ b/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml @@ -121,7 +121,7 @@ test_integrity_note: - id: SCN-candidate-isolation given: an inactive candidate in a namespace separate from project and stable runtime state when: acceptance is incomplete, complete, or the accepted files are later modified - then: incomplete staging is removed, successful staging stays inactive, read-only status writes nothing, stale validation blocks activation, Git staging binds to a clean exact commit, local dependencies remain runnable, and unrelated untracked files never enter the artifact + then: incomplete staging is removed, successful staging stays inactive, read-only status writes nothing, stale validation blocks activation, Git staging binds to a clean exact commit, local dependencies remain runnable, unrelated untracked files never enter the artifact, activation exposes a runnable stable launcher, and an owner path conflict blocks before pointer mutation - id: SCN-atomic-upgrade-rollback given: accepted forward-and-rollback-compatible versions installed side by side when: activation is interrupted after the atomic pointer swap and the service restarts diff --git a/src/self-host/service.mjs b/src/self-host/service.mjs index e6020bc..2d0a48d 100644 --- a/src/self-host/service.mjs +++ b/src/self-host/service.mjs @@ -4,6 +4,7 @@ import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, symlinkSync, writeFileSync, } from 'node:fs'; +import { homedir } from 'node:os'; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { TorchError } from '../kernel/errors.mjs'; import { torchDataHome } from '../kernel/paths.mjs'; @@ -39,6 +40,13 @@ function json(path) { } } +function nodeExists(path) { + try { lstatSync(path); return true; } catch (error) { + if (error.code === 'ENOENT') return false; + throw error; + } +} + function atomicJson(path, value) { mkdirSync(dirname(path), { recursive: true }); const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`; @@ -78,10 +86,12 @@ function inventory(root, { excludeValidation = false, include = () => true } = { code: 'CANDIDATE_SYMLINK_REFUSED', details: { path: name, target: link }, }); } - records.push({ path: name, bytes: Buffer.byteLength(link), type: 'symlink', link }); + records.push({ path: name, bytes: Buffer.byteLength(link), type: 'symlink', link, mode: stat.mode & 0o777 }); } if (stat.isDirectory()) visit(path); - else if (stat.isFile()) records.push({ path: name, bytes: stat.size, type: 'file' }); + else if (stat.isFile()) records.push({ + path: name, bytes: stat.size, type: 'file', mode: stat.mode & 0o777, + }); else if (stat.isSymbolicLink()) { /* recorded above */ } else throw new TorchError(`Unsupported candidate artifact entry: ${name}`, { code: 'CANDIDATE_ENTRY_REFUSED', details: { path: name }, @@ -95,7 +105,7 @@ function inventory(root, { excludeValidation = false, include = () => true } = { function treeHash(root, records) { const digest = createHash('sha256'); for (const record of records) { - digest.update(record.path).update('\0').update(record.type).update('\0'); + digest.update(record.path).update('\0').update(record.type).update('\0').update(String(record.mode)).update('\0'); digest.update(record.type === 'symlink' ? record.link : readFileSync(join(root, record.path))).update('\0'); } return digest.digest('hex'); @@ -163,6 +173,11 @@ function validateReleaseMetadata(root, requestedVersion) { code: 'CANDIDATE_BINARY_MISSING', details: { bin: bin ?? null }, }); } + if ((lstatSync(join(root, bin)).mode & 0o111) === 0) { + throw new TorchError('Candidate TORCH binary is not executable', { + code: 'CANDIDATE_BINARY_NOT_EXECUTABLE', details: { bin }, + }); + } return { version, packageMetadata, release, bin }; } @@ -179,6 +194,8 @@ export class VersionService { this.activePath = join(this.root, 'active'); this.registryPath = join(this.root, 'registry.json'); this.journalPath = join(this.root, 'activation-journal.json'); + this.binRoot = env.TORCH_BIN_HOME || join(env.HOME || homedir(), '.local', 'bin'); + this.launcherPath = join(this.binRoot, 'torch'); this.now = now; this.afterPointerSwap = afterPointerSwap; } @@ -295,6 +312,7 @@ export class VersionService { const activeVersion = this.#activeVersion(); const registry = this.#registry(); if (activeVersion === journal.to) { + this.#ensureLauncher(); atomicJson(this.registryPath, { ...registry, activeVersion: journal.to, previousVersion: journal.from, generation: registry.generation + 1, updatedAt: this.now().toISOString(), @@ -307,6 +325,31 @@ export class VersionService { rmSync(this.journalPath, { force: true }); } + #launcherTarget() { + return relative(this.binRoot, join(this.activePath, 'bin', 'torch.mjs')); + } + + #assertLauncherAvailable() { + const target = this.#launcherTarget(); + if (nodeExists(this.launcherPath)) { + let actual = null; + try { actual = readlinkSync(this.launcherPath); } catch { /* conflict reported below */ } + if (actual !== target) throw new TorchError('TORCH launcher path is not owned by this installation', { + code: 'LAUNCHER_PATH_CONFLICT', details: { path: this.launcherPath, expected: target, actual }, + }); + } + return target; + } + + #ensureLauncher() { + const target = this.#assertLauncherAvailable(); + if (nodeExists(this.launcherPath)) return; + mkdirSync(this.binRoot, { recursive: true }); + const temporary = `${this.launcherPath}.tmp-${process.pid}-${randomUUID()}`; + symlinkSync(target, temporary, 'file'); + renameSync(temporary, this.launcherPath); + } + status() { this.#reconcile(); const registry = this.#registry(); @@ -320,6 +363,11 @@ export class VersionService { root: this.root, activeVersion, previousVersion: registry.previousVersion, generation: registry.generation, installed, consistent: activeVersion === registry.activeVersion, + launcher: { + path: this.launcherPath, + installed: nodeExists(this.launcherPath), + expectedTarget: this.#launcherTarget(), + }, }; } @@ -355,6 +403,7 @@ export class VersionService { }); } } + this.#assertLauncherAvailable(); atomicJson(this.journalPath, { schema: 'torch.dev/activation-journal/v1alpha1', from, to: version, startedAt: this.now().toISOString(), }); diff --git a/test/fleet/cli-scenarios.test.mjs b/test/fleet/cli-scenarios.test.mjs index 9739ffa..b20b7f7 100644 --- a/test/fleet/cli-scenarios.test.mjs +++ b/test/fleet/cli-scenarios.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { execFileSync, spawnSync } from 'node:child_process'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { basename, join } from 'node:path'; import test from 'node:test'; @@ -21,7 +21,11 @@ function repo() { function run(root, args) { return spawnSync(process.execPath, [CLI, ...args], { cwd: root, - env: { ...process.env, XDG_DATA_HOME: join(tmpdir(), `${basename(root)}-data`) }, + env: { + ...process.env, + XDG_DATA_HOME: join(tmpdir(), `${basename(root)}-data`), + TORCH_BIN_HOME: join(tmpdir(), `${basename(root)}-bin`), + }, encoding: 'utf8', }); } @@ -30,6 +34,7 @@ function releaseCandidate(root, version) { const candidateRoot = join(root, `release-${version}`); mkdirSync(join(candidateRoot, 'bin'), { recursive: true }); writeFileSync(join(candidateRoot, 'bin', 'torch.mjs'), '#!/usr/bin/env node\n'); + chmodSync(join(candidateRoot, 'bin', 'torch.mjs'), 0o755); writeFileSync(join(candidateRoot, 'acceptance.mjs'), `console.log(${JSON.stringify([ 'SCN-init-read-only', 'SCN-cli-domain-review', 'SCN-worktree-bootstrap', 'SCN-mixed-runtime', 'SCN-spec-fleet-design', 'SCN-cli-spec-design', diff --git a/test/fleet/self-host.test.mjs b/test/fleet/self-host.test.mjs index ba60f7c..a26bd49 100644 --- a/test/fleet/self-host.test.mjs +++ b/test/fleet/self-host.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { - existsSync, mkdtempSync, mkdirSync, readFileSync, symlinkSync, writeFileSync, + chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, symlinkSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -20,7 +20,8 @@ function candidate(parent, version, marker) { schema: 'torch.dev/release/v1alpha1', version, state: { reads: ['torch.dev/state/v1alpha1'], writes: 'torch.dev/state/v1alpha1', rollback_safe: true }, }, null, 2)}\n`); - writeFileSync(join(root, 'bin', 'torch.mjs'), `export const marker = ${JSON.stringify(marker)};\n`); + writeFileSync(join(root, 'bin', 'torch.mjs'), `#!/usr/bin/env node\nexport const marker = ${JSON.stringify(marker)};\nconsole.log(marker);\n`); + chmodSync(join(root, 'bin', 'torch.mjs'), 0o755); return root; } @@ -34,7 +35,7 @@ function accepted(candidateRoot) { test('SCN-candidate-isolation: staging requires complete acceptance and never changes the active version', () => { const root = mkdtempSync(join(tmpdir(), 'torch-self-host-')); - const env = { ...process.env, XDG_DATA_HOME: join(root, 'xdg') }; + const env = { ...process.env, XDG_DATA_HOME: join(root, 'xdg'), TORCH_BIN_HOME: join(root, 'bin-home') }; const service = createVersionService({ env, now: () => new Date('2026-09-27T00:00:00Z') }); const source = candidate(root, '1.0.0', 'stable'); const plan = service.planCandidate({ source }); @@ -86,17 +87,37 @@ test('SCN-candidate-isolation: staging requires complete acceptance and never ch assert.deepEqual(JSON.parse(readFileSync(bounded.validation, 'utf8')).source, { mode: 'git-commit', commit: boundedPlan.sourceCommit, }); + const activated = service.activate('1.1.0'); + assert.equal(activated.launcher.installed, true); + assert.equal(execFileSync(activated.launcher.path, [], { encoding: 'utf8' }).trim(), 'bounded'); writeFileSync(join(checkout, 'bin', 'torch.mjs'), 'export const marker = "dirty";\n'); assert.throws( () => service.planCandidate({ source: checkout }), (error) => error.code === 'CANDIDATE_SOURCE_DIRTY', ); + + const conflictRoot = mkdtempSync(join(tmpdir(), 'torch-self-host-conflict-')); + const conflictEnv = { + ...process.env, + XDG_DATA_HOME: join(conflictRoot, 'xdg'), + TORCH_BIN_HOME: join(conflictRoot, 'bin-home'), + }; + const conflictService = createVersionService({ env: conflictEnv }); + const conflictCandidate = candidate(conflictRoot, '2.0.0', 'conflict'); + conflictService.installCandidate({ source: conflictCandidate, validate: accepted }); + mkdirSync(conflictEnv.TORCH_BIN_HOME, { recursive: true }); + writeFileSync(join(conflictEnv.TORCH_BIN_HOME, 'torch'), 'owner executable\n'); + assert.throws( + () => conflictService.activate('2.0.0'), + (error) => error.code === 'LAUNCHER_PATH_CONFLICT', + ); + assert.equal(conflictService.status().activeVersion, null); }); test('SCN-atomic-upgrade-rollback: a crash after pointer swap reconciles and rollback restores the exact prior version', () => { const root = mkdtempSync(join(tmpdir(), 'torch-self-host-')); - const env = { ...process.env, XDG_DATA_HOME: join(root, 'xdg') }; + const env = { ...process.env, XDG_DATA_HOME: join(root, 'xdg'), TORCH_BIN_HOME: join(root, 'bin-home') }; const v1 = candidate(root, '1.0.0', 'stable'); const v2 = candidate(root, '1.1.0', 'candidate'); const service = createVersionService({ env, now: () => new Date('2026-09-27T01:00:00Z') }); @@ -117,6 +138,8 @@ test('SCN-atomic-upgrade-rollback: a crash after pointer swap reconciles and rol assert.equal(status.activeVersion, '1.1.0'); assert.equal(status.previousVersion, '1.0.0'); assert.equal(existsSync(join(status.root, 'activation-journal.json')), false); + assert.equal(status.launcher.installed, true); + assert.equal(execFileSync(status.launcher.path, [], { encoding: 'utf8' }).trim(), 'candidate'); const rolledBack = recovered.rollback(); assert.equal(rolledBack.activeVersion, '1.0.0');