@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,125 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { assertIdentityCanStart } from './daemonIdentity.js';
|
|
4
|
+
import { normalizeWorkstationServer } from './serverIdentity.js';
|
|
5
|
+
import { saveOperation } from './enrollment.js';
|
|
6
|
+
import { clientDir, loadClientConfig, readKeys } from '../config.js';
|
|
7
|
+
import { createDaemonController } from '../daemonctl.js';
|
|
8
|
+
import { BindingStore } from './bindingStore.js';
|
|
9
|
+
import { inspectWorkstation } from './inspect.js';
|
|
10
|
+
import { enrollWorkstation, enrollmentFailure } from './enrollment.js';
|
|
11
|
+
import { withWorkstationLock } from './locks.js';
|
|
12
|
+
import { bindingGuardSchema, LocalControlError } from './protocol.js';
|
|
13
|
+
export { executeDeviceCommand } from './deviceCommands.js';
|
|
14
|
+
const lifecycleRequest = z.object({ operationId: z.string().min(1).max(128), guard: bindingGuardSchema }).strict();
|
|
15
|
+
export async function readLocalRequest(input) {
|
|
16
|
+
const chunks = [];
|
|
17
|
+
let size = 0;
|
|
18
|
+
for await (const part of input) {
|
|
19
|
+
const chunk = Buffer.from(part);
|
|
20
|
+
size += chunk.length;
|
|
21
|
+
if (size > 65_536)
|
|
22
|
+
throw new LocalControlError('UNSUPPORTED_PROTOCOL', 'Invalid local command input');
|
|
23
|
+
chunks.push(chunk);
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
throw new LocalControlError('UNSUPPORTED_PROTOCOL', 'Invalid local command input');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export async function executeLocalCommand(action, input, directory, controllerFactory, inspect = inspectWorkstation) {
|
|
33
|
+
if (action === 'register')
|
|
34
|
+
return enrollWorkstation(input, directory);
|
|
35
|
+
try {
|
|
36
|
+
const request = action === 'adopt-owner' ? lifecycleRequest.extend({ server: z.string(), expectedOwnerUserId: z.string().min(1), workstationId: z.string().min(1) }).parse(input) : action === 'set-enabled' ? lifecycleRequest.extend({ enabled: z.boolean() }).parse(input) : lifecycleRequest.parse(input);
|
|
37
|
+
return await withWorkstationLock(directory, 'operation', async () => {
|
|
38
|
+
const before = await inspect(directory);
|
|
39
|
+
if (before.revision !== request.guard.revision || before.runtime.generation !== request.guard.runtimeGeneration)
|
|
40
|
+
throw new LocalControlError('BINDING_CONFLICT', 'Workstation changed; check it again');
|
|
41
|
+
if (action === 'recover') {
|
|
42
|
+
if (before.runtime.state !== 'stopped' || before.keepers !== 'stopped')
|
|
43
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Stop and verify the workstation before recovering its identity');
|
|
44
|
+
await new BindingStore(directory).recover();
|
|
45
|
+
return { status: 'succeeded', inspection: await inspect(directory) };
|
|
46
|
+
}
|
|
47
|
+
if (before.pending?.kind === 'remove' && !['begin-remove', 'forget', 'stop'].includes(action))
|
|
48
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Finish removing this workstation before continuing');
|
|
49
|
+
if (before.runtime.state === 'unknown')
|
|
50
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Cannot verify the running workstation');
|
|
51
|
+
if (action === 'adopt-owner') {
|
|
52
|
+
const ownerRequest = lifecycleRequest.extend({ server: z.string(), expectedOwnerUserId: z.string().min(1), workstationId: z.string().min(1) }).parse(input);
|
|
53
|
+
if (before.identityState !== 'ready' || !before.binding || before.binding.ownerUserId || before.binding.server !== normalizeWorkstationServer(ownerRequest.server) || before.binding.workstationId !== ownerRequest.workstationId)
|
|
54
|
+
throw new LocalControlError('BINDING_CONFLICT', 'The local connection changed');
|
|
55
|
+
const config = await loadClientConfig(directory);
|
|
56
|
+
const keys = await readKeys(directory);
|
|
57
|
+
await new BindingStore(directory).commit({ ...config, ownerUserId: ownerRequest.expectedOwnerUserId }, { clientKey: keys.clientKey.toString('base64'), serverPub: keys.serverPub.toString('base64') }, { id: request.operationId, kind: 'register', phase: 'owner-confirmed', target: { server: before.binding.server, ownerUserId: ownerRequest.expectedOwnerUserId }, workstationId: before.binding.workstationId, outcome: 'succeeded' });
|
|
58
|
+
}
|
|
59
|
+
else if (action === 'forget') {
|
|
60
|
+
if (before.runtime.state !== 'stopped' || before.keepers !== 'stopped')
|
|
61
|
+
throw new LocalControlError('INSTANCE_OCCUPIED', 'Stop the workstation and its sessions before removing the connection');
|
|
62
|
+
await new BindingStore(directory).clear(request.guard.revision);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
if (before.identityState !== 'ready')
|
|
66
|
+
throw new LocalControlError('CORRUPT_IDENTITY', 'The workstation needs registration or repair');
|
|
67
|
+
const controller = controllerFactory?.() ?? createDaemonController({ identity: () => new BindingStore(directory).inspect(), runtime: async () => (await inspect(directory)).runtime });
|
|
68
|
+
if (action === 'set-enabled') {
|
|
69
|
+
const enabled = lifecycleRequest.extend({ enabled: z.boolean() }).parse(input).enabled;
|
|
70
|
+
if (enabled)
|
|
71
|
+
await assertIdentityCanStart(directory);
|
|
72
|
+
if (!controller.setEnabled)
|
|
73
|
+
throw new LocalControlError('UNSUPPORTED_PROTOCOL', 'Update the CLI to manage service enablement');
|
|
74
|
+
await controller.setEnabled(enabled, request);
|
|
75
|
+
}
|
|
76
|
+
else if (action === 'begin-remove') {
|
|
77
|
+
if (before.runtime.state !== 'stopped' || before.keepers !== 'stopped')
|
|
78
|
+
await controller.stop(request);
|
|
79
|
+
const stopped = await inspect(directory);
|
|
80
|
+
if (stopped.runtime.state !== 'stopped' || stopped.keepers !== 'stopped')
|
|
81
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Workstation shutdown could not be confirmed');
|
|
82
|
+
await saveOperation(directory, { id: request.operationId, kind: 'remove', phase: 'removing', target: { server: before.binding.server, ownerUserId: before.binding.ownerUserId ?? null }, workstationId: before.binding.workstationId, outcome: 'pending' });
|
|
83
|
+
}
|
|
84
|
+
else if (action === 'install') {
|
|
85
|
+
if (before.runtime.state !== 'stopped')
|
|
86
|
+
throw new LocalControlError('INSTANCE_OCCUPIED', 'Stop the workstation before changing its service');
|
|
87
|
+
await controller.install();
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
if (action === 'start' || action === 'restart')
|
|
91
|
+
await assertIdentityCanStart(directory);
|
|
92
|
+
await controller[action](request);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const after = await inspect(directory);
|
|
96
|
+
if (action === 'set-enabled') {
|
|
97
|
+
const expected = lifecycleRequest.extend({ enabled: z.boolean() }).parse(input).enabled;
|
|
98
|
+
if (!after.service || after.service.enabled !== expected || after.service.reason || (!expected && (after.runtime.state !== 'stopped' || after.keepers !== 'stopped'))) {
|
|
99
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Service enablement could not be confirmed');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (action === 'stop' && (after.runtime.state !== 'stopped' || after.keepers !== 'stopped'))
|
|
103
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Workstation shutdown could not be confirmed');
|
|
104
|
+
return { status: 'succeeded', inspection: after };
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
return enrollmentFailure(error, directory);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
export async function runLocalJsonCommand(action, input = process.stdin, write = value => { process.stdout.write(value); }, directory = clientDir(), controllerFactory, inspect = inspectWorkstation) {
|
|
112
|
+
let operationId = randomUUID();
|
|
113
|
+
let result;
|
|
114
|
+
try {
|
|
115
|
+
const request = await readLocalRequest(input);
|
|
116
|
+
if (typeof request === 'object' && request && 'operationId' in request && typeof request.operationId === 'string' && /^[\w-]{1,128}$/.test(request.operationId))
|
|
117
|
+
operationId = request.operationId;
|
|
118
|
+
result = await executeLocalCommand(action, request, directory, controllerFactory, inspect);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
result = await enrollmentFailure(error, directory);
|
|
122
|
+
}
|
|
123
|
+
const event = { schemaVersion: 1, kind: 'result', operationId, result };
|
|
124
|
+
write(JSON.stringify(event) + '\n');
|
|
125
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { loadClientConfig, readKeys } from '../config.js';
|
|
3
|
+
import { BindingStore } from './bindingStore.js';
|
|
4
|
+
import { withWorkstationLock } from './locks.js';
|
|
5
|
+
import { inspectRuntime } from './runtimeClient.js';
|
|
6
|
+
import { readControlFile } from './runtimeHost.js';
|
|
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
|
+
}
|
|
96
|
+
export async function assertIdentityCanStart(directory) {
|
|
97
|
+
try {
|
|
98
|
+
const operation = operationRecordSchema.parse(JSON.parse(await readControlFile(path.join(directory, 'operation.json'))));
|
|
99
|
+
if (operation.outcome === 'pending' || operation.outcome === 'unknown')
|
|
100
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', operation.kind === 'remove' ? 'Finish removing this workstation before starting it' : 'The workstation operation needs verification');
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (error.code !== 'ENOENT')
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export async function recoverStoppedIdentity(directory) {
|
|
108
|
+
await assertLegacyRoot(directory);
|
|
109
|
+
const store = new BindingStore(directory);
|
|
110
|
+
if ((await store.inspect()).identityState === 'incomplete') {
|
|
111
|
+
const runtime = await inspectRuntime(directory);
|
|
112
|
+
const { inspectKeepers } = await import('./inspect.js');
|
|
113
|
+
if (runtime.state !== 'stopped' || await inspectKeepers(directory) !== 'stopped')
|
|
114
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Stop and verify the existing workstation before recovering its identity');
|
|
115
|
+
}
|
|
116
|
+
await store.recover();
|
|
117
|
+
}
|
|
118
|
+
/** Publish runtime ownership under the same lock as identity writes; initialization uses this snapshot only. */
|
|
119
|
+
export async function claimDaemonIdentity(directory, claim) {
|
|
120
|
+
const deadline = Date.now() + 10_000;
|
|
121
|
+
for (;;)
|
|
122
|
+
try {
|
|
123
|
+
return await withWorkstationLock(directory, 'operation', async () => {
|
|
124
|
+
await assertLegacyRoot(directory);
|
|
125
|
+
const runtime = await inspectRuntime(directory);
|
|
126
|
+
if (runtime.state !== 'stopped')
|
|
127
|
+
throw new LocalControlError(runtime.state === 'running' ? 'INSTANCE_OCCUPIED' : 'OUTCOME_UNKNOWN', 'The existing workstation must be verified before startup');
|
|
128
|
+
const store = new BindingStore(directory);
|
|
129
|
+
await recoverStoppedIdentity(directory);
|
|
130
|
+
await assertIdentityCanStart(directory);
|
|
131
|
+
if ((await store.inspect()).identityState !== 'ready')
|
|
132
|
+
throw new LocalControlError('CORRUPT_IDENTITY', 'The workstation identity needs registration or repair');
|
|
133
|
+
const config = await loadClientConfig(directory);
|
|
134
|
+
const keys = await readKeys(directory);
|
|
135
|
+
return claim({ config, ...keys });
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (!(error instanceof LocalControlError) || error.code !== 'BUSY' || Date.now() >= deadline)
|
|
140
|
+
throw error;
|
|
141
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import { sanitizeName } from '../names.js';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { promises as fs } from 'node:fs';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { requestWorkstationRegistration } from '../register.js';
|
|
8
|
+
import { BindingStore } from './bindingStore.js';
|
|
9
|
+
import { inspectRuntime } from './runtimeClient.js';
|
|
10
|
+
import { inspectWorkstation } from './inspect.js';
|
|
11
|
+
import { bindingGuardSchema, LocalControlError, operationRecordSchema } from './protocol.js';
|
|
12
|
+
import { assertLockOwnership, withWorkstationLock } from './locks.js';
|
|
13
|
+
import { normalizeWorkstationServer } from './serverIdentity.js';
|
|
14
|
+
import { privateDirectory } from '../keepers/kit/paths.js';
|
|
15
|
+
import { KeeperLauncher } from '../keepers/kit/launcher.js';
|
|
16
|
+
import { stopKeepers } from '../keepers/stop.js';
|
|
17
|
+
export const registrationInputSchema = z.object({ operationId: z.string().min(1).max(128), server: z.string(), expectedOwnerUserId: z.string().min(1), name: z.string().min(1).max(200).optional(), workstationId: z.string().min(1).optional(), workspace: z.object({ name: z.string(), cwd: z.string(), shell: z.string() }).optional(), guard: bindingGuardSchema, switchConfirmed: z.boolean(), accessToken: z.string().min(1).max(32_768) }).strict();
|
|
18
|
+
export async function saveOperation(directory, operation) {
|
|
19
|
+
assertLockOwnership();
|
|
20
|
+
await privateDirectory(directory);
|
|
21
|
+
const file = path.join(directory, 'operation.json');
|
|
22
|
+
const temporary = file + '.' + randomUUID();
|
|
23
|
+
const handle = await fs.open(temporary, 'wx', 0o600);
|
|
24
|
+
try {
|
|
25
|
+
await handle.writeFile(JSON.stringify(operationRecordSchema.parse(operation)));
|
|
26
|
+
await handle.sync();
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
await handle.close();
|
|
30
|
+
}
|
|
31
|
+
assertLockOwnership();
|
|
32
|
+
await fs.rename(temporary, file);
|
|
33
|
+
}
|
|
34
|
+
export async function enrollmentFailure(error, directory) {
|
|
35
|
+
const e = error instanceof LocalControlError ? error : new LocalControlError('SERVICE_FAILED', 'The local workstation operation failed. Check its status and try again.');
|
|
36
|
+
return { status: e.code === 'BUSY' ? 'busy' : e.code === 'OUTCOME_UNKNOWN' ? 'pending' : ['BINDING_CONFLICT', 'INSTANCE_OCCUPIED'].includes(e.code) ? 'conflict' : 'failed', inspection: await inspectWorkstation(directory).catch(() => null), error: { code: e.code, message: e.message } };
|
|
37
|
+
}
|
|
38
|
+
async function defaultShell() {
|
|
39
|
+
const candidates = [process.env.SHELL, os.userInfo().shell, process.platform === 'darwin' ? '/bin/zsh' : '/bin/sh'];
|
|
40
|
+
for (const shell of candidates)
|
|
41
|
+
if (shell && path.isAbsolute(shell) && !/[\x00-\x1f]/.test(shell)) {
|
|
42
|
+
try {
|
|
43
|
+
await fs.access(shell, fs.constants.X_OK);
|
|
44
|
+
if ((await fs.stat(shell)).isFile())
|
|
45
|
+
return shell;
|
|
46
|
+
}
|
|
47
|
+
catch { /* Try the user's login shell, then the platform fallback. */ }
|
|
48
|
+
}
|
|
49
|
+
throw new LocalControlError('UNSUPPORTED_INSTALLATION', 'No executable login shell is available');
|
|
50
|
+
}
|
|
51
|
+
async function enroll(input, directory, deps) {
|
|
52
|
+
const server = normalizeWorkstationServer(input.server);
|
|
53
|
+
const store = new BindingStore(directory);
|
|
54
|
+
await store.recover();
|
|
55
|
+
const identity = await store.inspect();
|
|
56
|
+
const runtime = await deps.runtime();
|
|
57
|
+
if (runtime.state !== 'stopped')
|
|
58
|
+
throw new LocalControlError(runtime.state === 'running' ? 'INSTANCE_OCCUPIED' : 'OUTCOME_UNKNOWN', 'Stop the existing workstation before registering this computer');
|
|
59
|
+
if (input.guard && (identity.revision !== input.guard.revision || runtime.generation !== input.guard.runtimeGeneration))
|
|
60
|
+
throw new LocalControlError('BINDING_CONFLICT', 'Workstation changed; check it again');
|
|
61
|
+
if (identity.identityState === 'corrupt' && (!identity.binding || !input.expectedOwnerUserId || (identity.binding.ownerUserId && identity.binding.ownerUserId !== input.expectedOwnerUserId) || identity.binding.server !== server))
|
|
62
|
+
throw new LocalControlError('CORRUPT_IDENTITY', 'The saved workstation identity needs verification');
|
|
63
|
+
const switching = identity.binding && (identity.binding.server !== server || (input.expectedOwnerUserId && identity.binding.ownerUserId !== input.expectedOwnerUserId));
|
|
64
|
+
if (switching && !input.switchConfirmed)
|
|
65
|
+
throw new LocalControlError('BINDING_CONFLICT', 'Confirm switching this computer to the selected server and account');
|
|
66
|
+
const inspection = await inspectWorkstation(directory);
|
|
67
|
+
if (inspection.pending && ['pending', 'unknown'].includes(inspection.pending.outcome))
|
|
68
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'An earlier registration needs verification. Refresh the workstation list before retrying.');
|
|
69
|
+
if (identity.binding) {
|
|
70
|
+
const reports = await stopKeepers(new KeeperLauncher({ configDir: directory, ...identity.binding }));
|
|
71
|
+
if (reports.some(report => !report.result.stopped))
|
|
72
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Existing workstation sessions could not be stopped');
|
|
73
|
+
}
|
|
74
|
+
const history = await store.history();
|
|
75
|
+
const known = input.expectedOwnerUserId ? history.find(item => item.server === server && item.ownerUserId === input.expectedOwnerUserId) : undefined;
|
|
76
|
+
const same = identity.binding?.server === server && (!input.expectedOwnerUserId || identity.binding.ownerUserId === input.expectedOwnerUserId);
|
|
77
|
+
const workstationId = input.workstationId ?? known?.workstationId ?? (same ? identity.binding?.workstationId : undefined);
|
|
78
|
+
const operation = { id: input.operationId, kind: switching ? 'switch' : 'register', phase: 'registering', target: { server, ownerUserId: input.expectedOwnerUserId ?? null }, outcome: 'pending' };
|
|
79
|
+
await saveOperation(directory, operation);
|
|
80
|
+
try {
|
|
81
|
+
const workspace = input.workspace;
|
|
82
|
+
const shell = workspace?.shell ?? await defaultShell();
|
|
83
|
+
const response = await requestWorkstationRegistration(server, input.name ?? sanitizeName(os.hostname(), 'computer'), input.accessToken, workspace, workstationId, shell);
|
|
84
|
+
if (input.expectedOwnerUserId && response.ownerUserId !== input.expectedOwnerUserId)
|
|
85
|
+
throw new LocalControlError('OWNER_MISMATCH', 'The server returned a different workstation owner');
|
|
86
|
+
assertLockOwnership();
|
|
87
|
+
await store.commit({ server, workstationId: response.workstationId, ...(response.ownerUserId ? { ownerUserId: response.ownerUserId } : {}), workspaces: workspace ? [{ name: workspace.name, cwd: workspace.cwd }] : [] }, response, { ...operation, workstationId: response.workstationId, phase: 'registered', outcome: 'succeeded' }, identity.identityState === 'corrupt');
|
|
88
|
+
return { status: 'succeeded', inspection: await inspectWorkstation(directory) };
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
await saveOperation(directory, { ...operation, outcome: error instanceof LocalControlError && error.code !== 'OUTCOME_UNKNOWN' ? 'failed' : 'unknown' });
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export async function enrollWorkstation(input, directory, deps = { runtime: () => inspectRuntime(directory) }) {
|
|
96
|
+
try {
|
|
97
|
+
const parsed = registrationInputSchema.parse(input);
|
|
98
|
+
return await withWorkstationLock(directory, 'operation', () => enroll(parsed, directory, deps));
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
return enrollmentFailure(error, directory);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export async function enrollLegacyWorkstation(input, directory) {
|
|
105
|
+
await withWorkstationLock(directory, 'operation', () => enroll(input, directory, { runtime: () => inspectRuntime(directory) }));
|
|
106
|
+
}
|