stateport 0.0.0-stage → 1.0.0-rc.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 StatePort contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,15 @@
1
- # Temporary Holding Version
1
+ # StatePort
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Save a browser reproduction as a State Card, verify a fix against the same
4
+ conditions, and reopen it in a later coding-agent session.
5
+
6
+ Requires an existing Node.js 24.x/npm installation. Install with `npx stateport`
7
+ (PowerShell: `npx.cmd stateport`). The transparent bootstrap verifies and downloads
8
+ the platform runtime and configures the detected supported coding agent.
9
+ Desktop is optional. Capture uses a separate StatePort-owned browser profile.
10
+ The system-browser runtime excludes Chromium; the pinned browser fallback is a
11
+ separate explicit, integrity-checked download. Developer execution requires
12
+ valid StatePort Developer access.
13
+
14
+ Documentation: https://docs.stateport.dev/
15
+ Source: https://github.com/StatePort-Dev/stateport-agent-skills/tree/main/bootstrap
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ import { homedir } from 'node:os';
3
+ import { isAbsolute, join } from 'node:path';
4
+ import { readFile } from 'node:fs/promises';
5
+ import { spawn } from 'node:child_process';
6
+ import { installCandidate, readCurrent, withInstallTransaction } from '../lib/install.mjs';
7
+ import { installPublishedRelease } from '../lib/release.mjs';
8
+ import { readUpdatePolicy, setUpdatePolicy } from '../lib/update-policy.mjs';
9
+ import { updateStatus, checkForUpdates, selectSessionRuntime, rollbackGeneration } from '../lib/updates.mjs';
10
+ import { updateControl } from '../lib/controls.mjs';
11
+ import { ensureLauncher } from '../lib/launcher.mjs';
12
+
13
+ function cacheRoot() {
14
+ const configured = process.platform === 'win32' ? process.env.LOCALAPPDATA : process.platform === 'linux' ? process.env.XDG_DATA_HOME : undefined;
15
+ const base = configured && isAbsolute(configured) ? configured
16
+ : process.platform === 'win32' ? join(homedir(), 'AppData', 'Local')
17
+ : process.platform === 'darwin' ? join(homedir(), 'Library', 'Caches') : join(homedir(), '.local', 'share');
18
+ return join(base, 'StatePort', 'agent-runtime');
19
+ }
20
+ const write = value => process.stdout.write(JSON.stringify(value) + '\n');
21
+ async function run(runtime, args, capture = false) {
22
+ return new Promise((resolve, reject) => {
23
+ const child = spawn(process.execPath, [runtime.entry, ...args], { stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', windowsHide: true });
24
+ let output = ''; let size = 0;
25
+ child.stdout?.on('data', chunk => { size += chunk.length; if (size > 1024 * 1024) child.kill(); else output += chunk; });
26
+ // No captured native/backend error strings enter bootstrap diagnostics.
27
+ child.stderr?.resume();
28
+ child.once('error', () => reject(Error('RUNTIME_UNAVAILABLE')));
29
+ child.once('close', code => {
30
+ if (code !== 0) { reject(Error('RUNTIME_FAILED')); return; }
31
+ if (!capture) { resolve(undefined); return; }
32
+ try { resolve(JSON.parse(output)); } catch { reject(Error('RUNTIME_REPLY_INVALID')); }
33
+ });
34
+ });
35
+ }
36
+ async function main() {
37
+ if (Number(process.versions.node.split('.')[0]) !== 24) throw Error('NODE_24_LTS_CANDIDATE_REQUIRED');
38
+ if (!((process.platform === 'linux' || process.platform === 'win32') && process.arch === 'x64') && !(process.platform === 'darwin' && process.arch === 'arm64')) throw Error('PLATFORM_UNSUPPORTED');
39
+ const args = process.argv.slice(2);
40
+ const commands = new Set(['install', 'update', 'updates', 'repair-launcher', 'rollback', 'uninstall', 'browser-install', 'doctor', 'open', 'access', 'mcp', 'task-session', 'task-start', 'task-call', 'task-tools', 'task-close']);
41
+ const command = commands.has(args[0]) ? args.shift() : 'install';
42
+ const cache = cacheRoot();
43
+ const trust = JSON.parse(await readFile(new URL('../update-release.json', import.meta.url), 'utf8'));
44
+ if (command === 'repair-launcher') {
45
+ if (args.length !== 1 || args[0] !== '--confirm') throw Error('INVALID_ARGUMENTS');
46
+ const runtime = await readCurrent(cache);
47
+ if (runtime?.generation?.launcher !== 1) throw Error('LAUNCHER_REPAIR_UNSUPPORTED');
48
+ write({ status: 'launcher_repaired', launcher: await ensureLauncher(cache, trust, { repair: true }), userData: 'preserved', editedLoader: 'backed_up_if_changed' }); return;
49
+ }
50
+ if (command === 'updates') { write(await updateControl(cache, trust, args)); return; }
51
+ if (['doctor', 'open', 'access', 'mcp', 'task-session', 'task-start', 'task-call', 'task-tools', 'task-close'].includes(command)) {
52
+ const runtime = await readCurrent(cache); if (!runtime) throw Error('RUNTIME_NOT_INSTALLED');
53
+ if (runtime.generation) {
54
+ // Stable loader performs safe next-session resolution. Read-only and
55
+ // existing task-call/close commands never trigger update checks.
56
+ const loader = join(cache, 'launcher', 'main.cjs');
57
+ await run({ entry: loader }, [command, ...args]); return;
58
+ }
59
+ await run(runtime, [command, ...args]); return;
60
+ }
61
+ if (command === 'uninstall') {
62
+ if (args.length) throw Error('INVALID_ARGUMENTS');
63
+ await withInstallTransaction(cache, async transaction => {
64
+ const runtime = await transaction.current();
65
+ if (runtime) {
66
+ const setup = await run(runtime, ['remove-agent-setup'], true);
67
+ if (setup.status !== 'removed') { write({ status: 'conflict', setup, userData: 'preserved' }); process.exitCode = 1; return; }
68
+ }
69
+ write(await transaction.uninstall());
70
+ }); return;
71
+ }
72
+ if (command === 'rollback') {
73
+ if (args.length) throw Error('INVALID_ARGUMENTS');
74
+ const policy = await readUpdatePolicy(cache);
75
+ if (policy.mode === 'pinned') throw Error('PIN_RELEASE_REQUIRED');
76
+ const runtime = await rollbackGeneration(cache);
77
+ write({ status: 'rolled_back', runtime: runtime.productVersion, setup: await run(runtime, ['setup'], true), hostDiscovered: 'unverified' }); return;
78
+ }
79
+ let source, manifestSha256, agent, offline = false, confirmDownload, consentUpdates = false;
80
+ while (args.length) {
81
+ const option = args.shift();
82
+ if (option === '--offline' && !offline) { offline = true; continue; }
83
+ if (option === '--consent-updates' && !consentUpdates) { consentUpdates = true; continue; }
84
+ const value = args.shift();
85
+ if (!value) throw Error('INVALID_ARGUMENTS');
86
+ if (option === '--candidate-dir' && !source) source = value;
87
+ else if (option === '--manifest-sha256' && !manifestSha256) manifestSha256 = value;
88
+ else if (option === '--agent' && !agent && ['codex', 'claude', 'cursor'].includes(value)) agent = value;
89
+ else if (option === '--confirm-download' && !confirmDownload && /^[a-f0-9]{64}$/.test(value)) confirmDownload = value;
90
+ else throw Error('INVALID_ARGUMENTS');
91
+ }
92
+ if ((source && !manifestSha256) || (!source && manifestSha256) || (source && offline)) throw Error('INVALID_ARGUMENTS');
93
+ let runtime;
94
+ const policy = await readUpdatePolicy(cache);
95
+ if (policy.mode === 'pinned' && (command === 'update' || command === 'browser-install' || (manifestSha256 && manifestSha256 !== policy.pin.digest))) throw Error('PIN_RELEASE_REQUIRED');
96
+ if (command === 'update' && !source && !offline && trust.status === 'published') {
97
+ const outcome = await checkForUpdates(cache, trust, { force: true, manual: true, confirmDownload });
98
+ const selected = await selectSessionRuntime(cache, trust, { manual: true });
99
+ write({ status: 'update_checked', outcome, selected: { version: selected.productVersion, digest: selected.digest }, updates: await updateStatus(cache) }); return;
100
+ }
101
+ if (offline) {
102
+ runtime = await readCurrent(cache); if (!runtime) throw Error('OFFLINE_CACHE_UNAVAILABLE');
103
+ } else if (!source) {
104
+ const release = JSON.parse(await readFile(new URL('../release.json', import.meta.url), 'utf8'));
105
+ if (release.status === 'unpublished') throw Error('PUBLIC_RUNTIME_RELEASE_UNPUBLISHED');
106
+ const current = await readCurrent(cache);
107
+ runtime = await installPublishedRelease(release, cache, fetch, { browserPreference: command === 'browser-install' || current?.browserPreference === 'pinned' ? 'pinned' : 'system', confirmDownload, onDownload: info => write({ status: 'downloading_verified_release', ...info }) });
108
+ if (runtime.status === 'browser_download_required') { write(runtime); return; }
109
+ }
110
+ write({ status: 'installing_candidate', permissions: ['copy verified runtime to user-owned versioned cache', 'configure unchanged owned agent skills/MCP'], trial: 'not_started' });
111
+ runtime ??= await installCandidate({ source, cache, manifestSha256 });
112
+ // Persist a quiet Notify default; repeat setup never changes existing policy.
113
+ if (consentUpdates) await setUpdatePolicy(cache, { mode: 'auto', consent: true });
114
+ else {
115
+ const { atomicJson } = await import('../lib/install.mjs');
116
+ await withInstallTransaction(cache, async () => atomicJson(cache, 'update-policy.json', await readUpdatePolicy(cache)));
117
+ }
118
+ const launcher = runtime.generation ? await ensureLauncher(cache, trust) : undefined;
119
+ const doctor = await run(runtime, ['doctor'], true);
120
+ const setup = await run(runtime, agent ? ['setup', '--agent', agent] : ['setup'], true);
121
+ write({ status: 'candidate_installed', version: runtime.productVersion, unchanged: runtime.unchanged, doctor, setup,
122
+ cache: offline ? 'verified_offline' : 'verified', hostDiscovered: 'unverified', updates: { mode: (await readUpdatePolicy(cache)).mode, delivery: 'candidate', launcher }, currentTask: {
123
+ command: process.execPath, args: [launcher ?? runtime.entry, 'task-session'], selection: 'verified_at_new_session_start', protocol: 'agent-task-session/1',
124
+ version: runtime.productVersion, digest: runtime.digest, generation: runtime.generation,
125
+ input: 'The installed version below is an offer; the ready frame binds the selected runtime at new-session start. Keep this host execution session and stdin open. First JSON line has project, scope, optional deadlineMs. Example for an originally authorized fix task: {"project":"/absolute/original/project","scope":{"userRequestedTask":true,"intent":"fix_verify","sourceOrigins":["http://127.0.0.1:3000"],"frontendOrigins":["http://127.0.0.1:3001"],"stateCardIds":[]},"deadlineMs":600000}. Replace project/origins/Card IDs from the original user task; installation grants no scope. Then JSON lines: operation tools; operation call with requestId/name/arguments; operation close.',
126
+ permissions: 'Use normal host approval for this exact runtime command when required. Installation does not grant browser, project or task permissions.',
127
+ }, published: false });
128
+ }
129
+ void main().catch(error => {
130
+ const code = error instanceof Error && /^[A-Z0-9_]+$/.test(error.message) ? error.message : 'INSTALL_FAILED';
131
+ write({ status: 'blocked', code, nodePrerequisite: code.startsWith('NODE_') ? 'https://nodejs.org/en/download' : undefined });
132
+ process.exitCode = 1;
133
+ });
@@ -0,0 +1,142 @@
1
+ import { gzipSync, gunzipSync } from 'node:zlib';
2
+ import { posix } from 'node:path';
3
+
4
+ const MAX_BYTES = 512 * 1024 * 1024;
5
+ export function validArchivePath(path, maximumLength = 1024) {
6
+ return typeof path === 'string' && path.length <= maximumLength && /^[A-Za-z0-9_.@ ()+-]+(?:\/[A-Za-z0-9_.@ ()+-]+)*$/.test(path)
7
+ && path.split('/').every(part => part.length <= 255 && part !== '.' && part !== '..' && !/[. ]$/.test(part) && !/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?: *\.| *$)/i.test(part));
8
+ }
9
+ function ustarParts(path) {
10
+ if (Buffer.byteLength(path) > 255) return undefined;
11
+ if (Buffer.byteLength(path) <= 100) return { name: path, prefix: '' };
12
+ let split = path.lastIndexOf('/');
13
+ while (split >= 0 && (Buffer.byteLength(path.slice(0, split)) > 155 || Buffer.byteLength(path.slice(split + 1)) > 100)) split = path.lastIndexOf('/', split - 1);
14
+ return split < 0 ? undefined : { prefix: path.slice(0, split), name: path.slice(split + 1) };
15
+ }
16
+ function pathHeader(path) {
17
+ const tail = ` path=${path}\n`; let size = Buffer.byteLength(tail) + 1;
18
+ while (String(size).length + Buffer.byteLength(tail) !== size) size = String(size).length + Buffer.byteLength(tail);
19
+ return Buffer.from(`${size}${tail}`);
20
+ }
21
+ function parsePathHeader(bytes) {
22
+ const space = bytes.indexOf(32), length = bytes.subarray(0, space).toString('utf8');
23
+ if (space < 1 || !/^[1-9][0-9]{0,3}$/.test(length) || Number(length) !== bytes.length || bytes.at(-1) !== 10 || bytes.subarray(space + 1, space + 6).toString('utf8') !== 'path=') throw Error('ARCHIVE_PAX');
24
+ const path = bytes.subarray(space + 6, -1).toString('utf8');
25
+ // One non-sticky path record for the verified browser closure only. No global,
26
+ // linkpath, size, uid, xattr, duplicate record or arbitrary metadata overrides.
27
+ if (!validArchivePath(path) || !/^bundled-browser\/browser\/chromium-[0-9]+\//.test(path) || ustarParts(path)) throw Error('ARCHIVE_PAX');
28
+ return path;
29
+ }
30
+ function requirePathHeaderSchema(files) {
31
+ const metadata = files.find(file => file.path === 'candidate.json');
32
+ try {
33
+ if (!metadata || !Buffer.isBuffer(metadata.bytes) || metadata.bytes.length > 1024 * 1024) throw Error();
34
+ const value = JSON.parse(metadata.bytes.toString('utf8'));
35
+ if (value.schemaVersion !== 'agent-runtime-candidate/1.2.0' || value.archiveFormat !== 'ustar+pax-path/1') throw Error();
36
+ } catch { throw Error('ARCHIVE_SCHEMA'); }
37
+ }
38
+ function field(header, offset, length) { return header.subarray(offset, offset + length).toString('utf8').replace(/\0.*$/s, ''); }
39
+ function octal(header, offset, length) {
40
+ const value = field(header, offset, length).trim();
41
+ if (!/^[0-7]+$/.test(value)) throw Error('ARCHIVE_NUMBER');
42
+ const result = Number.parseInt(value, 8); if (!Number.isSafeInteger(result)) throw Error('ARCHIVE_NUMBER'); return result;
43
+ }
44
+ function checksum(header) { return header.reduce((sum, byte, index) => sum + (index >= 148 && index < 156 ? 32 : byte), 0); }
45
+ export function validateBundleLinks(files) {
46
+ const links = new Map(files.filter(file => file.kind === 'symlink').map(file => [file.path, file]));
47
+ const regular = files.filter(file => file.kind !== 'symlink').map(file => file.path);
48
+ function target(file) {
49
+ const prefix = file.path.split('/').slice(0, 3).join('/') + '/';
50
+ if (!/^bundled-browser\/browser\/chromium-[0-9]+\//.test(file.path) || typeof file.target !== 'string' || file.target.length > 100 || file.target.includes('\\') || posix.isAbsolute(file.target)) throw Error('ARCHIVE_LINK');
51
+ let concrete = false;
52
+ for (const part of file.target.split('/')) {
53
+ if (!part || ((part === '.' || part === '..') && concrete)) throw Error('ARCHIVE_LINK');
54
+ if (part !== '.' && part !== '..') concrete = true;
55
+ }
56
+ const resolved = posix.normalize(posix.join(posix.dirname(file.path), file.target));
57
+ if (!validArchivePath(resolved) || !resolved.startsWith(prefix)) throw Error('ARCHIVE_LINK');
58
+ return resolved;
59
+ }
60
+ function resolveLink(path, visited = new Set()) {
61
+ for (const [name, file] of links) if (path === name || path.startsWith(name + '/')) {
62
+ if (visited.has(name) || visited.size >= 64) throw Error('ARCHIVE_LINK');
63
+ return resolveLink(target(file) + path.slice(name.length), new Set([...visited, name]));
64
+ }
65
+ if (!regular.some(name => name === path || name.startsWith(path + '/'))) throw Error('ARCHIVE_LINK');
66
+ return path;
67
+ }
68
+ for (const file of links.values()) {
69
+ if (files.some(other => other.path.startsWith(file.path + '/'))) throw Error('ARCHIVE_LINK');
70
+ resolveLink(target(file), new Set([file.path]));
71
+ }
72
+ }
73
+ export function decodeRuntimeArchive(compressed, options = {}) {
74
+ if (!Buffer.isBuffer(compressed) || compressed.length > MAX_BYTES) throw Error('ARCHIVE_SIZE');
75
+ const tar = gunzipSync(compressed, { maxOutputLength: MAX_BYTES });
76
+ if (tar.length % 512) throw Error('ARCHIVE_TRUNCATED');
77
+ const files = [], seen = new Set(); let offset = 0, pendingPath, usesPathHeaders = false, headers = 0;
78
+ while (offset < tar.length) {
79
+ const header = tar.subarray(offset, offset + 512); offset += 512;
80
+ if (header.every(byte => byte === 0)) {
81
+ if (pendingPath !== undefined) throw Error('ARCHIVE_PAX');
82
+ if (!tar.subarray(offset).every(byte => byte === 0)) throw Error('ARCHIVE_TRAILING_DATA');
83
+ if (usesPathHeaders) requirePathHeaderSchema(files);
84
+ validateBundleLinks(files); return files;
85
+ }
86
+ if (++headers > 8192) throw Error('ARCHIVE_SIZE');
87
+ if (octal(header, 148, 8) !== checksum(header)) throw Error('ARCHIVE_CHECKSUM');
88
+ const isLink = header[156] === 50, isPathHeader = header[156] === 120;
89
+ if (header[156] !== 0 && header[156] !== 48 && !(isLink && options.allowBundleLinks === true) && !(isPathHeader && options.allowPathHeaders === true)) throw Error('ARCHIVE_TYPE');
90
+ const prefix = field(header, 345, 155), name = field(header, 0, 100), physicalPath = prefix ? prefix + '/' + name : name;
91
+ if (!validArchivePath(physicalPath, 255)) throw Error('ARCHIVE_PATH');
92
+ const size = octal(header, 124, 12), mode = octal(header, 100, 8);
93
+ if (size > MAX_BYTES || offset + Math.ceil(size / 512) * 512 > tar.length) throw Error('ARCHIVE_SIZE');
94
+ if (isPathHeader) {
95
+ if (pendingPath !== undefined || size < 1 || size > 2048) throw Error('ARCHIVE_PAX');
96
+ pendingPath = parsePathHeader(tar.subarray(offset, offset + size)); usesPathHeaders = true;
97
+ offset += Math.ceil(size / 512) * 512; continue;
98
+ }
99
+ const path = pendingPath ?? physicalPath; pendingPath = undefined;
100
+ if (seen.has(path.toLowerCase())) throw Error('ARCHIVE_PATH'); seen.add(path.toLowerCase());
101
+ if (files.length >= 4096) throw Error('ARCHIVE_SIZE');
102
+ if (isLink) { if (size !== 0) throw Error('ARCHIVE_LINK'); files.push({ path, kind: 'symlink', target: field(header, 157, 100) }); continue; }
103
+ if (size > MAX_BYTES || offset + size > tar.length || files.length >= 4096) throw Error('ARCHIVE_SIZE');
104
+ files.push({ path, bytes: Buffer.from(tar.subarray(offset, offset + size)), ...(mode & 0o111 ? { executable: true } : {}) });
105
+ offset += Math.ceil(size / 512) * 512;
106
+ }
107
+ throw Error('ARCHIVE_TERMINATOR_MISSING');
108
+ }
109
+ export function encodeRuntimeArchive(files, options = {}) {
110
+ validateBundleLinks(files);
111
+ const chunks = [], seen = new Set(); let total = 1024, usesPathHeaders = false;
112
+ const append = (header, data) => {
113
+ const padding = Buffer.alloc((512 - data.length % 512) % 512);
114
+ total += 512 + data.length + padding.length; if (total > MAX_BYTES) throw Error('ARCHIVE_SIZE');
115
+ chunks.push(header, data, padding);
116
+ };
117
+ const makeHeader = (path, type, data, file = {}) => {
118
+ const parts = ustarParts(path); if (!parts) throw Error('ARCHIVE_PATH');
119
+ const header = Buffer.alloc(512); header.write(parts.name, 0); header.write(parts.prefix, 345);
120
+ const number = (value, offset, length) => { const text = value.toString(8).padStart(length - 1, '0'); if (text.length >= length) throw Error('ARCHIVE_SIZE'); header.write(text + '\0', offset); };
121
+ number(file.executable ? 0o755 : 0o644, 100, 8); number(0, 108, 8); number(0, 116, 8); number(data.length, 124, 12); number(0, 136, 12);
122
+ header[156] = type; if (type === 50) header.write(file.target, 157);
123
+ header.write('ustar\0', 257); header.write('00', 263);
124
+ header.write(checksum(header).toString(8).padStart(6, '0') + '\0 ', 148);
125
+ return header;
126
+ };
127
+ for (const file of files) {
128
+ if (!validArchivePath(file.path) || seen.has(file.path.toLowerCase())) throw Error('ARCHIVE_PATH'); seen.add(file.path.toLowerCase());
129
+ const isLink = file.kind === 'symlink', data = isLink ? Buffer.alloc(0) : file.bytes;
130
+ if (!Buffer.isBuffer(data) || files.length > 4096) throw Error('ARCHIVE_SIZE');
131
+ let physicalPath = file.path;
132
+ if (!ustarParts(file.path)) {
133
+ if (options.allowPathHeaders !== true) throw Error('ARCHIVE_PATH');
134
+ const record = pathHeader(file.path); parsePathHeader(record); usesPathHeaders = true;
135
+ append(makeHeader('PaxHeaders/browser', 120, record), record);
136
+ physicalPath = 'Payload/browser';
137
+ }
138
+ append(makeHeader(physicalPath, isLink ? 50 : 48, data, file), data);
139
+ }
140
+ if (usesPathHeaders) requirePathHeaderSchema(files);
141
+ return gzipSync(Buffer.concat([...chunks, Buffer.alloc(1024)]));
142
+ }
@@ -0,0 +1,23 @@
1
+ import { readInstalled } from './install.mjs';
2
+ import { readUpdatePolicy, setUpdatePolicy, hex } from './update-policy.mjs';
3
+ import { updateStatus, checkForUpdates, retryQuarantine } from './updates.mjs';
4
+
5
+ /** Shared transparent-bootstrap and verified installed-launcher CLI surface. */
6
+ export async function updateControl(cache, trust, args, running) {
7
+ const [action = 'status', ...extra] = args;
8
+ if (action === 'status' && !extra.length) return updateStatus(cache, running);
9
+ if (action === 'check' && !extra.length) return checkForUpdates(cache, trust, { force: true });
10
+ if (action === 'retry' && extra.length === 1 && hex(extra[0])) return retryQuarantine(cache, extra[0]);
11
+ if (action === 'auto' && extra.length === 1 && extra[0] === '--consent') return setUpdatePolicy(cache, { mode: 'auto', consent: true });
12
+ if (action === 'notify' && (!extra.length || (extra.length === 1 && extra[0] === '--release-pin'))) return setUpdatePolicy(cache, { mode: 'notify', releasePin: extra[0] === '--release-pin' });
13
+ if (action === 'pin' && extra.length === 1 && hex(extra[0])) {
14
+ const runtime = await readInstalled(cache, extra[0]);
15
+ return setUpdatePolicy(cache, { mode: 'pinned', pin: { version: runtime.productVersion, digest: runtime.digest } });
16
+ }
17
+ if (action === 'channel' && extra.length === 2 && ['stable', 'beta', 'rc'].includes(extra[0]) && extra[1] === '--confirm') {
18
+ const policy = await readUpdatePolicy(cache);
19
+ if (trust.status === 'published' && !trust.channels?.[extra[0]] && trust.channel !== extra[0]) throw Error('UPDATE_CHANNEL_UNAVAILABLE');
20
+ return setUpdatePolicy(cache, { mode: policy.mode, channel: extra[0], pin: policy.pin });
21
+ }
22
+ throw Error('INVALID_ARGUMENTS');
23
+ }
@@ -0,0 +1,233 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, mkdir, open, readdir, readlink, rename, rm, writeFile, symlink } from 'node:fs/promises';
3
+ import { createHash, randomBytes } from 'node:crypto';
4
+ import { dirname, isAbsolute, join, parse, relative, resolve } from 'node:path';
5
+ import { validArchivePath, validateBundleLinks } from './archive.mjs';
6
+
7
+ const digest = bytes => createHash('sha256').update(bytes).digest('hex');
8
+ const hex = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
9
+ const maximumPayloadBytes = 512 * 1024 * 1024;
10
+ const missing = error => error?.code === 'ENOENT';
11
+
12
+ export async function safePath(path) {
13
+ const absolute = resolve(path), root = parse(absolute).root;
14
+ let cursor = root;
15
+ for (const part of relative(root, absolute).split(/[\\/]/)) {
16
+ if (!part) continue; cursor = join(cursor, part);
17
+ try { const stat = await lstat(cursor); if (stat.isSymbolicLink()) throw Error('UNSAFE_PATH'); }
18
+ catch (error) { if (missing(error)) break; throw error; }
19
+ }
20
+ }
21
+ export async function directory(path) {
22
+ await safePath(path); await mkdir(path, { recursive: true, mode: 0o700 }); await safePath(path);
23
+ if (!(await lstat(path)).isDirectory()) throw Error('UNSAFE_PATH');
24
+ }
25
+ export async function bytes(path, maximumBytes) {
26
+ await safePath(path);
27
+ const before = await lstat(path);
28
+ if (!before.isFile() || before.isSymbolicLink() || before.size > maximumBytes) throw Error('UNSAFE_PATH');
29
+ const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
30
+ try {
31
+ const stat = await handle.stat();
32
+ if (!stat.isFile() || stat.dev !== before.dev || stat.ino !== before.ino || stat.size > maximumBytes) throw Error('UNSAFE_PATH');
33
+ const value = await handle.readFile();
34
+ if (value.length > maximumBytes) throw Error('PAYLOAD_SIZE');
35
+ return value;
36
+ } finally { await handle.close(); }
37
+ }
38
+ function manifest(value) {
39
+ if (!value || !['agent-runtime-candidate/1.0.0','agent-runtime-candidate/1.1.0','agent-runtime-candidate/1.2.0'].includes(value.schemaVersion) || value.entry !== 'main.cjs' || value.nodeCandidate !== '24.x' || typeof value.productVersion !== 'string' || value.productVersion.length > 128 || !/^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/.test(value.productVersion)) throw Error('MANIFEST_INVALID');
40
+ if (value.schemaVersion === 'agent-runtime-candidate/1.2.0' && value.archiveFormat !== 'ustar+pax-path/1') throw Error('MANIFEST_INVALID');
41
+ if (value.platform !== process.platform || value.arch !== process.arch) throw Error('PLATFORM_MISMATCH');
42
+ if (!Array.isArray(value.files) || value.files.length < 1 || value.files.length > 4096) throw Error('MANIFEST_INVALID');
43
+ const names = new Set(); let total = 0;
44
+ for (const file of value.files) {
45
+ if (!validArchivePath(file.path, value.schemaVersion === 'agent-runtime-candidate/1.2.0' ? 1024 : 255) || file.path === 'candidate.json') throw Error('MANIFEST_PATH');
46
+ if (file.executable !== undefined && typeof file.executable !== 'boolean') throw Error('MANIFEST_INVALID');
47
+ const lower = file.path.toLowerCase();
48
+ if (names.has(lower)) throw Error('MANIFEST_PATH'); names.add(lower);
49
+ if (file.kind === 'symlink') {
50
+ if (value.schemaVersion === 'agent-runtime-candidate/1.0.0' || process.platform === 'win32') throw Error('BUNDLE_LINK_UNSUPPORTED');
51
+ continue;
52
+ }
53
+ if (file.kind !== undefined) throw Error('MANIFEST_INVALID');
54
+ if (!Number.isSafeInteger(file.bytes) || file.bytes < 0 || !hex(file.sha256)) throw Error('MANIFEST_INVALID');
55
+ total += file.bytes; if (!Number.isSafeInteger(total) || total > maximumPayloadBytes) throw Error('PAYLOAD_SIZE');
56
+ }
57
+ if (!names.has('main.cjs')) throw Error('MANIFEST_INVALID');
58
+ validateBundleLinks(value.files);
59
+ return value;
60
+ }
61
+ async function loadManifest(source, expectedDigest) {
62
+ if (!hex(expectedDigest)) throw Error('MANIFEST_INTEGRITY');
63
+ const raw = await bytes(join(source, 'candidate.json'), 1024 * 1024);
64
+ if (digest(raw) !== expectedDigest) throw Error('MANIFEST_INTEGRITY');
65
+ return { raw, value: manifest(JSON.parse(raw.toString('utf8'))) };
66
+ }
67
+ async function verifyPayload(root, value) {
68
+ for (const file of value.files) {
69
+ if (file.kind === 'symlink') {
70
+ const path = join(root, file.path); await safePath(dirname(path));
71
+ if (!(await lstat(path)).isSymbolicLink() || await readlink(path) !== file.target) throw Error('PAYLOAD_INTEGRITY');
72
+ continue;
73
+ }
74
+ const data = await bytes(join(root, file.path), file.bytes);
75
+ if (data.length !== file.bytes || digest(data) !== file.sha256) throw Error('PAYLOAD_INTEGRITY');
76
+ }
77
+ const expected = new Set(['candidate.json', ...value.files.map(file => file.path)]);
78
+ async function walk(path) {
79
+ for (const entry of await readdir(path, { withFileTypes: true })) {
80
+ const child = join(path, entry.name);
81
+ if (entry.isSymbolicLink()) {
82
+ const name = relative(root, child).split('\\').join('/');
83
+ if (!value.files.some(file => file.path === name && file.kind === 'symlink') || !expected.delete(name)) throw Error('UNSAFE_PATH');
84
+ continue;
85
+ }
86
+ if (entry.isDirectory()) await walk(child);
87
+ else if (!entry.isFile() || !expected.delete(relative(root, child).split('\\').join('/'))) throw Error('PAYLOAD_CONFLICT');
88
+ }
89
+ }
90
+ await walk(root); if (expected.size) throw Error('PAYLOAD_INTEGRITY');
91
+ }
92
+ async function withLock(cache, work) {
93
+ if (!isAbsolute(cache)) throw Error('UNSAFE_PATH');
94
+ await directory(cache);
95
+ const lock = join(cache, '.install.lock');
96
+ await safePath(lock);
97
+ try { await mkdir(lock, { mode: 0o700 }); } catch { throw Error('INSTALL_BUSY_OR_RECOVERY_REQUIRED'); }
98
+ try { return await work(); }
99
+ finally { await safePath(lock); await rm(lock, { recursive: true }); }
100
+ }
101
+ async function pointer(cache) {
102
+ let raw;
103
+ try { raw = await bytes(join(cache, 'current.json'), 4096); } catch (error) { if (missing(error)) return undefined; throw error; }
104
+ const value = JSON.parse(raw.toString('utf8'));
105
+ if (value.schemaVersion !== 'agent-install-pointer/1' || !hex(value.current) || (value.previous !== null && !hex(value.previous)) || Object.keys(value).sort().join(',') !== 'current,previous,schemaVersion') throw Error('INSTALL_CONFLICT');
106
+ return { value, raw };
107
+ }
108
+ async function switchPointer(cache, value, before) {
109
+ const current = await pointer(cache);
110
+ if (current?.raw.toString('utf8') !== before?.raw.toString('utf8')) throw Error('INSTALL_CONFLICT');
111
+ const temporary = join(cache, `.current-${randomBytes(16).toString('hex')}`);
112
+ try {
113
+ await writeFile(temporary, JSON.stringify(value) + '\n', { mode: 0o600, flag: 'wx', flush: true });
114
+ await safePath(join(cache, 'current.json')); await rename(temporary, join(cache, 'current.json')); await syncDirectory(cache);
115
+ }
116
+ finally { await safePath(temporary); await rm(temporary, { force: true }); }
117
+ }
118
+ // Directory sync is meaningful on supported POSIX filesystems. Windows does not
119
+ // expose directory fsync through this API; flushed files + rename still retain
120
+ // immutable payloads. Native crash/power-loss qualification stays independent.
121
+ async function syncDirectory(path) {
122
+ if (process.platform === 'win32') return;
123
+ const handle = await open(path, constants.O_RDONLY);
124
+ try { await handle.sync(); } finally { await handle.close(); }
125
+ }
126
+ async function syncTree(root) {
127
+ for (const entry of await readdir(root, { withFileTypes: true })) {
128
+ if (entry.isDirectory()) await syncTree(join(root, entry.name));
129
+ }
130
+ await syncDirectory(root);
131
+ }
132
+ export async function atomicJson(cache, name, value) {
133
+ if (!/^[a-z][a-z0-9-]*\.json$/.test(name)) throw Error('UNSAFE_PATH');
134
+ await directory(cache);
135
+ const target = join(cache, name), temporary = join(cache, `.write-${randomBytes(16).toString('hex')}`);
136
+ await safePath(target);
137
+ try {
138
+ await writeFile(temporary, JSON.stringify(value) + '\n', { mode: 0o600, flag: 'wx', flush: true });
139
+ await safePath(target); await rename(temporary, target); await syncDirectory(cache);
140
+ } finally { await safePath(temporary); await rm(temporary, { force: true }); }
141
+ }
142
+ export async function readInstalled(cache, manifestSha256) {
143
+ if (!hex(manifestSha256)) throw Error('MANIFEST_INTEGRITY');
144
+ const runtimeRoot = join(cache, 'versions', manifestSha256);
145
+ const loaded = await loadManifest(runtimeRoot, manifestSha256);
146
+ await verifyPayload(runtimeRoot, loaded.value);
147
+ return { digest: manifestSha256, runtimeRoot, entry: join(runtimeRoot, 'main.cjs'), productVersion: loaded.value.productVersion, browserPreference: loaded.value.browserPreference ?? 'system', generation: loaded.value.generation };
148
+ }
149
+ export async function readCurrent(cache) {
150
+ const current = await pointer(cache); if (!current) return undefined;
151
+ return readInstalled(cache, current.value.current);
152
+ }
153
+ export async function readPrevious(cache) {
154
+ const current = await pointer(cache);
155
+ return current?.value.previous ? readInstalled(cache, current.value.previous) : undefined;
156
+ }
157
+ async function stageVerified({ source, cache, manifestSha256 }) {
158
+ source = resolve(source);
159
+ const loaded = await loadManifest(source, manifestSha256);
160
+ await verifyPayload(source, loaded.value);
161
+ const versions = join(cache, 'versions'); await directory(versions);
162
+ const destination = join(versions, manifestSha256);
163
+ let exists = true;
164
+ try { await lstat(destination); } catch (error) { if (missing(error)) exists = false; else throw error; }
165
+ if (exists) { const saved = await loadManifest(destination, manifestSha256); await verifyPayload(destination, saved.value); }
166
+ else {
167
+ const stage = join(versions, `.stage-${randomBytes(16).toString('hex')}`); await directory(stage);
168
+ try {
169
+ for (const file of loaded.value.files.filter(file => file.kind !== 'symlink')) {
170
+ const data = await bytes(join(source, file.path), file.bytes);
171
+ if (data.length !== file.bytes || digest(data) !== file.sha256) throw Error('PAYLOAD_INTEGRITY');
172
+ const target = join(stage, file.path); await directory(dirname(target));
173
+ await writeFile(target, data, { flag: 'wx', mode: file.executable ? 0o700 : 0o600, flush: true });
174
+ }
175
+ for (const file of loaded.value.files.filter(file => file.kind === 'symlink')) {
176
+ const target = join(stage, file.path); await directory(dirname(target)); await symlink(file.target, target);
177
+ }
178
+ await writeFile(join(stage, 'candidate.json'), loaded.raw, { flag: 'wx', mode: 0o600, flush: true });
179
+ await verifyPayload(stage, loaded.value); await syncTree(stage);
180
+ await safePath(destination); await rename(stage, destination); await syncDirectory(versions);
181
+ } finally { await safePath(stage); await rm(stage, { recursive: true, force: true }); }
182
+ }
183
+ return readInstalled(cache, manifestSha256);
184
+ }
185
+ async function activateVerified(cache, manifestSha256) {
186
+ const installed = await readInstalled(cache, manifestSha256), before = await pointer(cache);
187
+ if (before?.value.current === manifestSha256) return { ...installed, unchanged: true };
188
+ await switchPointer(cache, { schemaVersion: 'agent-install-pointer/1', current: manifestSha256, previous: before?.value.current ?? null }, before);
189
+ return { ...installed, unchanged: false };
190
+ }
191
+ /** The sole mutation lock. The callback may stage/activate without reentrant locks. */
192
+ export async function withInstallTransaction(cache, work) {
193
+ return withLock(cache, () => work({
194
+ stage: options => stageVerified({ ...options, cache }),
195
+ activate: manifestSha256 => activateVerified(cache, manifestSha256),
196
+ current: () => readCurrent(cache),
197
+ installed: manifestSha256 => readInstalled(cache, manifestSha256),
198
+ previous: async () => { const current = await pointer(cache); return current?.value.previous ? readInstalled(cache, current.value.previous) : undefined; },
199
+ uninstall: () => uninstallVerified(cache),
200
+ write: (name, value) => atomicJson(cache, name, value),
201
+ }));
202
+ }
203
+ export async function stageCandidate(options) {
204
+ return withInstallTransaction(options.cache, transaction => transaction.stage(options));
205
+ }
206
+ export async function activateCandidate(cache, manifestSha256) {
207
+ return withInstallTransaction(cache, transaction => transaction.activate(manifestSha256));
208
+ }
209
+ export async function installCandidate(options) {
210
+ return withInstallTransaction(options.cache, async transaction => {
211
+ await transaction.stage(options);
212
+ return transaction.activate(options.manifestSha256);
213
+ });
214
+ }
215
+ export async function rollback(cache) {
216
+ return withLock(cache, async () => {
217
+ const before = await pointer(cache);
218
+ if (!before?.value.previous) throw Error('ROLLBACK_UNAVAILABLE');
219
+ const loaded = await loadManifest(join(cache, 'versions', before.value.previous), before.value.previous);
220
+ await verifyPayload(join(cache, 'versions', before.value.previous), loaded.value);
221
+ await switchPointer(cache, { schemaVersion: 'agent-install-pointer/1', current: before.value.previous, previous: before.value.current }, before);
222
+ return readCurrent(cache);
223
+ });
224
+ }
225
+ export async function uninstallPointer(cache) {
226
+ return withLock(cache, () => uninstallVerified(cache));
227
+ }
228
+ async function uninstallVerified(cache) {
229
+ const current = await readCurrent(cache);
230
+ if (current) { await rm(join(cache, 'current.json')); await syncDirectory(cache); }
231
+ // Exact runtime versions are retained: active processes and rollback never lose their code.
232
+ return { status: 'uninstalled', retainedRuntimeCache: true, userData: 'preserved' };
233
+ }
@@ -0,0 +1,55 @@
1
+ import { readFile, writeFile, rename, rm, mkdir, lstat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { randomBytes } from 'node:crypto';
4
+ import { bytes, directory, safePath, withInstallTransaction } from './install.mjs';
5
+
6
+ const loader = `// StatePort loader contract 1. Repair explicitly through the official bootstrap.
7
+ (async () => {
8
+ const { join } = require('node:path');
9
+ const { pathToFileURL } = require('node:url');
10
+ const { readCurrent, bytes } = await import('./lib/install.mjs');
11
+ const cache = join(__dirname, '..');
12
+ const runtime = await readCurrent(cache);
13
+ if (!runtime) throw Error('RUNTIME_NOT_INSTALLED');
14
+ if (!runtime.generation || runtime.generation.launcher !== 1) throw Error('LAUNCHER_REPAIR_REQUIRED');
15
+ const trust = JSON.parse((await bytes(join(__dirname, 'release.json'), 65536)).toString('utf8'));
16
+ const { launchSession } = await import(pathToFileURL(join(runtime.runtimeRoot, 'distribution', 'session.mjs')).href);
17
+ await launchSession({ cache, runtime, trust, args: process.argv.slice(2) });
18
+ })().catch(() => { process.stderr.write('STATEPORT_LAUNCHER_UNAVAILABLE\\n'); process.exitCode = 1; });
19
+ `;
20
+ /** Install once from trusted bootstrap bytes. Future generation updates never
21
+ * rewrite this loader/npm cache; changed loader/key requires explicit repair. */
22
+ export async function ensureLauncher(cache, trust, { repair = false } = {}) {
23
+ return withInstallTransaction(cache, async () => {
24
+ const target = join(cache, 'launcher'); await safePath(target);
25
+ const expected = new Map([['main.cjs', Buffer.from(loader)], ['release.json', Buffer.from(JSON.stringify(trust) + '\n')]]);
26
+ for (const name of ['install.mjs', 'archive.mjs']) expected.set(`lib/${name}`, await readFile(new URL(name, import.meta.url)));
27
+ try {
28
+ for (const [name, value] of expected) if (!(await bytes(join(target, name), value.length)).equals(value)) throw Error('LAUNCHER_REPAIR_REQUIRED');
29
+ return join(target, 'main.cjs');
30
+ } catch (error) { if (!repair && error?.code !== 'ENOENT') throw Error('LAUNCHER_REPAIR_REQUIRED'); }
31
+ // A partially existing/foreign loader is a conflict, not a repair request.
32
+ let backup;
33
+ try {
34
+ const existing = await lstat(target);
35
+ if (!repair || !existing.isDirectory() || existing.isSymbolicLink()) throw Error('LAUNCHER_REPAIR_REQUIRED');
36
+ backup = join(cache, `.launcher-backup-${randomBytes(16).toString('hex')}`);
37
+ } catch (error) { if (error?.code !== 'ENOENT') throw error; }
38
+ const stage = join(cache, `.launcher-${randomBytes(16).toString('hex')}`); await directory(stage);
39
+ try {
40
+ for (const [name, value] of expected) {
41
+ const path = join(stage, name); await mkdir(join(path, '..'), { recursive: true, mode: 0o700 });
42
+ await writeFile(path, value, { mode: 0o600, flag: 'wx', flush: true });
43
+ }
44
+ await safePath(target);
45
+ if (backup) await rename(target, backup);
46
+ await rename(stage, target);
47
+ } catch (error) {
48
+ if (backup) {
49
+ try { await lstat(target); } catch (missing) { if (missing?.code === 'ENOENT') await rename(backup, target); }
50
+ }
51
+ throw error;
52
+ } finally { await rm(stage, { recursive: true, force: true }); }
53
+ return join(target, 'main.cjs');
54
+ });
55
+ }
@@ -0,0 +1,154 @@
1
+ import { createHash, verify } from 'node:crypto';
2
+ import { mkdir, mkdtemp, rm, writeFile, symlink, realpath } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { decodeRuntimeArchive } from './archive.mjs';
6
+ import { installCandidate } from './install.mjs';
7
+
8
+ const sha = bytes => createHash('sha256').update(bytes).digest('hex');
9
+ const hex = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
10
+ export function trustedUrl(value) {
11
+ const url = new URL(value);
12
+ if (url.protocol !== 'https:' || url.username || url.password || !['github.com', 'objects.githubusercontent.com', 'release-assets.githubusercontent.com'].includes(url.hostname)) throw Error('RELEASE_URL');
13
+ if (url.hostname === 'github.com' && !url.pathname.startsWith('/StatePort-Dev/stateport-releases/releases/download/')) throw Error('RELEASE_URL');
14
+ return url.href;
15
+ }
16
+ function trustedMetadataUrl(value, channel) {
17
+ if (!['stable', 'beta', 'rc'].includes(channel)) throw Error('RELEASE_URL');
18
+ const url = new URL(value);
19
+ if (url.hostname !== 'raw.githubusercontent.com') return trustedUrl(value);
20
+ // Only the signed channel document may be mutable. Runtime archives still
21
+ // use immutable release assets and never inherit this metadata exception.
22
+ if (url.protocol !== 'https:' || url.username || url.password || url.port || url.search || url.hash
23
+ || url.pathname !== `/StatePort-Dev/stateport-releases/main/channels/${channel}.json`) throw Error('RELEASE_URL');
24
+ return url.href;
25
+ }
26
+ function signedDocument(envelope, config, pinned = true) {
27
+ if (config.status !== 'published') throw Error('RELEASE_UNPUBLISHED');
28
+ if (!config.publicKey || envelope?.keyId !== config.keyId) throw Error('RELEASE_KEY');
29
+ if (typeof envelope.payload !== 'string' || envelope.payload.length > 1398104 || typeof envelope.signature !== 'string' || envelope.signature.length !== 88 || !/^[A-Za-z0-9+/]+={0,2}$/.test(envelope.payload) || !/^[A-Za-z0-9+/]+={0,2}$/.test(envelope.signature)) throw Error('RELEASE_INTEGRITY');
30
+ const bytes = Buffer.from(envelope.payload, 'base64');
31
+ if (bytes.length > 1024 * 1024 || (pinned && (!hex(config.manifestSha256) || sha(bytes) !== config.manifestSha256))) throw Error('RELEASE_INTEGRITY');
32
+ if (!verify(null, bytes, config.publicKey, Buffer.from(envelope.signature, 'base64'))) throw Error('RELEASE_SIGNATURE');
33
+ return { document: JSON.parse(bytes.toString('utf8')), digest: sha(bytes) };
34
+ }
35
+ export function verifyReleaseEnvelope(envelope, config) {
36
+ const { document } = signedDocument(envelope, config);
37
+ if (document.schemaVersion !== 'agent-runtime-release/1' || document.version !== config.version) throw Error('RELEASE_VERSION');
38
+ return validateAssets(document);
39
+ }
40
+ function validateAssets(document) {
41
+ if (!Array.isArray(document.assets) || document.assets.length !== 3) {
42
+ // A unit fixture can explicitly supply all three; publication cannot omit macOS.
43
+ throw Error('RELEASE_PLATFORM_MATRIX');
44
+ }
45
+ const platforms = new Set();
46
+ for (const asset of document.assets) {
47
+ const key = `${asset.platform}-${asset.arch}`;
48
+ if (!['linux-x64', 'win32-x64', 'darwin-arm64'].includes(key) || platforms.has(key)) throw Error('RELEASE_PLATFORM_MATRIX'); platforms.add(key);
49
+ for (const payload of [asset, asset.browserFallback]) {
50
+ if (!payload || !Number.isSafeInteger(payload.bytes) || payload.bytes < 1 || payload.bytes > 512 * 1024 * 1024 || !hex(payload.sha256) || !hex(payload.manifestSha256)) throw Error('RELEASE_ASSET');
51
+ trustedUrl(payload.url);
52
+ }
53
+ }
54
+ return document;
55
+ }
56
+ export async function download(url, maximumBytes, fetcher, options = {}) {
57
+ const validateUrl = options.metadataChannel === undefined ? trustedUrl : value => trustedMetadataUrl(value, options.metadataChannel);
58
+ url = validateUrl(url);
59
+ const timeout = AbortSignal.timeout(options.timeoutMs ?? 120_000);
60
+ const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
61
+ for (let hop = 0; hop <= 5; hop++) {
62
+ const response = await fetcher(url, { redirect: 'manual', credentials: 'omit', referrerPolicy: 'no-referrer', signal });
63
+ if ([301, 302, 303, 307, 308].includes(response.status)) {
64
+ const location = response.headers.get('location'); await response.body?.cancel();
65
+ if (!location) throw Error('RELEASE_HTTP'); url = validateUrl(new URL(location, url).href); continue;
66
+ }
67
+ if (!response.ok || !response.body) throw Error('RELEASE_HTTP');
68
+ const chunks = []; let size = 0;
69
+ for await (const chunk of response.body) { size += chunk.length; if (size > maximumBytes) throw Error('RELEASE_SIZE'); chunks.push(Buffer.from(chunk)); }
70
+ return Buffer.concat(chunks);
71
+ }
72
+ throw Error('RELEASE_REDIRECTS');
73
+ }
74
+ export async function installPublishedRelease(config, cache, fetcher = fetch, options = {}) {
75
+ if (config.status !== 'published') throw Error('RELEASE_UNPUBLISHED');
76
+ const envelope = JSON.parse((await download(config.url, 2 * 1024 * 1024, fetcher)).toString('utf8'));
77
+ const document = verifyReleaseEnvelope(envelope, config);
78
+ return installReleaseDocument(document, cache, fetcher, options);
79
+ }
80
+ /** One archive verifier/extractor for explicit and opportunistic delivery. */
81
+ export async function installReleaseDocument(document, cache, fetcher = fetch, options = {}) {
82
+ validateAssets(document);
83
+ const platform = document.assets.find(asset => asset.platform === process.platform && asset.arch === process.arch);
84
+ if (!platform) throw Error('PLATFORM_UNSUPPORTED');
85
+ const asset = options.browserPreference === 'pinned' ? platform.browserFallback : platform;
86
+ if (options.browserPreference === 'pinned' && options.confirmDownload !== asset.sha256) return {
87
+ status: 'browser_download_required', bytes: asset.bytes, sha256: asset.sha256, version: document.version,
88
+ action: 'Repeat browser-install with --confirm-download and this exact SHA-256 after host approval. No browser downloaded yet.',
89
+ };
90
+ options.onDownload?.({ bytes: asset.bytes, version: document.version, browser: options.browserPreference === 'pinned' ? 'pinned_chromium' : 'system' });
91
+ const bytes = await download(asset.url, asset.bytes, fetcher, options);
92
+ if (bytes.length !== asset.bytes || sha(bytes) !== asset.sha256) throw Error('RELEASE_ASSET_INTEGRITY');
93
+ const files = decodeRuntimeArchive(bytes, { allowBundleLinks: true, allowPathHeaders: true });
94
+ const metadataFile = files.find(file => file.path === 'candidate.json');
95
+ if (!metadataFile || sha(metadataFile.bytes) !== asset.manifestSha256) throw Error('RELEASE_ASSET_INTEGRITY');
96
+ const metadata = JSON.parse(metadataFile.bytes.toString('utf8'));
97
+ if (metadata.productVersion !== document.version) throw Error('RELEASE_VERSION');
98
+ if (options.generation && JSON.stringify(metadata.generation) !== JSON.stringify(options.generation)) throw Error('RELEASE_GENERATION');
99
+ if ((metadata.browserPreference ?? 'system') !== (options.browserPreference ?? 'system')) throw Error('RELEASE_BROWSER_VARIANT');
100
+ // macOS aliases /var to /private/var. Resolve only the trusted OS temporary
101
+ // root before allocating our unique directory; payload/cache paths stay strict.
102
+ const temporary = await mkdtemp(join(await realpath(tmpdir()), 'stateport-release-'));
103
+ try {
104
+ for (const file of files.filter(file => file.kind !== 'symlink')) {
105
+ const path = join(temporary, file.path); await mkdir(dirname(path), { recursive: true, mode: 0o700 });
106
+ await writeFile(path, file.bytes, { flag: 'wx', mode: file.executable ? 0o700 : 0o600 });
107
+ }
108
+ for (const file of files.filter(file => file.kind === 'symlink')) {
109
+ if (process.platform === 'win32') throw Error('BUNDLE_LINK_UNSUPPORTED');
110
+ const path = join(temporary, file.path); await mkdir(dirname(path), { recursive: true, mode: 0o700 }); await symlink(file.target, path);
111
+ }
112
+ return await (options.install ?? installCandidate)({ source: temporary, cache, manifestSha256: asset.manifestSha256 });
113
+ } finally { await rm(temporary, { recursive: true, force: true }); }
114
+ }
115
+
116
+ function semverParts(value) {
117
+ if (typeof value !== 'string' || value.length > 128 || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/.test(value)) throw Error('RELEASE_VERSION');
118
+ const [base, ...suffix] = value.split('-');
119
+ const parts = base.split('.').map(Number);
120
+ if (parts.some(part => !Number.isSafeInteger(part))) throw Error('RELEASE_VERSION');
121
+ const pre = suffix.join('-').split('.').filter(Boolean);
122
+ if (pre.some(part => /^0\d+$/.test(part))) throw Error('RELEASE_VERSION');
123
+ return { parts, pre };
124
+ }
125
+ export function compareVersions(a, b) {
126
+ const x = semverParts(a), y = semverParts(b);
127
+ for (let i = 0; i < 3; i++) if (x.parts[i] !== y.parts[i]) return Math.sign(x.parts[i] - y.parts[i]);
128
+ if (!x.pre.length || !y.pre.length) return x.pre.length === y.pre.length ? 0 : x.pre.length ? -1 : 1;
129
+ for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i++) {
130
+ const a = x.pre[i], b = y.pre[i]; if (a === b) continue;
131
+ if (a === undefined || b === undefined) return a === undefined ? -1 : 1;
132
+ const an = /^\d+$/.test(a), bn = /^\d+$/.test(b);
133
+ if (an && bn) return a.length === b.length ? (a < b ? -1 : 1) : Math.sign(a.length - b.length);
134
+ if (an !== bn) return an ? -1 : 1;
135
+ return a < b ? -1 : 1;
136
+ }
137
+ return 0;
138
+ }
139
+ /** Dynamic selection is authenticated by the same independently pinned key,
140
+ * never by a digest returned beside an untrusted download. */
141
+ export function verifyChannelEnvelope(envelope, trust, highWater = {}) {
142
+ const verified = signedDocument(envelope, trust, false), d = verified.document;
143
+ const keys = 'assets,channel,publisher,schemaVersion,sequence,version';
144
+ if (d.schemaVersion !== 'agent-runtime-release/2' || Object.keys(d).sort().join(',') !== keys || !Number.isSafeInteger(d.sequence) || d.sequence < 1) throw Error('RELEASE_SCHEMA');
145
+ semverParts(d.version);
146
+ if (d.publisher !== trust.publisher || trust.publisher !== 'StatePort-Dev') throw Error('RELEASE_PUBLISHER');
147
+ if (d.channel !== trust.channel || !['stable', 'beta', 'rc'].includes(d.channel)) throw Error('RELEASE_CHANNEL');
148
+ if ((d.channel === 'stable' && d.version.includes('-')) || (d.channel !== 'stable' && !d.version.includes(`-${d.channel}.`))) throw Error('RELEASE_CHANNEL');
149
+ if (highWater.sequence && (d.sequence < highWater.sequence || (d.sequence === highWater.sequence && verified.digest !== highWater.releaseDigest))) throw Error('RELEASE_REPLAY');
150
+ if (highWater.version && compareVersions(d.version, highWater.version) < 0) throw Error('RELEASE_DOWNGRADE');
151
+ if (highWater.version === d.version && highWater.releaseDigest !== verified.digest) throw Error('RELEASE_VERSION_REPLACED');
152
+ validateAssets(d);
153
+ return verified;
154
+ }
@@ -0,0 +1,55 @@
1
+ import { fork } from 'node:child_process';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { selectSessionRuntime, quarantineStartup } from './updates.mjs';
4
+ import { updateControl } from './controls.mjs';
5
+
6
+ /** A task-session or stdio MCP selects exactly once. Its runtime child and every
7
+ * worker retain that immutable path. There is no post-operation retry/fallback. */
8
+ export async function launchSession({ cache, runtime, trust, args }) {
9
+ const [command = 'doctor'] = args;
10
+ if (command === 'updates') {
11
+ process.stdout.write(JSON.stringify(await updateControl(cache, trust, args.slice(1), runtime)) + '\n'); return;
12
+ }
13
+ const newSession = ['mcp', 'task-session', 'task-start', 'open'].includes(command);
14
+ const selected = newSession ? await selectSessionRuntime(cache, trust) : runtime;
15
+ const child = fork(selected.entry, args, { stdio: ['inherit', 'inherit', 'inherit', 'ipc'], windowsHide: true });
16
+ let ready = !newSession;
17
+ let job;
18
+ const stopJob = () => {
19
+ if (!job || job.exitCode !== null) return;
20
+ job.send?.({ operation: 'abort' }, () => {});
21
+ const timer = setTimeout(() => { if (job.exitCode === null) job.kill(); }, 1000); timer.unref();
22
+ };
23
+ const startJob = () => {
24
+ if (!newSession || trust.status !== 'published' || job) return;
25
+ // Trusted runtime-owned implementation, finite IPC owner and hard ceiling.
26
+ job = fork(fileURLToPath(new URL('update-job.mjs', import.meta.url)), [], { stdio: ['ignore', 'ignore', 'ignore', 'ipc'], windowsHide: true });
27
+ job.on('error', () => {});
28
+ job.on('message', message => {
29
+ if (message?.protocol === 'agent-update-notice/1' && typeof message.version === 'string'
30
+ && /^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/.test(message.version) && /^[a-f0-9]{64}$/.test(message.digest)) {
31
+ process.stderr.write(`StatePort ${message.version} available. Check updates status.\n`);
32
+ }
33
+ });
34
+ job.send({ operation: 'check', cache, trust });
35
+ const maximum = setTimeout(stopJob, 131_000); maximum.unref();
36
+ job.once('exit', () => clearTimeout(maximum));
37
+ };
38
+ child.on('message', message => {
39
+ if (message?.protocol === 'agent-launch-ready/1') ready = true;
40
+ if (message?.protocol === 'agent-update-use/1') startJob();
41
+ });
42
+ const signals = ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK'];
43
+ const handlers = signals.map(signal => {
44
+ const handler = () => { stopJob(); try { child.kill(signal); } catch { child.kill(); } };
45
+ process.on(signal, handler); return [signal, handler];
46
+ });
47
+ await new Promise(resolve => {
48
+ child.once('error', () => { process.exitCode = 1; resolve(); });
49
+ child.once('exit', (code, signal) => { process.exitCode = code ?? (signal ? 1 : 0); resolve(); });
50
+ });
51
+ stopJob();
52
+ for (const [signal, handler] of handlers) process.off(signal, handler);
53
+ if (job?.connected) job.disconnect();
54
+ if (!ready && selected.digest !== runtime.digest) await quarantineStartup(cache, selected.digest, runtime).catch(() => {});
55
+ }
@@ -0,0 +1,16 @@
1
+ import { checkForUpdates } from './updates.mjs';
2
+ const abort = new AbortController();
3
+ let started = false;
4
+ const maximum = setTimeout(() => { abort.abort(); process.exit(0); }, 131_000);
5
+ maximum.unref();
6
+ process.once('disconnect', () => { abort.abort(); process.exit(0); });
7
+ process.on('message', async message => {
8
+ if (message?.operation === 'abort') { abort.abort(); return; }
9
+ if (started || message?.operation !== 'check' || !process.connected) return;
10
+ started = true;
11
+ try {
12
+ const result = await checkForUpdates(message.cache, message.trust, { signal: abort.signal });
13
+ if (result.notice && process.connected) process.send?.({ protocol: 'agent-update-notice/1', ...result.notice });
14
+ }
15
+ finally { clearTimeout(maximum); if (process.connected) process.disconnect(); }
16
+ });
@@ -0,0 +1,66 @@
1
+ import { join } from 'node:path';
2
+ import { bytes, withInstallTransaction } from './install.mjs';
3
+
4
+ export const UPDATE_TTL_MS = 86_400_000;
5
+ export const hex = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
6
+ const version = value => typeof value === 'string' && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[A-Za-z0-9]+(?:[.-][A-Za-z0-9]+)*)?$/.test(value);
7
+ export async function readJson(cache, name, fallback) {
8
+ try { return JSON.parse((await bytes(join(cache, name), 131072)).toString('utf8')); }
9
+ catch (error) { if (error?.code === 'ENOENT') return structuredClone(fallback); throw Error('UPDATE_STATE_INVALID'); }
10
+ }
11
+ export async function readUpdatePolicy(cache) {
12
+ const policy = await readJson(cache, 'update-policy.json', { schemaVersion: 'agent-update-policy/1', mode: 'notify', channel: 'rc', consent: false, pin: null });
13
+ if (policy.schemaVersion !== 'agent-update-policy/1' || !['auto', 'notify', 'pinned'].includes(policy.mode)
14
+ || !['stable', 'beta', 'rc'].includes(policy.channel) || typeof policy.consent !== 'boolean'
15
+ || (policy.mode === 'auto' && !policy.consent)
16
+ || (policy.mode === 'pinned' ? !policy.pin || !hex(policy.pin.digest) || !version(policy.pin.version) : policy.pin !== null)
17
+ || Object.keys(policy).sort().join(',') !== 'channel,consent,mode,pin,schemaVersion') throw Error('UPDATE_POLICY_INVALID');
18
+ return policy;
19
+ }
20
+ export async function setUpdatePolicy(cache, change) {
21
+ return withInstallTransaction(cache, async transaction => {
22
+ const before = await readUpdatePolicy(cache);
23
+ if (before.mode === 'pinned' && change.mode !== 'pinned' && change.releasePin !== true) throw Error('PIN_RELEASE_REQUIRED');
24
+ if (!['auto', 'notify', 'pinned'].includes(change.mode)) throw Error('UPDATE_POLICY_INVALID');
25
+ if (change.mode === 'auto' && change.consent !== true && !before.consent) throw Error('UPDATE_CONSENT_REQUIRED');
26
+ const policy = { schemaVersion: before.schemaVersion, mode: change.mode, channel: change.channel ?? before.channel,
27
+ consent: change.mode === 'auto' ? true : before.consent, pin: change.mode === 'pinned' ? change.pin : null };
28
+ if (!['stable', 'beta', 'rc'].includes(policy.channel) || (policy.pin !== null && (!policy.pin || !hex(policy.pin.digest) || !version(policy.pin.version)))) throw Error('UPDATE_POLICY_INVALID');
29
+ if (policy.pin) {
30
+ const installed = await transaction.installed(policy.pin.digest);
31
+ if (installed.productVersion !== policy.pin.version) throw Error('UPDATE_PIN_INVALID');
32
+ }
33
+ await transaction.write('update-policy.json', policy);
34
+ // Pending bytes remain cached; a policy change never kills an active process.
35
+ return policy;
36
+ });
37
+ }
38
+ export function checkDue(state, now = Date.now()) {
39
+ if (!Number.isSafeInteger(now) || now < 0) return false;
40
+ if (!Number.isSafeInteger(state.checkedAt) || state.checkedAt < 0) return true;
41
+ if (state.checkedAt > now + UPDATE_TTL_MS) return true; // rebase once under the shared lock
42
+ return now - state.checkedAt >= UPDATE_TTL_MS;
43
+ }
44
+ function canonical(value) {
45
+ if (Array.isArray(value)) return value.map(canonical);
46
+ if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])]));
47
+ return value;
48
+ }
49
+ export function validGeneration(value) {
50
+ const keys = 'arch,browser,launcher,mcp,nativeABI,nodeMajor,permissions,platform,schemaVersion,skills,storageRead,storageWrite,task,taskSession';
51
+ return value && Object.keys(value).sort().join(',') === keys && value.schemaVersion === 'agent-generation/1'
52
+ && value.launcher === 1 && value.nodeMajor === 24 && value.nativeABI === process.versions.modules
53
+ && value.platform === process.platform && value.arch === process.arch
54
+ && value.task === 'agent-task/1' && value.taskSession === 'agent-task-session/1'
55
+ && ['mcp', 'skills', 'storageRead', 'storageWrite'].every(key => hex(value[key]))
56
+ && Array.isArray(value.permissions) && value.permissions.length > 0 && value.permissions.length <= 16
57
+ && value.permissions.every(item => typeof item === 'string' && /^[a-z_-]{1,64}$/.test(item))
58
+ && value.browser && Object.keys(value.browser).sort().join(',') === 'payload,playwright,preference'
59
+ && ['system', 'pinned'].includes(value.browser.preference) && value.browser.playwright === '1.63.0'
60
+ && (value.browser.preference === 'system' ? value.browser.payload === null : hex(value.browser.payload));
61
+ }
62
+ /** Unknown contracts, different instructions or schemas require explicit setup/restart. */
63
+ export function compatibleGeneration(running, candidate) {
64
+ if (!validGeneration(running) || !validGeneration(candidate)) return 'UPDATE_INCOMPATIBLE';
65
+ return JSON.stringify(canonical(running)) === JSON.stringify(canonical(candidate)) ? null : 'UPDATE_INCOMPATIBLE';
66
+ }
@@ -0,0 +1,206 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { withInstallTransaction, readCurrent, readInstalled, readPrevious } from './install.mjs';
3
+ import { readJson, readUpdatePolicy, checkDue, compatibleGeneration, hex } from './update-policy.mjs';
4
+ import { download, verifyChannelEnvelope, installReleaseDocument, compareVersions } from './release.mjs';
5
+
6
+ const empty = () => ({ schemaVersion: 'agent-update-state/1', checkedAt: null, water: {}, available: null, pending: null, quarantine: [], hold: null });
7
+ async function stateFor(cache) {
8
+ const state = await readJson(cache, 'update-state.json', empty());
9
+ if (state.schemaVersion !== 'agent-update-state/1' || Object.keys(state).sort().join(',') !== 'available,checkedAt,hold,pending,quarantine,schemaVersion,water'
10
+ || (state.checkedAt !== null && (!Number.isSafeInteger(state.checkedAt) || state.checkedAt < 0))
11
+ || !state.water || typeof state.water !== 'object' || Array.isArray(state.water)
12
+ || !Array.isArray(state.quarantine) || state.quarantine.length > 32 || !state.quarantine.every(hex)
13
+ || (state.hold !== null && (typeof state.hold !== 'string' || !/^UPDATE_[A-Z_]{1,64}$/.test(state.hold)))) throw Error('UPDATE_STATE_INVALID');
14
+ for (const [channel, water] of Object.entries(state.water)) {
15
+ if (!['stable', 'beta', 'rc'].includes(channel) || !water || !Number.isSafeInteger(water.sequence) || water.sequence < 1
16
+ || !hex(water.releaseDigest) || typeof water.version !== 'string' || (water.notifiedDigest !== undefined && !hex(water.notifiedDigest))) throw Error('UPDATE_STATE_INVALID');
17
+ }
18
+ return state;
19
+ }
20
+ function channelTrust(trust, policy) {
21
+ if (trust.status !== 'published') throw Error('UPDATE_NOT_CONFIGURED');
22
+ const url = trust.channels?.[policy.channel] ?? (trust.channel === policy.channel ? trust.url : undefined);
23
+ if (!url) throw Error('UPDATE_CHANNEL_UNAVAILABLE');
24
+ return { ...trust, channel: policy.channel, url };
25
+ }
26
+ function assetFor(document, browserPreference) {
27
+ const platform = document.assets.find(asset => asset.platform === process.platform && asset.arch === process.arch);
28
+ if (!platform) throw Error('UPDATE_INCOMPATIBLE');
29
+ return browserPreference === 'pinned' ? platform.browserFallback : platform;
30
+ }
31
+ function quarantined(state, digest) { return state.quarantine.includes(digest); }
32
+ function quarantine(state, digest) {
33
+ state.quarantine = [...state.quarantine.filter(value => value !== digest), digest].slice(-32);
34
+ state.pending = null; state.hold = 'UPDATE_QUARANTINED';
35
+ }
36
+ const safeReasons = new Set(['UPDATE_INCOMPATIBLE', 'UPDATE_QUARANTINED', 'UPDATE_INTEGRATION_HELD', 'UPDATE_NOT_CONFIGURED', 'UPDATE_CHANNEL_UNAVAILABLE', 'UPDATE_BROWSER_CONFIRMATION_REQUIRED']);
37
+ const reason = error => safeReasons.has(error?.message) ? error.message : 'UPDATE_CHECK_FAILED';
38
+
39
+ /** Only a verified runtime can reach this child. No shell, Cards, vault writes,
40
+ * Capture, entitlement activation or user operation; stderr is never projected. */
41
+ export async function readiness(runtime, { integration = false, signal } = {}) {
42
+ return new Promise(resolve => {
43
+ const child = spawn(process.execPath, [runtime.entry, 'update-readiness'], { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'], signal });
44
+ let text = '', size = 0, finished = false;
45
+ const finish = result => { if (!finished) { finished = true; clearTimeout(timer); resolve(result); } };
46
+ const timer = setTimeout(() => { child.kill(); finish(false); }, 10_000);
47
+ child.stdout.on('data', chunk => { size += chunk.length; if (size > 65536) { child.kill(); finish(false); } else text += chunk; });
48
+ child.on('error', () => finish(false));
49
+ child.on('close', code => {
50
+ try {
51
+ const reply = JSON.parse(text);
52
+ finish(code === 0 && reply.status === 'ready' && reply.version === runtime.productVersion && reply.execution === 'allowed'
53
+ && compatibleGeneration(runtime.generation, reply.generation) === null
54
+ && (!integration || reply.integration === 'unchanged'));
55
+ } catch { finish(false); }
56
+ });
57
+ });
58
+ }
59
+ export async function updateStatus(cache, running) {
60
+ const current = await readCurrent(cache), policy = await readUpdatePolicy(cache), state = await stateFor(cache);
61
+ const previous = await readPrevious(cache).catch(() => undefined);
62
+ const summary = runtime => runtime ? { version: runtime.productVersion, digest: runtime.digest } : null;
63
+ const advertised = value => value && typeof value.version === 'string' && hex(value.digest) ? { version: value.version, digest: value.digest } : null;
64
+ return { schemaVersion: 'agent-update-status/1', running: summary(running), selected: summary(current), previous: summary(previous), mode: policy.mode, channel: policy.channel,
65
+ pin: policy.pin, checkedAt: state.checkedAt === null ? null : new Date(state.checkedAt).toISOString(), available: advertised(state.available),
66
+ pending: advertised(state.pending), hold: policy.mode === 'pinned' ? 'UPDATE_PINNED' : state.hold,
67
+ applies: state.pending ? 'next_eligible_session' : null };
68
+ }
69
+ /** One finite, shared, opportunistic transaction; errors never gate current work. */
70
+ export async function checkForUpdates(cache, trust, options = {}) {
71
+ if (trust.status !== 'published') return { status: 'not_configured' };
72
+ try {
73
+ return await withInstallTransaction(cache, async transaction => {
74
+ const policy = await readUpdatePolicy(cache), state = await stateFor(cache);
75
+ if (policy.mode === 'pinned') return { status: 'pinned' };
76
+ const now = options.now ?? Date.now();
77
+ if (!options.force && !checkDue(state, now)) return { status: 'throttled' };
78
+ const current = await transaction.current();
79
+ if (!current) return { status: 'not_installed' };
80
+ const config = channelTrust(trust, policy);
81
+ state.checkedAt = now; state.hold = null;
82
+ // Persist attempt before any network: failure/crash does not create a storm.
83
+ await transaction.write('update-state.json', state);
84
+ let notice;
85
+ try {
86
+ const signal = options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(130_000)]) : AbortSignal.timeout(130_000);
87
+ const envelope = JSON.parse((await download(config.url, 2 * 1024 * 1024, options.fetcher ?? fetch, { signal, timeoutMs: 5_000, metadataChannel: policy.channel })).toString('utf8'));
88
+ const verified = verifyChannelEnvelope(envelope, config, state.water[policy.channel]);
89
+ const document = verified.document, asset = assetFor(document, current.browserPreference);
90
+ // A channel never replaces an installed version with different bytes.
91
+ const order = compareVersions(document.version, current.productVersion);
92
+ if (order < 0 || (order === 0 && asset.manifestSha256 !== current.digest)) throw Error('UPDATE_INCOMPATIBLE');
93
+ state.water[policy.channel] = { ...state.water[policy.channel], sequence: document.sequence, version: document.version, releaseDigest: verified.digest };
94
+ state.available = { version: document.version, digest: asset.manifestSha256 };
95
+ if (policy.mode === 'notify' && order > 0 && state.water[policy.channel].notifiedDigest !== asset.manifestSha256) {
96
+ notice = { version: document.version, digest: asset.manifestSha256 };
97
+ // Optional inert high-water metadata; older readers ignore it. No
98
+ // new execution/trust authority is derived from notification state.
99
+ state.water[policy.channel].notifiedDigest = asset.manifestSha256;
100
+ }
101
+ if (order === 0) { state.available = null; state.pending = null; }
102
+ else if (quarantined(state, asset.manifestSha256)) state.hold = 'UPDATE_QUARANTINED';
103
+ else if (compatibleGeneration(current.generation, asset.generation)) state.hold = 'UPDATE_INCOMPATIBLE';
104
+ else if (policy.mode === 'auto' || options.manual === true) {
105
+ if (current.browserPreference === 'pinned' && options.confirmDownload !== asset.sha256) throw Error('UPDATE_BROWSER_CONFIRMATION_REQUIRED');
106
+ let candidate;
107
+ try { candidate = await transaction.installed(asset.manifestSha256); }
108
+ catch (error) {
109
+ if (error?.code !== 'ENOENT') throw error; // edited cache is never repaired silently
110
+ candidate = await installReleaseDocument(document, cache, options.fetcher ?? fetch, {
111
+ browserPreference: current.browserPreference, confirmDownload: options.confirmDownload, generation: asset.generation,
112
+ signal, install: transaction.stage,
113
+ });
114
+ }
115
+ if (candidate.productVersion !== document.version || compatibleGeneration(candidate.generation, asset.generation)) throw Error('UPDATE_INCOMPATIBLE');
116
+ if (!(await (options.readiness ?? readiness)(candidate, { signal }))) quarantine(state, candidate.digest);
117
+ else state.pending = { version: candidate.productVersion, digest: candidate.digest, envelope };
118
+ }
119
+ } catch (error) { state.hold = reason(error); }
120
+ await transaction.write('update-state.json', state);
121
+ return { status: state.pending ? 'pending_next_session' : state.hold ? 'held' : 'checked', ...(notice ? { notice } : {}) };
122
+ });
123
+ } catch { return { status: 'unavailable' }; }
124
+ }
125
+ /** Resolves once, before a new task/MCP process. Calls already inside a task
126
+ * never use this function. A failed candidate never causes action replay. */
127
+ export async function selectSessionRuntime(cache, trust, options = {}) {
128
+ const before = await readCurrent(cache);
129
+ if (!before) throw Error('RUNTIME_NOT_INSTALLED');
130
+ try {
131
+ return await withInstallTransaction(cache, async transaction => {
132
+ const current = await transaction.current(), policy = await readUpdatePolicy(cache), state = await stateFor(cache);
133
+ if (policy.mode === 'pinned') {
134
+ const pinned = await transaction.installed(policy.pin.digest);
135
+ if (pinned.productVersion !== policy.pin.version || compatibleGeneration(current.generation, pinned.generation)) throw Error('UPDATE_INCOMPATIBLE');
136
+ if (!(await (options.readiness ?? readiness)(pinned))) throw Error('UPDATE_INCOMPATIBLE');
137
+ return pinned;
138
+ }
139
+ if ((policy.mode !== 'auto' && options.manual !== true) || !state.pending) return current;
140
+ const pending = state.pending;
141
+ try {
142
+ const config = channelTrust(trust, policy), verified = verifyChannelEnvelope(pending.envelope, config, state.water[policy.channel]);
143
+ const asset = assetFor(verified.document, current.browserPreference);
144
+ if (pending.digest !== asset.manifestSha256 || pending.version !== verified.document.version || quarantined(state, pending.digest)
145
+ || compareVersions(pending.version, current.productVersion) <= 0 || compatibleGeneration(current.generation, asset.generation)) throw Error('UPDATE_INCOMPATIBLE');
146
+ const candidate = await transaction.installed(pending.digest);
147
+ if (compatibleGeneration(candidate.generation, asset.generation)) throw Error('UPDATE_INCOMPATIBLE');
148
+ if (!(await (options.ownership ?? (runtime => readiness(runtime, { integration: true })))(current))) {
149
+ state.hold = 'UPDATE_INTEGRATION_HELD'; await transaction.write('update-state.json', state); return current;
150
+ }
151
+ if (!(await (options.readiness ?? readiness)(candidate))) {
152
+ quarantine(state, candidate.digest); await transaction.write('update-state.json', state); return current;
153
+ }
154
+ const selected = await transaction.activate(candidate.digest);
155
+ state.pending = null; state.hold = null;
156
+ await transaction.write('update-state.json', state);
157
+ return selected;
158
+ } catch {
159
+ if (hex(pending.digest)) quarantine(state, pending.digest);
160
+ else state.hold = 'UPDATE_CHECK_FAILED';
161
+ await transaction.write('update-state.json', state);
162
+ return current;
163
+ }
164
+ });
165
+ } catch {
166
+ // An explicit exact pin cannot silently execute different bytes, even if
167
+ // its preflight or the shared lock is unavailable. Read-only recovery stays.
168
+ const policy = await readUpdatePolicy(cache).catch(() => undefined);
169
+ if (policy?.mode === 'pinned' && policy.pin.digest !== before.digest) throw Error('UPDATE_PIN_UNAVAILABLE');
170
+ return before; // other unknown lock/cache/policy never gates installed recovery
171
+ }
172
+ }
173
+ export async function retryQuarantine(cache, digest) {
174
+ if (!hex(digest)) throw Error('INVALID_ARGUMENTS');
175
+ return withInstallTransaction(cache, async transaction => {
176
+ const state = await stateFor(cache);
177
+ state.quarantine = state.quarantine.filter(value => value !== digest); state.hold = null;
178
+ await transaction.write('update-state.json', state);
179
+ return { status: 'retry_allowed', digest };
180
+ });
181
+ }
182
+ export async function rollbackGeneration(cache, options = {}) {
183
+ return withInstallTransaction(cache, async transaction => {
184
+ const current = await transaction.current(), previous = await transaction.previous();
185
+ if (!current || !previous) throw Error('ROLLBACK_UNAVAILABLE');
186
+ if (compatibleGeneration(current.generation, previous.generation)) throw Error('UPDATE_INCOMPATIBLE');
187
+ if (!(await (options.readiness ?? readiness)(previous))) throw Error('UPDATE_ROLLBACK_UNAVAILABLE');
188
+ const state = await stateFor(cache);
189
+ const selected = await transaction.activate(previous.digest);
190
+ state.pending = null; state.hold = 'UPDATE_ROLLED_BACK';
191
+ await transaction.write('update-state.json', state);
192
+ return selected;
193
+ });
194
+ }
195
+ /** Restore code for future sessions only. Never replays this process's request. */
196
+ export async function quarantineStartup(cache, digest, previous, options = {}) {
197
+ return withInstallTransaction(cache, async transaction => {
198
+ const state = await stateFor(cache), current = await transaction.current();
199
+ quarantine(state, digest);
200
+ if (current?.digest === digest && previous && previous.digest !== digest) {
201
+ const saved = await transaction.installed(previous.digest);
202
+ if (!compatibleGeneration(current.generation, saved.generation) && await (options.readiness ?? readiness)(saved)) await transaction.activate(saved.digest);
203
+ }
204
+ await transaction.write('update-state.json', state);
205
+ });
206
+ }
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "stateport",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0-rc.5",
4
+ "private": false,
5
+ "description": "StatePort agent runtime bootstrap",
6
+ "type": "module",
7
+ "bin": {
8
+ "stateport": "./bin/stateport.mjs"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "lib",
13
+ "release.json",
14
+ "update-release.json",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "engines": {
19
+ "node": ">=24 <25"
20
+ },
21
+ "scripts": {
22
+ "build": "node --check bin/stateport.mjs",
23
+ "check": "node --test tests/*.test.mjs"
24
+ },
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/StatePort-Dev/stateport-agent-skills.git",
29
+ "directory": "bootstrap"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public",
33
+ "registry": "https://registry.npmjs.org/"
34
+ }
35
+ }
package/release.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "schemaVersion": "agent-bootstrap-release/1.0.0",
3
+ "status": "published",
4
+ "version": "1.0.0-rc.5",
5
+ "url": "https://github.com/StatePort-Dev/stateport-releases/releases/download/v1.0.0-rc.5-agent.1/release.json",
6
+ "manifestSha256": "c092f1970cb90b7b648278989275c9c3ab5ed9217a30fcaea31b31103f2598e4",
7
+ "keyId": "stateport-agent-release-2026-11-03",
8
+ "publicKey": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA8uC2jzpeW53A/MGquI0i7vjAHRpk6MKslN2asEIQQjs=\n-----END PUBLIC KEY-----\n"
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "schemaVersion": "agent-update-trust/1",
3
+ "status": "published",
4
+ "publisher": "StatePort-Dev",
5
+ "channel": "rc",
6
+ "url": "https://raw.githubusercontent.com/StatePort-Dev/stateport-releases/main/channels/rc.json",
7
+ "keyId": "stateport-agent-release-2026-11-03",
8
+ "publicKey": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA8uC2jzpeW53A/MGquI0i7vjAHRpk6MKslN2asEIQQjs=\n-----END PUBLIC KEY-----\n"
9
+ }