@platless/axom-cli 0.6.36 → 0.6.52
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/README.md +7 -1
- package/dist/axom-patch/cli/README.md +31 -2
- package/dist/axom-patch/cli/account-limits/generate.py +2 -2
- package/dist/axom-patch/cli/claude-replay-lifecycle.json +16 -0
- package/dist/axom-patch/cli/codex-terminal-output.json +2 -2
- package/dist/axom-patch/cli/probes/claude-replay-lifecycle.mjs +31 -0
- package/dist/axom-patch/cli/probes/codex-terminal-output.mjs +1 -1
- package/dist/axom-patch/cli/probes/zcode-env-account.mjs +81 -0
- package/dist/axom-patch/cli/probes/zcode-subagent-subwork.mjs +7 -0
- package/dist/axom-patch/cli/qoder/dist/axom-acp-compat.mjs +137 -41
- package/dist/axom-patch/cli/qoder/releases/1.1.75.json +5 -88
- package/dist/axom-patch/cli/qoder-native-acp.json +23 -23
- package/dist/axom-patch/cli/runtime.json +3 -1
- package/dist/axom-patch/cli/zcode/env-account.mjs +13 -0
- package/dist/axom-patch/cli/zcode/generate-env-account.py +52 -0
- package/dist/axom-patch/cli/zcode-account-limits.json +7 -7
- package/dist/axom-patch/cli/zcode-env-account.json +58 -0
- package/dist/axom-patch/cli/zcode-models.json +9 -9
- package/dist/axom-patch/cli/zcode-subagent-subwork.json +10 -10
- package/dist/cli.js +9 -11
- package/dist/commands/auth.js +4 -1
- package/dist/commands/update.js +68 -7
- package/dist/commands/workstation.js +94 -223
- package/dist/commands/workstationRegistrations.js +116 -0
- package/dist/credentialBroker.js +13 -2
- package/dist/credentials.js +16 -0
- package/dist/install/claude/commands.js +2 -2
- package/dist/keepers/terminal/keeper.mjs +1089 -673
- package/dist/keepers/terminal/manifest.json +4 -4
- package/dist/native/darwin-x64/axom-quic +0 -0
- package/dist/workstation/acp/acpAgents.js +11 -4
- package/dist/workstation/acp/outbox.js +12 -5
- package/dist/workstation/acp/permission.js +2 -2
- package/dist/workstation/acp/processor/checkpoint.js +4 -3
- package/dist/workstation/acp/processor/control.js +2 -2
- package/dist/workstation/acp/processor/jsonrpc.js +8 -0
- package/dist/workstation/acp/processor/session.js +3 -2
- package/dist/workstation/acp/processor/state.js +1 -0
- package/dist/workstation/acp/processor/transport/local.js +19 -3
- package/dist/workstation/acp/processor/updates.js +25 -0
- package/dist/workstation/acp/processorSession.js +12 -1
- package/dist/workstation/acp/registry/catalog.js +6 -6
- package/dist/workstation/acp/test/keeperWorld.js +4 -3
- package/dist/workstation/agentSessions.js +76 -23
- package/dist/workstation/channels/agent.js +9 -1
- package/dist/workstation/channels/computer.js +27 -4
- package/dist/workstation/channels/management.js +36 -5
- package/dist/workstation/config.js +18 -6
- package/dist/workstation/control/bindingStore.js +4 -0
- package/dist/workstation/control/commands.js +1 -0
- package/dist/workstation/control/daemonIdentity.js +90 -0
- package/dist/workstation/control/deviceCommands.js +191 -0
- package/dist/workstation/control/deviceProtocol.js +68 -0
- package/dist/workstation/control/locks.js +51 -4
- package/dist/workstation/control/rootFormat.js +16 -0
- package/dist/workstation/control/runtimeClient.js +60 -5
- package/dist/workstation/control/runtimeHost.js +96 -9
- package/dist/workstation/control/serverIdentity.js +2 -2
- package/dist/workstation/control/updateTransition.js +91 -0
- package/dist/workstation/daemon.js +262 -360
- package/dist/workstation/daemonctl.js +150 -12
- package/dist/workstation/desktopControl/automationDevice.js +219 -0
- package/dist/workstation/desktopControl/automationJson.js +74 -0
- package/dist/workstation/desktopControl/automationLocalState.js +23 -0
- package/dist/workstation/desktopControl/automationPreparation.js +1 -0
- package/dist/workstation/desktopControl/automationProtocol.js +253 -0
- package/dist/workstation/desktopControl/automationSchema.generated.js +1 -0
- package/dist/workstation/desktopControl/automationSession.js +81 -0
- package/dist/workstation/desktopControl/automationTransport.js +138 -0
- package/dist/workstation/desktopControl/capabilities.js +2 -1
- package/dist/workstation/desktopControl/deviceAdmission.js +229 -0
- package/dist/workstation/desktopControl/deviceOwnership.js +130 -0
- package/dist/workstation/desktopControl/deviceSockets.js +2 -2
- package/dist/workstation/desktopControl/helperProcess.js +250 -1
- package/dist/workstation/desktopControl/hostGrant.js +65 -0
- package/dist/workstation/desktopControl/lobby.js +4 -2
- package/dist/workstation/desktopControl/macProvider.js +41 -40
- package/dist/workstation/desktopControl/nativeDeviceAdmission.js +207 -0
- package/dist/workstation/desktopControl/nativeProvider.js +31 -5
- package/dist/workstation/desktopControl/ownershipProtocol.js +31 -0
- package/dist/workstation/desktopControl/protocol.js +15 -6
- package/dist/workstation/desktopControl/supervisor.js +110 -17
- package/dist/workstation/desktopControl/wallpaper.js +26 -6
- package/dist/workstation/exec/policy.js +5 -0
- package/dist/workstation/files/client.js +10 -1
- package/dist/workstation/files/host.js +9 -2
- package/dist/workstation/journal.js +5 -2
- package/dist/workstation/keepers/acp/client.js +38 -2
- package/dist/workstation/keepers/kit/buildVersion.js +35 -11
- package/dist/workstation/keepers/kit/client.js +2 -2
- package/dist/workstation/keepers/kit/launcher.js +80 -4
- package/dist/workstation/keepers/legacy.js +16 -2
- package/dist/workstation/keepers/policy.js +5 -34
- package/dist/workstation/keepers/terminal/client.js +115 -23
- package/dist/workstation/keepers/terminal/exec.js +6 -3
- package/dist/workstation/keepers/terminal/frames.js +73 -0
- package/dist/workstation/keepers/terminal/generations.js +5 -8
- package/dist/workstation/keepers/terminal/gridProtocol.js +16 -0
- package/dist/workstation/keepers/terminal/input.js +146 -0
- package/dist/workstation/keepers/terminal/keeper.js +22 -8
- package/dist/workstation/keepers/terminal/main.js +1 -1
- package/dist/workstation/keepers/terminal/metadata.js +138 -0
- package/dist/workstation/keepers/terminal/protocol.js +4 -28
- package/dist/workstation/keepers/terminal/pty.js +1 -330
- package/dist/workstation/keepers/terminal/screen.js +246 -0
- package/dist/workstation/keepers/terminal/sessions.js +526 -0
- package/dist/workstation/oauth/deviceAuthorization.js +31 -17
- package/dist/workstation/protocol/management.js +23 -4
- package/dist/workstation/rd/artifact.js +4 -3
- package/dist/workstation/rd/control.js +3 -5
- package/dist/workstation/rd/host.js +213 -0
- package/dist/workstation/rd/migrationReadiness.js +46 -0
- package/dist/workstation/rd/provider.js +44 -10
- package/dist/workstation/rd/state.js +14 -2
- package/dist/workstation/rd/update.js +5 -8
- package/dist/workstation/rd/upkeep.js +44 -2
- package/dist/workstation/registrations/enrollment.js +250 -0
- package/dist/workstation/registrations/enrollmentPending.js +361 -0
- package/dist/workstation/registrations/migration.js +356 -0
- package/dist/workstation/registrations/model.js +34 -0
- package/dist/workstation/registrations/removal.js +222 -0
- package/dist/workstation/registrations/store.js +737 -0
- package/dist/workstation/runtime/desktopBinding.js +20 -0
- package/dist/workstation/runtime/deviceDesktop.js +193 -0
- package/dist/workstation/runtime/deviceRuntime.js +568 -0
- package/dist/workstation/runtime/deviceServices.js +117 -0
- package/dist/workstation/runtime/events.js +20 -0
- package/dist/workstation/runtime/ownership.js +100 -0
- package/dist/workstation/runtime/registrationRuntime.js +575 -0
- package/dist/workstation/runtime/remoteDesktopAdapter.js +186 -0
- package/dist/workstation/runtime/removalActivity.js +184 -0
- package/dist/workstation/service/installer.js +93 -1
- package/dist/workstation/service/launchd.js +1 -0
- package/dist/workstation/service/systemd.js +1 -0
- package/dist/workstation/systemMetrics.js +3 -0
- package/dist/workstation/tools/client.js +8 -27
- package/dist/workstation/tools/info.js +6 -13
- package/dist/workstation/tools/terminalRouter.js +17 -172
- package/dist/workstation/tools/validation.js +55 -1
- package/dist/workstation/update/archiveEvidence.js +206 -0
- package/dist/workstation/update/coordinator.js +34 -13
- package/dist/workstation/update/deviceCompatibility.js +119 -0
- package/dist/workstation/update/deviceCoordinator.js +369 -0
- package/dist/workstation/update/deviceHealth.js +104 -0
- package/dist/workstation/update/deviceHealthProtocol.js +26 -0
- package/dist/workstation/update/deviceRecoveryLifecycle.js +46 -0
- package/dist/workstation/update/deviceRollback.js +78 -0
- package/dist/workstation/update/globalInstall.js +80 -10
- package/dist/workstation/update/legacyBridge.js +414 -0
- package/dist/workstation/update/legacyOwner.js +39 -0
- package/dist/workstation/update/npmInstaller.js +2 -2
- package/dist/workstation/update/privateDurability.js +61 -0
- package/dist/workstation/update/recoveryPlan.js +124 -0
- package/dist/workstation/update/runUpdate.js +7 -1
- package/dist/workstation/update/taskRegistry.js +161 -3
- package/dist/workstation/update/watchdog.js +27427 -14410
- package/dist/workstation/update/watchdogProcess.js +72 -0
- package/package.json +7 -3
- package/dist/native/darwin-arm64/axom-quic +0 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { spawn, execFile } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import semver from 'semver';
|
|
8
|
+
import { withPrivateLock } from '../control/locks.js';
|
|
9
|
+
import { processCommand, pidAlive } from '../keepers/kit/lock.js';
|
|
10
|
+
import { processStartTime, sameProcess } from '../keepers/kit/orphanSweep.js';
|
|
11
|
+
import { captureStartupPackageEvidence } from './deviceHealth.js';
|
|
12
|
+
import { readDeviceBootstrapRecovery, readDeviceWatchdogPlan, readPrivateUpdateBytes, updateTransactionKey, deviceRollbackCompleted } from './recoveryPlan.js';
|
|
13
|
+
import { persistDeviceRollbackPhase } from './deviceRollback.js';
|
|
14
|
+
import { writeDurablePrivateUpdate, syncPrivateRecoveryFile } from './privateDurability.js';
|
|
15
|
+
import { launchWatchdogProcess } from './watchdogProcess.js';
|
|
16
|
+
import { UpdateStateStore } from './stateStore.js';
|
|
17
|
+
const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
18
|
+
export function needsWatchdogCompatibility(plan) { return !!semver.valid(plan.deviceRecovery.rollback.version) && semver.lt(plan.deviceRecovery.rollback.version, '0.6.50'); }
|
|
19
|
+
export async function ensureCompatibleWatchdog(context, original, runSystemd) {
|
|
20
|
+
const key = updateTransactionKey(original.commandId), root = context.configDir, update = path.join(root, 'update'), originalPlan = path.join(update, `watchdog-${key}.json`);
|
|
21
|
+
await readDeviceWatchdogPlan(originalPlan, root);
|
|
22
|
+
const program = path.join(update, `compat-recovery-${key}.mjs`), metadata = path.join(update, `compat-recovery-${key}.json`);
|
|
23
|
+
const source = path.join(path.dirname(path.dirname(context.cliPath)), 'dist/workstation/update/watchdog.js'), bytes = await fs.readFile(source);
|
|
24
|
+
try {
|
|
25
|
+
await fs.writeFile(program, bytes, { mode: 0o700, flag: 'wx' });
|
|
26
|
+
await syncPrivateRecoveryFile(program);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error.code !== 'EEXIST' || digest(await fs.readFile(program)) !== digest(bytes))
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
const plan = { schemaVersion: 4, root, program, programHash: digest(bytes), originalPlan, originalPlanHash: digest(await readPrivateUpdateBytes(originalPlan, 1_048_576)), context };
|
|
33
|
+
await writeDurablePrivateUpdate(metadata, JSON.stringify(plan));
|
|
34
|
+
await launchWatchdogProcess({ nodePath: context.nodePath, program, plan: metadata, lifecycle: 'managed', logPath: path.join(update, 'compat-recovery.log') }, { platform: context.platform, runSystemd });
|
|
35
|
+
}
|
|
36
|
+
export function rollbackServiceContext(plan, nodePath = process.execPath) {
|
|
37
|
+
if (process.platform !== 'linux' && process.platform !== 'darwin')
|
|
38
|
+
throw Error('Managed rollback requires a supported user service');
|
|
39
|
+
const user = os.userInfo();
|
|
40
|
+
return { platform: process.platform, username: user.username, userId: process.getuid?.() ?? user.uid, homeDir: os.homedir(), nodePath, cliPath: plan.cliPath, npmPath: plan.npmPath, npmPrefix: plan.npmPrefix, configDir: plan.deviceRecovery.root, pathEnv: plan.npmEnvironment?.PATH ?? process.env.PATH ?? '/usr/bin:/bin', npmConfigPrefix: plan.npmPrefix };
|
|
41
|
+
}
|
|
42
|
+
export async function provisionVerifiedRollback(context, osDeps = {}) {
|
|
43
|
+
const pkg = path.dirname(path.dirname(context.cliPath)), metadata = JSON.parse(await fs.readFile(path.join(pkg, 'package.json'), 'utf8'));
|
|
44
|
+
const startup = await captureStartupPackageEvidence(pkg, metadata.version), recovery = await readDeviceBootstrapRecovery(context.configDir, startup);
|
|
45
|
+
if (!recovery?.transaction.installedRollback || !recovery.transaction.recoveryRequired)
|
|
46
|
+
throw Error('Service repair requires the original exact installed rollback receipt');
|
|
47
|
+
const { createServiceManager } = await import('../service/installer.js');
|
|
48
|
+
await createServiceManager(context, osDeps).install({ interactive: false, restartRunning: true });
|
|
49
|
+
}
|
|
50
|
+
async function transaction(root) { const { deviceTransactionSchema } = await import('./deviceCoordinator.js'); return deviceTransactionSchema.parse(JSON.parse((await readPrivateUpdateBytes(path.join(root, 'update/device-transaction.json'), 33_554_432)).toString())); }
|
|
51
|
+
async function originalPlan(metadata) { const bytes = await readPrivateUpdateBytes(metadata.originalPlan, 1_048_576); if (digest(bytes) !== metadata.originalPlanHash)
|
|
52
|
+
throw Error('Original compatibility watchdog plan changed'); return readDeviceWatchdogPlan(metadata.originalPlan, metadata.root); }
|
|
53
|
+
async function originalAlive(context, plan, planPath) {
|
|
54
|
+
const expected = `${context.nodePath} ${plan.program.path} ${planPath}`;
|
|
55
|
+
const listing = await promisify(execFile)('ps', ['-ww', '-axo', 'pid=,command='], { maxBuffer: 8_388_608 });
|
|
56
|
+
for (const line of listing.stdout.split('\n')) {
|
|
57
|
+
const match = /^\s*(\d+)\s+(.*)$/.exec(line);
|
|
58
|
+
if (!match || match[2] !== expected)
|
|
59
|
+
continue;
|
|
60
|
+
const pid = Number(match[1]), start = await processStartTime(pid);
|
|
61
|
+
if (start && pidAlive(pid) && await processCommand(pid) === expected && await sameProcess(pid, start))
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
async function executeOriginal(metadata, plan) { await new Promise((resolve, reject) => { const child = spawn(metadata.context.nodePath, [plan.program.path, metadata.originalPlan], { stdio: 'inherit', env: { ...process.env, AXOM_WORKSTATION_CONFIG_DIR: metadata.root } }); child.once('error', reject); child.once('close', () => resolve()); }); }
|
|
67
|
+
async function retireOriginal(metadata) { const plan = await originalPlan(metadata); await fs.unlink(metadata.originalPlan); await fs.unlink(plan.program.path); }
|
|
68
|
+
/** A destination-owned compatibility process leaves the original program/plan byte-for-byte
|
|
69
|
+
* intact, waits for any existing owner, and repairs only a positively installed rollback. */
|
|
70
|
+
export async function runCompatibleWatchdog(metadata, deps = {}) {
|
|
71
|
+
if (metadata.schemaVersion !== 4 || metadata.context.configDir !== metadata.root || path.dirname(metadata.program) !== path.join(metadata.root, 'update') || digest(await fs.readFile(metadata.program)) !== metadata.programHash)
|
|
72
|
+
throw Error('Compatibility recovery program binding changed');
|
|
73
|
+
await withPrivateLock(path.join(metadata.root, 'update', path.basename(metadata.program, '.mjs')), async () => {
|
|
74
|
+
let state = await transaction(metadata.root);
|
|
75
|
+
try {
|
|
76
|
+
await fs.lstat(metadata.originalPlan);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (error.code === 'ENOENT' && !state.recoveryRequired && ['SUCCEEDED', 'ROLLED_BACK'].includes(state.phase))
|
|
80
|
+
return;
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
const plan = await originalPlan(metadata);
|
|
84
|
+
const pause = deps.wait ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
|
|
85
|
+
while (await (deps.alive ?? (() => originalAlive(metadata.context, plan, metadata.originalPlan)))())
|
|
86
|
+
await pause(250);
|
|
87
|
+
state = await transaction(metadata.root);
|
|
88
|
+
try {
|
|
89
|
+
await fs.lstat(metadata.originalPlan);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (error.code === 'ENOENT' && !state.recoveryRequired && ['SUCCEEDED', 'ROLLED_BACK'].includes(state.phase))
|
|
93
|
+
return;
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
if (!state.installedRollback && ['RESTARTING', 'SUCCEEDED'].includes(state.phase)) {
|
|
97
|
+
await (deps.runOriginal ?? (() => executeOriginal(metadata, plan)))();
|
|
98
|
+
state = await transaction(metadata.root);
|
|
99
|
+
}
|
|
100
|
+
if (state.installedRollback && state.recoveryRequired && ['FAILED', 'RESTARTING'].includes(state.phase)) {
|
|
101
|
+
await (deps.provision ?? provisionVerifiedRollback)(metadata.context);
|
|
102
|
+
await persistDeviceRollbackPhase(metadata.root, plan.deviceRecovery.expectation, 'ROLLED_BACK');
|
|
103
|
+
await new UpdateStateStore(metadata.root).save({ phase: 'ROLLED_BACK', commandId: state.transactionId, version: state.release.version, releaseSha512: state.release.sha512, remoteForcePolicy: 'drain-only' });
|
|
104
|
+
state = await transaction(metadata.root);
|
|
105
|
+
}
|
|
106
|
+
if (state.installedRollback && state.phase === 'ROLLED_BACK' && !state.recoveryRequired) {
|
|
107
|
+
const pkg = path.dirname(path.dirname(metadata.context.cliPath)), version = JSON.parse(await fs.readFile(path.join(pkg, 'package.json'), 'utf8')).version;
|
|
108
|
+
if (!await deviceRollbackCompleted(metadata.root, await captureStartupPackageEvidence(pkg, version), state.transactionId))
|
|
109
|
+
throw Error('Compatible rollback completion no longer matches its frozen archive');
|
|
110
|
+
try {
|
|
111
|
+
await retireOriginal(metadata);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (error.code !== 'ENOENT')
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
}
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { constants, promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { LocalControlError } from '../control/protocol.js';
|
|
6
|
+
import { deploymentDownloadURL, normalizeWorkstationServer } from '../control/serverIdentity.js';
|
|
7
|
+
import { archiveEvidenceSchema } from './archiveEvidence.js';
|
|
8
|
+
import { captureStartupPackageEvidence, deviceHealthMatches, queryDeviceHealth, deviceHealthRequestSchema, deviceHealthWitnessSchema, deviceInstalledRollbackSchema, deviceUpdateOriginSchema, runtimeFilesDigest } from './deviceHealth.js';
|
|
9
|
+
import { writeDurablePrivateUpdate } from './privateDurability.js';
|
|
10
|
+
const identity = z.string().min(1).max(128).refine(value => !/[\u0000-\u001f\u007f]/.test(value));
|
|
11
|
+
const releaseSchema = z.object({ version: identity, url: z.string().min(1).max(4096), size: z.number().int().positive().max(536_870_912), sha512: z.string().regex(/^[a-f0-9]{128}$/i).transform(value => value.toLowerCase()) }).strict();
|
|
12
|
+
export const deviceTransactionSchema = z.object({
|
|
13
|
+
schemaVersion: z.literal(1), transactionId: z.string().uuid(), challenge: z.string().regex(/^[a-f0-9]{64}$/),
|
|
14
|
+
initiatingDaemonGeneration: identity,
|
|
15
|
+
origin: deviceUpdateOriginSchema,
|
|
16
|
+
release: releaseSchema, resolvedSource: z.string().min(1).max(4096),
|
|
17
|
+
waiters: z.array(z.object({ registrationId: identity.nullable(), commandId: identity, managementGeneration: identity.nullable(), registrationRevision: identity.optional() }).strict().refine(value => (value.registrationId === null) === (value.managementGeneration === null))).min(1).max(128),
|
|
18
|
+
phase: z.enum(['REQUESTED', 'DRAINING', 'BUSY', 'DOWNLOADING', 'VERIFYING', 'INSTALLING', 'RESTARTING', 'SUCCEEDED', 'FAILED', 'ROLLED_BACK']),
|
|
19
|
+
recoveryRequired: z.boolean().optional(),
|
|
20
|
+
lifecycle: z.enum(['none', 'container', 'provision-and-arm']),
|
|
21
|
+
installation: z.enum(['not-started', 'started']).optional(),
|
|
22
|
+
restartPolicy: z.object({ schemaVersion: z.literal(1), mode: z.enum(['manual', 'external']), predecessor: z.object({ generation: identity, prepared: z.literal(true) }).strict().optional() }).strict().optional(),
|
|
23
|
+
targetEvidence: archiveEvidenceSchema.optional(),
|
|
24
|
+
installedRollback: deviceInstalledRollbackSchema.optional(),
|
|
25
|
+
}).strict();
|
|
26
|
+
export class DeviceUpdateCoordinator {
|
|
27
|
+
options;
|
|
28
|
+
active;
|
|
29
|
+
privateWrites = Promise.resolve();
|
|
30
|
+
constructor(options) {
|
|
31
|
+
this.options = options;
|
|
32
|
+
}
|
|
33
|
+
get transactionPath() { return path.join(this.options.directory, 'update/device-transaction.json'); }
|
|
34
|
+
serializeWrite(operation) {
|
|
35
|
+
const writing = this.privateWrites.then(operation);
|
|
36
|
+
this.privateWrites = writing.catch(() => { });
|
|
37
|
+
return writing;
|
|
38
|
+
}
|
|
39
|
+
async save(transaction) {
|
|
40
|
+
const parsed = deviceTransactionSchema.parse(transaction);
|
|
41
|
+
const serialized = JSON.stringify(parsed) + '\n';
|
|
42
|
+
if (Buffer.byteLength(serialized) > 33_554_432)
|
|
43
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Complete private target inventory exceeds the supported transaction bound');
|
|
44
|
+
await writeDurablePrivateUpdate(this.transactionPath, serialized);
|
|
45
|
+
}
|
|
46
|
+
async previous() {
|
|
47
|
+
let handle;
|
|
48
|
+
try {
|
|
49
|
+
handle = await fs.open(this.transactionPath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error.code === 'ENOENT')
|
|
53
|
+
return;
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const stat = await handle.stat();
|
|
58
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > 33_554_432 || stat.mode & 0o077 || process.getuid && stat.uid !== process.getuid())
|
|
59
|
+
throw new Error('Unsafe device update evidence');
|
|
60
|
+
return deviceTransactionSchema.parse(JSON.parse(await handle.readFile('utf8')));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Preserve corrupt device update evidence and recover with a compatible CLI');
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
await handle.close();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async snapshot(origin) {
|
|
70
|
+
const idle = { phase: 'SUCCEEDED', remoteForcePolicy: 'drain-only' };
|
|
71
|
+
const member = this.options.device.registrationIdentity(origin.registrationId);
|
|
72
|
+
if (!member || member.server !== origin.server)
|
|
73
|
+
return idle;
|
|
74
|
+
let transaction = this.active?.transaction ?? await this.previous();
|
|
75
|
+
if (!transaction)
|
|
76
|
+
return idle;
|
|
77
|
+
const waiter = [...transaction.waiters].reverse().find(waiter => waiter.registrationId === origin.registrationId);
|
|
78
|
+
const revision = waiter?.registrationRevision ?? (transaction.origin.kind === 'registration' && transaction.origin.registrationId === origin.registrationId ? transaction.origin.registrationRevision : undefined);
|
|
79
|
+
if (!waiter || revision !== member.revision)
|
|
80
|
+
return idle;
|
|
81
|
+
let healthPending = false;
|
|
82
|
+
if (['RESTARTING', 'SUCCEEDED'].includes(transaction.phase) && !transaction.recoveryRequired && transaction.targetEvidence && transaction.origin.kind === 'registration' && this.options.generation !== transaction.initiatingDaemonGeneration) {
|
|
83
|
+
const expected = { transactionId: transaction.transactionId, challenge: transaction.challenge, initiatingDaemonGeneration: transaction.initiatingDaemonGeneration, origin: transaction.origin, release: transaction.release, resolvedSource: transaction.resolvedSource, runtimeDigest: runtimeFilesDigest(transaction.targetEvidence.runtimeFiles) };
|
|
84
|
+
try {
|
|
85
|
+
const witness = await (this.options.health ?? queryDeviceHealth)(this.options.directory, expected);
|
|
86
|
+
if (deviceHealthMatches(expected, witness)) {
|
|
87
|
+
const latest = await this.previous(), parsed = deviceHealthWitnessSchema.parse(witness);
|
|
88
|
+
const current = this.options.management?.(origin.registrationId), currentMember = this.options.device.registrationIdentity(origin.registrationId);
|
|
89
|
+
const initiating = expected.origin.kind === 'registration' ? this.options.management?.(expected.origin.registrationId) : undefined;
|
|
90
|
+
const correlated = latest && latest.transactionId === transaction.transactionId && latest.challenge === transaction.challenge && JSON.stringify([latest.initiatingDaemonGeneration, latest.origin, latest.release, latest.resolvedSource, latest.targetEvidence]) === JSON.stringify([transaction.initiatingDaemonGeneration, transaction.origin, transaction.release, transaction.resolvedSource, transaction.targetEvidence]) && latest.waiters.some(value => JSON.stringify(value) === JSON.stringify(waiter));
|
|
91
|
+
if (correlated && parsed.daemonGeneration === this.options.generation && current?.origin.managementGeneration === origin.managementGeneration && currentMember?.revision === member.revision && currentMember.server === member.server && initiating?.origin.managementGeneration === parsed.registration?.managementGeneration)
|
|
92
|
+
transaction = latest;
|
|
93
|
+
else
|
|
94
|
+
healthPending = true;
|
|
95
|
+
}
|
|
96
|
+
else
|
|
97
|
+
healthPending = true;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
healthPending = true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { phase: healthPending ? 'RESTARTING' : transaction.phase, commandId: waiter.commandId, version: transaction.release.version, releaseSha512: transaction.release.sha512, remoteForcePolicy: 'drain-only', ...(transaction.recoveryRequired ? { phase: 'FAILED', error: 'Original device recovery remains unresolved' } : {}) };
|
|
104
|
+
}
|
|
105
|
+
persist(active) {
|
|
106
|
+
const snapshot = deviceTransactionSchema.parse(active.transaction);
|
|
107
|
+
active.writes = active.writes.catch(() => { }).then(() => active.prerequisite).then(async () => {
|
|
108
|
+
try {
|
|
109
|
+
await this.serializeWrite(() => this.save(snapshot));
|
|
110
|
+
active.owned = true;
|
|
111
|
+
active.persistenceUncertain = false;
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
active.persistenceUncertain = true;
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
return active.writes;
|
|
119
|
+
}
|
|
120
|
+
request(origin, command) {
|
|
121
|
+
if (this.options.lifecycle === 'none')
|
|
122
|
+
return Promise.resolve({ phase: 'FAILED', commandId: command.commandId, remoteForcePolicy: 'drain-only', error: 'managed_restart_required: remote device updates require an explicitly managed or container restart' });
|
|
123
|
+
if (this.options.initialized && !this.options.initialized())
|
|
124
|
+
return Promise.resolve({ phase: 'BUSY', commandId: command.commandId, remoteForcePolicy: 'drain-only', error: 'Original device initialization must complete before update admission' });
|
|
125
|
+
const registration = this.options.device.registrationIdentity(origin.registrationId);
|
|
126
|
+
if (!registration || registration.server !== normalizeWorkstationServer(origin.server))
|
|
127
|
+
throw new LocalControlError('BINDING_CONFLICT', 'Original update registration changed');
|
|
128
|
+
return this.accept({ kind: 'registration', registrationId: registration.id, registrationRevision: registration.revision, managementGeneration: origin.managementGeneration, server: registration.server }, command);
|
|
129
|
+
}
|
|
130
|
+
local(operationId, release, source) {
|
|
131
|
+
if (this.options.lifecycle === 'none')
|
|
132
|
+
return Promise.resolve({ phase: 'FAILED', commandId: operationId, remoteForcePolicy: 'drain-only', error: 'stop_first: the foreground owner cannot safely hand off this update; stop and verify it, then use an explicitly managed workstation or compatible local recovery and explicitly restart to verify the installed package' });
|
|
133
|
+
if (this.options.initialized && !this.options.initialized())
|
|
134
|
+
return Promise.resolve({ phase: 'BUSY', commandId: operationId, remoteForcePolicy: 'drain-only', error: 'Original device initialization must complete before update admission' });
|
|
135
|
+
return this.accept({ kind: 'local', server: normalizeWorkstationServer(source) }, { commandId: operationId, release, force: false });
|
|
136
|
+
}
|
|
137
|
+
accept(origin, command) {
|
|
138
|
+
const release = releaseSchema.parse(command.release);
|
|
139
|
+
const commandId = identity.parse(command.commandId);
|
|
140
|
+
const source = normalizeWorkstationServer(origin.server);
|
|
141
|
+
const resolvedSource = deploymentDownloadURL(source, release.url);
|
|
142
|
+
const registrationId = origin.kind === 'registration' ? origin.registrationId : null;
|
|
143
|
+
const managementGeneration = origin.kind === 'registration' ? origin.managementGeneration : null;
|
|
144
|
+
const registrationRevision = origin.kind === 'registration' ? origin.registrationRevision : undefined;
|
|
145
|
+
if (this.active) {
|
|
146
|
+
const active = this.active;
|
|
147
|
+
const target = active.transaction.release;
|
|
148
|
+
if (target.version !== release.version || target.sha512 !== release.sha512 || target.size !== release.size || target.url !== release.url)
|
|
149
|
+
return Promise.resolve({ phase: 'UPDATE_IN_PROGRESS', commandId, version: release.version, remoteForcePolicy: 'drain-only', error: 'A different declared release already owns the device update transaction' });
|
|
150
|
+
const waiter = active.transaction.waiters.find(value => value.registrationId === registrationId && value.commandId === commandId);
|
|
151
|
+
if (waiter)
|
|
152
|
+
waiter.managementGeneration = managementGeneration;
|
|
153
|
+
else {
|
|
154
|
+
if (active.transaction.waiters.length >= 128)
|
|
155
|
+
return Promise.resolve({ phase: 'BUSY', commandId, remoteForcePolicy: 'drain-only', error: 'The device update waiter limit is reached' });
|
|
156
|
+
active.transaction.waiters.push({ registrationId, commandId, managementGeneration, registrationRevision });
|
|
157
|
+
}
|
|
158
|
+
const accepted = this.persist(active);
|
|
159
|
+
if (active.restorationPending) {
|
|
160
|
+
active.restorationPending = false;
|
|
161
|
+
active.done = this.restore(active);
|
|
162
|
+
void active.done.catch(() => { });
|
|
163
|
+
}
|
|
164
|
+
return accepted.then(() => active.done).then(status => ({ ...status, commandId, remoteForcePolicy: 'drain-only' }));
|
|
165
|
+
}
|
|
166
|
+
const transaction = {
|
|
167
|
+
schemaVersion: 1, transactionId: randomUUID(), challenge: randomBytes(32).toString('hex'), initiatingDaemonGeneration: this.options.generation,
|
|
168
|
+
origin: { ...origin, server: source },
|
|
169
|
+
release, resolvedSource, waiters: [{ registrationId, commandId, managementGeneration, registrationRevision }], phase: 'REQUESTED', lifecycle: this.options.lifecycle ?? 'none', installation: 'not-started',
|
|
170
|
+
...(this.options.lifecycle === 'container' ? { restartPolicy: { schemaVersion: 1, mode: 'external' } } : {}),
|
|
171
|
+
};
|
|
172
|
+
const lease = this.options.device.acquireMaintenance({ transactionId: transaction.transactionId, ...(origin.kind === 'registration' ? { origin: { registrationId: origin.registrationId, revision: origin.registrationRevision } } : {}) });
|
|
173
|
+
const previous = this.previous().then(async (prior) => {
|
|
174
|
+
if (prior && (prior.recoveryRequired || !['SUCCEEDED', 'FAILED', 'BUSY', 'ROLLED_BACK'].includes(prior.phase)))
|
|
175
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'An original device update still requires authenticated completion or recovery');
|
|
176
|
+
if (prior && ['FAILED', 'BUSY'].includes(prior.phase) && prior.installation !== 'not-started')
|
|
177
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Original package mutation cannot be excluded');
|
|
178
|
+
try {
|
|
179
|
+
if ((await fs.readdir(path.dirname(this.transactionPath))).some(name => /^watchdog-.*\.json(?:\..*)?$/.test(name)))
|
|
180
|
+
throw new LocalControlError('BUSY', 'The original armed watchdog must finish its own transaction cleanup before another update');
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
if (error.code !== 'ENOENT')
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
void previous.catch(() => { });
|
|
188
|
+
const active = { transaction, lease, prerequisite: previous, owned: false, writes: Promise.resolve(), done: Promise.resolve({ phase: 'REQUESTED' }) };
|
|
189
|
+
this.active = active;
|
|
190
|
+
active.done = this.run(active);
|
|
191
|
+
void active.done.catch(() => { });
|
|
192
|
+
return active.done.then(status => ({ ...status, commandId, remoteForcePolicy: 'drain-only' }));
|
|
193
|
+
}
|
|
194
|
+
async run(active) {
|
|
195
|
+
const { transaction, lease } = active;
|
|
196
|
+
let status = { phase: 'FAILED', commandId: transaction.transactionId, version: transaction.release.version, remoteForcePolicy: 'drain-only' };
|
|
197
|
+
let persisted = false;
|
|
198
|
+
try {
|
|
199
|
+
await this.persist(active);
|
|
200
|
+
persisted = true;
|
|
201
|
+
await lease.ready;
|
|
202
|
+
const observed = await lease.observe();
|
|
203
|
+
if (observed.status !== 'candidate')
|
|
204
|
+
status = { ...status, phase: 'BUSY', error: 'Existing device work or unknown original obligations prevent update; retry after they settle' };
|
|
205
|
+
else
|
|
206
|
+
status = { ...await this.options.execute({ commandId: transaction.transactionId, release: { ...transaction.release }, force: false }, {
|
|
207
|
+
source: transaction.origin.server, transaction: deviceTransactionSchema.parse(transaction),
|
|
208
|
+
currentTransaction: () => deviceTransactionSchema.parse(transaction),
|
|
209
|
+
recordPhase: async (status) => {
|
|
210
|
+
if (status.phase === 'SUCCEEDED')
|
|
211
|
+
return;
|
|
212
|
+
if (status.phase === 'INSTALLING')
|
|
213
|
+
transaction.installation = 'started';
|
|
214
|
+
if (status.phase === 'FAILED' && transaction.installation === 'started')
|
|
215
|
+
transaction.recoveryRequired = true;
|
|
216
|
+
transaction.phase = status.phase === 'UPDATE_IN_PROGRESS' ? 'FAILED' : status.phase;
|
|
217
|
+
await this.persist(active);
|
|
218
|
+
},
|
|
219
|
+
recordTarget: async (raw) => {
|
|
220
|
+
const evidence = archiveEvidenceSchema.parse(raw);
|
|
221
|
+
if (evidence.version !== transaction.release.version || evidence.sha512 !== transaction.release.sha512 || evidence.size !== transaction.release.size)
|
|
222
|
+
throw new LocalControlError('BINDING_CONFLICT', 'Verified target archive does not match the pinned release');
|
|
223
|
+
if (transaction.targetEvidence && JSON.stringify(transaction.targetEvidence) !== JSON.stringify(evidence))
|
|
224
|
+
throw new LocalControlError('BINDING_CONFLICT', 'The original target archive evidence is immutable');
|
|
225
|
+
transaction.targetEvidence = evidence;
|
|
226
|
+
transaction.phase = 'VERIFYING';
|
|
227
|
+
await this.persist(active);
|
|
228
|
+
},
|
|
229
|
+
observe: async () => (await lease.observe()).status === 'candidate',
|
|
230
|
+
handoff: async () => {
|
|
231
|
+
if (transaction.restartPolicy && !transaction.targetEvidence)
|
|
232
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Exact verified target inventory is required before original restart handoff');
|
|
233
|
+
await lease.handoff();
|
|
234
|
+
if (transaction.restartPolicy) {
|
|
235
|
+
transaction.restartPolicy.predecessor = { generation: this.options.generation, prepared: true };
|
|
236
|
+
await this.persist(active);
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
}), remoteForcePolicy: 'drain-only' };
|
|
240
|
+
active.outcome = status;
|
|
241
|
+
return status;
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
if (active.persistenceUncertain) {
|
|
245
|
+
transaction.recoveryRequired = true;
|
|
246
|
+
active.restorationPending = true;
|
|
247
|
+
active.outcome ??= status;
|
|
248
|
+
await this.persist(active);
|
|
249
|
+
}
|
|
250
|
+
else if (status.phase !== 'RESTARTING' && !transaction.recoveryRequired) {
|
|
251
|
+
active.outcome ??= status;
|
|
252
|
+
await this.release(active);
|
|
253
|
+
}
|
|
254
|
+
else if (status.phase === 'RESTARTING' && persisted) {
|
|
255
|
+
transaction.phase = 'RESTARTING';
|
|
256
|
+
await this.persist(active);
|
|
257
|
+
}
|
|
258
|
+
let pending;
|
|
259
|
+
do {
|
|
260
|
+
pending = active.writes;
|
|
261
|
+
await pending.catch(() => { });
|
|
262
|
+
} while (pending !== active.writes);
|
|
263
|
+
if (status.phase !== 'RESTARTING' && !transaction.recoveryRequired && !active.restorationPending && this.active === active)
|
|
264
|
+
this.active = undefined;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
async release(active) {
|
|
268
|
+
if (!active.owned) {
|
|
269
|
+
try {
|
|
270
|
+
await active.lease.release();
|
|
271
|
+
active.restorationPending = false;
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
active.restorationPending = true;
|
|
275
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Original admission restoration remains pending; prior private evidence was preserved');
|
|
276
|
+
}
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
await active.lease.release(async () => {
|
|
281
|
+
const status = active.outcome ?? { phase: 'FAILED' };
|
|
282
|
+
active.transaction.phase = status.phase === 'UPDATE_IN_PROGRESS' ? 'FAILED' : status.phase;
|
|
283
|
+
active.transaction.recoveryRequired = false;
|
|
284
|
+
await this.persist(active);
|
|
285
|
+
let pending;
|
|
286
|
+
do {
|
|
287
|
+
pending = active.writes;
|
|
288
|
+
await pending;
|
|
289
|
+
} while (pending !== active.writes);
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
active.restorationPending = true;
|
|
294
|
+
active.transaction.phase = 'FAILED';
|
|
295
|
+
active.transaction.recoveryRequired = true;
|
|
296
|
+
await this.persist(active);
|
|
297
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Original maintenance restoration remains pending; preserve the update transaction and retry its exact operation');
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
async restore(active) {
|
|
301
|
+
await this.release(active);
|
|
302
|
+
const status = active.outcome ?? { phase: 'FAILED', error: 'Original update failed before a confirmed outcome' };
|
|
303
|
+
let pending;
|
|
304
|
+
do {
|
|
305
|
+
pending = active.writes;
|
|
306
|
+
if (active.owned)
|
|
307
|
+
await pending;
|
|
308
|
+
else
|
|
309
|
+
await pending.catch(() => { });
|
|
310
|
+
} while (pending !== active.writes);
|
|
311
|
+
if (this.active === active)
|
|
312
|
+
this.active = undefined;
|
|
313
|
+
return status;
|
|
314
|
+
}
|
|
315
|
+
async health(raw, generation) {
|
|
316
|
+
const request = deviceHealthRequestSchema.parse(raw);
|
|
317
|
+
const transaction = await this.previous();
|
|
318
|
+
const startup = this.options.startupPackage;
|
|
319
|
+
if (!transaction || !startup || !this.options.packageRoot || this.options.initialized?.() !== true || transaction.recoveryRequired || !['RESTARTING', 'SUCCEEDED'].includes(transaction.phase) || !transaction.targetEvidence || request.transactionId !== transaction.transactionId || request.challenge !== transaction.challenge || generation !== this.options.generation || generation === transaction.initiatingDaemonGeneration)
|
|
320
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Exact initialized successor health is not yet available');
|
|
321
|
+
if (startup.version !== transaction.release.version || runtimeFilesDigest(transaction.targetEvidence.runtimeFiles) !== startup.runtimeDigest || transaction.targetEvidence.sha512 !== transaction.release.sha512 || transaction.targetEvidence.size !== transaction.release.size || transaction.targetEvidence.version !== transaction.release.version || deploymentDownloadURL(transaction.origin.server, transaction.release.url) !== transaction.resolvedSource)
|
|
322
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'The original startup package does not match the exact pinned installed target');
|
|
323
|
+
try {
|
|
324
|
+
const current = await captureStartupPackageEvidence(this.options.packageRoot, startup.version);
|
|
325
|
+
if (current.runtimeDigest !== startup.runtimeDigest)
|
|
326
|
+
throw new Error('Installed runtime changed');
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Installed target bytes no longer match the original startup capture');
|
|
330
|
+
}
|
|
331
|
+
const currentRegistration = () => {
|
|
332
|
+
if (transaction.origin.kind !== 'registration')
|
|
333
|
+
return null;
|
|
334
|
+
const origin = transaction.origin;
|
|
335
|
+
const current = this.options.management?.(origin.registrationId);
|
|
336
|
+
const member = this.options.device.registrationIdentity(origin.registrationId);
|
|
337
|
+
const release = current?.release;
|
|
338
|
+
if (!current || !member || member.revision !== origin.registrationRevision || member.server !== origin.server || current.origin.server !== origin.server || current.origin.registrationId !== origin.registrationId || current.origin.managementGeneration === origin.managementGeneration || !release || release.version !== transaction.release.version || release.url !== transaction.release.url || release.size !== transaction.release.size || release.sha512.toLowerCase() !== transaction.release.sha512)
|
|
339
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'The initiating registration has not authenticated its exact fresh successor connection');
|
|
340
|
+
return { registrationId: origin.registrationId, registrationRevision: member.revision, server: member.server, managementGeneration: current.origin.managementGeneration };
|
|
341
|
+
};
|
|
342
|
+
const registration = currentRegistration();
|
|
343
|
+
const assertCurrent = () => { if (JSON.stringify(currentRegistration()) !== JSON.stringify(registration) || this.options.initialized?.() !== true)
|
|
344
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'The exact initiating successor connection changed during recovery'); };
|
|
345
|
+
const expectedTransaction = JSON.stringify(transaction);
|
|
346
|
+
const complete = () => this.serializeWrite(async () => {
|
|
347
|
+
if (JSON.stringify(await this.previous()) !== expectedTransaction)
|
|
348
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'The exact successor transaction changed before durable completion');
|
|
349
|
+
assertCurrent();
|
|
350
|
+
transaction.phase = 'SUCCEEDED';
|
|
351
|
+
await this.save(transaction);
|
|
352
|
+
try {
|
|
353
|
+
assertCurrent();
|
|
354
|
+
}
|
|
355
|
+
catch (error) {
|
|
356
|
+
transaction.phase = 'RESTARTING';
|
|
357
|
+
await this.save(transaction);
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
if (this.options.completeRecovery)
|
|
362
|
+
await this.options.completeRecovery(complete);
|
|
363
|
+
else
|
|
364
|
+
await complete();
|
|
365
|
+
assertCurrent();
|
|
366
|
+
const witness = deviceHealthWitnessSchema.parse({ schemaVersion: 1, transactionId: transaction.transactionId, challenge: transaction.challenge, daemonGeneration: generation, startupVersion: startup.version, release: { ...transaction.release }, resolvedSource: transaction.resolvedSource, runtimeDigest: startup.runtimeDigest, registration });
|
|
367
|
+
return witness;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { constants, promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { rootReaderSchema } from './archiveEvidence.js';
|
|
5
|
+
import { runtimeRequest } from '../control/runtimeClient.js';
|
|
6
|
+
import { deviceHealthRequestSchema, deviceHealthWitnessSchema, runtimeFilesDigest } from './deviceHealthProtocol.js';
|
|
7
|
+
export * from './deviceHealthProtocol.js';
|
|
8
|
+
export function deviceHealthMatches(expected, raw) {
|
|
9
|
+
const parsed = deviceHealthWitnessSchema.safeParse(raw);
|
|
10
|
+
if (!parsed.success)
|
|
11
|
+
return false;
|
|
12
|
+
const witness = parsed.data;
|
|
13
|
+
if (witness.transactionId !== expected.transactionId || witness.challenge !== expected.challenge || witness.daemonGeneration === expected.initiatingDaemonGeneration || witness.startupVersion !== expected.release.version || witness.runtimeDigest !== expected.runtimeDigest || witness.resolvedSource !== expected.resolvedSource)
|
|
14
|
+
return false;
|
|
15
|
+
if (witness.release.version !== expected.release.version || witness.release.url !== expected.release.url || witness.release.size !== expected.release.size || witness.release.sha512 !== expected.release.sha512)
|
|
16
|
+
return false;
|
|
17
|
+
if (expected.origin.kind === 'local')
|
|
18
|
+
return witness.registration === null;
|
|
19
|
+
const registration = witness.registration;
|
|
20
|
+
return !!registration && registration.registrationId === expected.origin.registrationId && registration.registrationRevision === expected.origin.registrationRevision && registration.server === expected.origin.server && registration.managementGeneration !== expected.origin.managementGeneration;
|
|
21
|
+
}
|
|
22
|
+
export async function queryDeviceHealth(root, expected) {
|
|
23
|
+
return runtimeRequest(root, 'update-health', undefined, { request: deviceHealthRequestSchema.parse({ transactionId: expected.transactionId, challenge: expected.challenge }) });
|
|
24
|
+
}
|
|
25
|
+
export async function captureStartupPackageEvidence(packageRoot, runningVersion) {
|
|
26
|
+
const paths = ['package.json'];
|
|
27
|
+
const names = new Map();
|
|
28
|
+
const walk = async (relative, depth) => {
|
|
29
|
+
if (depth > 64)
|
|
30
|
+
throw Error('Unsupported runtime inventory depth');
|
|
31
|
+
const directory = await fs.lstat(path.join(packageRoot, relative));
|
|
32
|
+
if (!directory.isDirectory() || directory.isSymbolicLink())
|
|
33
|
+
throw Error('Unsafe installed runtime directory');
|
|
34
|
+
for (const entry of await fs.readdir(path.join(packageRoot, relative), { withFileTypes: true })) {
|
|
35
|
+
const name = `${relative}/${entry.name}`;
|
|
36
|
+
if (/[^\x20-\x7e]|\\/.test(name) || name.length > 4096 || entry.name === '.' || entry.name === '..')
|
|
37
|
+
throw Error('Unsupported runtime inventory name');
|
|
38
|
+
const alias = name.toLowerCase();
|
|
39
|
+
if (names.has(alias))
|
|
40
|
+
throw Error('Ambiguous runtime inventory alias');
|
|
41
|
+
names.set(alias, name);
|
|
42
|
+
if (names.size > 65_536)
|
|
43
|
+
throw Error('Runtime inventory member limit exceeded');
|
|
44
|
+
if (entry.isDirectory())
|
|
45
|
+
await walk(name, depth + 1);
|
|
46
|
+
else if (entry.isFile())
|
|
47
|
+
paths.push(name);
|
|
48
|
+
else
|
|
49
|
+
throw Error('Unsupported installed runtime member');
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
await walk('dist', 0);
|
|
53
|
+
if (!paths.includes('dist/cli.js'))
|
|
54
|
+
throw Error('The installed executable entrypoint is missing');
|
|
55
|
+
const runtimeFiles = [];
|
|
56
|
+
let metadata;
|
|
57
|
+
let total = 0;
|
|
58
|
+
for (const relative of paths.sort()) {
|
|
59
|
+
const handle = await fs.open(path.join(packageRoot, relative), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
60
|
+
try {
|
|
61
|
+
const before = await handle.stat();
|
|
62
|
+
if (!before.isFile() || before.nlink !== 1 || before.size > (relative === 'package.json' ? 1_048_576 : 134_217_728))
|
|
63
|
+
throw Error('Unsafe installed runtime file');
|
|
64
|
+
total += before.size;
|
|
65
|
+
if (total > 1_073_741_824)
|
|
66
|
+
throw Error('Installed runtime inventory size limit exceeded');
|
|
67
|
+
const bytes = await handle.readFile();
|
|
68
|
+
const after = await handle.stat();
|
|
69
|
+
if (bytes.length !== before.size || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs)
|
|
70
|
+
throw Error('Installed runtime file changed during startup capture');
|
|
71
|
+
runtimeFiles.push({ path: relative, size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') });
|
|
72
|
+
if (relative === 'package.json')
|
|
73
|
+
metadata = JSON.parse(bytes.toString('utf8'));
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
await handle.close();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata))
|
|
80
|
+
throw Error('Installed package identity is unavailable');
|
|
81
|
+
const value = metadata;
|
|
82
|
+
if (value.name !== '@platless/axom-cli' || value.version !== runningVersion || !rootReaderSchema.safeParse(value.axomWorkstationRootReader).success || !value.bin || typeof value.bin !== 'object' || value.bin.axom !== 'dist/cli.js')
|
|
83
|
+
throw Error('Installed package lacks a compatible root-format-2 reader identity');
|
|
84
|
+
const finalPaths = ['package.json'];
|
|
85
|
+
let finalCount = 0;
|
|
86
|
+
const collect = async (relative, depth = 0) => {
|
|
87
|
+
if (depth > 64 || ++finalCount > 65_536)
|
|
88
|
+
throw Error('Runtime inventory changed beyond supported limits');
|
|
89
|
+
for (const entry of await fs.readdir(path.join(packageRoot, relative), { withFileTypes: true })) {
|
|
90
|
+
if (++finalCount > 65_536)
|
|
91
|
+
throw Error('Runtime inventory changed beyond supported limits');
|
|
92
|
+
if (entry.isDirectory())
|
|
93
|
+
await collect(`${relative}/${entry.name}`, depth + 1);
|
|
94
|
+
else if (entry.isFile())
|
|
95
|
+
finalPaths.push(`${relative}/${entry.name}`);
|
|
96
|
+
else
|
|
97
|
+
throw Error('Runtime inventory changed during startup capture');
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
await collect('dist');
|
|
101
|
+
if (JSON.stringify(finalPaths.sort()) !== JSON.stringify(paths.sort()))
|
|
102
|
+
throw Error('Runtime inventory changed during startup capture');
|
|
103
|
+
return Object.freeze({ version: runningVersion, runtimeDigest: runtimeFilesDigest(runtimeFiles), runtimeFiles: Object.freeze(runtimeFiles.map(file => Object.freeze(file))) });
|
|
104
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { archiveEvidenceSchema, rootReaderSchema } from './archiveEvidence.js';
|
|
4
|
+
const bounded = z.string().min(1).max(128).refine(value => !/[\u0000-\u001f\u007f]/.test(value));
|
|
5
|
+
const url = z.string().min(1).max(4096);
|
|
6
|
+
export const deviceReleaseSchema = z.object({ version: bounded, url, size: z.number().int().positive().max(536_870_912), sha512: z.string().regex(/^[a-f0-9]{128}$/) }).strict();
|
|
7
|
+
export const deviceUpdateOriginSchema = z.discriminatedUnion('kind', [
|
|
8
|
+
z.object({ kind: z.literal('registration'), registrationId: bounded, registrationRevision: bounded, managementGeneration: bounded, server: url }).strict(),
|
|
9
|
+
z.object({ kind: z.literal('local'), server: url }).strict(),
|
|
10
|
+
]);
|
|
11
|
+
export const deviceHealthRequestSchema = z.object({ transactionId: z.string().uuid(), challenge: z.string().regex(/^[a-f0-9]{64}$/) }).strict();
|
|
12
|
+
export const deviceHealthExpectationSchema = deviceHealthRequestSchema.extend({ initiatingDaemonGeneration: bounded, origin: deviceUpdateOriginSchema, release: deviceReleaseSchema, resolvedSource: url, runtimeDigest: z.string().regex(/^[a-f0-9]{64}$/) }).strict();
|
|
13
|
+
export const deviceHealthWitnessSchema = deviceHealthRequestSchema.extend({
|
|
14
|
+
schemaVersion: z.literal(1), daemonGeneration: bounded, startupVersion: bounded, release: deviceReleaseSchema, resolvedSource: url, runtimeDigest: z.string().regex(/^[a-f0-9]{64}$/),
|
|
15
|
+
registration: z.object({ registrationId: bounded, registrationRevision: bounded, server: url, managementGeneration: bounded }).strict().nullable(),
|
|
16
|
+
}).strict();
|
|
17
|
+
export const deviceRollbackSourceSchema = z.discriminatedUnion('kind', [z.object({ kind: z.literal('local-pack'), packageRoot: url.optional() }).strict(), z.object({ kind: z.literal('authenticated-release'), server: url, release: deviceReleaseSchema }).strict()]);
|
|
18
|
+
export const deviceWatchdogRecoverySchema = z.object({
|
|
19
|
+
schemaVersion: z.literal(2), root: url, expectation: deviceHealthExpectationSchema,
|
|
20
|
+
rollback: z.object({ archive: url, name: z.literal('@platless/axom-cli'), version: bounded, size: z.number().int().positive().max(536_870_912), sha512: z.string().regex(/^[a-f0-9]{128}$/), reader: rootReaderSchema, source: deviceRollbackSourceSchema }).strict(),
|
|
21
|
+
}).strict();
|
|
22
|
+
export const deviceRollbackReceiptSchema = deviceHealthExpectationSchema.extend({ schemaVersion: z.literal(1), prepared: z.literal(true), daemonGeneration: bounded }).strict();
|
|
23
|
+
export const deviceInstalledRollbackSchema = z.object({ schemaVersion: z.literal(1), transactionId: z.string().uuid(), archive: archiveEvidenceSchema, handoff: deviceRollbackReceiptSchema }).strict();
|
|
24
|
+
export function runtimeFilesDigest(files) {
|
|
25
|
+
return createHash('sha256').update(JSON.stringify([...files].sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0))).digest('hex');
|
|
26
|
+
}
|