@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.
Files changed (258) hide show
  1. package/README.md +25 -6
  2. package/dist/axom-patch/cli/README.md +129 -12
  3. package/dist/axom-patch/cli/account-limits/generate.py +15 -12
  4. package/dist/axom-patch/cli/account-limits/kimi-proxy.mjs +20 -3
  5. package/dist/axom-patch/cli/claude-account-limits.json +9 -9
  6. package/dist/axom-patch/cli/claude-replay-lifecycle.json +16 -0
  7. package/dist/axom-patch/cli/codex-account-limits.json +11 -11
  8. package/dist/axom-patch/cli/codex-terminal-output.json +16 -0
  9. package/dist/axom-patch/cli/commandcode-account-limits.json +9 -9
  10. package/dist/axom-patch/cli/kimi-quota.mjs +12 -0
  11. package/dist/axom-patch/cli/kimi-subagents.mjs +173 -0
  12. package/dist/axom-patch/cli/nightly-task.md +55 -13
  13. package/dist/axom-patch/cli/probes/claude-replay-lifecycle.mjs +31 -0
  14. package/dist/axom-patch/cli/probes/codex-terminal-output.mjs +33 -0
  15. package/dist/axom-patch/cli/probes/kimi-subagent-subwork.mjs +82 -0
  16. package/dist/axom-patch/cli/probes/zcode-env-account.mjs +81 -0
  17. package/dist/axom-patch/cli/probes/zcode-subagent-subwork.mjs +7 -0
  18. package/dist/axom-patch/cli/qoder/dist/axom-acp-compat.mjs +1687 -0
  19. package/dist/axom-patch/cli/qoder/releases/1.1.75.json +277 -0
  20. package/dist/axom-patch/cli/qoder-background-terminal.md +13 -6
  21. package/dist/axom-patch/cli/qoder-foreground-output.md +14 -8
  22. package/dist/axom-patch/cli/qoder-native-acp.json +227 -0
  23. package/dist/axom-patch/cli/qoder-steering.md +20 -25
  24. package/dist/axom-patch/cli/qoder-subagent-subwork.md +17 -68
  25. package/dist/axom-patch/cli/runtime.json +19 -19
  26. package/dist/axom-patch/cli/zcode/env-account.mjs +13 -0
  27. package/dist/axom-patch/cli/zcode/generate-env-account.py +52 -0
  28. package/dist/axom-patch/cli/zcode-account-limits.json +10 -11
  29. package/dist/axom-patch/cli/zcode-env-account.json +58 -0
  30. package/dist/axom-patch/cli/zcode-models.json +9 -10
  31. package/dist/axom-patch/cli/zcode-subagent-subwork.json +10 -11
  32. package/dist/cli.js +11 -45
  33. package/dist/commands/auth.js +4 -1
  34. package/dist/commands/knowledge.js +47 -0
  35. package/dist/commands/update.js +126 -3
  36. package/dist/commands/workstation.js +101 -164
  37. package/dist/commands/workstationRegistrations.js +116 -0
  38. package/dist/credentialBroker.js +13 -2
  39. package/dist/credentials.js +16 -0
  40. package/dist/install/claude/commands.js +15 -15
  41. package/dist/keepers/terminal/keeper.mjs +1089 -673
  42. package/dist/keepers/terminal/manifest.json +4 -4
  43. package/dist/mcp.js +1 -1
  44. package/dist/native/darwin-x64/axom-quic +0 -0
  45. package/dist/workstation/acp/acpAgents.js +113 -102
  46. package/dist/workstation/acp/acpClient.js +26 -0
  47. package/dist/workstation/acp/builds/install/npm.js +99 -10
  48. package/dist/workstation/acp/initializeResponse.js +6 -1
  49. package/dist/workstation/acp/managedPatches.js +189 -25
  50. package/dist/workstation/acp/managedRuntime.js +314 -0
  51. package/dist/workstation/acp/outbox.js +12 -5
  52. package/dist/workstation/acp/patchAssets.js +40 -0
  53. package/dist/workstation/acp/permission.js +2 -2
  54. package/dist/workstation/acp/processor/checkpoint.js +26 -34
  55. package/dist/workstation/acp/processor/control.js +7 -80
  56. package/dist/workstation/acp/processor/jsonrpc.js +8 -0
  57. package/dist/workstation/acp/processor/runtime.js +5 -81
  58. package/dist/workstation/acp/processor/session.js +54 -82
  59. package/dist/workstation/acp/processor/state.js +7 -13
  60. package/dist/workstation/acp/processor/step.js +3 -20
  61. package/dist/workstation/acp/processor/test/fixtureLog.js +9 -16
  62. package/dist/workstation/acp/processor/transport/local.js +19 -3
  63. package/dist/workstation/acp/processor/updates.js +156 -105
  64. package/dist/workstation/acp/processorSession.js +20 -173
  65. package/dist/workstation/acp/registry/catalog.js +65 -39
  66. package/dist/workstation/acp/registry/launch.js +16 -1
  67. package/dist/workstation/acp/switching.js +0 -3
  68. package/dist/workstation/acp/test/fakeAgent.js +13 -0
  69. package/dist/workstation/acp/test/keeperWorld.js +32 -3
  70. package/dist/workstation/acp/test/raw/sources.js +16 -130
  71. package/dist/workstation/acp/test/realAdapter.js +1 -47
  72. package/dist/workstation/acp/test/scriptAgent.js +11 -3
  73. package/dist/workstation/agentFlow.js +1 -1
  74. package/dist/workstation/agentSessions.js +114 -166
  75. package/dist/workstation/channels/agent.js +9 -1
  76. package/dist/workstation/channels/computer.js +27 -4
  77. package/dist/workstation/channels/management.js +130 -15
  78. package/dist/workstation/channels/url.js +1 -1
  79. package/dist/workstation/config.js +33 -9
  80. package/dist/workstation/connection/encryptedChannel.js +4 -1
  81. package/dist/workstation/control/bindingStore.js +204 -0
  82. package/dist/workstation/control/commands.js +125 -0
  83. package/dist/workstation/control/daemonIdentity.js +143 -0
  84. package/dist/workstation/control/deviceCommands.js +191 -0
  85. package/dist/workstation/control/deviceProtocol.js +68 -0
  86. package/dist/workstation/control/enrollment.js +106 -0
  87. package/dist/workstation/control/inspect.js +71 -0
  88. package/dist/workstation/control/locks.js +110 -0
  89. package/dist/workstation/control/protocol.js +40 -0
  90. package/dist/workstation/control/restartGuard.js +46 -0
  91. package/dist/workstation/control/rootFormat.js +16 -0
  92. package/dist/workstation/control/runtimeClient.js +152 -0
  93. package/dist/workstation/control/runtimeHost.js +235 -0
  94. package/dist/workstation/control/serverIdentity.js +35 -0
  95. package/dist/workstation/control/updateTransition.js +91 -0
  96. package/dist/workstation/daemon.js +266 -264
  97. package/dist/workstation/daemonctl.js +226 -25
  98. package/dist/workstation/desktopControl/audio.js +99 -0
  99. package/dist/workstation/desktopControl/automationDevice.js +219 -0
  100. package/dist/workstation/desktopControl/automationJson.js +74 -0
  101. package/dist/workstation/desktopControl/automationLocalState.js +23 -0
  102. package/dist/workstation/desktopControl/automationProtocol.js +253 -0
  103. package/dist/workstation/desktopControl/automationSchema.generated.js +1 -0
  104. package/dist/workstation/desktopControl/automationSession.js +81 -0
  105. package/dist/workstation/desktopControl/automationTransport.js +138 -0
  106. package/dist/workstation/desktopControl/capabilities.js +93 -0
  107. package/dist/workstation/desktopControl/captureModes.js +9 -0
  108. package/dist/workstation/desktopControl/captureTargetLabel.js +9 -0
  109. package/dist/workstation/desktopControl/deviceAdmission.js +229 -0
  110. package/dist/workstation/desktopControl/deviceOwnership.js +130 -0
  111. package/dist/workstation/desktopControl/deviceSockets.js +246 -0
  112. package/dist/workstation/desktopControl/helperProcess.js +602 -0
  113. package/dist/workstation/desktopControl/hostGrant.js +65 -0
  114. package/dist/workstation/desktopControl/lobby.js +131 -0
  115. package/dist/workstation/desktopControl/macProvider.js +156 -0
  116. package/dist/workstation/desktopControl/media.js +331 -0
  117. package/dist/workstation/desktopControl/nativeDeviceAdmission.js +207 -0
  118. package/dist/workstation/desktopControl/nativeProvider.js +1044 -0
  119. package/dist/workstation/desktopControl/networkEvidence.js +43 -0
  120. package/dist/workstation/desktopControl/overview.js +58 -0
  121. package/dist/workstation/desktopControl/ownershipProtocol.js +31 -0
  122. package/dist/workstation/desktopControl/protocol.js +311 -0
  123. package/dist/workstation/desktopControl/provider.js +1 -0
  124. package/dist/workstation/desktopControl/quicMedia.js +240 -0
  125. package/dist/workstation/desktopControl/rateController.js +226 -0
  126. package/dist/workstation/desktopControl/supervisor.js +1774 -0
  127. package/dist/workstation/desktopControl/transport.js +27 -0
  128. package/dist/workstation/desktopControl/wallpaper.js +98 -0
  129. package/dist/workstation/exec/policy.js +5 -0
  130. package/dist/workstation/files/client.js +10 -1
  131. package/dist/workstation/files/credentials.js +142 -0
  132. package/dist/workstation/files/host.js +9 -2
  133. package/dist/workstation/files/text.js +42 -8
  134. package/dist/workstation/journal.js +5 -2
  135. package/dist/workstation/keepers/acp/client.js +38 -2
  136. package/dist/workstation/keepers/kit/buildVersion.js +35 -11
  137. package/dist/workstation/keepers/kit/bundle.js +21 -17
  138. package/dist/workstation/keepers/kit/client.js +2 -2
  139. package/dist/workstation/keepers/kit/launcher.js +123 -7
  140. package/dist/workstation/keepers/kit/paths.js +2 -1
  141. package/dist/workstation/keepers/legacy.js +16 -2
  142. package/dist/workstation/keepers/policy.js +5 -34
  143. package/dist/workstation/keepers/terminal/client.js +115 -23
  144. package/dist/workstation/keepers/terminal/exec.js +6 -3
  145. package/dist/workstation/keepers/terminal/frames.js +73 -0
  146. package/dist/workstation/keepers/terminal/generations.js +5 -8
  147. package/dist/workstation/keepers/terminal/gridProtocol.js +16 -0
  148. package/dist/workstation/keepers/terminal/input.js +146 -0
  149. package/dist/workstation/keepers/terminal/keeper.js +22 -8
  150. package/dist/workstation/keepers/terminal/main.js +1 -1
  151. package/dist/workstation/keepers/terminal/metadata.js +138 -0
  152. package/dist/workstation/keepers/terminal/protocol.js +4 -28
  153. package/dist/workstation/keepers/terminal/pty.js +1 -330
  154. package/dist/workstation/keepers/terminal/screen.js +246 -0
  155. package/dist/workstation/keepers/terminal/sessions.js +526 -0
  156. package/dist/workstation/managedInstall.js +57 -0
  157. package/dist/workstation/oauth/deviceAuthorization.js +31 -17
  158. package/dist/workstation/protocol/agent.js +2 -15
  159. package/dist/workstation/protocol/management.js +113 -5
  160. package/dist/workstation/protocol/runtimeDiagnostics.js +15 -0
  161. package/dist/workstation/rd/artifact.js +120 -0
  162. package/dist/workstation/rd/control.js +117 -0
  163. package/dist/workstation/rd/display.js +29 -0
  164. package/dist/workstation/rd/host.js +213 -0
  165. package/dist/workstation/rd/migrationReadiness.js +46 -0
  166. package/dist/workstation/rd/provider.js +256 -0
  167. package/dist/workstation/rd/sessions.js +391 -0
  168. package/dist/workstation/rd/state.js +57 -0
  169. package/dist/workstation/rd/update.js +168 -0
  170. package/dist/workstation/rd/upkeep.js +80 -0
  171. package/dist/workstation/register.js +27 -37
  172. package/dist/workstation/registrations/enrollment.js +250 -0
  173. package/dist/workstation/registrations/enrollmentPending.js +361 -0
  174. package/dist/workstation/registrations/migration.js +356 -0
  175. package/dist/workstation/registrations/model.js +34 -0
  176. package/dist/workstation/registrations/removal.js +222 -0
  177. package/dist/workstation/registrations/store.js +737 -0
  178. package/dist/workstation/runtime/desktopBinding.js +20 -0
  179. package/dist/workstation/runtime/deviceDesktop.js +193 -0
  180. package/dist/workstation/runtime/deviceRuntime.js +568 -0
  181. package/dist/workstation/runtime/deviceServices.js +117 -0
  182. package/dist/workstation/runtime/events.js +20 -0
  183. package/dist/workstation/runtime/ownership.js +100 -0
  184. package/dist/workstation/runtime/registrationRuntime.js +575 -0
  185. package/dist/workstation/runtime/remoteDesktopAdapter.js +186 -0
  186. package/dist/workstation/runtime/removalActivity.js +184 -0
  187. package/dist/workstation/service/installer.js +197 -14
  188. package/dist/workstation/service/launchd.js +2 -0
  189. package/dist/workstation/service/systemd.js +1 -0
  190. package/dist/workstation/storageMetrics.js +118 -0
  191. package/dist/workstation/systemMetrics.js +47 -92
  192. package/dist/workstation/toolchain.js +94 -0
  193. package/dist/workstation/tools/browser.js +28 -4
  194. package/dist/workstation/tools/client.js +8 -27
  195. package/dist/workstation/tools/info.js +6 -13
  196. package/dist/workstation/tools/terminalRouter.js +17 -172
  197. package/dist/workstation/tools/validation.js +55 -1
  198. package/dist/workstation/update/archiveEvidence.js +206 -0
  199. package/dist/workstation/update/artifact.js +2 -1
  200. package/dist/workstation/update/coordinator.js +34 -13
  201. package/dist/workstation/update/deviceCompatibility.js +119 -0
  202. package/dist/workstation/update/deviceCoordinator.js +369 -0
  203. package/dist/workstation/update/deviceHealth.js +104 -0
  204. package/dist/workstation/update/deviceHealthProtocol.js +26 -0
  205. package/dist/workstation/update/deviceRecoveryLifecycle.js +46 -0
  206. package/dist/workstation/update/deviceRollback.js +78 -0
  207. package/dist/workstation/update/globalInstall.js +186 -0
  208. package/dist/workstation/update/legacyBridge.js +414 -0
  209. package/dist/workstation/update/legacyOwner.js +39 -0
  210. package/dist/workstation/update/npmInstaller.js +51 -25
  211. package/dist/workstation/update/privateDurability.js +61 -0
  212. package/dist/workstation/update/rdRollback.js +145 -0
  213. package/dist/workstation/update/recoveryPlan.js +124 -0
  214. package/dist/workstation/update/runUpdate.js +10 -3
  215. package/dist/workstation/update/taskRegistry.js +161 -3
  216. package/dist/workstation/update/watchdog.js +29806 -333
  217. package/dist/workstation/update/watchdogProcess.js +72 -0
  218. package/package.json +11 -7
  219. package/skills/axom/SKILL.md +11 -11
  220. package/skills/axom-definition/SKILL.md +3 -3
  221. package/skills/axom-platform/SKILL.md +3 -3
  222. package/dist/axom-patch/cli/probes/qoder-steering.mjs +0 -89
  223. package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +0 -61
  224. package/dist/axom-patch/cli/qoder-steering.json +0 -52
  225. package/dist/axom-patch/cli/qoder-subagent-origin.json +0 -73
  226. package/dist/commands/guide.js +0 -38
  227. package/dist/runtime-deps/qoder-sdk/package-lock.json +0 -1189
  228. package/dist/runtime-deps/qoder-sdk/package.json +0 -8
  229. package/dist/workstation/acp/delegationMcp.js +0 -24
  230. package/dist/workstation/acp/processor/sideChannel.js +0 -80
  231. package/dist/workstation/acp/qoderStartup.js +0 -45
  232. package/dist/workstation/acp/test/raw/qoderCaptures.js +0 -125
  233. package/dist/workstation/qoderAdapter/backgroundProcess.js +0 -118
  234. package/dist/workstation/qoderAdapter/backgroundTerminal.js +0 -193
  235. package/dist/workstation/qoderAdapter/backgroundTerminalFold.js +0 -372
  236. package/dist/workstation/qoderAdapter/callbackContext.js +0 -76
  237. package/dist/workstation/qoderAdapter/completedContinuation.js +0 -139
  238. package/dist/workstation/qoderAdapter/contract.js +0 -78
  239. package/dist/workstation/qoderAdapter/foregroundOutput.js +0 -36
  240. package/dist/workstation/qoderAdapter/history.js +0 -85
  241. package/dist/workstation/qoderAdapter/identityIndex.js +0 -191
  242. package/dist/workstation/qoderAdapter/inputQueue.js +0 -42
  243. package/dist/workstation/qoderAdapter/main.js +0 -95
  244. package/dist/workstation/qoderAdapter/mapper.js +0 -117
  245. package/dist/workstation/qoderAdapter/observerPlugin.js +0 -245
  246. package/dist/workstation/qoderAdapter/observerReceiver.js +0 -187
  247. package/dist/workstation/qoderAdapter/permissions.js +0 -61
  248. package/dist/workstation/qoderAdapter/profile.js +0 -93
  249. package/dist/workstation/qoderAdapter/sdkDriver.js +0 -48
  250. package/dist/workstation/qoderAdapter/sdkRuntime.js +0 -93
  251. package/dist/workstation/qoderAdapter/sdkRuntimeInstall.js +0 -145
  252. package/dist/workstation/qoderAdapter/session.js +0 -319
  253. package/dist/workstation/qoderAdapter/subagentMeta.js +0 -28
  254. package/dist/workstation/qoderAdapter/subwork.js +0 -85
  255. package/dist/workstation/qoderAdapter/taskControl.js +0 -38
  256. package/dist/workstation/qoderAdapter/taskLedger.js +0 -163
  257. package/dist/workstation/qoderAdapter/usage.js +0 -37
  258. /package/dist/workstation/{qoderAdapter/types.js → desktopControl/automationPreparation.js} +0 -0
@@ -11,7 +11,8 @@ import { TaskRegistry } from '../update/taskRegistry.js';
11
11
  import { toFileError } from '../files/errors.js';
12
12
  import { object } from '../tools/validation.js';
13
13
  import { isUnaccountedTool } from '../tools/router.js';
14
- import { requestRootFileExecutors } from '../files/adapters/computerFileFrames.js';
14
+ import { requestRootFileExecutors, fileErrorFrame } from '../files/adapters/computerFileFrames.js';
15
+ import { DeviceEventRouter } from '../runtime/events.js';
15
16
  /**
16
17
  * Consumes computer frames without serializing long-running commands behind one another.
17
18
  * All command failures and post-close sends are contained here because this is called detached
@@ -211,6 +212,20 @@ export async function dispatchComputerFrames(messages, send, execute, tasks = ne
211
212
  };
212
213
  try {
213
214
  for await (const frame of messages) {
215
+ if (durable.admission?.() === false) {
216
+ if (frame.t === 'exec_request') {
217
+ safeSend({ t: 'exec_exit', id: frame.id, exitCode: null, stdout: '', stderr: 'Registration admission is paused', timedOut: false, truncated: false });
218
+ continue;
219
+ }
220
+ if (frame.t === 'tool_request' && frame.method !== 'tools.info') {
221
+ safeSend({ t: 'tool_result', id: frame.id, ok: false, result: {}, error: 'Registration admission is paused' });
222
+ continue;
223
+ }
224
+ if (frame.t === 'file_write_request' || frame.t === 'file_mkdir_request') {
225
+ safeSend(fileErrorFrame(frame, { code: 'workstation_offline', message: 'Registration admission is paused' }));
226
+ continue;
227
+ }
228
+ }
214
229
  if (frame.t.startsWith('desktop_'))
215
230
  desktop?.onFrame(frame);
216
231
  else if (frame.t === 'tool_request')
@@ -279,9 +294,17 @@ export class ComputerChannel {
279
294
  return { closed: channel.closed, close: () => channel.close() };
280
295
  }
281
296
  async dispatch(channel) {
282
- const desktop = new DesktopBridge(resolveBrowserBinding(this.options.browserBinding), frame => channel.send(frame), () => channel.bufferedAmount);
283
- await dispatchComputerFrames(channel.messages, (frame) => channel.send(frame), this.options.runExec ?? ((request, onOutput, signal) => runExec(request, onOutput, signal)), this.tasks, this.options, this.tools, desktop, {
284
- host: this.execs, bufferedAmount: () => channel.bufferedAmount, log: this.options.log,
297
+ const registrationId = this.options.registrationId;
298
+ const events = registrationId ? new DeviceEventRouter((_recipient, event) => channel.send(event.payload), new Set([registrationId])) : undefined;
299
+ const send = (frame) => {
300
+ if (events && registrationId)
301
+ events.publish({ scope: 'registration', registrationId, payload: frame });
302
+ else
303
+ channel.send(frame);
304
+ };
305
+ const desktop = new DesktopBridge(this.options.desktopEnabled === false ? undefined : resolveBrowserBinding(this.options.browserBinding), frame => channel.send(frame), () => channel.bufferedAmount);
306
+ await dispatchComputerFrames(channel.messages, send, this.options.runExec ?? ((request, onOutput, signal) => runExec(request, onOutput, signal)), this.tasks, this.options, this.tools, desktop, {
307
+ host: this.execs, bufferedAmount: () => channel.bufferedAmount, log: this.options.log, admission: this.options.admission,
285
308
  });
286
309
  }
287
310
  }
@@ -1,16 +1,22 @@
1
1
  import { deriveSessionKey, randomBytes } from '../crypto.js';
2
2
  import { connectEncryptedChannel } from '../connection/encryptedChannel.js';
3
3
  import { CLIENT_PACKAGE_NAME, CLIENT_VERSION } from '../version.js';
4
- import { decodeManagementFrame, encodeManagementFrame, MANAGEMENT_WIRE_DRAINING_VERSION, MANAGEMENT_WIRE_GPU_VERSION, MANAGEMENT_WIRE_KEEPERS_VERSION, MANAGEMENT_WIRE_LOAD_AVERAGES_VERSION, MANAGEMENT_WIRE_METRICS_VERSION, MANAGEMENT_WIRE_MIN_VERSION, MANAGEMENT_WIRE_PRESSURE_VERSION, MANAGEMENT_WIRE_VERSION, } from '../protocol/management.js';
4
+ import { decodeManagementFrame, encodeManagementFrame, MANAGEMENT_WIRE_DRAINING_VERSION, MANAGEMENT_WIRE_GPU_VERSION, MANAGEMENT_WIRE_KEEPERS_VERSION, MANAGEMENT_WIRE_LOAD_AVERAGES_VERSION, MANAGEMENT_WIRE_METRICS_VERSION, MANAGEMENT_WIRE_MIN_VERSION, MANAGEMENT_WIRE_PRESSURE_VERSION, MANAGEMENT_WIRE_VERSION, ManagementFrameAdmission, } from '../protocol/management.js';
5
5
  import { bridgesForWire, helloRoster } from '../protocol/common.js';
6
6
  import { AGENT_WIRE_MIN_VERSION, AGENT_WIRE_VERSION } from '../protocol/agent.js';
7
7
  import { osInfo } from '../osinfo.js';
8
8
  import { workstationChannelUrl } from './url.js';
9
+ import { DesktopCapabilityReporter } from '../desktopControl/capabilities.js';
9
10
  const protocols = {
10
11
  MANAGEMENT: { min: MANAGEMENT_WIRE_MIN_VERSION, max: MANAGEMENT_WIRE_VERSION },
11
12
  // COMPUTER v5: durable exec frames (exec_started/attach/status/ack, offsets on exec_output; spec §4.3).
12
- COMPUTER: { min: 1, max: 5 }, AGENT: { min: AGENT_WIRE_MIN_VERSION, max: AGENT_WIRE_VERSION },
13
+ // COMPUTER v6: this client refuses credential storage to the agent file tools (files/credentials.ts); the server sends
14
+ // file frames only to v6 clients.
15
+ COMPUTER: { min: 1, max: 6 }, AGENT: { min: AGENT_WIRE_MIN_VERSION, max: AGENT_WIRE_VERSION },
13
16
  };
17
+ function toolchainField(toolchain) {
18
+ return toolchain ? { toolchain: { python: toolchain.python, node: toolchain.node } } : {};
19
+ }
14
20
  /** Exported so tests can assert the wire frame's `clientPackage` without opening a socket. */
15
21
  export function buildHelloFrame(options, clientNonce) {
16
22
  return {
@@ -91,7 +97,16 @@ export function metricsForManagementWire(metrics, serverManagementWire) {
91
97
  *
92
98
  * Exported for tests; production's only caller is `routeMessages`.
93
99
  */
94
- export function dispatchUpdateCommand(channel, command, onUpdate, serverManagementWire) {
100
+ export function dispatchUpdateCommand(channel, command, onUpdate, serverManagementWire, requiredRemoteUpdatePolicy) {
101
+ if (requiredRemoteUpdatePolicy && command.remoteForcePolicy !== requiredRemoteUpdatePolicy) {
102
+ if (channel.state === 'OPEN') {
103
+ try {
104
+ channel.send(toClientUpdateStatusFrame(command, { phase: 'FAILED', remoteForcePolicy: 'drain-only', error: 'upgrade_required: the server must support authenticated device update readiness' }, serverManagementWire));
105
+ }
106
+ catch { }
107
+ }
108
+ return Promise.resolve();
109
+ }
95
110
  return onUpdate(command)
96
111
  .then((status) => {
97
112
  if (channel.state !== 'OPEN')
@@ -177,9 +192,43 @@ export class ManagementChannel {
177
192
  serverManagementWire;
178
193
  /** The open channel of the current connection, for frames this client sends unprompted ({@link pushRoster}). */
179
194
  channel;
195
+ desktopReporter;
196
+ connectionAttempt = 0;
197
+ generation;
180
198
  constructor(options) {
181
199
  this.options = options;
182
200
  }
201
+ async receiveDeviceFact(event) {
202
+ const generation = this.generation;
203
+ const channel = this.channel;
204
+ if (event.scope !== 'device' || !generation || channel?.state !== 'OPEN')
205
+ return;
206
+ if (event.kind === 'tool-state' && this.options.onExternalAgentsScan) {
207
+ const bridges = await this.options.onExternalAgentsScan();
208
+ if (this.generation === generation && this.channel === channel)
209
+ this.pushRoster(bridges);
210
+ }
211
+ else if (event.kind === 'desktop-capability')
212
+ this.refreshDesktopCapabilities(generation);
213
+ }
214
+ refreshDesktopCapabilities(generation) {
215
+ if (generation === undefined || generation === this.generation)
216
+ this.desktopReporter?.refresh();
217
+ }
218
+ /** T10 reports only on its authenticated current management socket. */
219
+ sendDesktopState(generation, frame) {
220
+ if (frame.t === 'desktop_ownership_result' && (this.serverManagementWire ?? 0) < 10)
221
+ return false;
222
+ if (generation !== this.generation || (this.serverManagementWire ?? 0) < 9 || this.channel?.state !== 'OPEN')
223
+ return false;
224
+ try {
225
+ this.channel.send(frame);
226
+ return true;
227
+ }
228
+ catch {
229
+ return false;
230
+ }
231
+ }
183
232
  /**
184
233
  * Tell the server about a roster that changed without a scan asking (adapter builds moved by Axom,
185
234
  * spec §6.1): the same `external_agents_scan_result` frame a scan answers with, under a client-made
@@ -199,14 +248,22 @@ export class ManagementChannel {
199
248
  }
200
249
  }
201
250
  async connect(signal) {
251
+ const attempt = ++this.connectionAttempt;
252
+ this.desktopReporter?.dispose();
253
+ this.desktopReporter = undefined;
254
+ this.generation = undefined;
255
+ this.channel = undefined;
202
256
  this.serverManagementWire = undefined;
203
257
  const nonce = randomBytes(16);
204
258
  let initialHeartbeat = true;
259
+ const admission = new ManagementFrameAdmission(() => attempt === this.connectionAttempt && !signal?.aborted);
205
260
  const channel = await connectEncryptedChannel({
206
261
  url: workstationChannelUrl(this.options.server, 'management', this.options.allowInsecureWs),
207
262
  signal,
208
263
  encode: encodeManagementFrame,
209
264
  decode: decodeManagementFrame,
265
+ decodeBytes: decodeManagementFrame,
266
+ admitFrame: frame => admission.admit(frame),
210
267
  heartbeat: (seq, sentAtEpochMs) => this.heartbeat(seq, sentAtEpochMs),
211
268
  initialFrame: () => {
212
269
  initialHeartbeat = false;
@@ -226,16 +283,41 @@ export class ManagementChannel {
226
283
  if (initialHeartbeat)
227
284
  throw new Error('Management proof heartbeat was not sent');
228
285
  const ready = await this.nextReady(channel);
286
+ if (attempt !== this.connectionAttempt || channel.state !== 'OPEN') {
287
+ channel.close();
288
+ throw new Error('Management connection superseded');
289
+ }
229
290
  this.serverManagementWire = ready.protocols.MANAGEMENT;
291
+ this.generation = ready.generation;
292
+ this.channel = channel;
293
+ const reporter = new DesktopCapabilityReporter({ selectedWire: this.serverManagementWire, generation: ready.generation,
294
+ provider: this.options.desktopProvider, send: report => {
295
+ if (attempt === this.connectionAttempt && this.channel === channel && channel.state === 'OPEN')
296
+ channel.send({ t: 'workstation_capabilities', ...report });
297
+ } });
298
+ this.desktopReporter = reporter;
299
+ // Starts before the existing awaited update hook; heartbeat and reporting never await discovery.
300
+ reporter.refresh();
301
+ const retire = () => {
302
+ admission.dispose();
303
+ reporter.dispose();
304
+ try {
305
+ this.options.onClosed?.(ready.generation);
306
+ }
307
+ catch { /* transport retirement must complete */ }
308
+ if (this.channel === channel) {
309
+ this.channel = undefined;
310
+ this.generation = undefined;
311
+ this.desktopReporter = undefined;
312
+ this.serverManagementWire = undefined;
313
+ }
314
+ };
315
+ void channel.closed.then(retire, retire);
230
316
  // A readiness record is evidence from this encrypted, proof-of-key management generation — it
231
317
  // is never driven by the local browser/device-authorization flow.
232
- await this.options.onReady?.({ generation: ready.generation, release: ready.release });
318
+ await this.options.onReady?.({ generation: ready.generation, selectedWire: ready.protocols.MANAGEMENT, release: ready.release });
233
319
  const pending = new Map();
234
- this.channel = channel;
235
- void channel.closed.then(() => { if (this.channel === channel)
236
- this.channel = undefined; }, () => { if (this.channel === channel)
237
- this.channel = undefined; });
238
- void this.routeMessages(channel, pending);
320
+ void this.routeMessages(channel, pending, ready.generation);
239
321
  return {
240
322
  generation: ready.generation,
241
323
  closed: channel.closed,
@@ -255,6 +337,11 @@ export class ManagementChannel {
255
337
  };
256
338
  }
257
339
  async heartbeat(seq, sentAtEpochMs) {
340
+ const selectedWire = this.serverManagementWire;
341
+ // Availability may change without restarting the CLI (Desktop sharing is process-owned).
342
+ // Discovery stays bounded and never delays the heartbeat or creates a capture session.
343
+ this.desktopReporter?.refresh();
344
+ const revision = this.desktopReporter?.revision;
258
345
  const snapshot = await this.options.snapshot();
259
346
  return {
260
347
  t: 'heartbeat', seq, sentAtEpochMs, clientVersion: CLIENT_VERSION,
@@ -266,11 +353,13 @@ export class ManagementChannel {
266
353
  },
267
354
  activeShellTasks: snapshot.activeShellTasks, activeAgentTasks: snapshot.activeAgentTasks,
268
355
  protocols, os: osInfo(),
269
- ...(this.options.metrics && (this.serverManagementWire ?? 0) >= MANAGEMENT_WIRE_METRICS_VERSION
270
- ? { metrics: metricsForManagementWire(this.options.metrics(), this.serverManagementWire) }
356
+ ...toolchainField(this.options.toolchain?.() ?? null),
357
+ ...(this.options.metrics && (selectedWire ?? 0) >= MANAGEMENT_WIRE_METRICS_VERSION
358
+ ? { metrics: metricsForManagementWire(this.options.metrics(), selectedWire) }
271
359
  : {}),
272
- ...(snapshot.update ? { update: statusForManagementWire(snapshot.update, this.serverManagementWire) } : {}),
273
- ...await keepersForManagementWire(this.options.keepers, this.serverManagementWire, this.options.keepersCapMs),
360
+ ...(snapshot.update ? { update: statusForManagementWire(snapshot.update, selectedWire) } : {}),
361
+ ...await keepersForManagementWire(this.options.keepers, selectedWire, this.options.keepersCapMs),
362
+ ...((selectedWire ?? 0) >= 9 && revision !== undefined ? { capabilitiesRevision: revision } : {}),
274
363
  };
275
364
  }
276
365
  async nextReady(channel) {
@@ -279,9 +368,35 @@ export class ManagementChannel {
279
368
  return frame;
280
369
  throw new Error('Management channel closed before ready');
281
370
  }
282
- async routeMessages(channel, pending) {
371
+ async routeMessages(channel, pending, generation) {
283
372
  try {
284
373
  for await (const frame of channel.messages) {
374
+ if (channel !== this.channel)
375
+ break;
376
+ if (frame.t === 'desktop_automation_prepare') {
377
+ if (frame.deviceGeneration === this.generation && this.generation && this.options.onAutomationPrepare)
378
+ void this.options.onAutomationPrepare(frame, this.generation).catch(() => { });
379
+ continue;
380
+ }
381
+ if (frame.t === 'workstation_capabilities_request') {
382
+ if ((this.serverManagementWire ?? 0) >= 9 && frame.generation === this.generation)
383
+ this.desktopReporter?.refresh();
384
+ continue;
385
+ }
386
+ if ((frame.t === 'desktop_background' || frame.t === 'desktop_lobby' || frame.t === 'desktop_prepare' || frame.t === 'desktop_cancel' || frame.t === 'desktop_ownership') && (this.serverManagementWire ?? 0) >= 9) {
387
+ if (frame.t === 'desktop_ownership' && (this.serverManagementWire ?? 0) < 10)
388
+ continue;
389
+ if (frame.t !== 'desktop_cancel' && frame.deviceGeneration !== this.generation)
390
+ continue;
391
+ if (this.options.onDesktopCommand && this.generation) {
392
+ // Capture the authenticated generation now: later callbacks cannot borrow a reconnect.
393
+ try {
394
+ void this.options.onDesktopCommand(frame, this.generation).catch(() => undefined);
395
+ }
396
+ catch { /* bounded desktop admission failure */ }
397
+ }
398
+ continue;
399
+ }
285
400
  if (frame.t === 'channel_ticket') {
286
401
  const queue = pending.get(frame.channel);
287
402
  queue?.shift()?.resolve(frame.ticket);
@@ -290,7 +405,7 @@ export class ManagementChannel {
290
405
  if (frame.t === 'client_update' && this.options.onUpdate) {
291
406
  // Deliberately not awaited — see dispatchUpdateCommand for why inline handling both
292
407
  // stalled this loop for the whole update and threw the final status away.
293
- void dispatchUpdateCommand(channel, frame, this.options.onUpdate, this.serverManagementWire);
408
+ void dispatchUpdateCommand(channel, frame, command => this.options.onUpdate(command, generation), this.serverManagementWire, this.options.requiredRemoteUpdatePolicy);
294
409
  continue;
295
410
  }
296
411
  if (frame.t === 'keeper_control' && this.options.onKeeperControl) {
@@ -11,7 +11,7 @@ function ipv4Octets(hostname) {
11
11
  return octets.every((octet) => octet <= 255) ? octets : null;
12
12
  }
13
13
  /** Deliberately classifies literal names/IPs only: DNS answers must never downgrade transport security. */
14
- function isTrustedPlaintextHost(rawHostname) {
14
+ export function isTrustedPlaintextHost(rawHostname) {
15
15
  const hostname = rawHostname.toLowerCase().replace(/^\[|\]$/g, '');
16
16
  if (TRUSTED_PLAINTEXT_NAMES.has(hostname) || hostname.endsWith('.localhost'))
17
17
  return true;
@@ -1,20 +1,34 @@
1
+ import { loadManagedCliInstallSync, npmExecutionContext } from './managedInstall.js';
1
2
  import { resolveBrowserBinding } from './desktop/binding.js';
2
3
  import { promises as fs } from 'node:fs';
3
4
  import os from 'node:os';
4
5
  import path from 'node:path';
5
6
  import { fileURLToPath } from 'node:url';
6
7
  import yaml from 'js-yaml';
8
+ import { z } from 'zod';
9
+ import { withWorkstationLock } from './control/locks.js';
10
+ import { assertLegacyRoot } from './control/rootFormat.js';
11
+ const localPath = z.string().refine(p => path.isAbsolute(p) && !p.includes('\0'));
12
+ const nativeConfig = z.strictObject({ kind: z.literal('linux-native'), helperPath: localPath, runtimeDir: localPath });
7
13
  export function updateServiceContext(configDir = clientDir(), overrides = {}) {
8
14
  // config.ts lives in dist/workstation/ after packaging, so two levels up is the installed package root.
9
15
  const packageRoot = overrides.packageRoot ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
10
- const nodePath = overrides.nodePath ?? process.execPath;
16
+ const managed = loadManagedCliInstallSync(packageRoot);
17
+ const nodePath = managed?.nodePath ?? overrides.nodePath ?? process.execPath;
18
+ const npmPrefix = managed?.prefix ?? overrides.npmPrefix ?? process.env.AXOM_WORKSTATION_NPM_PREFIX ?? path.resolve(packageRoot, '../../../..');
19
+ // A previous sudo npm command can leave the interactive user's cache root-owned. Updates and
20
+ // their detached rollback watchdog share a cache owned by this workstation service instead.
21
+ const env = { ...npmExecutionContext({ prefix: npmPrefix, nodePath }, process.env), NPM_CONFIG_CACHE: path.join(configDir, 'update/npm-cache') };
22
+ delete env.npm_config_cache;
11
23
  return {
12
24
  configDir,
25
+ ...(managed ? { managed } : {}),
26
+ env,
13
27
  platform: overrides.platform ?? (process.platform === 'darwin' ? 'darwin' : 'linux'),
14
28
  nodePath,
15
- npmPath: overrides.npmPath ?? process.env.AXOM_WORKSTATION_NPM_PATH ?? path.join(path.dirname(nodePath), 'npm'),
29
+ npmPath: managed?.npmPath ?? overrides.npmPath ?? process.env.AXOM_WORKSTATION_NPM_PATH ?? path.join(path.dirname(nodePath), 'npm'),
16
30
  // packageRoot is under lib/node_modules/@platless; climb to npm's global prefix.
17
- npmPrefix: overrides.npmPrefix ?? process.env.AXOM_WORKSTATION_NPM_PREFIX ?? path.resolve(packageRoot, '../../../..'),
31
+ npmPrefix,
18
32
  packageRoot,
19
33
  };
20
34
  }
@@ -25,8 +39,11 @@ export function clientDir() {
25
39
  return process.env.AXOM_WORKSTATION_CONFIG_DIR ?? path.join(os.homedir(), '.axom', 'workstation');
26
40
  }
27
41
  export async function saveClientConfig(dir, cfg) {
28
- await fs.mkdir(dir, { recursive: true });
29
- await fs.writeFile(path.join(dir, 'config.yml'), yaml.dump(cfg), { mode: 0o600 });
42
+ await withWorkstationLock(dir, 'operation', async () => {
43
+ await assertLegacyRoot(dir);
44
+ await fs.mkdir(dir, { recursive: true });
45
+ await fs.writeFile(path.join(dir, 'config.yml'), yaml.dump(cfg), { mode: 0o600 });
46
+ });
30
47
  }
31
48
  export async function loadClientConfig(dir) {
32
49
  const raw = await fs.readFile(path.join(dir, 'config.yml'), 'utf8');
@@ -35,12 +52,19 @@ export async function loadClientConfig(dir) {
35
52
  throw new Error('Invalid workstation config: server and workstationId are required; run axom auth again');
36
53
  }
37
54
  const browserBinding = parsed.browserBinding ? resolveBrowserBinding(parsed.browserBinding, {}) : undefined;
38
- return { ...(browserBinding ? { browserBinding } : {}), server: parsed.server, workstationId: parsed.workstationId, ...(Array.isArray(parsed.workspaces) ? { workspaces: parsed.workspaces } : {}) };
55
+ const desktopProvider = parsed.desktopProvider ? nativeConfig.parse({ ...parsed.desktopProvider,
56
+ helperPath: parsed.desktopProvider.helperPath === '/usr/local/bin/axom-desktop-helper' ? '/usr/local/bin/axom-desktop-keeper' : parsed.desktopProvider.helperPath,
57
+ }) : process.env.AXOM_DESKTOP_NATIVE_PROVIDER === '1' ?
58
+ nativeConfig.parse({ kind: 'linux-native', helperPath: '/usr/local/bin/axom-desktop-keeper', runtimeDir: '/run/axom-desktop' }) : undefined;
59
+ return { ...(browserBinding ? { browserBinding } : {}), ...(desktopProvider ? { desktopProvider } : {}), server: parsed.server, workstationId: parsed.workstationId, ...(typeof parsed.ownerUserId === 'string' && parsed.ownerUserId ? { ownerUserId: parsed.ownerUserId } : {}), ...(Array.isArray(parsed.workspaces) ? { workspaces: parsed.workspaces } : {}) };
39
60
  }
40
61
  export async function writeKeys(dir, clientKeyB64, serverPubB64) {
41
- await fs.mkdir(dir, { recursive: true });
42
- await fs.writeFile(path.join(dir, 'client.key'), clientKeyB64, { mode: 0o600 });
43
- await fs.writeFile(path.join(dir, 'server.pub'), serverPubB64);
62
+ await withWorkstationLock(dir, 'operation', async () => {
63
+ await assertLegacyRoot(dir);
64
+ await fs.mkdir(dir, { recursive: true });
65
+ await fs.writeFile(path.join(dir, 'client.key'), clientKeyB64, { mode: 0o600 });
66
+ await fs.writeFile(path.join(dir, 'server.pub'), serverPubB64);
67
+ });
44
68
  }
45
69
  export async function readKeys(dir) {
46
70
  const clientKey = Buffer.from((await fs.readFile(path.join(dir, 'client.key'), 'utf8')).trim(), 'base64');
@@ -279,7 +279,10 @@ export function connectEncryptedChannel(options) {
279
279
  if (!isBinary)
280
280
  return;
281
281
  try {
282
- const frame = options.decode(crypto.open(sessionKey, bytes).toString());
282
+ const plaintext = crypto.open(sessionKey, bytes);
283
+ const frame = options.decodeBytes ? options.decodeBytes(plaintext) : options.decode(plaintext.toString());
284
+ if (options.admitFrame && !options.admitFrame(frame))
285
+ return;
283
286
  const acknowledged = [...outstandingAcks].some((sequence) => options.isHeartbeatAck(frame, sequence));
284
287
  // A correctly encrypted and decoded frame proves the peer is still reachable. Heartbeat
285
288
  // acknowledgements are one such frame, but computer streaming/agent events may legitimately
@@ -0,0 +1,204 @@
1
+ import { assertLockOwnership } from './locks.js';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { promises as fs } from 'node:fs';
4
+ import path from 'node:path';
5
+ import yaml from 'js-yaml';
6
+ import { loadClientConfig } from '../config.js';
7
+ import { privateDirectory } from '../keepers/kit/paths.js';
8
+ import { bindingSchema, operationRecordSchema, LocalControlError } from './protocol.js';
9
+ import { normalizeWorkstationServer } from './serverIdentity.js';
10
+ import { assertLegacyRoot } from './rootFormat.js';
11
+ const IDENTITY_FILES = ['client.key', 'server.pub', 'config.yml'];
12
+ const missing = (error) => error.code === 'ENOENT';
13
+ async function readPrivateFile(file) {
14
+ const stat = await fs.lstat(file);
15
+ if (!stat.isFile() || stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid()) || stat.size > 1_048_576)
16
+ throw new Error('Unsafe workstation identity file');
17
+ return fs.readFile(file, 'utf8');
18
+ }
19
+ async function exists(file) {
20
+ try {
21
+ await fs.lstat(file);
22
+ return true;
23
+ }
24
+ catch (error) {
25
+ if (missing(error))
26
+ return false;
27
+ throw error;
28
+ }
29
+ }
30
+ async function atomicWrite(file, value) {
31
+ assertLockOwnership();
32
+ const temporary = `${file}.${randomUUID()}.tmp`;
33
+ try {
34
+ const handle = await fs.open(temporary, 'wx', 0o600);
35
+ try {
36
+ await handle.writeFile(value);
37
+ await handle.sync();
38
+ }
39
+ finally {
40
+ await handle.close();
41
+ }
42
+ await fs.rename(temporary, file);
43
+ }
44
+ finally {
45
+ await fs.rm(temporary, { force: true });
46
+ }
47
+ }
48
+ async function syncDirectory(directory) {
49
+ const handle = await fs.open(directory, 'r');
50
+ try {
51
+ await handle.sync();
52
+ }
53
+ finally {
54
+ await handle.close();
55
+ }
56
+ }
57
+ function validateKeys(keys) {
58
+ for (const value of [keys.clientKey, keys.serverPub]) {
59
+ if (!/^[A-Za-z0-9+/]{43}=$/.test(value) || Buffer.from(value, 'base64').length !== 32 || Buffer.from(value, 'base64').toString('base64') !== value) {
60
+ throw new LocalControlError('CORRUPT_IDENTITY', 'Workstation connection keys are invalid');
61
+ }
62
+ }
63
+ }
64
+ function bindingOf(config) {
65
+ return bindingSchema.parse({ server: normalizeWorkstationServer(config.server), workstationId: config.workstationId, ...(config.ownerUserId ? { ownerUserId: config.ownerUserId } : {}) });
66
+ }
67
+ /** Caller holds the shared operation lock for every mutating method. */
68
+ export class BindingStore {
69
+ directory;
70
+ pending;
71
+ constructor(directory) {
72
+ this.directory = directory;
73
+ this.pending = path.join(directory, 'identity.pending');
74
+ }
75
+ async inspect() {
76
+ const pending = await exists(this.pending);
77
+ const present = await Promise.all(IDENTITY_FILES.map(file => exists(path.join(this.directory, file))));
78
+ if (!present.some(Boolean))
79
+ return { binding: null, revision: 'unregistered', identityState: pending ? 'incomplete' : 'absent' };
80
+ let readableBinding = null;
81
+ let configDigest = 'corrupt';
82
+ try {
83
+ configDigest = createHash('sha256').update(await readPrivateFile(path.join(this.directory, 'config.yml'))).digest('hex');
84
+ await readPrivateFile(path.join(this.directory, 'config.yml'));
85
+ const binding = bindingOf(await loadClientConfig(this.directory));
86
+ readableBinding = binding;
87
+ const keys = { clientKey: (await readPrivateFile(path.join(this.directory, 'client.key'))).trim(), serverPub: (await readPrivateFile(path.join(this.directory, 'server.pub'))).trim() };
88
+ validateKeys(keys);
89
+ const revision = createHash('sha256').update(JSON.stringify(binding)).update('\0').update(keys.clientKey).update('\0').update(keys.serverPub).digest('hex');
90
+ return { binding, revision, identityState: pending ? 'incomplete' : 'ready' };
91
+ }
92
+ catch {
93
+ return { binding: readableBinding, revision: configDigest, identityState: pending ? 'incomplete' : 'corrupt' };
94
+ }
95
+ }
96
+ async history() {
97
+ try {
98
+ const data = JSON.parse(await readPrivateFile(path.join(this.directory, 'binding-history.json')));
99
+ if (!Array.isArray(data))
100
+ throw new Error('Invalid binding history');
101
+ return data.map(value => bindingSchema.parse(value));
102
+ }
103
+ catch (error) {
104
+ if (missing(error))
105
+ return [];
106
+ throw new LocalControlError('CORRUPT_IDENTITY', 'Workstation binding history is unreadable');
107
+ }
108
+ }
109
+ async recover() {
110
+ await assertLegacyRoot(this.directory);
111
+ if (!await exists(this.pending))
112
+ return;
113
+ const stat = await fs.lstat(this.pending);
114
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid()))
115
+ throw new LocalControlError('CORRUPT_IDENTITY', 'Unsafe pending workstation identity');
116
+ let marker;
117
+ try {
118
+ marker = await readPrivateFile(path.join(this.pending, 'ready'));
119
+ }
120
+ catch {
121
+ throw new LocalControlError('OUTCOME_UNKNOWN', 'Registration needs verification: incomplete local transaction');
122
+ }
123
+ if (marker === 'clear') {
124
+ await this.finishClear();
125
+ return;
126
+ }
127
+ if (marker !== '1')
128
+ throw new LocalControlError('CORRUPT_IDENTITY', 'Unknown workstation identity transaction');
129
+ const configText = await readPrivateFile(path.join(this.pending, 'config.yml'));
130
+ const config = await loadClientConfig(this.pending);
131
+ bindingOf(config);
132
+ const keys = { clientKey: (await readPrivateFile(path.join(this.pending, 'client.key'))).trim(), serverPub: (await readPrivateFile(path.join(this.pending, 'server.pub'))).trim() };
133
+ validateKeys(keys);
134
+ const operation = operationRecordSchema.parse(JSON.parse(await readPrivateFile(path.join(this.pending, 'operation.json'))));
135
+ let history;
136
+ if (await exists(path.join(this.pending, 'binding-history.json'))) {
137
+ const staged = JSON.parse(await readPrivateFile(path.join(this.pending, 'binding-history.json')));
138
+ if (!Array.isArray(staged))
139
+ throw new LocalControlError('CORRUPT_IDENTITY', 'Invalid staged binding history');
140
+ history = staged.map(value => bindingSchema.parse(value));
141
+ }
142
+ // All staging is validated before any existing identity file changes.
143
+ for (const [name, value] of [['client.key', keys.clientKey], ['server.pub', keys.serverPub], ['config.yml', configText]])
144
+ await atomicWrite(path.join(this.directory, name), value);
145
+ if (history)
146
+ await atomicWrite(path.join(this.directory, 'binding-history.json'), JSON.stringify(history));
147
+ await atomicWrite(path.join(this.directory, 'operation.json'), JSON.stringify(operation));
148
+ await syncDirectory(this.directory);
149
+ await fs.rm(this.pending, { recursive: true });
150
+ await syncDirectory(this.directory);
151
+ }
152
+ async commit(config, keys, operation, repair = false) {
153
+ await assertLegacyRoot(this.directory);
154
+ const binding = bindingOf(config);
155
+ validateKeys(keys);
156
+ const safeOperation = operationRecordSchema.parse(operation);
157
+ await privateDirectory(this.directory);
158
+ await this.recover();
159
+ const current = await this.inspect();
160
+ if (current.identityState === 'corrupt' && !(repair && current.binding && (!current.binding.ownerUserId || current.binding.ownerUserId === binding.ownerUserId) && current.binding.server === binding.server && current.binding.workstationId === binding.workstationId))
161
+ throw new LocalControlError('CORRUPT_IDENTITY', 'Existing workstation identity needs repair');
162
+ const history = await this.history();
163
+ for (const previous of [current.binding, binding])
164
+ if (previous) {
165
+ const i = history.findIndex(item => item.server === previous.server && item.ownerUserId === previous.ownerUserId);
166
+ if (i >= 0)
167
+ history.splice(i, 1);
168
+ history.push(previous);
169
+ }
170
+ await fs.mkdir(this.pending, { mode: 0o700 });
171
+ for (const [name, value] of Object.entries({
172
+ 'config.yml': yaml.dump({ ...config, server: binding.server }), 'client.key': keys.clientKey, 'server.pub': keys.serverPub,
173
+ 'operation.json': JSON.stringify(safeOperation), 'binding-history.json': JSON.stringify(history),
174
+ }))
175
+ await atomicWrite(path.join(this.pending, name), value);
176
+ await atomicWrite(path.join(this.pending, 'ready'), '1');
177
+ await syncDirectory(this.pending);
178
+ await syncDirectory(this.directory);
179
+ await this.recover();
180
+ }
181
+ async clear(expectedRevision) {
182
+ await assertLegacyRoot(this.directory);
183
+ await this.recover();
184
+ const current = await this.inspect();
185
+ if (current.revision !== expectedRevision || current.identityState !== 'ready')
186
+ throw new LocalControlError('BINDING_CONFLICT', 'Workstation identity changed; check it again');
187
+ await fs.mkdir(this.pending, { mode: 0o700 });
188
+ await atomicWrite(path.join(this.pending, 'clear.json'), JSON.stringify(current.binding));
189
+ await atomicWrite(path.join(this.pending, 'ready'), 'clear');
190
+ await syncDirectory(this.pending);
191
+ await syncDirectory(this.directory);
192
+ await this.finishClear();
193
+ }
194
+ async finishClear() {
195
+ const removed = bindingSchema.parse(JSON.parse(await readPrivateFile(path.join(this.pending, 'clear.json'))));
196
+ const history = (await this.history()).filter(item => item.server !== removed.server || item.workstationId !== removed.workstationId);
197
+ await atomicWrite(path.join(this.directory, 'binding-history.json'), JSON.stringify(history));
198
+ for (const name of [...IDENTITY_FILES, 'operation.json'])
199
+ await fs.rm(path.join(this.directory, name), { force: true });
200
+ await syncDirectory(this.directory);
201
+ await fs.rm(this.pending, { recursive: true });
202
+ await syncDirectory(this.directory);
203
+ }
204
+ }