@platless/axom-cli 0.5.9 → 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 +25 -6
- package/dist/axom-patch/cli/README.md +129 -12
- package/dist/axom-patch/cli/account-limits/generate.py +15 -12
- package/dist/axom-patch/cli/account-limits/kimi-proxy.mjs +20 -3
- package/dist/axom-patch/cli/claude-account-limits.json +9 -9
- package/dist/axom-patch/cli/claude-replay-lifecycle.json +16 -0
- package/dist/axom-patch/cli/codex-account-limits.json +11 -11
- package/dist/axom-patch/cli/codex-terminal-output.json +16 -0
- package/dist/axom-patch/cli/commandcode-account-limits.json +9 -9
- package/dist/axom-patch/cli/kimi-quota.mjs +12 -0
- package/dist/axom-patch/cli/kimi-subagents.mjs +173 -0
- package/dist/axom-patch/cli/nightly-task.md +55 -13
- package/dist/axom-patch/cli/probes/claude-replay-lifecycle.mjs +31 -0
- package/dist/axom-patch/cli/probes/codex-terminal-output.mjs +33 -0
- package/dist/axom-patch/cli/probes/kimi-subagent-subwork.mjs +82 -0
- 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 +1687 -0
- package/dist/axom-patch/cli/qoder/releases/1.1.75.json +277 -0
- package/dist/axom-patch/cli/qoder-background-terminal.md +13 -6
- package/dist/axom-patch/cli/qoder-foreground-output.md +14 -8
- package/dist/axom-patch/cli/qoder-native-acp.json +227 -0
- package/dist/axom-patch/cli/qoder-steering.md +20 -25
- package/dist/axom-patch/cli/qoder-subagent-subwork.md +17 -68
- package/dist/axom-patch/cli/runtime.json +19 -19
- 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 +10 -11
- package/dist/axom-patch/cli/zcode-env-account.json +58 -0
- package/dist/axom-patch/cli/zcode-models.json +9 -10
- package/dist/axom-patch/cli/zcode-subagent-subwork.json +10 -11
- package/dist/cli.js +11 -45
- package/dist/commands/auth.js +4 -1
- package/dist/commands/knowledge.js +47 -0
- package/dist/commands/update.js +126 -3
- package/dist/commands/workstation.js +101 -164
- 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 +15 -15
- package/dist/keepers/terminal/keeper.mjs +1089 -673
- package/dist/keepers/terminal/manifest.json +4 -4
- package/dist/mcp.js +1 -1
- package/dist/native/darwin-x64/axom-quic +0 -0
- package/dist/workstation/acp/acpAgents.js +113 -102
- package/dist/workstation/acp/acpClient.js +26 -0
- package/dist/workstation/acp/builds/install/npm.js +99 -10
- package/dist/workstation/acp/initializeResponse.js +6 -1
- package/dist/workstation/acp/managedPatches.js +189 -25
- package/dist/workstation/acp/managedRuntime.js +314 -0
- package/dist/workstation/acp/outbox.js +12 -5
- package/dist/workstation/acp/patchAssets.js +40 -0
- package/dist/workstation/acp/permission.js +2 -2
- package/dist/workstation/acp/processor/checkpoint.js +26 -34
- package/dist/workstation/acp/processor/control.js +7 -80
- package/dist/workstation/acp/processor/jsonrpc.js +8 -0
- package/dist/workstation/acp/processor/runtime.js +5 -81
- package/dist/workstation/acp/processor/session.js +54 -82
- package/dist/workstation/acp/processor/state.js +7 -13
- package/dist/workstation/acp/processor/step.js +3 -20
- package/dist/workstation/acp/processor/test/fixtureLog.js +9 -16
- package/dist/workstation/acp/processor/transport/local.js +19 -3
- package/dist/workstation/acp/processor/updates.js +156 -105
- package/dist/workstation/acp/processorSession.js +20 -173
- package/dist/workstation/acp/registry/catalog.js +65 -39
- package/dist/workstation/acp/registry/launch.js +16 -1
- package/dist/workstation/acp/switching.js +0 -3
- package/dist/workstation/acp/test/fakeAgent.js +13 -0
- package/dist/workstation/acp/test/keeperWorld.js +32 -3
- package/dist/workstation/acp/test/raw/sources.js +16 -130
- package/dist/workstation/acp/test/realAdapter.js +1 -47
- package/dist/workstation/acp/test/scriptAgent.js +11 -3
- package/dist/workstation/agentFlow.js +1 -1
- package/dist/workstation/agentSessions.js +114 -166
- package/dist/workstation/channels/agent.js +9 -1
- package/dist/workstation/channels/computer.js +27 -4
- package/dist/workstation/channels/management.js +130 -15
- package/dist/workstation/channels/url.js +1 -1
- package/dist/workstation/config.js +33 -9
- package/dist/workstation/connection/encryptedChannel.js +4 -1
- package/dist/workstation/control/bindingStore.js +204 -0
- package/dist/workstation/control/commands.js +125 -0
- package/dist/workstation/control/daemonIdentity.js +143 -0
- package/dist/workstation/control/deviceCommands.js +191 -0
- package/dist/workstation/control/deviceProtocol.js +68 -0
- package/dist/workstation/control/enrollment.js +106 -0
- package/dist/workstation/control/inspect.js +71 -0
- package/dist/workstation/control/locks.js +110 -0
- package/dist/workstation/control/protocol.js +40 -0
- package/dist/workstation/control/restartGuard.js +46 -0
- package/dist/workstation/control/rootFormat.js +16 -0
- package/dist/workstation/control/runtimeClient.js +152 -0
- package/dist/workstation/control/runtimeHost.js +235 -0
- package/dist/workstation/control/serverIdentity.js +35 -0
- package/dist/workstation/control/updateTransition.js +91 -0
- package/dist/workstation/daemon.js +266 -264
- package/dist/workstation/daemonctl.js +226 -25
- package/dist/workstation/desktopControl/audio.js +99 -0
- 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/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 +93 -0
- package/dist/workstation/desktopControl/captureModes.js +9 -0
- package/dist/workstation/desktopControl/captureTargetLabel.js +9 -0
- package/dist/workstation/desktopControl/deviceAdmission.js +229 -0
- package/dist/workstation/desktopControl/deviceOwnership.js +130 -0
- package/dist/workstation/desktopControl/deviceSockets.js +246 -0
- package/dist/workstation/desktopControl/helperProcess.js +602 -0
- package/dist/workstation/desktopControl/hostGrant.js +65 -0
- package/dist/workstation/desktopControl/lobby.js +131 -0
- package/dist/workstation/desktopControl/macProvider.js +156 -0
- package/dist/workstation/desktopControl/media.js +331 -0
- package/dist/workstation/desktopControl/nativeDeviceAdmission.js +207 -0
- package/dist/workstation/desktopControl/nativeProvider.js +1044 -0
- package/dist/workstation/desktopControl/networkEvidence.js +43 -0
- package/dist/workstation/desktopControl/overview.js +58 -0
- package/dist/workstation/desktopControl/ownershipProtocol.js +31 -0
- package/dist/workstation/desktopControl/protocol.js +311 -0
- package/dist/workstation/desktopControl/provider.js +1 -0
- package/dist/workstation/desktopControl/quicMedia.js +240 -0
- package/dist/workstation/desktopControl/rateController.js +226 -0
- package/dist/workstation/desktopControl/supervisor.js +1774 -0
- package/dist/workstation/desktopControl/transport.js +27 -0
- package/dist/workstation/desktopControl/wallpaper.js +98 -0
- package/dist/workstation/exec/policy.js +5 -0
- package/dist/workstation/files/client.js +10 -1
- package/dist/workstation/files/credentials.js +142 -0
- package/dist/workstation/files/host.js +9 -2
- package/dist/workstation/files/text.js +42 -8
- 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/bundle.js +21 -17
- package/dist/workstation/keepers/kit/client.js +2 -2
- package/dist/workstation/keepers/kit/launcher.js +123 -7
- package/dist/workstation/keepers/kit/paths.js +2 -1
- 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/managedInstall.js +57 -0
- package/dist/workstation/oauth/deviceAuthorization.js +31 -17
- package/dist/workstation/protocol/agent.js +2 -15
- package/dist/workstation/protocol/management.js +113 -5
- package/dist/workstation/protocol/runtimeDiagnostics.js +15 -0
- package/dist/workstation/rd/artifact.js +120 -0
- package/dist/workstation/rd/control.js +117 -0
- package/dist/workstation/rd/display.js +29 -0
- package/dist/workstation/rd/host.js +213 -0
- package/dist/workstation/rd/migrationReadiness.js +46 -0
- package/dist/workstation/rd/provider.js +256 -0
- package/dist/workstation/rd/sessions.js +391 -0
- package/dist/workstation/rd/state.js +57 -0
- package/dist/workstation/rd/update.js +168 -0
- package/dist/workstation/rd/upkeep.js +80 -0
- package/dist/workstation/register.js +27 -37
- 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 +197 -14
- package/dist/workstation/service/launchd.js +2 -0
- package/dist/workstation/service/systemd.js +1 -0
- package/dist/workstation/storageMetrics.js +118 -0
- package/dist/workstation/systemMetrics.js +47 -92
- package/dist/workstation/toolchain.js +94 -0
- package/dist/workstation/tools/browser.js +28 -4
- 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/artifact.js +2 -1
- 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 +186 -0
- package/dist/workstation/update/legacyBridge.js +414 -0
- package/dist/workstation/update/legacyOwner.js +39 -0
- package/dist/workstation/update/npmInstaller.js +51 -25
- package/dist/workstation/update/privateDurability.js +61 -0
- package/dist/workstation/update/rdRollback.js +145 -0
- package/dist/workstation/update/recoveryPlan.js +124 -0
- package/dist/workstation/update/runUpdate.js +10 -3
- package/dist/workstation/update/taskRegistry.js +161 -3
- package/dist/workstation/update/watchdog.js +29806 -333
- package/dist/workstation/update/watchdogProcess.js +72 -0
- package/package.json +11 -7
- package/skills/axom/SKILL.md +11 -11
- package/skills/axom-definition/SKILL.md +3 -3
- package/skills/axom-platform/SKILL.md +3 -3
- package/dist/axom-patch/cli/probes/qoder-steering.mjs +0 -89
- package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +0 -61
- package/dist/axom-patch/cli/qoder-steering.json +0 -52
- package/dist/axom-patch/cli/qoder-subagent-origin.json +0 -73
- package/dist/commands/guide.js +0 -38
- package/dist/runtime-deps/qoder-sdk/package-lock.json +0 -1189
- package/dist/runtime-deps/qoder-sdk/package.json +0 -8
- package/dist/workstation/acp/delegationMcp.js +0 -24
- package/dist/workstation/acp/processor/sideChannel.js +0 -80
- package/dist/workstation/acp/qoderStartup.js +0 -45
- package/dist/workstation/acp/test/raw/qoderCaptures.js +0 -125
- package/dist/workstation/qoderAdapter/backgroundProcess.js +0 -118
- package/dist/workstation/qoderAdapter/backgroundTerminal.js +0 -193
- package/dist/workstation/qoderAdapter/backgroundTerminalFold.js +0 -372
- package/dist/workstation/qoderAdapter/callbackContext.js +0 -76
- package/dist/workstation/qoderAdapter/completedContinuation.js +0 -139
- package/dist/workstation/qoderAdapter/contract.js +0 -78
- package/dist/workstation/qoderAdapter/foregroundOutput.js +0 -36
- package/dist/workstation/qoderAdapter/history.js +0 -85
- package/dist/workstation/qoderAdapter/identityIndex.js +0 -191
- package/dist/workstation/qoderAdapter/inputQueue.js +0 -42
- package/dist/workstation/qoderAdapter/main.js +0 -95
- package/dist/workstation/qoderAdapter/mapper.js +0 -117
- package/dist/workstation/qoderAdapter/observerPlugin.js +0 -245
- package/dist/workstation/qoderAdapter/observerReceiver.js +0 -187
- package/dist/workstation/qoderAdapter/permissions.js +0 -61
- package/dist/workstation/qoderAdapter/profile.js +0 -93
- package/dist/workstation/qoderAdapter/sdkDriver.js +0 -48
- package/dist/workstation/qoderAdapter/sdkRuntime.js +0 -93
- package/dist/workstation/qoderAdapter/sdkRuntimeInstall.js +0 -145
- package/dist/workstation/qoderAdapter/session.js +0 -319
- package/dist/workstation/qoderAdapter/subagentMeta.js +0 -28
- package/dist/workstation/qoderAdapter/subwork.js +0 -85
- package/dist/workstation/qoderAdapter/taskControl.js +0 -38
- package/dist/workstation/qoderAdapter/taskLedger.js +0 -163
- package/dist/workstation/qoderAdapter/usage.js +0 -37
- /package/dist/workstation/{qoderAdapter/types.js → desktopControl/automationPreparation.js} +0 -0
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { withWorkstationLock } from '../control/locks.js';
|
|
1
2
|
import { spawn as nodeSpawn } from 'node:child_process';
|
|
2
3
|
import { redactPaths, redactSecrets } from './redact.js';
|
|
3
4
|
import { verifyOptionalPty } from './watchdog.js';
|
|
5
|
+
import { attemptGlobalInstallFallback } from './globalInstall.js';
|
|
4
6
|
const STDERR_LIMIT = 4096;
|
|
5
7
|
function absolute(value, field) {
|
|
6
8
|
if (!value.startsWith('/') || /[\u0000-\u001f\u007f]/.test(value))
|
|
@@ -21,46 +23,70 @@ export class NpmInstaller {
|
|
|
21
23
|
this.spawn = options.spawn ?? nodeSpawn;
|
|
22
24
|
}
|
|
23
25
|
async install(archive) {
|
|
26
|
+
if (this.options.configDir && !this.options.installationLockHeld)
|
|
27
|
+
return withWorkstationLock(this.options.configDir, 'installation', () => this.installUnlocked(archive));
|
|
28
|
+
return this.installUnlocked(archive);
|
|
29
|
+
}
|
|
30
|
+
async installUnlocked(archive) {
|
|
24
31
|
absolute(archive, 'archive');
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
// `npmPrefix` survives for the writability pre-check, the service definition, and rollback —
|
|
29
|
-
// it is derived from where this package actually sits, so it still names npm's real prefix.
|
|
32
|
+
// Pin the already selected installation, including ordinary global npm installs.
|
|
33
|
+
const env = { ...process.env, ...this.options.env, NPM_CONFIG_PREFIX: this.options.npmPrefix };
|
|
34
|
+
delete env.npm_config_prefix;
|
|
30
35
|
const args = ['install', '--global', archive, '--ignore-scripts', '--no-audit', '--no-fund'];
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
try {
|
|
37
|
+
await new Promise((resolve, reject) => {
|
|
38
|
+
let stderr = '';
|
|
39
|
+
let child;
|
|
40
|
+
try {
|
|
41
|
+
child = this.spawn(this.options.npmPath, args, { env, shell: false, stdio: ['ignore', 'ignore', 'pipe'], windowsHide: true });
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
reject(new Error(`npm install could not start: ${sanitizeNpmError(error instanceof Error ? error.message : String(error))}`));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
child.stderr?.on('data', (chunk) => { if (stderr.length < STDERR_LIMIT)
|
|
48
|
+
stderr += chunk.toString().slice(0, STDERR_LIMIT - stderr.length); });
|
|
49
|
+
child.once('error', (error) => reject(new Error(`npm install could not start: ${sanitizeNpmError(error.message)}`)));
|
|
50
|
+
child.once('close', (code, signal) => {
|
|
51
|
+
if (code === 0)
|
|
52
|
+
resolve();
|
|
53
|
+
else
|
|
54
|
+
reject(new Error(`npm install failed: ${sanitizeNpmError(stderr) || `exit ${code ?? signal ?? 'unknown'}`}`));
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
catch (primaryError) {
|
|
59
|
+
// `npm install --global` reinstalling an already-present package renames the old install
|
|
60
|
+
// directory aside and extracts the new one in its place; that sequence has been observed to
|
|
61
|
+
// fail unreliably on at least one host/filesystem combination even from an empty target (see
|
|
62
|
+
// globalInstall.ts). The fallback reproduces only the two operations proven to work there. A
|
|
63
|
+
// fallback failure surfaces the *original* error: it is the one a real npm bug report needs.
|
|
64
|
+
//
|
|
65
|
+
// Both console lines below are the only record of how often this fallback actually fires
|
|
66
|
+
// fleet-wide — there is no telemetry channel for "the primary path failed but we recovered",
|
|
67
|
+
// and a daemon's own stdout/stderr is already collected wherever this process's logs are.
|
|
68
|
+
console.warn(`npm install --global failed, attempting the tar/local-install fallback: ${sanitizeNpmError(primaryError instanceof Error ? primaryError.message : String(primaryError))}`);
|
|
34
69
|
try {
|
|
35
|
-
|
|
70
|
+
await attemptGlobalInstallFallback(this.options.npmPath, this.options.npmPrefix, archive, this.options.env, this.spawn);
|
|
36
71
|
}
|
|
37
|
-
catch
|
|
38
|
-
|
|
39
|
-
return;
|
|
72
|
+
catch {
|
|
73
|
+
throw primaryError;
|
|
40
74
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
child.once('error', (error) => reject(new Error(`npm install could not start: ${sanitizeNpmError(error.message)}`)));
|
|
44
|
-
child.once('close', (code, signal) => {
|
|
45
|
-
if (code === 0)
|
|
46
|
-
resolve();
|
|
47
|
-
else
|
|
48
|
-
reject(new Error(`npm install failed: ${sanitizeNpmError(stderr) || `exit ${code ?? signal ?? 'unknown'}`}`));
|
|
49
|
-
});
|
|
50
|
-
});
|
|
75
|
+
console.warn('npm install --global fallback succeeded; the global reinstall step is unreliable on this host and should be investigated');
|
|
76
|
+
}
|
|
51
77
|
await verifyOptionalPty(this.options.npmPrefix, this.spawn);
|
|
52
78
|
}
|
|
53
|
-
/**
|
|
79
|
+
/** Snapshots installed files, without rerunning source-only lifecycle/build scripts. */
|
|
54
80
|
async pack(packageRoot, destination) {
|
|
55
81
|
absolute(packageRoot, 'packageRoot');
|
|
56
82
|
absolute(destination, 'destination');
|
|
57
|
-
const args = ['pack', packageRoot, '--pack-destination', destination, '--json'];
|
|
83
|
+
const args = ['pack', packageRoot, '--pack-destination', destination, '--json', '--ignore-scripts'];
|
|
58
84
|
return new Promise((resolve, reject) => {
|
|
59
85
|
let stdout = '';
|
|
60
86
|
let stderr = '';
|
|
61
87
|
let child;
|
|
62
88
|
try {
|
|
63
|
-
child = this.spawn(this.options.npmPath, args, { shell: false, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
89
|
+
child = this.spawn(this.options.npmPath, args, { ...(this.options.env ? { env: this.options.env } : {}), shell: false, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
64
90
|
}
|
|
65
91
|
catch (error) {
|
|
66
92
|
reject(new Error(`npm pack could not start: ${sanitizeNpmError(error instanceof Error ? error.message : String(error))}`));
|
|
@@ -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
|
+
}
|