@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,46 @@
|
|
|
1
|
+
import { deviceHealthExpectationSchema, deviceHealthMatches, deviceHealthWitnessSchema, queryDeviceHealth, runtimeFilesDigest } from './deviceHealth.js';
|
|
2
|
+
import { deviceRollbackCompleted, readDeviceBootstrapRecovery } from './recoveryPlan.js';
|
|
3
|
+
export function createDeviceRecoveryTick(options) {
|
|
4
|
+
let running = false, attempts = 0;
|
|
5
|
+
const binding = options.bootstrap?.transaction;
|
|
6
|
+
const expectation = binding?.targetEvidence ? deviceHealthExpectationSchema.parse({ transactionId: binding.transactionId, challenge: binding.challenge,
|
|
7
|
+
initiatingDaemonGeneration: binding.initiatingDaemonGeneration, origin: binding.origin, release: binding.release, resolvedSource: binding.resolvedSource,
|
|
8
|
+
runtimeDigest: runtimeFilesDigest(binding.targetEvidence.runtimeFiles),
|
|
9
|
+
}) : undefined;
|
|
10
|
+
return async () => {
|
|
11
|
+
if (running || options.signal.aborted || !options.initialized())
|
|
12
|
+
return;
|
|
13
|
+
running = true;
|
|
14
|
+
try {
|
|
15
|
+
const original = options.lease();
|
|
16
|
+
if (original && options.bootstrap?.transaction.installedRollback) {
|
|
17
|
+
if (await deviceRollbackCompleted(options.root, options.startup, options.bootstrap.transaction.transactionId)) {
|
|
18
|
+
await original.release(async () => {
|
|
19
|
+
if (!await deviceRollbackCompleted(options.root, options.startup, options.bootstrap.transaction.transactionId))
|
|
20
|
+
throw Error('Original rollback completion changed during admission restoration');
|
|
21
|
+
});
|
|
22
|
+
options.released(original);
|
|
23
|
+
}
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (original) {
|
|
27
|
+
if (!binding || !expectation || binding.origin.kind !== 'local' || options.bootstrap?.plan || binding.lifecycle === 'provision-and-arm' || binding.initiatingDaemonGeneration === options.generation || original.transactionId !== binding.transactionId || attempts >= 5)
|
|
28
|
+
return;
|
|
29
|
+
attempts += 1;
|
|
30
|
+
const current = await readDeviceBootstrapRecovery(options.root, options.startup);
|
|
31
|
+
if (!current || current.plan || current.transaction.installedRollback || current.transaction.transactionId !== binding.transactionId || current.transaction.challenge !== binding.challenge || JSON.stringify(current.transaction.origin) !== JSON.stringify(binding.origin) || JSON.stringify(current.transaction.release) !== JSON.stringify(binding.release) || current.transaction.resolvedSource !== binding.resolvedSource || current.transaction.initiatingDaemonGeneration !== binding.initiatingDaemonGeneration)
|
|
32
|
+
throw Error('Original local successor recovery binding changed');
|
|
33
|
+
if (options.signal.aborted || !options.initialized() || options.lease() !== original)
|
|
34
|
+
return;
|
|
35
|
+
const witness = deviceHealthWitnessSchema.parse(await queryDeviceHealth(options.root, expectation));
|
|
36
|
+
if (!deviceHealthMatches(expectation, witness) || witness.daemonGeneration !== options.generation)
|
|
37
|
+
throw Error('Private control did not confirm the exact original successor');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
await options.reconcile();
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
running = false;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { deviceHealthExpectationSchema, deviceInstalledRollbackSchema, deviceRollbackReceiptSchema, runtimeFilesDigest } from './deviceHealthProtocol.js';
|
|
3
|
+
import { readArchiveEvidence } from './archiveEvidence.js';
|
|
4
|
+
import { readPrivateUpdateBytes, updateTransactionKey } from './recoveryPlan.js';
|
|
5
|
+
import { runtimeRequest } from '../control/runtimeClient.js';
|
|
6
|
+
import { LocalControlError } from '../control/protocol.js';
|
|
7
|
+
import { writeDurablePrivateUpdate } from './privateDurability.js';
|
|
8
|
+
export { deviceRollbackReceiptSchema } from './deviceHealthProtocol.js';
|
|
9
|
+
export function rollbackHandoffPath(root, transactionId) { return path.join(root, 'update', `rollback-handoff-${updateTransactionKey(transactionId)}.json`); }
|
|
10
|
+
export async function writePrivateDeviceEvidence(file, value) {
|
|
11
|
+
await writeDurablePrivateUpdate(file, JSON.stringify(value) + '\n');
|
|
12
|
+
}
|
|
13
|
+
function receiptMatches(expected, raw) {
|
|
14
|
+
const receipt = deviceRollbackReceiptSchema.parse(raw);
|
|
15
|
+
const { schemaVersion: _schemaVersion, prepared: _prepared, daemonGeneration: _generation, ...binding } = receipt;
|
|
16
|
+
if (receipt.daemonGeneration === expected.initiatingDaemonGeneration || JSON.stringify(deviceHealthExpectationSchema.parse(binding)) !== JSON.stringify(expected))
|
|
17
|
+
throw Error('Original target cleanup receipt changed its private recovery binding');
|
|
18
|
+
return receipt;
|
|
19
|
+
}
|
|
20
|
+
export async function assertDeviceRollbackReceipt(root, expected, raw) {
|
|
21
|
+
const receipt = receiptMatches(expected, raw);
|
|
22
|
+
const durable = receiptMatches(expected, JSON.parse((await readPrivateUpdateBytes(rollbackHandoffPath(root, expected.transactionId), 65536)).toString('utf8')));
|
|
23
|
+
if (JSON.stringify(receipt) !== JSON.stringify(durable))
|
|
24
|
+
throw Error('Original target cleanup receipt was not durably confirmed');
|
|
25
|
+
}
|
|
26
|
+
export async function queryDeviceRollbackHandoff(root, expected) {
|
|
27
|
+
return runtimeRequest(root, 'prepare-update-rollback', undefined, { request: expected }, 20_000);
|
|
28
|
+
}
|
|
29
|
+
export async function prepareDeviceRollbackHandoff(options, raw) {
|
|
30
|
+
const expected = deviceHealthExpectationSchema.parse(raw);
|
|
31
|
+
const { deviceTransactionSchema } = await import('./deviceCoordinator.js');
|
|
32
|
+
const transaction = deviceTransactionSchema.parse(JSON.parse((await readPrivateUpdateBytes(path.join(options.root, 'update/device-transaction.json'), 33_554_432)).toString('utf8')));
|
|
33
|
+
const actual = deviceHealthExpectationSchema.parse({ transactionId: transaction.transactionId, challenge: transaction.challenge, initiatingDaemonGeneration: transaction.initiatingDaemonGeneration, origin: transaction.origin, release: transaction.release, resolvedSource: transaction.resolvedSource, runtimeDigest: transaction.targetEvidence ? runtimeFilesDigest(transaction.targetEvidence.runtimeFiles) : '' });
|
|
34
|
+
if (JSON.stringify(expected) !== JSON.stringify(actual) || options.generation === expected.initiatingDaemonGeneration || options.startup.version !== expected.release.version || options.startup.runtimeDigest !== expected.runtimeDigest || !['RESTARTING', 'SUCCEEDED', 'FAILED'].includes(transaction.phase))
|
|
35
|
+
throw new LocalControlError('BINDING_CONFLICT', 'Only the exact original target daemon can prepare this rollback');
|
|
36
|
+
try {
|
|
37
|
+
const receipt = receiptMatches(expected, JSON.parse((await readPrivateUpdateBytes(rollbackHandoffPath(options.root, expected.transactionId), 65536)).toString('utf8')));
|
|
38
|
+
if (receipt.daemonGeneration !== options.generation)
|
|
39
|
+
throw Error('A replacement daemon cannot reuse another original cleanup receipt');
|
|
40
|
+
await writePrivateDeviceEvidence(rollbackHandoffPath(options.root, expected.transactionId), receipt);
|
|
41
|
+
return receipt;
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error.code !== 'ENOENT')
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
const lease = options.lease();
|
|
48
|
+
if (lease.transactionId !== transaction.transactionId)
|
|
49
|
+
throw new LocalControlError('BINDING_CONFLICT', 'Original maintenance transaction changed');
|
|
50
|
+
await lease.ready;
|
|
51
|
+
if ((await lease.observe()).status !== 'candidate')
|
|
52
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Original target work or unknown obligations prevent automatic rollback');
|
|
53
|
+
await lease.handoff();
|
|
54
|
+
const receipt = deviceRollbackReceiptSchema.parse({ ...actual, schemaVersion: 1, prepared: true, daemonGeneration: options.generation });
|
|
55
|
+
await writePrivateDeviceEvidence(rollbackHandoffPath(options.root, expected.transactionId), receipt);
|
|
56
|
+
return receipt;
|
|
57
|
+
}
|
|
58
|
+
export async function persistDeviceRollbackPhase(root, expected, phase) {
|
|
59
|
+
const { deviceTransactionSchema } = await import('./deviceCoordinator.js');
|
|
60
|
+
const file = path.join(root, 'update/device-transaction.json');
|
|
61
|
+
const transaction = deviceTransactionSchema.parse(JSON.parse((await readPrivateUpdateBytes(file, 33_554_432)).toString('utf8')));
|
|
62
|
+
if (transaction.transactionId !== expected.transactionId || transaction.challenge !== expected.challenge || transaction.release.sha512 !== expected.release.sha512 || transaction.resolvedSource !== expected.resolvedSource)
|
|
63
|
+
throw Error('Original private rollback transaction changed');
|
|
64
|
+
await writePrivateDeviceEvidence(file, deviceTransactionSchema.parse({ ...transaction, phase, recoveryRequired: phase === 'FAILED' }));
|
|
65
|
+
}
|
|
66
|
+
export async function persistDeviceInstalledRollback(recovery, rawHandoff) {
|
|
67
|
+
await assertDeviceRollbackReceipt(recovery.root, recovery.expectation, rawHandoff);
|
|
68
|
+
const archive = await readArchiveEvidence(recovery.rollback.archive);
|
|
69
|
+
if (archive.sha512 !== recovery.rollback.sha512 || archive.size !== recovery.rollback.size || archive.version !== recovery.rollback.version)
|
|
70
|
+
throw Error('Frozen rollback package changed before installed evidence persistence');
|
|
71
|
+
const { deviceTransactionSchema } = await import('./deviceCoordinator.js');
|
|
72
|
+
const file = path.join(recovery.root, 'update/device-transaction.json');
|
|
73
|
+
const transaction = deviceTransactionSchema.parse(JSON.parse((await readPrivateUpdateBytes(file, 33_554_432)).toString('utf8')));
|
|
74
|
+
if (transaction.transactionId !== recovery.expectation.transactionId || transaction.challenge !== recovery.expectation.challenge || transaction.resolvedSource !== recovery.expectation.resolvedSource)
|
|
75
|
+
throw Error('Original installed rollback transaction changed');
|
|
76
|
+
const installedRollback = deviceInstalledRollbackSchema.parse({ schemaVersion: 1, transactionId: transaction.transactionId, archive, handoff: rawHandoff });
|
|
77
|
+
await writePrivateDeviceEvidence(file, deviceTransactionSchema.parse({ ...transaction, phase: 'RESTARTING', recoveryRequired: true, installedRollback }));
|
|
78
|
+
}
|
|
@@ -30,14 +30,72 @@ export async function attemptGlobalInstallFallback(npmPath, npmPrefix, archive,
|
|
|
30
30
|
throw new Error('Archive package.json has no usable "name"');
|
|
31
31
|
const bin = manifest.bin;
|
|
32
32
|
const target = path.join(npmPrefix, 'lib/node_modules', name);
|
|
33
|
-
await fs.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
33
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
34
|
+
// Finish extraction and dependency resolution before changing the installed package. Keep the
|
|
35
|
+
// previous tree and command entries until every replacement succeeds, including bin linking.
|
|
36
|
+
const staging = await fs.mkdtemp(path.join(path.dirname(target), '.axom-install-'));
|
|
37
|
+
const candidate = path.join(staging, 'candidate');
|
|
38
|
+
const previous = path.join(staging, 'previous');
|
|
39
|
+
let retired = false;
|
|
40
|
+
let replaced = false;
|
|
41
|
+
let retainRecovery = false;
|
|
42
|
+
const commands = [];
|
|
43
|
+
try {
|
|
44
|
+
await fs.mkdir(candidate);
|
|
45
|
+
await run(spawn, 'tar', ['-xzf', archive, '-C', candidate, '--strip-components=1'], {});
|
|
46
|
+
const installEnv = { ...process.env, ...env };
|
|
47
|
+
delete installEnv.NPM_CONFIG_PREFIX;
|
|
48
|
+
delete installEnv.npm_config_prefix;
|
|
49
|
+
await run(spawn, npmPath, ['install', '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund'], { cwd: candidate, env: installEnv });
|
|
50
|
+
try {
|
|
51
|
+
await fs.rename(target, previous);
|
|
52
|
+
retired = true;
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (error.code !== 'ENOENT')
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
await fs.rename(candidate, target);
|
|
59
|
+
replaced = true;
|
|
60
|
+
await relinkBinaries(npmPrefix, target, name, bin, staging, commands);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
const restoreErrors = [];
|
|
64
|
+
for (const { link, backup } of commands.reverse()) {
|
|
65
|
+
try {
|
|
66
|
+
await fs.rm(link, { force: true });
|
|
67
|
+
if (backup)
|
|
68
|
+
await fs.rename(backup, link);
|
|
69
|
+
}
|
|
70
|
+
catch (restoreError) {
|
|
71
|
+
restoreErrors.push(restoreError);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// A command restoration failure must not prevent restoring the package itself.
|
|
75
|
+
try {
|
|
76
|
+
if (replaced)
|
|
77
|
+
await fs.rm(target, { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
catch (restoreError) {
|
|
80
|
+
restoreErrors.push(restoreError);
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
if (retired)
|
|
84
|
+
await fs.rename(previous, target);
|
|
85
|
+
}
|
|
86
|
+
catch (restoreError) {
|
|
87
|
+
restoreErrors.push(restoreError);
|
|
88
|
+
}
|
|
89
|
+
if (restoreErrors.length) {
|
|
90
|
+
retainRecovery = true;
|
|
91
|
+
throw new AggregateError([error, ...restoreErrors], `Global install fallback failed and its previous installation could not be restored; recovery files retained at ${staging}`);
|
|
92
|
+
}
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
if (!retainRecovery)
|
|
97
|
+
await fs.rm(staging, { recursive: true, force: true }).catch(() => undefined);
|
|
98
|
+
}
|
|
41
99
|
}
|
|
42
100
|
async function readArchivePackageJson(archive, spawn) {
|
|
43
101
|
const raw = await capture(spawn, 'tar', ['-xzf', archive, '-O', 'package/package.json'], {});
|
|
@@ -49,7 +107,7 @@ async function readArchivePackageJson(archive, spawn) {
|
|
|
49
107
|
}
|
|
50
108
|
}
|
|
51
109
|
/** Reproduces npm's own `bin` linking: a relative symlink from `<prefix>/bin/<name>` to the script. */
|
|
52
|
-
async function relinkBinaries(npmPrefix, target, packageName, bin) {
|
|
110
|
+
async function relinkBinaries(npmPrefix, target, packageName, bin, staging, commands) {
|
|
53
111
|
const entries = binEntries(packageName, bin);
|
|
54
112
|
if (entries.length === 0)
|
|
55
113
|
return;
|
|
@@ -58,7 +116,19 @@ async function relinkBinaries(npmPrefix, target, packageName, bin) {
|
|
|
58
116
|
for (const [command, relativeScript] of entries) {
|
|
59
117
|
const script = path.join(target, relativeScript);
|
|
60
118
|
const link = path.join(binDir, command);
|
|
61
|
-
|
|
119
|
+
let backup;
|
|
120
|
+
try {
|
|
121
|
+
const existing = await fs.lstat(link);
|
|
122
|
+
if (existing.isDirectory())
|
|
123
|
+
throw new Error('Refusing to replace a directory with an npm command');
|
|
124
|
+
backup = path.join(staging, `bin-${commands.length}`);
|
|
125
|
+
await fs.rename(link, backup);
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (error.code !== 'ENOENT')
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
commands.push({ link, backup });
|
|
62
132
|
await fs.symlink(path.relative(binDir, script), link);
|
|
63
133
|
await fs.chmod(script, 0o755).catch(() => undefined);
|
|
64
134
|
}
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { constants, promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { spawn, execFile } from 'node:child_process';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import semver from 'semver';
|
|
9
|
+
import { BindingStore } from '../control/bindingStore.js';
|
|
10
|
+
import { inspectRuntime, inspectDeviceRuntime } from '../control/runtimeClient.js';
|
|
11
|
+
import { normalizeWorkstationServer, workstationEndpoint } from '../control/serverIdentity.js';
|
|
12
|
+
import { loadClientConfig, readKeys } from '../config.js';
|
|
13
|
+
import yaml from 'js-yaml';
|
|
14
|
+
import { captureStartupPackageEvidence } from './deviceHealth.js';
|
|
15
|
+
import { runtimeFilesDigest } from './deviceHealthProtocol.js';
|
|
16
|
+
import { readArchiveEvidence } from './archiveEvidence.js';
|
|
17
|
+
import { downloadVerified } from './artifact.js';
|
|
18
|
+
import { readPrivateUpdateBytes, updateTransactionKey } from './recoveryPlan.js';
|
|
19
|
+
import { writeDurablePrivateUpdate, syncPrivateRecoveryFile, ensureDurablePrivateDirectory } from './privateDurability.js';
|
|
20
|
+
import { UpdateStateStore } from './stateStore.js';
|
|
21
|
+
import { withPrivateLock, withWorkstationLock } from '../control/locks.js';
|
|
22
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
23
|
+
import { LocalControlError } from '../control/protocol.js';
|
|
24
|
+
import { pidAlive, processCommand } from '../keepers/kit/lock.js';
|
|
25
|
+
import { launchWatchdogProcess } from './watchdogProcess.js';
|
|
26
|
+
import { recoverAuthenticatedLegacyOwner } from './legacyOwner.js';
|
|
27
|
+
const hash = (bytes) => createHash('sha512').update(bytes).digest('hex');
|
|
28
|
+
const metadata = ['config.yml', 'client.key', 'server.pub', 'operation.json', 'binding-history.json', 'rd/state.json', 'desktop-provider.json', 'registrations.json', 'registration-tombstones.json', 'registrations.pending.json'];
|
|
29
|
+
const planName = 'legacy-bridge.json';
|
|
30
|
+
const legacyPlanSchema = z.object({ commandId: z.string().uuid(), targetVersion: z.string(), archive: z.string(), marker: z.string(), state: z.string(), npmPath: z.string(), npmPrefix: z.string(), cliPath: z.string() }).passthrough();
|
|
31
|
+
const bridgeSchema = z.object({
|
|
32
|
+
schemaVersion: z.literal(3), lifecycle: z.enum(['managed', 'container']).default('managed'), root: z.string(), commandId: z.string().uuid(), version: z.string(), releaseSha512: z.string(),
|
|
33
|
+
predecessor: z.string(), server: z.string(), workstationId: z.string(), ownerUserId: z.string().nullable(),
|
|
34
|
+
runtimeDigest: z.string(), rollback: z.string(), rollbackSha512: z.string(), rollbackVersion: z.string(),
|
|
35
|
+
program: z.string(), context: z.object({ platform: z.enum(['linux', 'darwin']), username: z.string(), userId: z.number(), homeDir: z.string(), nodePath: z.string(), cliPath: z.string(), npmPath: z.string(), npmPrefix: z.string(), configDir: z.string(), pathEnv: z.string(), npmConfigPrefix: z.string().optional() }).passthrough(),
|
|
36
|
+
originals: z.record(z.string(), z.string()),
|
|
37
|
+
});
|
|
38
|
+
const bridgePath = (root) => path.join(root, 'update', planName);
|
|
39
|
+
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
40
|
+
const migrationScope = new AsyncLocalStorage();
|
|
41
|
+
export function withLegacyBridgeMigration(root, task) { return migrationScope.run(path.resolve(root), task); }
|
|
42
|
+
export async function assertLegacyBridgeMutationAllowed(root, startup) {
|
|
43
|
+
const bridge = await readLegacyBridge(root, startup);
|
|
44
|
+
if (bridge && migrationScope.getStore() !== path.resolve(root) && !startup)
|
|
45
|
+
throw new LocalControlError('BUSY', 'The original legacy update still owns registration admission until recovery completes');
|
|
46
|
+
}
|
|
47
|
+
async function confirmedBridgeReady(plan) {
|
|
48
|
+
try {
|
|
49
|
+
const ready = JSON.parse((await readPrivateUpdateBytes(path.join(plan.root, 'update/legacy-bridge-ready.json'), 4096)).toString());
|
|
50
|
+
return ready.commandId === plan.commandId && ready.runtimeDigest === plan.runtimeDigest && typeof ready.generation === 'string' && ready.generation.length > 0 && ready.generation !== plan.predecessor;
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (error.code === 'ENOENT')
|
|
54
|
+
return false;
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function finalizeConfirmedBridge(plan) {
|
|
59
|
+
await new UpdateStateStore(plan.root).save({ phase: 'SUCCEEDED', commandId: plan.commandId, version: plan.version, releaseSha512: plan.releaseSha512 });
|
|
60
|
+
await fs.rename(bridgePath(plan.root), path.join(plan.root, 'update/legacy-bridge-completed.json'));
|
|
61
|
+
}
|
|
62
|
+
async function snapshotMetadata(root, name) {
|
|
63
|
+
if (name !== 'server.pub')
|
|
64
|
+
return readPrivateUpdateBytes(path.join(root, name), 1_048_576);
|
|
65
|
+
const handle = await fs.open(path.join(root, name), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
66
|
+
try {
|
|
67
|
+
const stat = await handle.stat();
|
|
68
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > 1_048_576 || stat.mode & 0o022 || process.getuid && stat.uid !== process.getuid())
|
|
69
|
+
throw Error('Unsafe legacy public key');
|
|
70
|
+
return handle.readFile();
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
await handle.close();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export async function legacyDesktopContinuity(root) {
|
|
77
|
+
if (await readLegacyBridge(root))
|
|
78
|
+
return true;
|
|
79
|
+
try {
|
|
80
|
+
const plan = bridgeSchema.parse(JSON.parse((await readPrivateUpdateBytes(path.join(root, 'update/legacy-bridge-completed.json'), 8_388_608)).toString()));
|
|
81
|
+
return plan.root === path.resolve(root);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (error.code === 'ENOENT')
|
|
85
|
+
return false;
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export async function assertLegacyBridgeTransferred(plan) {
|
|
90
|
+
const marker = JSON.parse((await readPrivateUpdateBytes(path.join(plan.root, 'update/restart-ready.json'), 4096)).toString());
|
|
91
|
+
if (marker.commandId !== plan.commandId || marker.version !== plan.version || typeof marker.generation !== 'string' || !marker.generation.startsWith('legacy-bridge:'))
|
|
92
|
+
throw Error('Legacy watchdog recovery ownership has not transferred');
|
|
93
|
+
if ((await fs.readdir(path.join(plan.root, 'update'))).some(name => /^watchdog-.*\.json$/.test(name)))
|
|
94
|
+
throw Error('Legacy watchdog plans have not been archived after recovery transfer');
|
|
95
|
+
}
|
|
96
|
+
export async function readLegacyBridge(root, startup) {
|
|
97
|
+
let bytes;
|
|
98
|
+
try {
|
|
99
|
+
bytes = await readPrivateUpdateBytes(bridgePath(root), 8_388_608);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (error.code === 'ENOENT')
|
|
103
|
+
return;
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
const plan = bridgeSchema.parse(JSON.parse(bytes.toString()));
|
|
107
|
+
if (plan.root !== path.resolve(root) || plan.context.configDir !== plan.root || plan.rollback !== path.join(root, 'update', `legacy-rollback-${updateTransactionKey(plan.commandId)}.tgz`) || plan.program !== path.join(root, 'update', `legacy-bridge-${updateTransactionKey(plan.commandId)}.mjs`))
|
|
108
|
+
throw Error('Legacy update bridge paths changed');
|
|
109
|
+
if (startup && (startup.version !== plan.version || startup.runtimeDigest !== plan.runtimeDigest))
|
|
110
|
+
throw Error('Legacy update bridge installed target inventory changed');
|
|
111
|
+
if (Object.keys(plan.originals).some(name => !metadata.includes(name)))
|
|
112
|
+
throw Error('Legacy update bridge metadata scope changed');
|
|
113
|
+
return plan;
|
|
114
|
+
}
|
|
115
|
+
/** The predecessor already authenticated this release; bind its private update receipt and plan
|
|
116
|
+
* to the actual installed bytes before replacing its format1-only rollback authority. */
|
|
117
|
+
export async function prepareLegacyBridge(root, context, options = {}) {
|
|
118
|
+
root = path.resolve(root);
|
|
119
|
+
const installedVersion = JSON.parse(await fs.readFile(path.join(path.dirname(path.dirname(context.cliPath)), 'package.json'), 'utf8')).version;
|
|
120
|
+
const predecessor = await inspectRuntime(root);
|
|
121
|
+
const identity = await new BindingStore(root).inspect();
|
|
122
|
+
if (identity.identityState !== 'ready' || !identity.binding)
|
|
123
|
+
throw Error('The original legacy workstation owner must confirm the update');
|
|
124
|
+
if (options.container ? predecessor.state !== 'stopped' : predecessor.state !== 'running' || !predecessor.generation || !predecessor.binding || normalizeWorkstationServer(predecessor.binding.server) !== normalizeWorkstationServer(identity.binding.server) || predecessor.binding.workstationId !== identity.binding.workstationId)
|
|
125
|
+
throw Error('The original legacy workstation owner must confirm the update');
|
|
126
|
+
for (const name of ['device.json', 'legacy-migration.json', 'registrations', 'registrations.json', 'registration-tombstones.json', 'registrations.pending.json']) {
|
|
127
|
+
try {
|
|
128
|
+
await fs.lstat(path.join(root, name));
|
|
129
|
+
throw Error('Legacy bridge requires the original unmigrated root');
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (error.code !== 'ENOENT')
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const state = JSON.parse((await readPrivateUpdateBytes(path.join(root, 'update-state.json'), 65536)).toString());
|
|
137
|
+
if (state.phase !== 'RESTARTING' || state.version !== installedVersion || typeof state.releaseSha512 !== 'string')
|
|
138
|
+
throw Error('Legacy automatic migration requires the exact restarting release receipt');
|
|
139
|
+
const update = path.join(root, 'update');
|
|
140
|
+
const oldPlanPath = path.join(update, `watchdog-${updateTransactionKey(state.commandId)}.json`);
|
|
141
|
+
let old;
|
|
142
|
+
if (options.container) {
|
|
143
|
+
try {
|
|
144
|
+
await fs.lstat(oldPlanPath);
|
|
145
|
+
throw Error('Container update unexpectedly has an active service watchdog');
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
if (error.code !== 'ENOENT')
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
old = { commandId: state.commandId, targetVersion: state.version, archive: path.join(update, 'last-known-good/last-known-good.tgz'), marker: path.join(update, 'restart-ready.json'), state: path.join(root, 'update-state.json'), npmPath: context.npmPath, npmPrefix: context.npmPrefix, cliPath: context.cliPath };
|
|
152
|
+
}
|
|
153
|
+
else
|
|
154
|
+
old = legacyPlanSchema.parse(JSON.parse((await readPrivateUpdateBytes(oldPlanPath, 1_048_576)).toString()));
|
|
155
|
+
if (old.commandId !== state.commandId || old.targetVersion !== state.version || old.cliPath !== context.cliPath || old.npmPath !== context.npmPath || old.npmPrefix !== context.npmPrefix || old.archive !== path.join(update, 'last-known-good/last-known-good.tgz') || old.marker !== path.join(update, 'restart-ready.json') || old.state !== path.join(root, 'update-state.json'))
|
|
156
|
+
throw Error('Legacy watchdog does not identify the exact predecessor update');
|
|
157
|
+
const config = await loadClientConfig(root);
|
|
158
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
159
|
+
const response = await fetchImpl(workstationEndpoint(config.server, '/download/client/manifest.json'), { redirect: 'error', signal: AbortSignal.timeout(30_000) });
|
|
160
|
+
if (!response.ok)
|
|
161
|
+
throw Error('Cannot confirm the original release manifest');
|
|
162
|
+
const descriptor = z.object({ version: z.string(), url: z.string(), size: z.number().int().positive(), sha512: z.string().regex(/^[a-f0-9]{128}$/) });
|
|
163
|
+
const release = descriptor.extend({ legacyBridgeRollback: descriptor.optional() }).parse(await response.json());
|
|
164
|
+
if (release.version !== state.version || release.sha512 !== state.releaseSha512)
|
|
165
|
+
throw Error('The original authenticated legacy release changed');
|
|
166
|
+
const artifact = await downloadVerified(release, { server: config.server, fetch: fetchImpl });
|
|
167
|
+
let runtimeDigest;
|
|
168
|
+
try {
|
|
169
|
+
const archive = await readArchiveEvidence(artifact.path);
|
|
170
|
+
const startup = await captureStartupPackageEvidence(path.dirname(path.dirname(context.cliPath)), installedVersion);
|
|
171
|
+
runtimeDigest = runtimeFilesDigest(archive.runtimeFiles);
|
|
172
|
+
if (startup.runtimeDigest !== runtimeDigest)
|
|
173
|
+
throw Error('Installed legacy update differs from the authenticated target archive');
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
await artifact.cleanup();
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
await fs.access(old.archive);
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
if (error.code !== 'ENOENT' || !options.container || !release.legacyBridgeRollback)
|
|
183
|
+
throw Error('Legacy container rollback archive is unavailable; publish a verified legacyBridgeRollback descriptor');
|
|
184
|
+
const fallback = await downloadVerified(release.legacyBridgeRollback, { server: config.server, fetch: fetchImpl });
|
|
185
|
+
try {
|
|
186
|
+
await ensureDurablePrivateDirectory(path.join(update, 'last-known-good'));
|
|
187
|
+
await fs.copyFile(fallback.path, old.archive);
|
|
188
|
+
await fs.chmod(old.archive, 0o600);
|
|
189
|
+
await syncPrivateRecoveryFile(old.archive);
|
|
190
|
+
await writeDurablePrivateUpdate(path.join(update, 'last-known-good/last-known-good.json'), JSON.stringify({ name: '@platless/axom-cli', version: release.legacyBridgeRollback.version, sha512: release.legacyBridgeRollback.sha512 }));
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
await fallback.cleanup();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const rollbackBytes = await fs.readFile(old.archive);
|
|
197
|
+
const rollbackMetadata = JSON.parse((await promisify(execFile)('/usr/bin/tar', ['-xOf', old.archive, 'package/package.json'], { maxBuffer: 1_048_576 })).stdout);
|
|
198
|
+
if (rollbackMetadata.name !== '@platless/axom-cli' || !semver.valid(rollbackMetadata.version) || !semver.lt(rollbackMetadata.version, installedVersion) || rollbackMetadata.axomWorkstationRootReader?.rootFormat === 2)
|
|
199
|
+
throw Error('Legacy rollback package must identify the original earlier format1 CLI');
|
|
200
|
+
const cached = JSON.parse((await readPrivateUpdateBytes(path.join(update, 'last-known-good/last-known-good.json'), 65536)).toString());
|
|
201
|
+
if (cached.name !== rollbackMetadata.name || cached.version !== rollbackMetadata.version || cached.sha512 !== hash(rollbackBytes))
|
|
202
|
+
throw Error('Original last-known-good rollback digest changed');
|
|
203
|
+
const originals = {};
|
|
204
|
+
for (const name of metadata)
|
|
205
|
+
try {
|
|
206
|
+
originals[name] = (await snapshotMetadata(root, name)).toString('base64');
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
if (error.code !== 'ENOENT')
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
const key = updateTransactionKey(state.commandId);
|
|
213
|
+
const plan = { schemaVersion: 3, lifecycle: options.container ? 'container' : 'managed', root, commandId: state.commandId, version: state.version, releaseSha512: state.releaseSha512, predecessor: predecessor.generation ?? `stopped-container:${state.commandId}`, server: config.server, workstationId: identity.binding.workstationId, ownerUserId: identity.binding.ownerUserId ?? null, runtimeDigest, rollback: path.join(update, `legacy-rollback-${key}.tgz`), rollbackSha512: hash(rollbackBytes), rollbackVersion: rollbackMetadata.version, program: path.join(update, `legacy-bridge-${key}.mjs`), context: { ...context }, originals };
|
|
214
|
+
await fs.writeFile(plan.rollback, rollbackBytes, { mode: 0o600, flag: 'wx' });
|
|
215
|
+
await syncPrivateRecoveryFile(plan.rollback);
|
|
216
|
+
await fs.copyFile(options.programSource ?? path.join(path.dirname(fileURLToPath(import.meta.url)), 'watchdog.js'), plan.program);
|
|
217
|
+
await fs.chmod(plan.program, 0o700);
|
|
218
|
+
await syncPrivateRecoveryFile(plan.program);
|
|
219
|
+
await writeDurablePrivateUpdate(bridgePath(root), JSON.stringify(plan));
|
|
220
|
+
await (options.arm ?? armLegacyBridge)(plan);
|
|
221
|
+
// This marker transfers rollback responsibility, not update success. Public state remains
|
|
222
|
+
// RESTARTING until an authenticated, initialized migrated successor confirms it below.
|
|
223
|
+
await writeDurablePrivateUpdate(old.marker, JSON.stringify({ commandId: plan.commandId, version: plan.version, generation: `legacy-bridge:${randomUUID()}`, readyAt: new Date().toISOString() }));
|
|
224
|
+
const history = path.join(update, 'legacy-watchdog-history');
|
|
225
|
+
await ensureDurablePrivateDirectory(history);
|
|
226
|
+
for (const name of await fs.readdir(update))
|
|
227
|
+
if (/^watchdog-.*\.json$/.test(name))
|
|
228
|
+
await fs.rename(path.join(update, name), path.join(history, name));
|
|
229
|
+
const current = await inspectRuntime(root);
|
|
230
|
+
if (options.container ? current.state !== 'stopped' : current.state !== 'running' || current.generation !== plan.predecessor)
|
|
231
|
+
throw Error('The original legacy updater changed before service handoff');
|
|
232
|
+
if (!plan.ownerUserId) {
|
|
233
|
+
// Recovery is already frozen and independently armed before any network/account recovery.
|
|
234
|
+
const owner = await (options.ownerLookup ?? recoverAuthenticatedLegacyOwner)({ server: config.server, workstationId: config.workstationId, ...await readKeys(root), version: plan.rollbackVersion, update: { ...state, remoteForcePolicy: 'drain-only' } });
|
|
235
|
+
if (!owner.trim() || owner.length > 1024 || /[\u0000-\u001f\u007f]/.test(owner))
|
|
236
|
+
throw Error('Authenticated legacy owner is invalid');
|
|
237
|
+
await withWorkstationLock(root, 'operation', async () => {
|
|
238
|
+
const bytes = await snapshotMetadata(root, 'config.yml');
|
|
239
|
+
if (bytes.toString('base64') !== plan.originals['config.yml'])
|
|
240
|
+
throw Error('Legacy configuration changed during owner verification');
|
|
241
|
+
const configuration = yaml.load(bytes.toString());
|
|
242
|
+
await writeDurablePrivateUpdate(path.join(root, 'config.yml'), yaml.dump({ ...configuration, ownerUserId: owner }));
|
|
243
|
+
plan.ownerUserId = owner;
|
|
244
|
+
await writeDurablePrivateUpdate(bridgePath(root), JSON.stringify(plan));
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
return plan;
|
|
248
|
+
}
|
|
249
|
+
async function armLegacyBridge(plan) {
|
|
250
|
+
const managedLinux = plan.lifecycle === 'managed' && plan.context.platform === 'linux';
|
|
251
|
+
if (!managedLinux)
|
|
252
|
+
try {
|
|
253
|
+
const previous = JSON.parse((await readPrivateUpdateBytes(path.join(plan.root, 'update/legacy-bridge-armed.json'), 4096)).toString());
|
|
254
|
+
if (previous.commandId === plan.commandId && pidAlive(previous.pid) && (await processCommand(previous.pid))?.includes(plan.program))
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
catch { }
|
|
258
|
+
const logPath = path.join(plan.root, 'update/legacy-bridge.log'), log = await fs.open(logPath, 'a', 0o600);
|
|
259
|
+
let owner;
|
|
260
|
+
try {
|
|
261
|
+
owner = await launchWatchdogProcess({ nodePath: plan.context.nodePath, program: plan.program, plan: bridgePath(plan.root), lifecycle: managedLinux ? 'managed' : plan.lifecycle === 'container' ? 'container' : 'foreground', logPath }, { platform: plan.context.platform, spawn: (exe, args, options) => spawn(exe, args, { ...options, stdio: ['ignore', log.fd, log.fd] }) });
|
|
262
|
+
}
|
|
263
|
+
finally {
|
|
264
|
+
await log.close();
|
|
265
|
+
}
|
|
266
|
+
const armed = path.join(plan.root, 'update/legacy-bridge-armed.json');
|
|
267
|
+
const deadline = Date.now() + 10_000;
|
|
268
|
+
while (Date.now() < deadline) {
|
|
269
|
+
try {
|
|
270
|
+
const acknowledgement = JSON.parse((await readPrivateUpdateBytes(armed, 4096)).toString());
|
|
271
|
+
if (acknowledgement.commandId === plan.commandId && acknowledgement.pid === owner.pid)
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
catch { }
|
|
275
|
+
if (!pidAlive(owner.pid))
|
|
276
|
+
break;
|
|
277
|
+
await wait(50);
|
|
278
|
+
}
|
|
279
|
+
throw Error('Independent legacy bridge watchdog did not acknowledge recovery ownership: ' + (await fs.readFile(logPath, 'utf8')).slice(-2000));
|
|
280
|
+
}
|
|
281
|
+
export async function resumeLegacyManagedBridge(root, startup, options = {}) {
|
|
282
|
+
const plan = await readLegacyBridge(root, startup);
|
|
283
|
+
if (plan?.lifecycle === 'managed')
|
|
284
|
+
await (options.arm ?? armLegacyBridge)(plan);
|
|
285
|
+
}
|
|
286
|
+
/** Container updates replace npm then exit directly, without invoking workstation install. */
|
|
287
|
+
export async function prepareLegacyContainerBridge(root, context, options = {}) {
|
|
288
|
+
const existing = await readLegacyBridge(root);
|
|
289
|
+
if (existing) {
|
|
290
|
+
if (existing.lifecycle !== 'container')
|
|
291
|
+
throw Error('Container successor cannot adopt an OS service update');
|
|
292
|
+
await (options.arm ?? armLegacyBridge)(existing);
|
|
293
|
+
return existing;
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
await fs.lstat(path.join(root, 'device.json'));
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
if (error.code !== 'ENOENT')
|
|
301
|
+
throw error;
|
|
302
|
+
}
|
|
303
|
+
let state;
|
|
304
|
+
try {
|
|
305
|
+
state = JSON.parse((await readPrivateUpdateBytes(path.join(root, 'update-state.json'), 65536)).toString());
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
if (error.code === 'ENOENT')
|
|
309
|
+
return;
|
|
310
|
+
throw error;
|
|
311
|
+
}
|
|
312
|
+
if (state.phase !== 'RESTARTING')
|
|
313
|
+
return;
|
|
314
|
+
return prepareLegacyBridge(root, context, { ...options, container: true });
|
|
315
|
+
}
|
|
316
|
+
/** Keep the original command and drain-only policy visible through recovery completion. */
|
|
317
|
+
export async function legacyBridgeUpdateSnapshot(root) {
|
|
318
|
+
try {
|
|
319
|
+
await fs.lstat(path.join(root, 'update/device-transaction.json'));
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
if (error.code !== 'ENOENT')
|
|
324
|
+
throw error;
|
|
325
|
+
}
|
|
326
|
+
let plan = await readLegacyBridge(root);
|
|
327
|
+
if (!plan)
|
|
328
|
+
try {
|
|
329
|
+
plan = bridgeSchema.parse(JSON.parse((await readPrivateUpdateBytes(path.join(root, 'update/legacy-bridge-completed.json'), 8_388_608)).toString()));
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
if (error.code === 'ENOENT')
|
|
333
|
+
return;
|
|
334
|
+
throw error;
|
|
335
|
+
}
|
|
336
|
+
const state = await new UpdateStateStore(root).load();
|
|
337
|
+
return { ...(state?.commandId === plan.commandId ? state : { phase: 'SUCCEEDED', commandId: plan.commandId, version: plan.version, releaseSha512: plan.releaseSha512 }), remoteForcePolicy: 'drain-only' };
|
|
338
|
+
}
|
|
339
|
+
/** A failed container startup keeps its process alive until the independent bundle has restored
|
|
340
|
+
* both root metadata and npm. Only then may the external supervisor restart the process. */
|
|
341
|
+
export async function recoverLegacyContainer(plan) {
|
|
342
|
+
await new Promise((resolve, reject) => {
|
|
343
|
+
const child = spawn(plan.context.nodePath, [plan.program, bridgePath(plan.root), '--rollback-now'], { stdio: 'inherit' });
|
|
344
|
+
child.once('error', reject);
|
|
345
|
+
child.once('close', code => code === 0 ? resolve() : reject(Error(`Independent container rollback failed (${code})`)));
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
export async function legacyBridgeReady(root, startup, generation) {
|
|
349
|
+
const plan = await readLegacyBridge(root, startup);
|
|
350
|
+
if (!plan)
|
|
351
|
+
return true;
|
|
352
|
+
const inspection = await inspectDeviceRuntime(root);
|
|
353
|
+
const registration = inspection.registrations.find(entry => entry.workstationId === plan.workstationId && entry.ownerUserId === plan.ownerUserId && normalizeWorkstationServer(entry.server) === normalizeWorkstationServer(plan.server));
|
|
354
|
+
return inspection.runtime.generation === generation && generation !== plan.predecessor && !!registration && registration.connection === 'online' && registration.identityState === 'ready';
|
|
355
|
+
}
|
|
356
|
+
export async function completeLegacyBridge(root, startup, generation) {
|
|
357
|
+
await withPrivateLock(path.join(root, 'update/legacy-bridge-recovery'), async () => {
|
|
358
|
+
const plan = await readLegacyBridge(root, startup);
|
|
359
|
+
if (!plan)
|
|
360
|
+
return;
|
|
361
|
+
if (!await legacyBridgeReady(root, startup, generation))
|
|
362
|
+
throw Error('Original migrated successor readiness changed before recovery completion');
|
|
363
|
+
await writeDurablePrivateUpdate(path.join(root, 'update/legacy-bridge-ready.json'), JSON.stringify({ commandId: plan.commandId, generation, runtimeDigest: startup.runtimeDigest }));
|
|
364
|
+
await new UpdateStateStore(root).save({ phase: 'SUCCEEDED', commandId: plan.commandId, version: plan.version, releaseSha512: plan.releaseSha512 });
|
|
365
|
+
// The daemon can finish recovery after an OS reboot even when the original copied watcher
|
|
366
|
+
// no longer exists. This uses the same lock as the watcher's last rollback decision.
|
|
367
|
+
await fs.rename(bridgePath(root), path.join(root, 'update/legacy-bridge-completed.json'));
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
export async function releaseLegacyBridge(root, startup, generation, release) {
|
|
371
|
+
if (!await legacyBridgeReady(root, startup, generation))
|
|
372
|
+
return false;
|
|
373
|
+
await release(() => completeLegacyBridge(root, startup, generation));
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
export async function runLegacyBridge(plan, deps) {
|
|
377
|
+
await writeDurablePrivateUpdate(path.join(plan.root, 'update/legacy-bridge-armed.json'), JSON.stringify({ commandId: plan.commandId, pid: process.pid }));
|
|
378
|
+
const now = deps.now ?? Date.now, pause = deps.wait ?? wait, deadline = now() + (deps.timeoutMs ?? 120_000);
|
|
379
|
+
while (now() < deadline) {
|
|
380
|
+
if (!await readLegacyBridge(plan.root))
|
|
381
|
+
return;
|
|
382
|
+
if (await confirmedBridgeReady(plan)) {
|
|
383
|
+
await withPrivateLock(path.join(plan.root, 'update/legacy-bridge-recovery'), async () => { if (await readLegacyBridge(plan.root) && await confirmedBridgeReady(plan))
|
|
384
|
+
await finalizeConfirmedBridge(plan); });
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
await pause(250);
|
|
388
|
+
}
|
|
389
|
+
await withPrivateLock(path.join(plan.root, 'update/legacy-bridge-recovery'), async () => {
|
|
390
|
+
if (!await readLegacyBridge(plan.root))
|
|
391
|
+
return;
|
|
392
|
+
// Readiness can arrive during the final sleep, or before a rollback-now recovery invocation.
|
|
393
|
+
if (await confirmedBridgeReady(plan)) {
|
|
394
|
+
await finalizeConfirmedBridge(plan);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
// Stop only the daemon/service; keeper namespaces and their journals remain untouched.
|
|
398
|
+
await deps.stop();
|
|
399
|
+
if (hash(await fs.readFile(plan.rollback)) !== plan.rollbackSha512)
|
|
400
|
+
throw Error('Frozen legacy rollback archive changed');
|
|
401
|
+
for (const name of ['device.json', 'legacy-migration.json', 'registrations'])
|
|
402
|
+
await fs.rm(path.join(plan.root, name), { recursive: true, force: true });
|
|
403
|
+
for (const name of metadata) {
|
|
404
|
+
if (plan.originals[name])
|
|
405
|
+
await writeDurablePrivateUpdate(path.join(plan.root, name), Buffer.from(plan.originals[name], 'base64').toString());
|
|
406
|
+
else
|
|
407
|
+
await fs.rm(path.join(plan.root, name), { force: true });
|
|
408
|
+
}
|
|
409
|
+
await deps.install(plan.rollback);
|
|
410
|
+
await new UpdateStateStore(plan.root).save({ phase: 'ROLLED_BACK', commandId: plan.commandId, version: plan.version, error: 'Automatic legacy migration did not confirm authenticated successor readiness; restored the original CLI and registration' });
|
|
411
|
+
await fs.rename(bridgePath(plan.root), path.join(plan.root, 'update/legacy-bridge-rolled-back.json'));
|
|
412
|
+
await deps.restart();
|
|
413
|
+
});
|
|
414
|
+
}
|