@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,39 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import { deriveSessionKey, randomBytes } from '../crypto.js';
|
|
3
|
+
import { connectEncryptedChannel } from '../connection/encryptedChannel.js';
|
|
4
|
+
import { workstationChannelUrl } from '../channels/url.js';
|
|
5
|
+
import { decodeManagementFrame, encodeManagementFrame, MANAGEMENT_WIRE_MIN_VERSION, MANAGEMENT_WIRE_VERSION } from '../protocol/management.js';
|
|
6
|
+
import { AGENT_WIRE_MIN_VERSION, AGENT_WIRE_VERSION } from '../protocol/agent.js';
|
|
7
|
+
/** Resolve missing legacy ownership only after proving possession of the existing workstation key
|
|
8
|
+
* to its pinned Server key. The plaintext nonce challenge can never supply account identity. */
|
|
9
|
+
export async function recoverAuthenticatedLegacyOwner(options) {
|
|
10
|
+
const abort = new AbortController(), timer = setTimeout(() => abort.abort(), options.timeoutMs ?? 15_000), nonce = randomBytes(16);
|
|
11
|
+
let challenge;
|
|
12
|
+
const protocols = { MANAGEMENT: { min: MANAGEMENT_WIRE_MIN_VERSION, max: MANAGEMENT_WIRE_VERSION }, COMPUTER: { min: 1, max: 6 }, AGENT: { min: AGENT_WIRE_MIN_VERSION, max: AGENT_WIRE_VERSION } };
|
|
13
|
+
const heartbeat = (seq, sentAtEpochMs) => ({ t: 'heartbeat', seq, sentAtEpochMs, clientVersion: options.version, channels: { MANAGEMENT: 'CONNECTING', COMPUTER: 'DISCONNECTED', AGENT: 'DISCONNECTED' }, activeShellTasks: 0, activeAgentTasks: 0, update: { ...options.update, remoteForcePolicy: 'drain-only' } });
|
|
14
|
+
let channel;
|
|
15
|
+
try {
|
|
16
|
+
channel = await connectEncryptedChannel({ url: workstationChannelUrl(options.server, 'management'), signal: abort.signal, openTimeoutMs: options.timeoutMs ?? 15_000, encode: encodeManagementFrame, decode: decodeManagementFrame, decodeBytes: decodeManagementFrame, heartbeat, initialFrame: () => heartbeat(0, Date.now()), isHeartbeatAck: (frame, seq) => frame.t === 'heartbeat_ack' && frame.seq === seq, handshake: async (wire) => {
|
|
17
|
+
wire.sendText(encodeManagementFrame({ t: 'management_hello', workstationId: options.workstationId, workstation: os.hostname(), clientNonce: nonce.toString('base64'), clientVersion: options.version, clientPackage: '@platless/axom-cli', protocols }));
|
|
18
|
+
const response = await wire.receive();
|
|
19
|
+
if (response.isBinary)
|
|
20
|
+
throw Error('Expected management nonce challenge');
|
|
21
|
+
const welcome = JSON.parse(response.data.toString());
|
|
22
|
+
if (welcome.t !== 'welcome' || typeof welcome.serverNonce !== 'string' || Buffer.from(welcome.serverNonce, 'base64').length !== 16)
|
|
23
|
+
throw Error('Server did not authenticate this workstation identity');
|
|
24
|
+
challenge = welcome.serverNonce;
|
|
25
|
+
return deriveSessionKey(options.clientKey, options.serverPub, nonce, Buffer.from(challenge, 'base64'));
|
|
26
|
+
} });
|
|
27
|
+
for await (const frame of channel.messages)
|
|
28
|
+
if (frame.t === 'management_ready') {
|
|
29
|
+
if (frame.serverNonce !== challenge || !frame.generation || typeof frame.ownerUserId !== 'string' || !frame.ownerUserId.trim() || frame.ownerUserId.length > 1024 || /[\u0000-\u001f\u007f]/.test(frame.ownerUserId))
|
|
30
|
+
throw Error('Authenticated Server did not supply the legacy workstation owner; a compatible Server is required');
|
|
31
|
+
return frame.ownerUserId;
|
|
32
|
+
}
|
|
33
|
+
throw Error('Authenticated management connection closed before owner verification');
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
channel?.close();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -76,11 +76,11 @@ export class NpmInstaller {
|
|
|
76
76
|
}
|
|
77
77
|
await verifyOptionalPty(this.options.npmPrefix, this.spawn);
|
|
78
78
|
}
|
|
79
|
-
/**
|
|
79
|
+
/** Snapshots installed files, without rerunning source-only lifecycle/build scripts. */
|
|
80
80
|
async pack(packageRoot, destination) {
|
|
81
81
|
absolute(packageRoot, 'packageRoot');
|
|
82
82
|
absolute(destination, 'destination');
|
|
83
|
-
const args = ['pack', packageRoot, '--pack-destination', destination, '--json'];
|
|
83
|
+
const args = ['pack', packageRoot, '--pack-destination', destination, '--json', '--ignore-scripts'];
|
|
84
84
|
return new Promise((resolve, reject) => {
|
|
85
85
|
let stdout = '';
|
|
86
86
|
let stderr = '';
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { constants, promises as fs } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
async function syncDirectory(directory) {
|
|
5
|
+
const handle = await fs.open(directory, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
6
|
+
try {
|
|
7
|
+
if (!(await handle.stat()).isDirectory())
|
|
8
|
+
throw Error('Private recovery namespace is not a directory');
|
|
9
|
+
await handle.sync();
|
|
10
|
+
}
|
|
11
|
+
finally {
|
|
12
|
+
await handle.close();
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export async function ensureDurablePrivateDirectory(directory) {
|
|
16
|
+
try {
|
|
17
|
+
await fs.mkdir(directory, { mode: 0o700 });
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
if (error.code !== 'EEXIST')
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
const stat = await fs.lstat(directory);
|
|
24
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.mode & 0o077 || process.getuid && stat.uid !== process.getuid())
|
|
25
|
+
throw Error('Unsafe private recovery evidence directory');
|
|
26
|
+
await syncDirectory(directory);
|
|
27
|
+
await syncDirectory(path.dirname(directory));
|
|
28
|
+
}
|
|
29
|
+
export async function syncPrivateRecoveryFile(file) {
|
|
30
|
+
const handle = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
31
|
+
try {
|
|
32
|
+
const stat = await handle.stat();
|
|
33
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.mode & 0o077 || process.getuid && stat.uid !== process.getuid())
|
|
34
|
+
throw Error('Unsafe private recovery dependency');
|
|
35
|
+
await handle.sync();
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
await handle.close();
|
|
39
|
+
}
|
|
40
|
+
await syncDirectory(path.dirname(file));
|
|
41
|
+
}
|
|
42
|
+
export async function writeDurablePrivateUpdate(file, contents) {
|
|
43
|
+
const directory = path.dirname(file);
|
|
44
|
+
await ensureDurablePrivateDirectory(directory);
|
|
45
|
+
const temporary = path.join(directory, `.private-update-${randomUUID()}`);
|
|
46
|
+
try {
|
|
47
|
+
const handle = await fs.open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
|
|
48
|
+
try {
|
|
49
|
+
await handle.writeFile(contents);
|
|
50
|
+
await handle.sync();
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
await handle.close();
|
|
54
|
+
}
|
|
55
|
+
await fs.rename(temporary, file);
|
|
56
|
+
await syncDirectory(directory);
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
await fs.rm(temporary, { force: true });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { constants, promises as fs } from 'node:fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { deviceWatchdogRecoverySchema, runtimeFilesDigest } from './deviceHealthProtocol.js';
|
|
6
|
+
import { readArchiveEvidence } from './archiveEvidence.js';
|
|
7
|
+
import { deploymentDownloadURL } from '../control/serverIdentity.js';
|
|
8
|
+
const absolute = z.string().min(1).max(4096).refine(value => path.isAbsolute(value) && path.resolve(value) === value);
|
|
9
|
+
export const deviceWatchdogPlanSchema = z.object({
|
|
10
|
+
schemaVersion: z.literal(2),
|
|
11
|
+
program: z.object({ path: absolute, sha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict(),
|
|
12
|
+
deviceRecovery: deviceWatchdogRecoverySchema,
|
|
13
|
+
rdRollback: z.object({ directory: absolute, snapshot: absolute }).strict().optional(),
|
|
14
|
+
commandId: z.string().uuid(), targetVersion: z.string().min(1).max(128), archive: absolute,
|
|
15
|
+
marker: absolute, state: absolute, npmPath: absolute, npmPrefix: absolute, cliPath: absolute,
|
|
16
|
+
npmEnvironment: z.object({ NPM_CONFIG_PREFIX: absolute, PATH: z.string().max(32768), NPM_CONFIG_CACHE: absolute.optional() }).strict().optional(),
|
|
17
|
+
}).strict();
|
|
18
|
+
export function updateTransactionKey(transactionId) { return createHash('sha256').update(transactionId).digest('hex'); }
|
|
19
|
+
export async function readPrivateUpdateBytes(file, maximum) {
|
|
20
|
+
const handle = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
21
|
+
try {
|
|
22
|
+
const before = await handle.stat();
|
|
23
|
+
if (!before.isFile() || before.nlink !== 1 || before.size > maximum || before.mode & 0o077 || process.getuid && before.uid !== process.getuid())
|
|
24
|
+
throw Error('Unsafe private update evidence');
|
|
25
|
+
const bytes = await handle.readFile();
|
|
26
|
+
const after = await handle.stat();
|
|
27
|
+
if (bytes.length !== before.size || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs)
|
|
28
|
+
throw Error('Private update evidence changed while reading');
|
|
29
|
+
return bytes;
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
await handle.close();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export async function readDeviceWatchdogPlan(planPath, expectedRoot) {
|
|
36
|
+
const plan = deviceWatchdogPlanSchema.parse(JSON.parse((await readPrivateUpdateBytes(planPath, 1_048_576)).toString('utf8')));
|
|
37
|
+
const root = path.resolve(expectedRoot), key = updateTransactionKey(plan.commandId), update = path.join(root, 'update');
|
|
38
|
+
const recovery = plan.deviceRecovery;
|
|
39
|
+
if (recovery.root !== root || recovery.expectation.transactionId !== plan.commandId || recovery.expectation.release.version !== plan.targetVersion || recovery.rollback.archive !== plan.archive || planPath !== path.join(update, `watchdog-${key}.json`) || plan.program.path !== path.join(update, `watchdog-${key}.mjs`) || plan.archive !== path.join(update, `rollback-${key}.tgz`) || plan.marker !== path.join(update, 'restart-ready.json') || plan.state !== path.join(root, 'update-state.json') || plan.rdRollback && (plan.rdRollback.directory !== root || plan.rdRollback.snapshot !== path.join(update, `rd-rollback-${key}.json`)))
|
|
40
|
+
throw Error('Device watchdog plan does not bind the original transaction');
|
|
41
|
+
const program = await readPrivateUpdateBytes(plan.program.path, 33_554_432);
|
|
42
|
+
if (createHash('sha256').update(program).digest('hex') !== plan.program.sha256)
|
|
43
|
+
throw Error('Copied device watchdog program digest changed');
|
|
44
|
+
if (recovery.expectation.resolvedSource !== deploymentDownloadURL(recovery.expectation.origin.server, recovery.expectation.release.url))
|
|
45
|
+
throw Error('Copied watchdog source binding changed');
|
|
46
|
+
const source = recovery.rollback.source;
|
|
47
|
+
if (source.kind === 'local-pack' && (!source.packageRoot || !path.isAbsolute(source.packageRoot)))
|
|
48
|
+
throw Error('Original rollback package source is missing');
|
|
49
|
+
if (source.kind === 'authenticated-release' && (source.release.version !== recovery.rollback.version || source.release.sha512 !== recovery.rollback.sha512 || source.release.size !== recovery.rollback.size || !deploymentDownloadURL(source.server, source.release.url)))
|
|
50
|
+
throw Error('Original authenticated rollback source changed');
|
|
51
|
+
return plan;
|
|
52
|
+
}
|
|
53
|
+
export async function readDeviceBootstrapRecovery(root, startup) {
|
|
54
|
+
const { deviceTransactionSchema } = await import('./deviceCoordinator.js');
|
|
55
|
+
const transaction = deviceTransactionSchema.parse(JSON.parse((await readPrivateUpdateBytes(path.join(root, 'update/device-transaction.json'), 33_554_432)).toString('utf8')));
|
|
56
|
+
const device = z.object({ schemaVersion: z.literal(1), deviceId: z.string().uuid(), initialized: z.literal(true) }).strict().parse(JSON.parse((await readPrivateUpdateBytes(path.join(root, 'device.json'), 4096)).toString('utf8')));
|
|
57
|
+
if (!transaction.recoveryRequired && !transaction.installedRollback && transaction.installation === 'not-started' && ['BUSY', 'FAILED'].includes(transaction.phase)) {
|
|
58
|
+
if ((await fs.readdir(path.join(root, 'update'))).some(name => /^watchdog-.*\.json(?:\..*)?$/.test(name)))
|
|
59
|
+
throw Error('An armed watchdog still owns terminal recovery');
|
|
60
|
+
try {
|
|
61
|
+
const state = JSON.parse((await readPrivateUpdateBytes(path.join(root, 'update-state.json'), 65536)).toString('utf8'));
|
|
62
|
+
if (!['BUSY', 'FAILED', 'SUCCEEDED', 'ROLLED_BACK'].includes(state.phase))
|
|
63
|
+
throw Error('Public update evidence remains unresolved');
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (error.code !== 'ENOENT')
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (!device.initialized || transaction.recoveryRequired && !transaction.installedRollback || !transaction.targetEvidence || !['RESTARTING', 'SUCCEEDED', 'ROLLED_BACK', ...(transaction.installedRollback ? ['FAILED'] : [])].includes(transaction.phase))
|
|
72
|
+
throw Error('Only an already initialized device can adopt its exact new recovery transaction');
|
|
73
|
+
if (transaction.targetEvidence.version !== transaction.release.version || transaction.targetEvidence.sha512 !== transaction.release.sha512 || transaction.targetEvidence.size !== transaction.release.size || transaction.resolvedSource !== deploymentDownloadURL(transaction.origin.server, transaction.release.url))
|
|
74
|
+
throw Error('Exact target archive and source evidence changed');
|
|
75
|
+
const state = JSON.parse((await readPrivateUpdateBytes(path.join(root, 'update-state.json'), 65536)).toString('utf8'));
|
|
76
|
+
if (!['RESTARTING', 'SUCCEEDED', 'ROLLED_BACK', ...(transaction.installedRollback ? ['FAILED'] : [])].includes(state.phase) || state.commandId !== transaction.transactionId || state.version !== transaction.release.version || state.releaseSha512 && state.releaseSha512 !== transaction.release.sha512)
|
|
77
|
+
throw Error('Device recovery state does not match the exact transaction');
|
|
78
|
+
if (transaction.lifecycle !== 'provision-and-arm') {
|
|
79
|
+
const policy = transaction.restartPolicy;
|
|
80
|
+
if (!policy || policy.mode !== (transaction.lifecycle === 'container' ? 'external' : 'manual') || policy.predecessor?.generation !== transaction.initiatingDaemonGeneration || !policy.predecessor.prepared || transaction.installation !== 'started' || transaction.installedRollback || !['RESTARTING', 'SUCCEEDED'].includes(transaction.phase) || !['RESTARTING', 'SUCCEEDED'].includes(state.phase))
|
|
81
|
+
throw Error('Explicit restart policy and positive original predecessor handoff are required');
|
|
82
|
+
if ((await fs.readdir(path.join(root, 'update'))).some(name => /^watchdog-.*\.json(?:\..*)?$/.test(name)))
|
|
83
|
+
throw Error('An armed watchdog cannot be adopted as a no-watchdog restart');
|
|
84
|
+
if (startup.version !== transaction.release.version || startup.runtimeDigest !== runtimeFilesDigest(transaction.targetEvidence.runtimeFiles))
|
|
85
|
+
throw Error('Original startup inventory differs from the exact no-watchdog restart target');
|
|
86
|
+
return { transaction, plan: undefined };
|
|
87
|
+
}
|
|
88
|
+
if (transaction.restartPolicy)
|
|
89
|
+
throw Error('Managed watchdog policy cannot be replaced by a no-watchdog declaration');
|
|
90
|
+
const plan = await readDeviceWatchdogPlan(path.join(root, 'update', `watchdog-${updateTransactionKey(transaction.transactionId)}.json`), root);
|
|
91
|
+
const expected = plan.deviceRecovery.expectation;
|
|
92
|
+
if (expected.transactionId !== transaction.transactionId || expected.challenge !== transaction.challenge || expected.initiatingDaemonGeneration !== transaction.initiatingDaemonGeneration || JSON.stringify(expected.origin) !== JSON.stringify(transaction.origin) || JSON.stringify(expected.release) !== JSON.stringify(transaction.release) || expected.resolvedSource !== transaction.resolvedSource || expected.runtimeDigest !== runtimeFilesDigest(transaction.targetEvidence.runtimeFiles))
|
|
93
|
+
throw Error('Device recovery plan changed its original private transaction');
|
|
94
|
+
const rollback = await readArchiveEvidence(plan.archive), frozen = plan.deviceRecovery.rollback;
|
|
95
|
+
if (rollback.name !== frozen.name || rollback.version !== frozen.version || rollback.size !== frozen.size || rollback.sha512 !== frozen.sha512 || JSON.stringify(rollback.reader) !== JSON.stringify(frozen.reader))
|
|
96
|
+
throw Error('Frozen rollback reader archive changed');
|
|
97
|
+
if (transaction.installedRollback) {
|
|
98
|
+
const installed = transaction.installedRollback;
|
|
99
|
+
if (installed.transactionId !== transaction.transactionId || installed.archive.version !== rollback.version || installed.archive.sha512 !== rollback.sha512 || installed.archive.size !== rollback.size || runtimeFilesDigest(installed.archive.runtimeFiles) !== runtimeFilesDigest(rollback.runtimeFiles))
|
|
100
|
+
throw Error('Installed rollback evidence differs from the exact frozen recovery archive');
|
|
101
|
+
const { assertDeviceRollbackReceipt } = await import('./deviceRollback.js');
|
|
102
|
+
await assertDeviceRollbackReceipt(root, expected, installed.handoff);
|
|
103
|
+
}
|
|
104
|
+
else if (state.phase === 'ROLLED_BACK')
|
|
105
|
+
throw Error('A terminal label does not prove installed rollback bytes');
|
|
106
|
+
const expectedVersion = transaction.installedRollback ? rollback.version : transaction.release.version;
|
|
107
|
+
const expectedDigest = transaction.installedRollback ? runtimeFilesDigest(rollback.runtimeFiles) : expected.runtimeDigest;
|
|
108
|
+
if (startup.version !== expectedVersion || startup.runtimeDigest !== expectedDigest)
|
|
109
|
+
throw Error('Original process startup inventory is not the exact recovery package');
|
|
110
|
+
return { transaction, plan };
|
|
111
|
+
}
|
|
112
|
+
export async function deviceRollbackCompleted(root, startup, originalTransactionId) {
|
|
113
|
+
const { deviceTransactionSchema } = await import('./deviceCoordinator.js');
|
|
114
|
+
const transaction = deviceTransactionSchema.parse(JSON.parse((await readPrivateUpdateBytes(path.join(root, 'update/device-transaction.json'), 33_554_432)).toString('utf8')));
|
|
115
|
+
if (transaction.transactionId !== originalTransactionId || transaction.phase !== 'ROLLED_BACK' || transaction.recoveryRequired || !transaction.targetEvidence || !transaction.installedRollback)
|
|
116
|
+
return false;
|
|
117
|
+
const installed = transaction.installedRollback, archive = await readArchiveEvidence(path.join(root, 'update', `rollback-${updateTransactionKey(originalTransactionId)}.tgz`));
|
|
118
|
+
if (installed.transactionId !== originalTransactionId || installed.archive.sha512 !== archive.sha512 || installed.archive.size !== archive.size || installed.archive.version !== archive.version || runtimeFilesDigest(installed.archive.runtimeFiles) !== runtimeFilesDigest(archive.runtimeFiles) || startup.version !== archive.version || startup.runtimeDigest !== runtimeFilesDigest(archive.runtimeFiles))
|
|
119
|
+
throw Error('Completed rollback no longer matches original startup and frozen installed evidence');
|
|
120
|
+
const { assertDeviceRollbackReceipt, writePrivateDeviceEvidence } = await import('./deviceRollback.js');
|
|
121
|
+
await assertDeviceRollbackReceipt(root, { transactionId: transaction.transactionId, challenge: transaction.challenge, initiatingDaemonGeneration: transaction.initiatingDaemonGeneration, origin: transaction.origin, release: transaction.release, resolvedSource: transaction.resolvedSource, runtimeDigest: runtimeFilesDigest(transaction.targetEvidence.runtimeFiles) }, installed.handoff);
|
|
122
|
+
await writePrivateDeviceEvidence(path.join(root, 'update/device-transaction.json'), transaction);
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
@@ -8,6 +8,7 @@ import { TaskRegistry } from './taskRegistry.js';
|
|
|
8
8
|
import { LastKnownGoodCache, SERVICE_UNREGISTERED_REMEDIATION, writeReadinessMarker } from './watchdog.js';
|
|
9
9
|
import { updateServiceContext } from '../config.js';
|
|
10
10
|
import { CLIENT_VERSION } from '../version.js';
|
|
11
|
+
import { readArchiveEvidence } from './archiveEvidence.js';
|
|
11
12
|
/**
|
|
12
13
|
* The argv `provisionService` spawns the installed CLI with. Exported so a test can spawn the
|
|
13
14
|
* same command out-of-process and prove it resolves against the unified CLI's actual command
|
|
@@ -133,9 +134,14 @@ export function createUpdateCoordinator(deps) {
|
|
|
133
134
|
// rollback can re-provision with it; the watchdog runs from a copy outside the package and has
|
|
134
135
|
// no way to find it otherwise.
|
|
135
136
|
cliPath: deps.cliPath,
|
|
137
|
+
deviceTransaction: deps.deviceUpdate?.currentTransaction,
|
|
138
|
+
sourceServer: deps.server,
|
|
136
139
|
});
|
|
137
140
|
return new UpdateCoordinator({
|
|
138
|
-
tasks: deps.tasks ?? new TaskRegistry(), installer: npm, state: new UpdateStateStore(deps.dir),
|
|
141
|
+
tasks: deps.deviceUpdate ? undefined : deps.tasks ?? new TaskRegistry(), installer: npm, state: new UpdateStateStore(deps.dir),
|
|
142
|
+
deviceAdmission: deps.deviceUpdate,
|
|
143
|
+
verifyArtifact: deps.deviceUpdate ? async (artifact) => { await deps.deviceUpdate.recordTarget(await readArchiveEvidence(artifact.path)); } : undefined,
|
|
144
|
+
persistDevicePhase: deps.deviceUpdate?.recordPhase,
|
|
139
145
|
drainTimeoutMs: deps.drainTimeoutMs,
|
|
140
146
|
// Without an artifact source the coordinator cannot fetch the release at all: every update and
|
|
141
147
|
// reinstall fails at DOWNLOADING with "requires the configured server URL". The artifact is
|
|
@@ -8,8 +8,166 @@ export class TaskRegistry {
|
|
|
8
8
|
agentOpenReservations = new Map();
|
|
9
9
|
agentSessions = new Map();
|
|
10
10
|
idleWaiters = new Set();
|
|
11
|
+
acceptedAgentWaiters = new Set();
|
|
11
12
|
stopping = null;
|
|
12
|
-
|
|
13
|
+
baseAccepting = true;
|
|
14
|
+
externalClosures = 0;
|
|
15
|
+
lifetime = {};
|
|
16
|
+
lifetimeClosed = false;
|
|
17
|
+
accountingUnknown = false;
|
|
18
|
+
pendingAccepted = 0;
|
|
19
|
+
inFlightAccepted = 0;
|
|
20
|
+
reservedAccepted = 0;
|
|
21
|
+
accepted = new WeakMap();
|
|
22
|
+
externalHolds = new WeakMap();
|
|
23
|
+
get accepting() { return this.baseAccepting && this.externalClosures === 0 && !this.accountingUnknown && !this.lifetimeClosed; }
|
|
24
|
+
set accepting(open) { this.baseAccepting = open; }
|
|
25
|
+
holdExternalAdmission() {
|
|
26
|
+
if (!Number.isSafeInteger(this.externalClosures + 1)) {
|
|
27
|
+
this.accountingUnknown = true;
|
|
28
|
+
throw new Error('Admission accounting is uncertain');
|
|
29
|
+
}
|
|
30
|
+
const hold = Object.freeze({});
|
|
31
|
+
this.externalHolds.set(hold, true);
|
|
32
|
+
this.externalClosures += 1;
|
|
33
|
+
return hold;
|
|
34
|
+
}
|
|
35
|
+
releaseExternalAdmission(hold) {
|
|
36
|
+
const active = this.externalHolds.get(hold);
|
|
37
|
+
if (active === undefined)
|
|
38
|
+
throw new Error('Invalid admission hold');
|
|
39
|
+
if (!active)
|
|
40
|
+
return;
|
|
41
|
+
this.externalHolds.set(hold, false);
|
|
42
|
+
if (this.externalClosures <= 0) {
|
|
43
|
+
this.accountingUnknown = true;
|
|
44
|
+
throw new Error('Admission accounting is uncertain');
|
|
45
|
+
}
|
|
46
|
+
this.externalClosures -= 1;
|
|
47
|
+
}
|
|
48
|
+
beginAcceptedAgentWork(kind, frame, adoption) {
|
|
49
|
+
if (frame.t !== kind)
|
|
50
|
+
throw new Error('Original agent frame kind mismatch');
|
|
51
|
+
const durableTurn = kind === 'agent_prompt' && typeof frame.sessionRef === 'string' && this.isDurableSession(frame.sessionRef);
|
|
52
|
+
const conditionalAdoption = !this.accepting && !durableTurn && kind === 'agent_prompt' && typeof frame.sessionRef === 'string' && adoption !== null && typeof adoption === 'object' ? adoption : undefined;
|
|
53
|
+
if (!this.accepting && !((durableTurn || conditionalAdoption !== undefined) && this.externalClosures === 0 && !this.accountingUnknown && !this.lifetimeClosed))
|
|
54
|
+
return null;
|
|
55
|
+
if (!Number.isSafeInteger(this.inFlightAccepted + 1)) {
|
|
56
|
+
this.accountingUnknown = true;
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
const ticket = Object.freeze({});
|
|
60
|
+
this.accepted.set(ticket, { frame, kind, lifetime: this.lifetime, adoption: conditionalAdoption, phase: 'pending' });
|
|
61
|
+
this.pendingAccepted += 1;
|
|
62
|
+
this.inFlightAccepted += 1;
|
|
63
|
+
return ticket;
|
|
64
|
+
}
|
|
65
|
+
originalAccepted(ticket, kind, frame) {
|
|
66
|
+
const state = this.accepted.get(ticket);
|
|
67
|
+
if (!state || state.frame !== frame || state.kind !== kind || state.lifetime !== this.lifetime || this.lifetimeClosed || state.phase !== 'pending')
|
|
68
|
+
throw new Error('Invalid or expired original agent admission');
|
|
69
|
+
return state;
|
|
70
|
+
}
|
|
71
|
+
reserveAcceptedAgentOpen(ticket, frame) {
|
|
72
|
+
const state = this.originalAccepted(ticket, 'agent_open', frame);
|
|
73
|
+
const count = (this.agentOpenReservations.get(frame.sessionRef) ?? 0) + 1;
|
|
74
|
+
if (!Number.isSafeInteger(count)) {
|
|
75
|
+
this.accountingUnknown = true;
|
|
76
|
+
throw new Error('Admission accounting is uncertain');
|
|
77
|
+
}
|
|
78
|
+
this.agentOpenReservations.set(frame.sessionRef, count);
|
|
79
|
+
state.phase = 'reserved';
|
|
80
|
+
this.pendingAccepted -= 1;
|
|
81
|
+
this.reservedAccepted += 1;
|
|
82
|
+
}
|
|
83
|
+
consumeAcceptedAgentWork(ticket, kind, frame) {
|
|
84
|
+
const state = this.originalAccepted(ticket, kind, frame);
|
|
85
|
+
if (state.adoption) {
|
|
86
|
+
const sessionRef = frame.sessionRef;
|
|
87
|
+
const session = typeof sessionRef === 'string' ? this.agentSessions.get(sessionRef) : undefined;
|
|
88
|
+
if (kind !== 'agent_prompt' || !session?.durable || session.adoption !== state.adoption)
|
|
89
|
+
throw new Error('Original durable adoption is not established');
|
|
90
|
+
}
|
|
91
|
+
state.phase = 'consumed';
|
|
92
|
+
}
|
|
93
|
+
finishAcceptedAgentWork(ticket) {
|
|
94
|
+
const state = this.accepted.get(ticket);
|
|
95
|
+
if (!state)
|
|
96
|
+
throw new Error('Invalid original agent admission');
|
|
97
|
+
if (state.phase === 'finished')
|
|
98
|
+
return;
|
|
99
|
+
if (state.phase !== 'reserved')
|
|
100
|
+
this.pendingAccepted -= 1;
|
|
101
|
+
else
|
|
102
|
+
this.reservedAccepted -= 1;
|
|
103
|
+
this.inFlightAccepted -= 1;
|
|
104
|
+
state.phase = 'finished';
|
|
105
|
+
state.frame = undefined;
|
|
106
|
+
state.adoption = undefined;
|
|
107
|
+
if (![this.pendingAccepted, this.inFlightAccepted, this.reservedAccepted].every(count => Number.isSafeInteger(count) && count >= 0)) {
|
|
108
|
+
this.accountingUnknown = true;
|
|
109
|
+
throw new Error('Admission accounting is uncertain');
|
|
110
|
+
}
|
|
111
|
+
if (this.inFlightAccepted === 0)
|
|
112
|
+
for (const done of [...this.acceptedAgentWaiters])
|
|
113
|
+
done();
|
|
114
|
+
this.notifyIfIdle();
|
|
115
|
+
}
|
|
116
|
+
/** Wait for already accepted handlers, including asynchronous opens, to finish their late-stop cleanup. */
|
|
117
|
+
waitForAcceptedAgentWork(timeoutMs) {
|
|
118
|
+
if (this.accountingUnknown)
|
|
119
|
+
return Promise.resolve(false);
|
|
120
|
+
if (this.inFlightAccepted === 0)
|
|
121
|
+
return Promise.resolve(true);
|
|
122
|
+
return new Promise(resolve => {
|
|
123
|
+
const finish = (settled) => {
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
this.acceptedAgentWaiters.delete(done);
|
|
126
|
+
resolve(settled && !this.accountingUnknown);
|
|
127
|
+
};
|
|
128
|
+
const done = () => finish(true);
|
|
129
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
130
|
+
this.acceptedAgentWaiters.add(done);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
closeAgentWorkLifetime() { this.lifetimeClosed = true; this.baseAccepting = false; this.lifetime = {}; }
|
|
134
|
+
async cancelTargetWork() {
|
|
135
|
+
this.closeAgentWorkLifetime();
|
|
136
|
+
const shells = [...this.shellTasks.entries()];
|
|
137
|
+
for (const [controller] of shells)
|
|
138
|
+
controller.abort();
|
|
139
|
+
let timer;
|
|
140
|
+
try {
|
|
141
|
+
await Promise.race([Promise.all(shells.map(([, completed]) => completed)), new Promise((_, reject) => { timer = setTimeout(() => reject(Error('Original shell cleanup remains pending')), 5000); })]);
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
clearTimeout(timer);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
assertTargetWorkSettled() {
|
|
148
|
+
if (!this.lifetimeClosed || this.accountingUnknown || this.inFlightAccepted || this.agentOpenReservations.size || this.shellTasks.size)
|
|
149
|
+
throw Error('Original accepted work remains pending');
|
|
150
|
+
}
|
|
151
|
+
observeTargetPending() {
|
|
152
|
+
let reservations = 0;
|
|
153
|
+
for (const count of this.agentOpenReservations.values())
|
|
154
|
+
reservations += count;
|
|
155
|
+
let pending = this.inFlightAccepted + Math.max(0, reservations - this.reservedAccepted) + this.shellTasks.size;
|
|
156
|
+
let known = !this.accountingUnknown;
|
|
157
|
+
for (const session of this.agentSessions.values())
|
|
158
|
+
try {
|
|
159
|
+
if (session.busy())
|
|
160
|
+
pending += 1;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
known = false;
|
|
164
|
+
}
|
|
165
|
+
if (!Number.isSafeInteger(pending) || pending < 0) {
|
|
166
|
+
this.accountingUnknown = true;
|
|
167
|
+
known = false;
|
|
168
|
+
}
|
|
169
|
+
return { pending, registered: this.agentSessions.size, known };
|
|
170
|
+
}
|
|
13
171
|
/** Keeper-terminal execs also counted in `activeShellTasks` right now (see {@link countKeeperExec}). */
|
|
14
172
|
keeperExecsCounted = 0;
|
|
15
173
|
/**
|
|
@@ -167,7 +325,7 @@ export class TaskRegistry {
|
|
|
167
325
|
* its detach (see {@link AgentSessionRegistration}).
|
|
168
326
|
*/
|
|
169
327
|
registerAgentSession(sessionRef, close, busy, registration = {}) {
|
|
170
|
-
this.agentSessions.set(sessionRef, { close, busy, durable: registration.durable === true });
|
|
328
|
+
this.agentSessions.set(sessionRef, { close, busy, durable: registration.durable === true, adoption: registration.adoption });
|
|
171
329
|
}
|
|
172
330
|
/**
|
|
173
331
|
* 采样式判据没有"变空闲"这个事件可以挂钩,所以持有者在可能变空闲时喊一声。
|
|
@@ -214,7 +372,7 @@ export class TaskRegistry {
|
|
|
214
372
|
tryAdmitTurn(sessionRef) {
|
|
215
373
|
// Spec §8.2: a keeper-hosted session's prompt is admitted while draining — it enters keeper-acp's log first and a restart
|
|
216
374
|
// continues it; a fallback session's would be cut by the restart.
|
|
217
|
-
return this.accepting || this.agentSessions.get(sessionRef)?.durable === true;
|
|
375
|
+
return !this.lifetimeClosed && (this.accepting || this.agentSessions.get(sessionRef)?.durable === true && this.externalClosures === 0);
|
|
218
376
|
}
|
|
219
377
|
/**
|
|
220
378
|
* The restart path of an update (Task 21, spec §5.5.5): every durable session is detached (its registered close), so the next daemon
|