@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
|
@@ -5,6 +5,94 @@ import { withWorkstationLock } from './locks.js';
|
|
|
5
5
|
import { inspectRuntime } from './runtimeClient.js';
|
|
6
6
|
import { readControlFile } from './runtimeHost.js';
|
|
7
7
|
import { LocalControlError, operationRecordSchema } from './protocol.js';
|
|
8
|
+
import { assertLegacyRoot } from './rootFormat.js';
|
|
9
|
+
import { constants, promises as fs } from 'node:fs';
|
|
10
|
+
import { load as loadYaml } from 'js-yaml';
|
|
11
|
+
import { RegistrationStore } from '../registrations/store.js';
|
|
12
|
+
import { assertRegistrationEnrollmentAdmission, migrateLegacyRegistration } from '../registrations/migration.js';
|
|
13
|
+
import { assertDeviceUpdateTransitionSafe } from './updateTransition.js';
|
|
14
|
+
import { legacyDesktopContinuity } from '../update/legacyBridge.js';
|
|
15
|
+
export { assertDeviceUpdateTransitionSafe } from './updateTransition.js';
|
|
16
|
+
async function assertDeviceDesktopTransitionSafe(directory) {
|
|
17
|
+
if (await legacyDesktopContinuity(directory))
|
|
18
|
+
return;
|
|
19
|
+
const uncertain = () => new LocalControlError('OUTCOME_UNKNOWN', 'Previous desktop authority requires a verified physical retirement acknowledgement from Computer Use before device startup; preserve the provider/configuration evidence and finish that retirement with the compatible desktop application');
|
|
20
|
+
for (const name of ['desktop-provider.json', 'rd/state.json'])
|
|
21
|
+
try {
|
|
22
|
+
await fs.lstat(path.join(directory, name));
|
|
23
|
+
throw uncertain();
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (error.code !== 'ENOENT')
|
|
27
|
+
throw uncertain();
|
|
28
|
+
}
|
|
29
|
+
let handle;
|
|
30
|
+
try {
|
|
31
|
+
handle = await fs.open(path.join(directory, 'config.yml'), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (error.code === 'ENOENT')
|
|
35
|
+
return;
|
|
36
|
+
throw uncertain();
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const stat = await handle.stat();
|
|
40
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > 1_048_576 || (stat.mode & 0o077) || (process.getuid && stat.uid !== process.getuid()))
|
|
41
|
+
throw uncertain();
|
|
42
|
+
const config = loadYaml(await handle.readFile('utf8'));
|
|
43
|
+
if (config?.desktopProvider || config?.browserBinding)
|
|
44
|
+
throw uncertain();
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw uncertain();
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
await handle.close();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export async function claimDeviceDaemonIdentity(directory, claim, startup) {
|
|
54
|
+
directory = path.resolve(directory);
|
|
55
|
+
await assertDeviceUpdateTransitionSafe(directory, startup);
|
|
56
|
+
await assertDeviceDesktopTransitionSafe(directory);
|
|
57
|
+
const runtime = await inspectRuntime(directory);
|
|
58
|
+
if (runtime.state !== 'stopped')
|
|
59
|
+
throw new LocalControlError(runtime.state === 'running' ? 'INSTANCE_OCCUPIED' : 'OUTCOME_UNKNOWN', 'Verify and stop the predecessor daemon before starting the device owner');
|
|
60
|
+
let legacy = false;
|
|
61
|
+
for (const name of ['config.yml', 'client.key', 'server.pub', 'legacy-migration.json'])
|
|
62
|
+
try {
|
|
63
|
+
await fs.lstat(path.join(directory, name));
|
|
64
|
+
legacy = true;
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error.code !== 'ENOENT')
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
if (legacy) {
|
|
71
|
+
const migration = await migrateLegacyRegistration(directory, { startupPackage: startup });
|
|
72
|
+
if (migration.kind === 'needs-verification')
|
|
73
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Legacy registration requires verified handoff before device startup');
|
|
74
|
+
}
|
|
75
|
+
return withWorkstationLock(directory, 'operation', async () => {
|
|
76
|
+
await assertDeviceUpdateTransitionSafe(directory, startup);
|
|
77
|
+
await assertDeviceDesktopTransitionSafe(directory);
|
|
78
|
+
const current = await inspectRuntime(directory);
|
|
79
|
+
if (current.state !== 'stopped')
|
|
80
|
+
throw new LocalControlError(current.state === 'running' ? 'INSTANCE_OCCUPIED' : 'OUTCOME_UNKNOWN', 'Cannot verify the predecessor workstation owner');
|
|
81
|
+
await assertIdentityCanStart(directory);
|
|
82
|
+
await assertRegistrationEnrollmentAdmission(directory, { startupPackage: startup });
|
|
83
|
+
const store = new RegistrationStore(directory);
|
|
84
|
+
const inspection = await store.inspect();
|
|
85
|
+
if (!inspection.known)
|
|
86
|
+
throw new LocalControlError('CORRUPT_IDENTITY', 'Registration collection needs verification');
|
|
87
|
+
for (const entry of inspection.entries)
|
|
88
|
+
if (entry.state === 'ready' && entry.registration.keeperConfigDirectory === directory) {
|
|
89
|
+
const config = (await store.read(entry.id)).configuration;
|
|
90
|
+
if ((config?.desktopProvider || config?.browserBinding) && !await legacyDesktopContinuity(directory))
|
|
91
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Migrated desktop authority requires a verified physical retirement acknowledgement before device startup');
|
|
92
|
+
}
|
|
93
|
+
return claim({ store });
|
|
94
|
+
});
|
|
95
|
+
}
|
|
8
96
|
export async function assertIdentityCanStart(directory) {
|
|
9
97
|
try {
|
|
10
98
|
const operation = operationRecordSchema.parse(JSON.parse(await readControlFile(path.join(directory, 'operation.json'))));
|
|
@@ -17,6 +105,7 @@ export async function assertIdentityCanStart(directory) {
|
|
|
17
105
|
}
|
|
18
106
|
}
|
|
19
107
|
export async function recoverStoppedIdentity(directory) {
|
|
108
|
+
await assertLegacyRoot(directory);
|
|
20
109
|
const store = new BindingStore(directory);
|
|
21
110
|
if ((await store.inspect()).identityState === 'incomplete') {
|
|
22
111
|
const runtime = await inspectRuntime(directory);
|
|
@@ -32,6 +121,7 @@ export async function claimDaemonIdentity(directory, claim) {
|
|
|
32
121
|
for (;;)
|
|
33
122
|
try {
|
|
34
123
|
return await withWorkstationLock(directory, 'operation', async () => {
|
|
124
|
+
await assertLegacyRoot(directory);
|
|
35
125
|
const runtime = await inspectRuntime(directory);
|
|
36
126
|
if (runtime.state !== 'stopped')
|
|
37
127
|
throw new LocalControlError(runtime.state === 'running' ? 'INSTANCE_OCCUPIED' : 'OUTCOME_UNKNOWN', 'The existing workstation must be verified before startup');
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { addRegistrationRequestSchema, assertDeviceGuard, deviceInspectionSchema, deviceLifecycleRequestSchema, deviceResultSchema, parseDeviceInspection, removeRegistrationRequestSchema } from './deviceProtocol.js';
|
|
3
|
+
import { LocalControlError } from './protocol.js';
|
|
4
|
+
import { inspectDeviceRuntime, inspectRuntime, removeDeviceRuntimeRegistration, runtimeRequest } from './runtimeClient.js';
|
|
5
|
+
import { RegistrationStore } from '../registrations/store.js';
|
|
6
|
+
import { enrollRegistration } from '../registrations/enrollment.js';
|
|
7
|
+
import { withWorkstationLock } from './locks.js';
|
|
8
|
+
import { createDeviceDaemonController } from '../daemonctl.js';
|
|
9
|
+
import { inspectPendingEnrollments } from '../registrations/enrollmentPending.js';
|
|
10
|
+
import { assertRegistrationEnrollmentAdmission } from '../registrations/migration.js';
|
|
11
|
+
import { promises as fs } from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { prepareLegacyBridge } from '../update/legacyBridge.js';
|
|
14
|
+
import { serviceContext } from '../daemonctl.js';
|
|
15
|
+
import { createServiceManager } from '../service/installer.js';
|
|
16
|
+
const removalResponses = new WeakMap();
|
|
17
|
+
export async function inspectDeviceState(options) {
|
|
18
|
+
const intents = (await options.store.removalIntents()).filter(intent => !['completed', 'cancelled'].includes(intent.phase));
|
|
19
|
+
const inspection = await options.store.inspect();
|
|
20
|
+
if (!inspection.known)
|
|
21
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Registration collection needs verification');
|
|
22
|
+
const aggregate = await options.device?.activitySnapshot();
|
|
23
|
+
const activities = aggregate?.registrations;
|
|
24
|
+
const connections = await options.device?.connections();
|
|
25
|
+
const members = options.device?.snapshot().registrations ?? [];
|
|
26
|
+
return deviceInspectionSchema.parse({
|
|
27
|
+
schemaVersion: 2, deviceRevision: createHash('sha256').update(JSON.stringify(inspection.entries.map(entry => [entry.id, entry.state === 'ready' ? entry.registration.revision : entry.target.revision]))).digest('hex'),
|
|
28
|
+
runtime: { state: 'running', generation: options.generation, mode: options.mode },
|
|
29
|
+
...(aggregate ? { desktopActivity: aggregate.desktop } : {}),
|
|
30
|
+
registrations: inspection.entries.map(entry => {
|
|
31
|
+
const registration = entry.state === 'ready' ? entry.registration : { id: entry.id, ...entry.target };
|
|
32
|
+
const member = members.find(candidate => candidate.id === entry.id);
|
|
33
|
+
const intent = intents.find(candidate => candidate.registration.id === entry.id);
|
|
34
|
+
return { id: registration.id, server: registration.server, ownerUserId: registration.ownerUserId, workstationId: registration.workstationId, revision: registration.revision,
|
|
35
|
+
connection: connections?.get(entry.id) ?? 'unknown', identityState: entry.state === 'ready' ? 'ready' : 'corrupt',
|
|
36
|
+
removalState: intent ? intent.phase === 'restoring' ? 'cleanup-pending' : 'removing' : member?.cleanupPending ? 'cleanup-pending' : 'active',
|
|
37
|
+
activity: activities?.get(entry.id) ?? { known: false, agents: 0, terminals: 0, commands: 0, transfers: 0, desktopConsumers: 0 } };
|
|
38
|
+
}),
|
|
39
|
+
pending: [...intents.map(intent => ({ id: intent.operationId, kind: 'remove', phase: intent.phase, registrationId: intent.registration.id, target: { server: intent.registration.server, ownerUserId: intent.registration.ownerUserId }, outcome: 'pending' })), ...(await inspectPendingEnrollments(options.store.root)).map(enrollment => ({ id: enrollment.operationId, kind: 'register', phase: 'enrollment-needs-verification', registrationId: enrollment.registrationId, target: enrollment.target, outcome: 'unknown' }))],
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
export async function applyDeviceRemoval(value, options) {
|
|
43
|
+
const parsed = removeRegistrationRequestSchema.safeParse(value);
|
|
44
|
+
if (!parsed.success)
|
|
45
|
+
throw new LocalControlError('UNSUPPORTED_PROTOCOL', 'Invalid local device removal request');
|
|
46
|
+
const request = parsed.data;
|
|
47
|
+
let responses = removalResponses.get(options.controller);
|
|
48
|
+
if (!responses) {
|
|
49
|
+
responses = new Map();
|
|
50
|
+
removalResponses.set(options.controller, responses);
|
|
51
|
+
}
|
|
52
|
+
const binding = JSON.stringify([request.guard.registrationId, request.guard.registrationRevision, request.guard.runtimeGeneration, request.force]);
|
|
53
|
+
const existing = responses.get(request.operationId);
|
|
54
|
+
if (existing) {
|
|
55
|
+
if (existing.binding !== binding)
|
|
56
|
+
throw new LocalControlError('BINDING_CONFLICT', 'Original removal response binding changed');
|
|
57
|
+
return existing.response;
|
|
58
|
+
}
|
|
59
|
+
if (responses.size >= 128)
|
|
60
|
+
throw new LocalControlError('BUSY', 'Too many in-flight removal responses');
|
|
61
|
+
const response = (async () => {
|
|
62
|
+
const result = await options.controller.removeRegistration({ id: request.guard.registrationId, expectedRevision: request.guard.registrationRevision, runtimeGeneration: request.guard.runtimeGeneration, operationId: request.operationId, force: request.force });
|
|
63
|
+
return deviceResultSchema.parse({ operationId: request.operationId, registrationId: request.guard.registrationId, status: result.status === 'removed' ? 'succeeded' : result.status, activity: result.activity, inspection: await options.snapshot() });
|
|
64
|
+
})().finally(() => { responses.delete(request.operationId); });
|
|
65
|
+
responses.set(request.operationId, { binding, response });
|
|
66
|
+
return response;
|
|
67
|
+
}
|
|
68
|
+
export async function executeDeviceCommand(value, directory) {
|
|
69
|
+
const lifecycle = deviceLifecycleRequestSchema.safeParse(value);
|
|
70
|
+
if (lifecycle.success) {
|
|
71
|
+
const request = lifecycle.data;
|
|
72
|
+
await executeDeviceLifecycle(request.action, directory, { guard: request.guard, operationId: request.operationId });
|
|
73
|
+
return deviceResultSchema.parse({ operationId: request.operationId, registrationId: null, status: 'succeeded', inspection: await inspectLocalDevice(directory) });
|
|
74
|
+
}
|
|
75
|
+
const addition = addRegistrationRequestSchema.safeParse(value);
|
|
76
|
+
if (addition.success) {
|
|
77
|
+
const request = addition.data;
|
|
78
|
+
if (request.guard.runtimeGeneration !== null) {
|
|
79
|
+
try {
|
|
80
|
+
const result = deviceResultSchema.parse(await runtimeRequest(directory, 'add-registration', request.guard.runtimeGeneration, { request }, 30_000));
|
|
81
|
+
if (result.operationId !== request.operationId)
|
|
82
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Original enrollment reply does not match');
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (error instanceof LocalControlError)
|
|
87
|
+
throw error;
|
|
88
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Original device owner could not confirm enrollment');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return applyDeviceEnrollment(request, directory, async () => {
|
|
92
|
+
const runtime = await inspectRuntime(directory);
|
|
93
|
+
if (runtime.state !== 'stopped')
|
|
94
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'The workstation owner is not verified stopped');
|
|
95
|
+
await assertRegistrationEnrollmentAdmission(directory);
|
|
96
|
+
const current = await inspectDeviceState({ store: new RegistrationStore(directory), generation: 'stopped', mode: 'foreground' });
|
|
97
|
+
current.runtime = { state: 'stopped', generation: null, mode: 'unknown' };
|
|
98
|
+
return current;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
const parsed = removeRegistrationRequestSchema.safeParse(value);
|
|
102
|
+
if (!parsed.success)
|
|
103
|
+
throw new LocalControlError('UNSUPPORTED_PROTOCOL', 'Invalid local device removal request');
|
|
104
|
+
const request = parsed.data;
|
|
105
|
+
try {
|
|
106
|
+
return await removeDeviceRuntimeRegistration(directory, request);
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
if (!(error instanceof LocalControlError) || error.code !== 'OUTCOME_UNKNOWN')
|
|
110
|
+
throw error;
|
|
111
|
+
return { operationId: request.operationId, registrationId: request.guard.registrationId, status: 'pending', inspection: null, error: { code: 'OUTCOME_UNKNOWN', message: 'Original owner removal remains unconfirmed; retry the same operation' } };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
export async function executeDeviceLifecycle(action, directory, request) {
|
|
115
|
+
if (action === 'install' && !request) {
|
|
116
|
+
const legacy = await inspectRuntime(directory);
|
|
117
|
+
if (legacy.state === 'running' && legacy.binding) {
|
|
118
|
+
const context = { ...serviceContext(), configDir: directory };
|
|
119
|
+
await prepareLegacyBridge(directory, context);
|
|
120
|
+
await createServiceManager(context).install({ interactive: true });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const before = await inspectLocalDevice(directory);
|
|
125
|
+
if (request)
|
|
126
|
+
assertDeviceGuard(request.guard, before);
|
|
127
|
+
const controller = createDeviceDaemonController(directory);
|
|
128
|
+
if (before.runtime.state === 'running') {
|
|
129
|
+
if (action === 'install') {
|
|
130
|
+
// Existing updaters invoke the public install command while their original owner is
|
|
131
|
+
// still alive. Only the exact private recovery transaction can admit that handoff.
|
|
132
|
+
if (!request) {
|
|
133
|
+
let pendingUpdate = false;
|
|
134
|
+
try {
|
|
135
|
+
await fs.lstat(path.join(directory, 'update/device-transaction.json'));
|
|
136
|
+
pendingUpdate = true;
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (error.code !== 'ENOENT')
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
if (pendingUpdate) {
|
|
143
|
+
await controller.install({ updateGeneration: before.runtime.generation });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
throw new LocalControlError('INSTANCE_OCCUPIED', 'Stop the whole device before reinstalling its service');
|
|
148
|
+
}
|
|
149
|
+
await controller[action]({ guard: { deviceRevision: before.deviceRevision, runtimeGeneration: before.runtime.generation }, operationId: request?.operationId ?? randomUUID() });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
await withWorkstationLock(directory, 'operation', async () => {
|
|
153
|
+
if ((await inspectRuntime(directory)).state !== 'stopped')
|
|
154
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'The workstation owner changed before the service action');
|
|
155
|
+
const current = await inspectDeviceState({ store: new RegistrationStore(directory), generation: 'stopped', mode: 'foreground' });
|
|
156
|
+
current.runtime = { state: 'stopped', generation: null, mode: 'unknown' };
|
|
157
|
+
assertDeviceGuard(request?.guard ?? { deviceRevision: before.deviceRevision, runtimeGeneration: null }, current);
|
|
158
|
+
await controller[action]();
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
export async function applyDeviceEnrollment(value, directory, snapshot, assertAdmission) {
|
|
162
|
+
assertAdmission?.();
|
|
163
|
+
const parsed = addRegistrationRequestSchema.safeParse(value);
|
|
164
|
+
if (!parsed.success)
|
|
165
|
+
throw new LocalControlError('UNSUPPORTED_PROTOCOL', 'Invalid local device enrollment request');
|
|
166
|
+
const request = parsed.data;
|
|
167
|
+
const result = await enrollRegistration(request.input, directory, { beforeEnrollment: async () => { assertAdmission?.(); assertDeviceGuard(request.guard, await snapshot()); assertAdmission?.(); } });
|
|
168
|
+
return deviceResultSchema.parse({ operationId: request.operationId, registrationId: result.registration.id, status: 'succeeded', inspection: await snapshot() });
|
|
169
|
+
}
|
|
170
|
+
export async function inspectLocalDevice(directory) {
|
|
171
|
+
try {
|
|
172
|
+
return parseDeviceInspection(await inspectDeviceRuntime(directory));
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
const runtime = await inspectRuntime(directory);
|
|
176
|
+
if (runtime.state !== 'stopped') {
|
|
177
|
+
if (runtime.state === 'running' && runtime.binding)
|
|
178
|
+
throw new LocalControlError('UNSUPPORTED_PROTOCOL', 'A legacy single-registration owner is still running. Automatic migration requires its verified active update handoff. Allow the original update to finish and preserve the workstation data and update evidence.');
|
|
179
|
+
if (error instanceof LocalControlError && error.code === 'UNSUPPORTED_PROTOCOL')
|
|
180
|
+
throw error;
|
|
181
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'The workstation owner is unavailable or unknown; verify it before continuing');
|
|
182
|
+
}
|
|
183
|
+
return withWorkstationLock(directory, 'operation', async () => {
|
|
184
|
+
if ((await inspectRuntime(directory)).state !== 'stopped')
|
|
185
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'The workstation owner changed during inspection');
|
|
186
|
+
await assertRegistrationEnrollmentAdmission(directory);
|
|
187
|
+
const inspection = await inspectDeviceState({ store: new RegistrationStore(directory), generation: 'stopped', mode: 'foreground' });
|
|
188
|
+
return deviceInspectionSchema.parse({ ...inspection, runtime: { state: 'stopped', generation: null, mode: 'unknown' }, registrations: inspection.registrations.map(registration => ({ ...registration, connection: 'offline' })) });
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { registrationActivitySchema, registrationIdentitySchema, registrationIdSchema, registrationKey, registrationServerSchema } from '../registrations/model.js';
|
|
3
|
+
import { LocalControlError, localErrorCodeSchema } from './protocol.js';
|
|
4
|
+
import { operationIdSchema, registrationEnrollmentInputSchema } from '../registrations/enrollmentPending.js';
|
|
5
|
+
import { deviceReleaseSchema } from '../update/deviceHealthProtocol.js';
|
|
6
|
+
export const deviceGuardSchema = z.object({ deviceRevision: z.string().min(1), runtimeGeneration: z.string().min(1).nullable() }).strict();
|
|
7
|
+
export const registrationGuardSchema = deviceGuardSchema.extend({ registrationId: registrationIdSchema, registrationRevision: z.string().min(1) }).strict();
|
|
8
|
+
export const removeRegistrationRequestSchema = z.object({ schemaVersion: z.literal(2), action: z.literal('remove-registration'), operationId: operationIdSchema, guard: registrationGuardSchema.extend({ runtimeGeneration: z.string().min(1).max(128) }).strict(), force: z.boolean() }).strict();
|
|
9
|
+
export const addRegistrationRequestSchema = z.object({ schemaVersion: z.literal(2), action: z.literal('add-registration'), operationId: operationIdSchema, guard: deviceGuardSchema, input: registrationEnrollmentInputSchema }).strict().refine(value => value.operationId === value.input.operationId, 'Enrollment operation IDs must match');
|
|
10
|
+
export const deviceLifecycleRequestSchema = z.object({ schemaVersion: z.literal(2), action: z.enum(['start', 'stop', 'restart', 'install', 'uninstall']), operationId: operationIdSchema, guard: deviceGuardSchema }).strict();
|
|
11
|
+
export const deviceRuntimeUpdateRequestSchema = z.object({ schemaVersion: z.literal(2), action: z.literal('update'), operationId: operationIdSchema, server: registrationServerSchema, guard: deviceGuardSchema, release: deviceReleaseSchema }).strict();
|
|
12
|
+
export const registrationSnapshotSchema = registrationIdentitySchema.extend({
|
|
13
|
+
connection: z.enum(['offline', 'connecting', 'online', 'reconnecting', 'rejected', 'unknown']),
|
|
14
|
+
identityState: z.enum(['absent', 'ready', 'incomplete', 'corrupt']),
|
|
15
|
+
removalState: z.enum(['active', 'removing', 'cleanup-pending']),
|
|
16
|
+
activity: registrationActivitySchema,
|
|
17
|
+
}).strict();
|
|
18
|
+
export const deviceOperationKindSchema = z.enum(['register', 'start', 'stop', 'remove', 'update']);
|
|
19
|
+
export const deviceOperationRecordSchema = z.object({
|
|
20
|
+
id: z.string().min(1), kind: deviceOperationKindSchema, phase: z.string().min(1).max(80),
|
|
21
|
+
registrationId: registrationIdSchema.nullable(),
|
|
22
|
+
target: z.object({ server: registrationServerSchema, ownerUserId: z.string().min(1) }).strict().nullable(),
|
|
23
|
+
outcome: z.enum(['pending', 'succeeded', 'failed', 'unknown']),
|
|
24
|
+
}).strict();
|
|
25
|
+
export const deviceInspectionSchema = z.object({
|
|
26
|
+
schemaVersion: z.literal(2), deviceRevision: z.string().min(1),
|
|
27
|
+
runtime: z.object({
|
|
28
|
+
state: z.enum(['stopped', 'running', 'unknown']), generation: z.string().min(1).nullable(),
|
|
29
|
+
mode: z.enum(['launchd', 'foreground', 'unknown']),
|
|
30
|
+
}).strict(),
|
|
31
|
+
registrations: z.array(registrationSnapshotSchema),
|
|
32
|
+
pending: z.array(deviceOperationRecordSchema),
|
|
33
|
+
desktopActivity: z.strictObject({ known: z.boolean(), consumers: z.number().int().min(0).max(65536) }).optional(),
|
|
34
|
+
desktop: z.strictObject({ deviceEpoch: z.string().min(1).max(128), hostLifetimeId: z.string().min(1).max(128).nullable(), hostRetired: z.boolean() })
|
|
35
|
+
.refine(value => !value.hostRetired || value.hostLifetimeId !== null).optional(),
|
|
36
|
+
}).strict().refine(value => {
|
|
37
|
+
const ids = new Set(value.registrations.map(registration => registration.id));
|
|
38
|
+
const keys = new Set(value.registrations.map(registration => registrationKey(registration.server, registration.ownerUserId)));
|
|
39
|
+
return ids.size === value.registrations.length && keys.size === value.registrations.length;
|
|
40
|
+
}, 'Registrations require unique IDs and server/account identities');
|
|
41
|
+
export const deviceResultSchema = z.object({
|
|
42
|
+
operationId: z.string().min(1), registrationId: registrationIdSchema.nullable(),
|
|
43
|
+
status: z.enum(['succeeded', 'busy', 'pending', 'failed', 'conflict']),
|
|
44
|
+
inspection: deviceInspectionSchema.nullable(),
|
|
45
|
+
activity: registrationActivitySchema.nullable().optional(),
|
|
46
|
+
error: z.object({ code: localErrorCodeSchema, message: z.string().max(1024) }).strict().optional(),
|
|
47
|
+
}).strict().refine(value => value.status !== 'succeeded' || value.inspection !== null, 'Successful operations require inspection');
|
|
48
|
+
export const deviceEventSchema = z.discriminatedUnion('kind', [
|
|
49
|
+
z.object({ schemaVersion: z.literal(2), kind: z.literal('progress'), operationId: z.string().min(1), registrationId: registrationIdSchema.nullable(), phase: z.string().min(1).max(80) }).strict(),
|
|
50
|
+
z.object({ schemaVersion: z.literal(2), kind: z.literal('result'), operationId: z.string().min(1), result: deviceResultSchema }).strict(),
|
|
51
|
+
]).refine(value => value.kind !== 'result' || value.operationId === value.result.operationId, 'Result operation IDs must match');
|
|
52
|
+
export function parseDeviceInspection(value) {
|
|
53
|
+
if (typeof value === 'object' && value !== null && 'schemaVersion' in value && value.schemaVersion !== 2) {
|
|
54
|
+
throw new LocalControlError('UNSUPPORTED_PROTOCOL', 'Unsupported workstation device protocol');
|
|
55
|
+
}
|
|
56
|
+
return deviceInspectionSchema.parse(value);
|
|
57
|
+
}
|
|
58
|
+
export function assertDeviceGuard(guard, inspection) {
|
|
59
|
+
if (guard.deviceRevision !== inspection.deviceRevision || guard.runtimeGeneration !== inspection.runtime.generation) {
|
|
60
|
+
throw new LocalControlError('BINDING_CONFLICT', 'The local device changed');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function assertRegistrationGuard(guard, inspection) {
|
|
64
|
+
const registration = inspection.registrations.find(candidate => candidate.id === guard.registrationId);
|
|
65
|
+
if (!registration || registration.revision !== guard.registrationRevision || guard.runtimeGeneration !== inspection.runtime.generation) {
|
|
66
|
+
throw new LocalControlError('BINDING_CONFLICT', 'The local registration changed');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -9,8 +9,52 @@ import { pidAlive, processCommand } from '../keepers/kit/lock.js';
|
|
|
9
9
|
import { LocalControlError } from './protocol.js';
|
|
10
10
|
const ownership = new AsyncLocalStorage();
|
|
11
11
|
export function assertLockOwnership() {
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
let lease = ownership.getStore();
|
|
13
|
+
while (lease) {
|
|
14
|
+
if (!lease.valid || !lease.active)
|
|
15
|
+
throw new LocalControlError('BUSY', 'Workstation lock ownership was lost or released');
|
|
16
|
+
lease = lease.parent;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function operationLease(directory) {
|
|
20
|
+
const expected = path.join(path.resolve(directory), 'control/operation');
|
|
21
|
+
let lease = ownership.getStore();
|
|
22
|
+
let held;
|
|
23
|
+
while (lease) {
|
|
24
|
+
if (!lease.valid || !lease.active)
|
|
25
|
+
throw new LocalControlError('BUSY', 'Workstation operation lock ownership was lost or released');
|
|
26
|
+
if (lease.stem === expected)
|
|
27
|
+
held = lease;
|
|
28
|
+
lease = lease.parent;
|
|
29
|
+
}
|
|
30
|
+
if (!held)
|
|
31
|
+
throw new LocalControlError('BUSY', 'The workstation operation lock is required');
|
|
32
|
+
return held;
|
|
33
|
+
}
|
|
34
|
+
export function assertWorkstationOperationLock(directory) { return operationLease(directory); }
|
|
35
|
+
export function assertOutsideOperationLease() {
|
|
36
|
+
let lease = ownership.getStore();
|
|
37
|
+
while (lease) {
|
|
38
|
+
if (path.basename(lease.stem) === 'operation')
|
|
39
|
+
throw new LocalControlError('BUSY', 'Owner control requires an outside-operation context');
|
|
40
|
+
lease = lease.parent;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function registerOperationLeaseFinalizer(directory, finalizer) {
|
|
44
|
+
operationLease(directory).finalizers.add(finalizer);
|
|
45
|
+
}
|
|
46
|
+
function finalize(lease) {
|
|
47
|
+
lease.active = false;
|
|
48
|
+
const callbacks = [...lease.finalizers];
|
|
49
|
+
lease.finalizers.clear();
|
|
50
|
+
for (const callback of callbacks) {
|
|
51
|
+
try {
|
|
52
|
+
callback();
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
lease.finalizerErrors.push(error);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
14
58
|
}
|
|
15
59
|
export async function withPrivateLock(stem, work) {
|
|
16
60
|
await privateDirectory(path.dirname(stem));
|
|
@@ -34,11 +78,11 @@ export async function withPrivateLock(stem, work) {
|
|
|
34
78
|
if (lockPresent || error.code !== 'ENOENT')
|
|
35
79
|
throw new LocalControlError('BUSY', 'Another workstation operation is active');
|
|
36
80
|
}
|
|
37
|
-
const lease = { valid: true };
|
|
81
|
+
const lease = { valid: true, active: true, stem: path.resolve(stem), parent: ownership.getStore(), finalizers: new Set(), finalizerErrors: [] };
|
|
38
82
|
const nonce = randomUUID();
|
|
39
83
|
let release;
|
|
40
84
|
try {
|
|
41
|
-
release = await lockfile.lock(stem, { realpath: false, stale: 30_000, update: 2_000, retries: 0, onCompromised: () => { lease.valid = false; } });
|
|
85
|
+
release = await lockfile.lock(stem, { realpath: false, stale: 30_000, update: 2_000, retries: 0, onCompromised: () => { lease.valid = false; finalize(lease); } });
|
|
42
86
|
}
|
|
43
87
|
catch {
|
|
44
88
|
throw new LocalControlError('BUSY', 'Another workstation operation is active');
|
|
@@ -48,6 +92,7 @@ export async function withPrivateLock(stem, work) {
|
|
|
48
92
|
return await ownership.run(lease, async () => { const result = await work(); assertLockOwnership(); return result; });
|
|
49
93
|
}
|
|
50
94
|
finally {
|
|
95
|
+
finalize(lease);
|
|
51
96
|
if (lease.valid) {
|
|
52
97
|
try {
|
|
53
98
|
if (JSON.parse(await fs.readFile(ownerFile, 'utf8')).nonce === nonce)
|
|
@@ -56,6 +101,8 @@ export async function withPrivateLock(stem, work) {
|
|
|
56
101
|
catch { /* release still required */ }
|
|
57
102
|
await release();
|
|
58
103
|
}
|
|
104
|
+
if (lease.finalizerErrors.length)
|
|
105
|
+
throw new AggregateError(lease.finalizerErrors, 'Workstation lease finalizers failed');
|
|
59
106
|
}
|
|
60
107
|
}
|
|
61
108
|
export function withWorkstationLock(directory, name, work) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { LocalControlError } from './protocol.js';
|
|
4
|
+
export async function assertLegacyRoot(directory) {
|
|
5
|
+
for (const name of ['device.json', 'legacy-migration.json']) {
|
|
6
|
+
try {
|
|
7
|
+
await fs.lstat(path.join(directory, name));
|
|
8
|
+
}
|
|
9
|
+
catch (error) {
|
|
10
|
+
if (error.code === 'ENOENT')
|
|
11
|
+
continue;
|
|
12
|
+
throw error;
|
|
13
|
+
}
|
|
14
|
+
throw new LocalControlError('CORRUPT_IDENTITY', 'This workstation uses registrations; legacy identity writes and startup are unsupported');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -1,8 +1,52 @@
|
|
|
1
1
|
import net from 'node:net';
|
|
2
2
|
import { runtimePaths, readControlFile, runtimeOwnerLive } from './runtimeHost.js';
|
|
3
|
-
import { runtimeSnapshotSchema, stoppedRuntime, LocalControlError } from './protocol.js';
|
|
3
|
+
import { runtimeSnapshotSchema, stoppedRuntime, LocalControlError, localErrorCodeSchema } from './protocol.js';
|
|
4
4
|
import { pidAlive } from '../keepers/kit/lock.js';
|
|
5
|
-
|
|
5
|
+
import { parseDeviceInspection, deviceResultSchema, deviceRuntimeUpdateRequestSchema, removeRegistrationRequestSchema } from './deviceProtocol.js';
|
|
6
|
+
export async function removeDeviceRuntimeRegistration(directory, value) {
|
|
7
|
+
const request = removeRegistrationRequestSchema.parse(value);
|
|
8
|
+
let response;
|
|
9
|
+
try {
|
|
10
|
+
response = await runtimeRequest(directory, 'remove-registration', request.guard.runtimeGeneration, { request }, 15_000);
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
if (error instanceof LocalControlError)
|
|
14
|
+
throw error;
|
|
15
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Original running owner could not confirm removal');
|
|
16
|
+
}
|
|
17
|
+
const result = deviceResultSchema.parse(response);
|
|
18
|
+
if (result.operationId !== request.operationId || result.registrationId !== request.guard.registrationId)
|
|
19
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Original removal reply does not match');
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
export async function inspectDeviceRuntime(directory) {
|
|
23
|
+
return parseDeviceInspection(await runtimeRequest(directory, 'inspect'));
|
|
24
|
+
}
|
|
25
|
+
export async function prepareDeviceRuntimeStop(directory, generation, stopMode, operationId) {
|
|
26
|
+
const receipt = await runtimeRequest(directory, 'prepare-stop', generation, { stopMode, operationId }, 15_000);
|
|
27
|
+
if (receipt.prepared !== true || receipt.generation !== generation || receipt.stopMode !== stopMode)
|
|
28
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Device shutdown preparation was not confirmed');
|
|
29
|
+
}
|
|
30
|
+
export async function stopDeviceRuntime(directory, generation, stopMode) {
|
|
31
|
+
await runtimeRequest(directory, 'stop', generation, { stopMode });
|
|
32
|
+
const deadline = Date.now() + 10_000;
|
|
33
|
+
while (Date.now() < deadline) {
|
|
34
|
+
try {
|
|
35
|
+
const current = await inspectDeviceRuntime(directory);
|
|
36
|
+
if (current.runtime.generation !== generation)
|
|
37
|
+
throw new LocalControlError('BINDING_CONFLICT', 'A new device daemon started');
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error instanceof LocalControlError && error.code === 'BINDING_CONFLICT')
|
|
41
|
+
throw error;
|
|
42
|
+
if ((await inspectRuntime(directory)).state === 'stopped')
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
46
|
+
}
|
|
47
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Device shutdown has not been confirmed');
|
|
48
|
+
}
|
|
49
|
+
export async function runtimeRequest(directory, action, expectedGeneration, extra = {}, timeoutMs = 3000) {
|
|
6
50
|
const paths = runtimePaths(directory);
|
|
7
51
|
const owner = JSON.parse(await readControlFile(paths.ownerFile));
|
|
8
52
|
if (typeof owner.generation !== 'string' || !pidAlive(owner.pid))
|
|
@@ -19,7 +63,7 @@ export async function runtimeRequest(directory, action, expectedGeneration, extr
|
|
|
19
63
|
socket.destroy();
|
|
20
64
|
reject(error);
|
|
21
65
|
} };
|
|
22
|
-
socket.setTimeout(
|
|
66
|
+
socket.setTimeout(timeoutMs, () => fail(new LocalControlError('OUTCOME_UNKNOWN', 'Runtime did not respond')));
|
|
23
67
|
socket.on('error', () => fail(new LocalControlError('OUTCOME_UNKNOWN', 'Cannot contact runtime')));
|
|
24
68
|
socket.on('end', () => { if (!done)
|
|
25
69
|
fail(new LocalControlError('OUTCOME_UNKNOWN', 'Incomplete runtime response')); });
|
|
@@ -34,8 +78,10 @@ export async function runtimeRequest(directory, action, expectedGeneration, extr
|
|
|
34
78
|
return;
|
|
35
79
|
try {
|
|
36
80
|
const result = JSON.parse(data.split('\n')[0]);
|
|
37
|
-
if (!result.ok)
|
|
38
|
-
|
|
81
|
+
if (!result.ok) {
|
|
82
|
+
const code = localErrorCodeSchema.safeParse(result.code);
|
|
83
|
+
throw new LocalControlError(code.success ? code.data : 'SERVICE_FAILED', 'Runtime rejected the request');
|
|
84
|
+
}
|
|
39
85
|
done = true;
|
|
40
86
|
socket.destroy();
|
|
41
87
|
resolve(result.value);
|
|
@@ -95,3 +141,12 @@ export async function stopRuntime(directory, expectedGeneration) {
|
|
|
95
141
|
export async function requestRuntimeUpdate(directory, release, operationId) {
|
|
96
142
|
return await runtimeRequest(directory, 'update', undefined, { release, operationId });
|
|
97
143
|
}
|
|
144
|
+
export async function requestDeviceRuntimeUpdate(directory, raw) {
|
|
145
|
+
const request = deviceRuntimeUpdateRequestSchema.parse(raw);
|
|
146
|
+
if (!request.guard.runtimeGeneration)
|
|
147
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'An original live device owner is required for update admission');
|
|
148
|
+
const status = await runtimeRequest(directory, 'update', request.guard.runtimeGeneration, { request }, 30_000);
|
|
149
|
+
if (!status || status.commandId !== request.operationId || !['REQUESTED', 'DRAINING', 'BUSY', 'DOWNLOADING', 'VERIFYING', 'INSTALLING', 'RESTARTING', 'SUCCEEDED', 'FAILED', 'ROLLED_BACK', 'UPDATE_IN_PROGRESS'].includes(status.phase))
|
|
150
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Original device update reply does not match its operation');
|
|
151
|
+
return status;
|
|
152
|
+
}
|