@platless/axom-cli 0.6.36 → 0.6.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/README.md +7 -1
  2. package/dist/axom-patch/cli/README.md +31 -2
  3. package/dist/axom-patch/cli/account-limits/generate.py +2 -2
  4. package/dist/axom-patch/cli/claude-replay-lifecycle.json +16 -0
  5. package/dist/axom-patch/cli/codex-terminal-output.json +2 -2
  6. package/dist/axom-patch/cli/probes/claude-replay-lifecycle.mjs +31 -0
  7. package/dist/axom-patch/cli/probes/codex-terminal-output.mjs +1 -1
  8. package/dist/axom-patch/cli/probes/zcode-env-account.mjs +81 -0
  9. package/dist/axom-patch/cli/probes/zcode-subagent-subwork.mjs +7 -0
  10. package/dist/axom-patch/cli/qoder/dist/axom-acp-compat.mjs +137 -41
  11. package/dist/axom-patch/cli/qoder/releases/1.1.75.json +5 -88
  12. package/dist/axom-patch/cli/qoder-native-acp.json +23 -23
  13. package/dist/axom-patch/cli/runtime.json +3 -1
  14. package/dist/axom-patch/cli/zcode/env-account.mjs +13 -0
  15. package/dist/axom-patch/cli/zcode/generate-env-account.py +52 -0
  16. package/dist/axom-patch/cli/zcode-account-limits.json +7 -7
  17. package/dist/axom-patch/cli/zcode-env-account.json +58 -0
  18. package/dist/axom-patch/cli/zcode-models.json +9 -9
  19. package/dist/axom-patch/cli/zcode-subagent-subwork.json +10 -10
  20. package/dist/cli.js +9 -11
  21. package/dist/commands/auth.js +4 -1
  22. package/dist/commands/update.js +68 -7
  23. package/dist/commands/workstation.js +94 -223
  24. package/dist/commands/workstationRegistrations.js +116 -0
  25. package/dist/credentialBroker.js +13 -2
  26. package/dist/credentials.js +16 -0
  27. package/dist/install/claude/commands.js +2 -2
  28. package/dist/keepers/terminal/keeper.mjs +1089 -673
  29. package/dist/keepers/terminal/manifest.json +4 -4
  30. package/dist/native/darwin-x64/axom-quic +0 -0
  31. package/dist/workstation/acp/acpAgents.js +11 -4
  32. package/dist/workstation/acp/outbox.js +12 -5
  33. package/dist/workstation/acp/permission.js +2 -2
  34. package/dist/workstation/acp/processor/checkpoint.js +4 -3
  35. package/dist/workstation/acp/processor/control.js +2 -2
  36. package/dist/workstation/acp/processor/jsonrpc.js +8 -0
  37. package/dist/workstation/acp/processor/session.js +3 -2
  38. package/dist/workstation/acp/processor/state.js +1 -0
  39. package/dist/workstation/acp/processor/transport/local.js +19 -3
  40. package/dist/workstation/acp/processor/updates.js +25 -0
  41. package/dist/workstation/acp/processorSession.js +12 -1
  42. package/dist/workstation/acp/registry/catalog.js +6 -6
  43. package/dist/workstation/acp/test/keeperWorld.js +4 -3
  44. package/dist/workstation/agentSessions.js +76 -23
  45. package/dist/workstation/channels/agent.js +9 -1
  46. package/dist/workstation/channels/computer.js +27 -4
  47. package/dist/workstation/channels/management.js +36 -5
  48. package/dist/workstation/config.js +18 -6
  49. package/dist/workstation/control/bindingStore.js +4 -0
  50. package/dist/workstation/control/commands.js +1 -0
  51. package/dist/workstation/control/daemonIdentity.js +90 -0
  52. package/dist/workstation/control/deviceCommands.js +191 -0
  53. package/dist/workstation/control/deviceProtocol.js +68 -0
  54. package/dist/workstation/control/locks.js +51 -4
  55. package/dist/workstation/control/rootFormat.js +16 -0
  56. package/dist/workstation/control/runtimeClient.js +60 -5
  57. package/dist/workstation/control/runtimeHost.js +96 -9
  58. package/dist/workstation/control/serverIdentity.js +2 -2
  59. package/dist/workstation/control/updateTransition.js +91 -0
  60. package/dist/workstation/daemon.js +262 -360
  61. package/dist/workstation/daemonctl.js +150 -12
  62. package/dist/workstation/desktopControl/automationDevice.js +219 -0
  63. package/dist/workstation/desktopControl/automationJson.js +74 -0
  64. package/dist/workstation/desktopControl/automationLocalState.js +23 -0
  65. package/dist/workstation/desktopControl/automationPreparation.js +1 -0
  66. package/dist/workstation/desktopControl/automationProtocol.js +253 -0
  67. package/dist/workstation/desktopControl/automationSchema.generated.js +1 -0
  68. package/dist/workstation/desktopControl/automationSession.js +81 -0
  69. package/dist/workstation/desktopControl/automationTransport.js +138 -0
  70. package/dist/workstation/desktopControl/capabilities.js +2 -1
  71. package/dist/workstation/desktopControl/deviceAdmission.js +229 -0
  72. package/dist/workstation/desktopControl/deviceOwnership.js +130 -0
  73. package/dist/workstation/desktopControl/deviceSockets.js +2 -2
  74. package/dist/workstation/desktopControl/helperProcess.js +250 -1
  75. package/dist/workstation/desktopControl/hostGrant.js +65 -0
  76. package/dist/workstation/desktopControl/lobby.js +4 -2
  77. package/dist/workstation/desktopControl/macProvider.js +41 -40
  78. package/dist/workstation/desktopControl/nativeDeviceAdmission.js +207 -0
  79. package/dist/workstation/desktopControl/nativeProvider.js +31 -5
  80. package/dist/workstation/desktopControl/ownershipProtocol.js +31 -0
  81. package/dist/workstation/desktopControl/protocol.js +15 -6
  82. package/dist/workstation/desktopControl/supervisor.js +110 -17
  83. package/dist/workstation/desktopControl/wallpaper.js +26 -6
  84. package/dist/workstation/exec/policy.js +5 -0
  85. package/dist/workstation/files/client.js +10 -1
  86. package/dist/workstation/files/host.js +9 -2
  87. package/dist/workstation/journal.js +5 -2
  88. package/dist/workstation/keepers/acp/client.js +38 -2
  89. package/dist/workstation/keepers/kit/buildVersion.js +35 -11
  90. package/dist/workstation/keepers/kit/client.js +2 -2
  91. package/dist/workstation/keepers/kit/launcher.js +80 -4
  92. package/dist/workstation/keepers/legacy.js +16 -2
  93. package/dist/workstation/keepers/policy.js +5 -34
  94. package/dist/workstation/keepers/terminal/client.js +115 -23
  95. package/dist/workstation/keepers/terminal/exec.js +6 -3
  96. package/dist/workstation/keepers/terminal/frames.js +73 -0
  97. package/dist/workstation/keepers/terminal/generations.js +5 -8
  98. package/dist/workstation/keepers/terminal/gridProtocol.js +16 -0
  99. package/dist/workstation/keepers/terminal/input.js +146 -0
  100. package/dist/workstation/keepers/terminal/keeper.js +22 -8
  101. package/dist/workstation/keepers/terminal/main.js +1 -1
  102. package/dist/workstation/keepers/terminal/metadata.js +138 -0
  103. package/dist/workstation/keepers/terminal/protocol.js +4 -28
  104. package/dist/workstation/keepers/terminal/pty.js +1 -330
  105. package/dist/workstation/keepers/terminal/screen.js +246 -0
  106. package/dist/workstation/keepers/terminal/sessions.js +526 -0
  107. package/dist/workstation/oauth/deviceAuthorization.js +31 -17
  108. package/dist/workstation/protocol/management.js +23 -4
  109. package/dist/workstation/rd/artifact.js +4 -3
  110. package/dist/workstation/rd/control.js +3 -5
  111. package/dist/workstation/rd/host.js +213 -0
  112. package/dist/workstation/rd/migrationReadiness.js +46 -0
  113. package/dist/workstation/rd/provider.js +44 -10
  114. package/dist/workstation/rd/state.js +14 -2
  115. package/dist/workstation/rd/update.js +5 -8
  116. package/dist/workstation/rd/upkeep.js +44 -2
  117. package/dist/workstation/registrations/enrollment.js +250 -0
  118. package/dist/workstation/registrations/enrollmentPending.js +361 -0
  119. package/dist/workstation/registrations/migration.js +356 -0
  120. package/dist/workstation/registrations/model.js +34 -0
  121. package/dist/workstation/registrations/removal.js +222 -0
  122. package/dist/workstation/registrations/store.js +737 -0
  123. package/dist/workstation/runtime/desktopBinding.js +20 -0
  124. package/dist/workstation/runtime/deviceDesktop.js +193 -0
  125. package/dist/workstation/runtime/deviceRuntime.js +568 -0
  126. package/dist/workstation/runtime/deviceServices.js +117 -0
  127. package/dist/workstation/runtime/events.js +20 -0
  128. package/dist/workstation/runtime/ownership.js +100 -0
  129. package/dist/workstation/runtime/registrationRuntime.js +575 -0
  130. package/dist/workstation/runtime/remoteDesktopAdapter.js +186 -0
  131. package/dist/workstation/runtime/removalActivity.js +184 -0
  132. package/dist/workstation/service/installer.js +93 -1
  133. package/dist/workstation/service/launchd.js +1 -0
  134. package/dist/workstation/service/systemd.js +1 -0
  135. package/dist/workstation/systemMetrics.js +3 -0
  136. package/dist/workstation/tools/client.js +8 -27
  137. package/dist/workstation/tools/info.js +6 -13
  138. package/dist/workstation/tools/terminalRouter.js +17 -172
  139. package/dist/workstation/tools/validation.js +55 -1
  140. package/dist/workstation/update/archiveEvidence.js +206 -0
  141. package/dist/workstation/update/coordinator.js +34 -13
  142. package/dist/workstation/update/deviceCompatibility.js +119 -0
  143. package/dist/workstation/update/deviceCoordinator.js +369 -0
  144. package/dist/workstation/update/deviceHealth.js +104 -0
  145. package/dist/workstation/update/deviceHealthProtocol.js +26 -0
  146. package/dist/workstation/update/deviceRecoveryLifecycle.js +46 -0
  147. package/dist/workstation/update/deviceRollback.js +78 -0
  148. package/dist/workstation/update/globalInstall.js +80 -10
  149. package/dist/workstation/update/legacyBridge.js +414 -0
  150. package/dist/workstation/update/legacyOwner.js +39 -0
  151. package/dist/workstation/update/npmInstaller.js +2 -2
  152. package/dist/workstation/update/privateDurability.js +61 -0
  153. package/dist/workstation/update/recoveryPlan.js +124 -0
  154. package/dist/workstation/update/runUpdate.js +7 -1
  155. package/dist/workstation/update/taskRegistry.js +161 -3
  156. package/dist/workstation/update/watchdog.js +27427 -14410
  157. package/dist/workstation/update/watchdogProcess.js +72 -0
  158. package/package.json +7 -3
  159. package/dist/native/darwin-arm64/axom-quic +0 -0
@@ -1,4 +1,5 @@
1
1
  import { decodeDesktopManagementFrame, parseDesktopJson, DesktopProtocolError } from '../desktopControl/protocol.js';
2
+ import { parseAutomationJson } from '../desktopControl/automationJson.js';
2
3
  import { decodeChannelFrame, hasOptional, hasRequired, isBoolean, isBridgeInfoArray, isNullableString, isNumber, isObject, isString, } from './common.js';
3
4
  export const MANAGEMENT_WIRE_MIN_VERSION = 1;
4
5
  /** Adds the `DRAINING` value to ClientUpdatePhase. */
@@ -14,7 +15,8 @@ export const MANAGEMENT_WIRE_GPU_VERSION = 7;
14
15
  /** Adds `keepers` to heartbeats and the `keeper_control` / `keeper_control_result` pair (spec §8.1, Task 26). */
15
16
  export const MANAGEMENT_WIRE_KEEPERS_VERSION = 8;
16
17
  export const MANAGEMENT_WIRE_DESKTOP_VERSION = 9;
17
- export const MANAGEMENT_WIRE_VERSION = MANAGEMENT_WIRE_DESKTOP_VERSION;
18
+ export const MANAGEMENT_WIRE_DEVICE_OWNERSHIP_VERSION = 10;
19
+ export const MANAGEMENT_WIRE_VERSION = MANAGEMENT_WIRE_DEVICE_OWNERSHIP_VERSION;
18
20
  /** Bounds of one heartbeat's `keepers` (spec §8.1). */
19
21
  export const KEEPER_STATUS_MAX_ITEMS = 32;
20
22
  export const KEEPER_LABEL_MAX_CHARS = 64;
@@ -59,6 +61,7 @@ function isUpdateStatus(value) {
59
61
  && hasOptional(value, {
60
62
  commandId: isString, version: isString, releaseSha512: isString, force: isBoolean,
61
63
  activeShellTasks: isNumber, activeAgentTasks: isNumber, error: isString,
64
+ remoteForcePolicy: policy => policy === 'drain-only',
62
65
  });
63
66
  }
64
67
  function isOsInfo(value) {
@@ -130,7 +133,7 @@ function isManagementFrame(value) {
130
133
  }) && hasOptional(value, { bridges: isBridgeInfoArray });
131
134
  case 'management_ready': return hasRequired(value, {
132
135
  serverNonce: isString, generation: isString, protocols: isSelectedProtocolMap,
133
- }) && hasOptional(value, { release: isNullable(isRelease) });
136
+ }) && hasOptional(value, { release: isNullable(isRelease), ownerUserId: isString });
134
137
  case 'heartbeat': return hasRequired(value, {
135
138
  seq: isNumber, sentAtEpochMs: isNumber, clientVersion: isString,
136
139
  channels: (channels) => isObject(channels) && Object.entries(channels).every(([kind, state]) => CHANNEL_KINDS.has(kind) && isChannelState(state)),
@@ -148,7 +151,7 @@ function isManagementFrame(value) {
148
151
  case 'heartbeat_ack': return hasRequired(value, { seq: isNumber });
149
152
  case 'channel_ticket_request': return hasRequired(value, { channel: isChannelKind });
150
153
  case 'channel_ticket': return hasRequired(value, { channel: isChannelKind, ticket: isString, expiresAtEpochMs: isNumber });
151
- case 'client_update': return hasRequired(value, { commandId: isString, release: isRelease, force: isBoolean });
154
+ case 'client_update': return hasRequired(value, { commandId: isString, release: isRelease, force: isBoolean }) && hasOptional(value, { remoteForcePolicy: value => value === 'drain-only' });
152
155
  case 'client_update_status': return hasRequired(value, { status: isUpdateStatus });
153
156
  case 'external_agents_scan': return hasRequired(value, { commandId: isString });
154
157
  case 'external_agents_scan_result': return hasRequired(value, { commandId: isString, bridges: isBridgeInfoArray });
@@ -162,6 +165,8 @@ function isManagementFrame(value) {
162
165
  }
163
166
  export function encodeManagementFrame(frame) {
164
167
  const text = JSON.stringify(frame);
168
+ if (frame.t === 'desktop_automation_prepare')
169
+ decodeAutomationPrepare(text);
165
170
  if (isDesktopManagementType(frame.t))
166
171
  decodeDesktopManagementFrame(text);
167
172
  return text;
@@ -170,12 +175,22 @@ export function decodeManagementFrame(source) {
170
175
  const text = typeof source === 'string' ? source : new TextDecoder('utf-8', { fatal: true }).decode(source);
171
176
  // Desktop extensions get the strict lexical/shape guard before any lossy object parsing.
172
177
  const parsed = JSON.parse(text);
178
+ if (isObject(parsed) && parsed.t === 'desktop_automation_prepare')
179
+ return decodeAutomationPrepare(text);
173
180
  if (isObject(parsed) && isDesktopManagementType(parsed.t))
174
181
  return decodeDesktopManagementFrame(text);
175
182
  if (isObject(parsed) && parsed.t === 'heartbeat' && Object.hasOwn(parsed, 'capabilitiesRevision'))
176
183
  validateHeartbeatRevision(text);
177
184
  return decodeChannelFrame(text, DISCRIMINATORS, isManagementFrame);
178
185
  }
186
+ function decodeAutomationPrepare(text) {
187
+ const value = parseAutomationJson(text, 49152).value;
188
+ if (Object.keys(value).sort().join(',') !== 'automationProtocol,binding,deviceGeneration,grant,t' || value.automationProtocol !== 1 ||
189
+ typeof value.grant !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(value.grant) ||
190
+ value.binding?.route?.deviceGeneration !== value.deviceGeneration || !value.binding?.native || !value.binding?.preparation || typeof value.binding.executionId !== 'string')
191
+ throw new DesktopProtocolError('Invalid automation preparation');
192
+ return value;
193
+ }
179
194
  /** Select only the new root field. JSON.parse already validates syntax; legacy nesting, size,
180
195
  * unknown fields and duplicate legacy keys keep their established decoder behavior. */
181
196
  function validateHeartbeatRevision(text) {
@@ -215,7 +230,7 @@ function validateHeartbeatRevision(text) {
215
230
  }
216
231
  function isDesktopManagementType(type) {
217
232
  return typeof type === 'string' && ['desktop_background', 'desktop_lobby', 'desktop_lobby_result', 'workstation_capabilities', 'workstation_capabilities_request', 'desktop_prepare',
218
- 'desktop_prepare_result', 'desktop_cancel', 'desktop_session_state'].includes(type);
233
+ 'desktop_prepare_result', 'desktop_cancel', 'desktop_isolation_result', 'desktop_session_state', 'desktop_ownership', 'desktop_ownership_result'].includes(type);
219
234
  }
220
235
  export class ManagementFrameAdmission {
221
236
  isCurrent;
@@ -236,6 +251,10 @@ export class ManagementFrameAdmission {
236
251
  }
237
252
  if (frame.t === 'heartbeat' && frame.capabilitiesRevision !== undefined)
238
253
  return false;
254
+ if (frame.t === 'desktop_ownership')
255
+ return !!this.ready && this.ready.protocols.MANAGEMENT >= 10 && frame.deviceGeneration === this.ready.generation;
256
+ if (frame.t === 'desktop_automation_prepare')
257
+ return !!this.ready && this.ready.protocols.MANAGEMENT >= MANAGEMENT_WIRE_DESKTOP_VERSION && frame.deviceGeneration === this.ready.generation;
239
258
  if (!isDesktopManagementType(frame.t))
240
259
  return true;
241
260
  if (!this.ready || this.ready.protocols.MANAGEMENT < MANAGEMENT_WIRE_DESKTOP_VERSION)
@@ -7,6 +7,7 @@ import { deploymentDownloadURL } from '../control/serverIdentity.js';
7
7
  import { downloadVerified } from '../update/artifact.js';
8
8
  import { privateDirectory } from '../keepers/kit/paths.js';
9
9
  const legacyRdFiles = ['bin/axom-desktop-keeper', 'bin/axom-input-guardian', 'bin/axom-quic', 'xorg.conf'];
10
+ export const RD_DEVICE_HOST_PLATFORM = 'linux-x64-debian-13-device-host';
10
11
  export const rdFiles = [...legacyRdFiles, 'bin/axom-display', 'bin/axom-desktop-session'];
11
12
  const sha256 = z.string().regex(/^[a-f0-9]{64}$/);
12
13
  export const rdArtifactSchema = z.object({ version: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._+-]{0,127}$/),
@@ -16,15 +17,15 @@ export const rdArtifactSchema = z.object({ version: z.string().regex(/^[a-zA-Z0-
16
17
  files: z.record(z.string(), sha256),
17
18
  }).refine(artifact => {
18
19
  const files = artifact.ipcVersion === 1 ? legacyRdFiles : rdFiles;
19
- const licensed = artifact.ipcVersion === 2 && 'opus-LICENSE' in artifact.files;
20
- return Object.keys(artifact.files).length === files.length + (licensed ? 1 : 0) && files.every(file => file in artifact.files);
20
+ const licenses = artifact.ipcVersion === 2 ? ['opus-LICENSE', 'webp-LICENSE'].filter(file => file in artifact.files) : [];
21
+ return Object.keys(artifact.files).length === files.length + licenses.length && files.every(file => file in artifact.files);
21
22
  }, 'RD artifact files do not match its runtime version');
22
23
  export function rdPlatform(osRelease, architecture) {
23
24
  const values = Object.fromEntries(osRelease.split('\n').map(line => /^([A-Z_]+)=["']?([^"']*)["']?$/.exec(line)).filter(Boolean).map(match => [match[1], match[2]]));
24
25
  if (values.ID !== 'debian' || values.VERSION_ID !== '13' || architecture !== 'x64') {
25
26
  throw new Error('Remote desktop currently supports Debian 13 amd64; this distribution/architecture has no qualified RD package');
26
27
  }
27
- return 'linux-x64-debian-13';
28
+ return RD_DEVICE_HOST_PLATFORM;
28
29
  }
29
30
  export function unpackRd(bytes, artifact) {
30
31
  const archive = gunzipSync(bytes, { maxOutputLength: artifact.unpackedSize + 65536 });
@@ -38,16 +38,14 @@ export async function configureRd(directory, options) {
38
38
  });
39
39
  const server = normalizeWorkstationServer(options.server ?? registered?.server ?? resolveServer(await loadConfig()));
40
40
  if (options.disableRd) {
41
- await writeRdState(directory, previous ? { ...previous, enabled: false } : { schemaVersion: 1, enabled: false, server, session: 'auto' });
41
+ await writeRdState(directory, previous ? { ...previous, enabled: false } : { schemaVersion: 2, enabled: false, artifactSource: server, session: 'auto' });
42
42
  return;
43
43
  }
44
- if (registered && normalizeWorkstationServer(registered.server) !== server)
45
- throw new Error('This workstation is already registered to another server; change its registration before enabling RD');
46
44
  const { session, display } = rdSessionSelection(previous, options);
47
45
  const platform = rdPlatform(await fs.readFile('/etc/os-release', 'utf8'), process.arch);
48
46
  const installed = await installRd(directory, server, platform);
49
47
  await ensureDependencies(installed.directory, session, display, installed.artifact);
50
- await writeRdState(directory, { schemaVersion: 1, enabled: true, server, session, ...(display ? { display } : {}),
48
+ await writeRdState(directory, { schemaVersion: 2, enabled: true, artifactSource: server, session, ...(display ? { display } : {}),
51
49
  installation: installed.directory, artifact: installed.artifact });
52
50
  console.log(`Remote desktop enabled (${session}, ${installed.artifact.version}); capture starts only when an authorized viewer connects`);
53
51
  });
@@ -114,6 +112,6 @@ export async function rdStatus(directory) {
114
112
  if (error.code !== 'ENOENT')
115
113
  throw error;
116
114
  }
117
- return { enabled: state.enabled, installed: state.installation !== undefined, version: state.artifact?.version, selection: state.session, server: state.server,
115
+ return { enabled: state.enabled, installed: state.installation !== undefined, version: state.artifact?.version, selection: state.session, artifactSource: state.artifactSource,
118
116
  ...(state.display ? { requestedDisplay: state.display } : {}), ...(state.enabled ? { observed, update } : {}) };
119
117
  }
@@ -0,0 +1,213 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { randomBytes, createHash, randomUUID } from 'node:crypto';
4
+ import { spawn } from 'node:child_process';
5
+ import { createConnection } from 'node:net';
6
+ import { NativeDeviceAdmission } from '../desktopControl/nativeDeviceAdmission.js';
7
+ import { bounded } from '../desktopControl/helperProcess.js';
8
+ import { privateDirectory } from '../keepers/kit/paths.js';
9
+ import { readDeviceHostDescriptor, deviceHostGrantSchema } from '../desktopControl/hostGrant.js';
10
+ export class LinuxDeviceHost {
11
+ directory;
12
+ cleanupDescriptor;
13
+ static pending = new Map();
14
+ child;
15
+ owner;
16
+ exit;
17
+ stopCommandId = randomUUID();
18
+ admission;
19
+ grant;
20
+ bootstrapGrant;
21
+ enableAttempted = false;
22
+ published = false;
23
+ failed = false;
24
+ exitObserved = false;
25
+ constructor(directory, cleanupDescriptor) {
26
+ this.directory = directory;
27
+ this.cleanupDescriptor = cleanupDescriptor;
28
+ }
29
+ static async recoverPending(root) {
30
+ const host = this.pending.get(root);
31
+ if (!host)
32
+ return;
33
+ if (host.failed && !host.enableAttempted && !host.published && host.exitObserved) {
34
+ host.owner?.destroy();
35
+ await fs.rm(path.join(host.directory, 'launch.json'), { force: true });
36
+ await fs.rm(path.join(host.directory, 'host.sock'), { force: true });
37
+ await fs.rmdir(host.directory).catch(error => { if (error.code !== 'ENOENT')
38
+ throw error; });
39
+ this.pending.delete(root);
40
+ return;
41
+ }
42
+ if (!host.admission) {
43
+ const original = host.bootstrapGrant;
44
+ if (!original)
45
+ throw Error('native_host_launch_unconfirmed');
46
+ host.admission = await NativeDeviceAdmission.connect(original);
47
+ host.grant = { ...original, hostGeneration: host.admission.hostLifetimeId };
48
+ }
49
+ return host;
50
+ }
51
+ static async launch(root, executable, environment) {
52
+ if (this.pending.has(root))
53
+ throw Error('original_native_host_unconfirmed');
54
+ const cleanup = path.join(root, 'desktop-cleanup');
55
+ await privateDirectory(cleanup);
56
+ const directory = await fs.mkdtemp('/tmp/axom-device-host-');
57
+ await fs.chmod(directory, 0o700);
58
+ const host = new LinuxDeviceHost(directory, path.join(cleanup, randomBytes(16).toString('hex') + '.json'));
59
+ const binary = await fs.realpath(executable), sha256 = createHash('sha256').update(await fs.readFile(binary)).digest('hex');
60
+ const endpoint = { socket: path.join(directory, 'host.sock'), token: randomBytes(32).toString('hex') };
61
+ host.bootstrapGrant = { schemaVersion: 2, executable: binary, sha256, ...endpoint };
62
+ const bootstrap = path.join(directory, 'launch.json');
63
+ await fs.writeFile(bootstrap, JSON.stringify({ ...endpoint, parentPid: process.pid, cleanupDescriptor: host.cleanupDescriptor, executable: binary, sha256 }), { flag: 'wx', mode: 0o600 });
64
+ this.pending.set(root, host);
65
+ const child = spawn(binary, ['--device-host', bootstrap], { env: { ...environment, AXOM_ACCESS_TOKEN: undefined }, stdio: 'ignore' });
66
+ host.child = child;
67
+ host.exit = new Promise((resolve, reject) => { child.once('exit', () => { host.exitObserved = true; resolve(); }); child.once('error', reject); });
68
+ void host.exit.catch(() => { });
69
+ try {
70
+ const deadline = Date.now() + 5000;
71
+ while (Date.now() < deadline) {
72
+ if (child.exitCode !== null)
73
+ throw new Error('native_host_launch_failed');
74
+ try {
75
+ await fs.lstat(endpoint.socket);
76
+ break;
77
+ }
78
+ catch (error) {
79
+ if (error.code !== 'ENOENT')
80
+ throw error;
81
+ }
82
+ await new Promise(resolve => setTimeout(resolve, 10));
83
+ }
84
+ const owner = createConnection(endpoint.socket);
85
+ host.owner = owner;
86
+ await bounded(new Promise((resolve, reject) => {
87
+ owner.once('error', reject);
88
+ owner.once('connect', () => owner.write(endpoint.token));
89
+ owner.once('data', bytes => { if (bytes.length !== 1 || bytes[0] !== 1) {
90
+ reject(new Error('native_host_authentication_failed'));
91
+ return;
92
+ } owner.write('owner\n'); resolve(); });
93
+ owner.once('end', () => reject(new Error('native_host_owner_closed')));
94
+ }), 5000);
95
+ host.admission = await NativeDeviceAdmission.connect(endpoint);
96
+ host.grant = { schemaVersion: 2, hostGeneration: host.admission.hostLifetimeId, executable: binary, sha256, ...endpoint };
97
+ host.published = true;
98
+ await host.persistOriginal();
99
+ await new Promise((resolve, reject) => {
100
+ const peer = createConnection(endpoint.socket);
101
+ const timer = setTimeout(() => { peer.destroy(); reject(new Error('native_host_enable_unconfirmed')); }, 5000);
102
+ let authenticated = false, bytes = Buffer.alloc(0);
103
+ peer.once('connect', () => peer.write(endpoint.token));
104
+ peer.once('error', error => { clearTimeout(timer); reject(error); });
105
+ peer.on('data', chunk => { bytes = Buffer.concat([bytes, chunk]); if (!authenticated) {
106
+ if (bytes[0] !== 1) {
107
+ clearTimeout(timer);
108
+ peer.destroy();
109
+ reject(new Error('native_host_authentication_failed'));
110
+ return;
111
+ }
112
+ authenticated = true;
113
+ bytes = bytes.subarray(1);
114
+ host.enableAttempted = true;
115
+ peer.write('enable\n');
116
+ } if (bytes.includes(10)) {
117
+ clearTimeout(timer);
118
+ peer.destroy();
119
+ try {
120
+ if (JSON.stringify(JSON.parse(bytes.subarray(0, bytes.indexOf(10)).toString())) !== '{}')
121
+ throw Error();
122
+ resolve();
123
+ }
124
+ catch {
125
+ reject(new Error('native_host_enable_unconfirmed'));
126
+ }
127
+ } });
128
+ });
129
+ this.pending.delete(root);
130
+ return host;
131
+ }
132
+ catch (error) {
133
+ host.failed = true;
134
+ host.owner?.destroy();
135
+ throw error;
136
+ }
137
+ }
138
+ async persistOriginal() {
139
+ const expected = deviceHostGrantSchema.parse(this.grant);
140
+ try {
141
+ if (JSON.stringify(await readDeviceHostDescriptor(this.cleanupDescriptor)) !== JSON.stringify(expected))
142
+ throw Error('original_native_host_unconfirmed');
143
+ return;
144
+ }
145
+ catch (error) {
146
+ if (error.code !== 'ENOENT')
147
+ throw error;
148
+ }
149
+ const staging = this.cleanupDescriptor + '.writing-' + randomUUID();
150
+ const file = await fs.open(staging, 'wx', 0o600);
151
+ try {
152
+ await file.writeFile(JSON.stringify(expected));
153
+ await file.sync();
154
+ await file.close();
155
+ try {
156
+ await fs.link(staging, this.cleanupDescriptor);
157
+ }
158
+ catch (error) {
159
+ if (error.code !== 'EEXIST')
160
+ throw error;
161
+ }
162
+ if (JSON.stringify(await readDeviceHostDescriptor(this.cleanupDescriptor)) !== JSON.stringify(expected))
163
+ throw Error('original_native_host_unconfirmed');
164
+ }
165
+ finally {
166
+ await file.close();
167
+ await fs.unlink(staging);
168
+ }
169
+ }
170
+ async stopOriginal() {
171
+ let failure;
172
+ try {
173
+ await this.persistOriginal();
174
+ }
175
+ catch (error) {
176
+ failure = error;
177
+ }
178
+ try {
179
+ await this.admission.stopHost(this.stopCommandId);
180
+ }
181
+ catch (error) {
182
+ failure ??= error;
183
+ }
184
+ if (failure)
185
+ throw failure;
186
+ }
187
+ async finalizeOriginal() {
188
+ this.owner?.destroy();
189
+ if (this.exit)
190
+ try {
191
+ await bounded(this.exit, 5000);
192
+ }
193
+ catch (error) {
194
+ this.child?.kill('SIGTERM');
195
+ await bounded(this.exit, 5000);
196
+ }
197
+ try {
198
+ const descriptor = await readDeviceHostDescriptor(this.cleanupDescriptor);
199
+ if (descriptor.hostGeneration === this.grant.hostGeneration && descriptor.token === this.grant.token && descriptor.socket === this.grant.socket)
200
+ await fs.unlink(this.cleanupDescriptor);
201
+ }
202
+ catch (error) {
203
+ if (error.code !== 'ENOENT')
204
+ throw error;
205
+ }
206
+ await fs.rm(path.join(this.directory, 'launch.json'), { force: true });
207
+ await fs.rmdir(this.directory).catch(error => { if (error.code !== 'ENOENT')
208
+ throw error; });
209
+ for (const [root, original] of LinuxDeviceHost.pending)
210
+ if (original === this)
211
+ LinuxDeviceHost.pending.delete(root);
212
+ }
213
+ }
@@ -0,0 +1,46 @@
1
+ import path from 'node:path';
2
+ import { promises as fs } from 'node:fs';
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ import { z } from 'zod';
6
+ import { installRd, rdPlatform, verifyRdInstallation } from './artifact.js';
7
+ import { readRdState } from './state.js';
8
+ import { withPrivateLock } from '../control/locks.js';
9
+ const execute = promisify(execFile);
10
+ const capabilities = z.strictObject({ deviceAdmissionProtocol: z.literal(1), deviceHost: z.literal(true) });
11
+ /** Stage only. The caller must retain the legacy physical fence: qualification is not retirement
12
+ * of predecessor capture/input authority, and must never activate this state on its own. */
13
+ export async function prepareLegacyRdReadiness(directory, options = {}) {
14
+ return withPrivateLock(path.join(directory, 'rd', 'install'), async () => {
15
+ const previous = await readRdState(directory);
16
+ if (!previous?.enabled)
17
+ return { kind: 'not-enabled' };
18
+ const qualify = async (installation) => {
19
+ await verifyRdInstallation(installation.directory, installation.artifact);
20
+ const result = await (options.execute ?? execute)(path.join(installation.directory, 'bin/axom-desktop-keeper'), ['--device-capabilities'], {
21
+ timeout: 5000, maxBuffer: 8192,
22
+ env: { ...process.env, AXOM_ACCESS_TOKEN: undefined, AXOM_DEVICE_SCOPE: undefined, AXOM_DEVICE_CONTEXT: undefined,
23
+ AXOM_KEEPER_SOCKET: undefined, AXOM_KEEPER_TOKEN: undefined },
24
+ });
25
+ capabilities.parse(JSON.parse(result.stdout));
26
+ };
27
+ try {
28
+ await qualify({ directory: previous.installation, artifact: previous.artifact });
29
+ return { kind: 'ready', state: previous };
30
+ }
31
+ catch {
32
+ // A legacy --self-test or IPC version says nothing about device admission support.
33
+ }
34
+ try {
35
+ const platform = options.platform ?? rdPlatform(await fs.readFile('/etc/os-release', 'utf8'), process.arch);
36
+ const candidate = await (options.install ?? installRd)(directory, previous.artifactSource, platform);
37
+ await qualify(candidate);
38
+ if (JSON.stringify(await readRdState(directory)) !== JSON.stringify(previous))
39
+ throw Error('Remote desktop selection changed during migration staging');
40
+ return { kind: 'staged', state: { ...previous, installation: candidate.directory, artifact: candidate.artifact } };
41
+ }
42
+ catch (error) {
43
+ return { kind: 'unavailable', reason: `Remote desktop requires a qualified device host: ${error instanceof Error ? error.message : String(error)}` };
44
+ }
45
+ });
46
+ }
@@ -7,12 +7,12 @@ import { NativeProvider } from '../desktopControl/nativeProvider.js';
7
7
  import { HelperProcess, NativeCommandError } from '../desktopControl/helperProcess.js';
8
8
  import { desktopCapabilitiesSchema } from '../desktopControl/protocol.js';
9
9
  import { captureTargetLabel } from '../desktopControl/captureTargetLabel.js';
10
- import { normalizeWorkstationServer } from '../control/serverIdentity.js';
11
10
  import { privateDirectory } from '../keepers/kit/paths.js';
12
11
  import { verifyRdInstallation } from './artifact.js';
13
12
  import { readRdState } from './state.js';
14
13
  import { chooseSession, discoverSessions, ensureManagedSession, sessionAlive } from './sessions.js';
15
14
  import { desktopDisplayControl, parseXrandr } from './display.js';
15
+ import { LinuxDeviceHost } from './host.js';
16
16
  const execute = promisify(execFile);
17
17
  const sourceAxis = z.number().int().min(2).max(4096);
18
18
  const geometrySchema = z.object({ width: sourceAxis, height: sourceAxis, audio: z.boolean().optional() });
@@ -21,6 +21,33 @@ export class LinuxDesktopProvider {
21
21
  directory;
22
22
  server;
23
23
  legacy;
24
+ host;
25
+ get deviceAdmission() { return this.host?.admission; }
26
+ static async connect(directory) {
27
+ if (!(await readRdState(directory))?.enabled)
28
+ return;
29
+ const provider = new LinuxDesktopProvider(directory, '');
30
+ await provider.initialize();
31
+ if (!provider.host)
32
+ throw new NativeCommandError('unavailable');
33
+ return provider;
34
+ }
35
+ async stopOriginal() { if (this.host)
36
+ await this.host.stopOriginal(); }
37
+ async finalizeOriginal() { if (this.host)
38
+ await this.host.finalizeOriginal(); }
39
+ async retirementRequired() {
40
+ const current = await readRdState(this.directory);
41
+ return !current?.enabled || !this.state || current.artifact.sha512 !== this.state.artifact.sha512 || current.session !== this.state.session || current.display !== this.state.display || !this.session || !await sessionAlive(this.session);
42
+ }
43
+ async prepareAutomation(binding) {
44
+ await this.initialize();
45
+ if (!this.usingLegacy)
46
+ await this.validate();
47
+ if (!this.provider?.prepareAutomation)
48
+ throw new NativeCommandError('unsupported_automation_protocol');
49
+ return this.provider.prepareAutomation(binding);
50
+ }
24
51
  session;
25
52
  provider;
26
53
  state;
@@ -47,7 +74,7 @@ export class LinuxDesktopProvider {
47
74
  const current = await readRdState(this.directory);
48
75
  if (!current?.enabled || !this.state || current.artifact.sha512 !== this.state.artifact.sha512 ||
49
76
  current.session !== this.state.session || current.display !== this.state.display ||
50
- normalizeWorkstationServer(current.server) !== normalizeWorkstationServer(this.server) || !session || this.session !== session || !await sessionAlive(session)) {
77
+ !session || this.session !== session || !await sessionAlive(session)) {
51
78
  throw new NativeCommandError('unavailable');
52
79
  }
53
80
  }
@@ -75,8 +102,11 @@ export class LinuxDesktopProvider {
75
102
  this.state = undefined;
76
103
  return;
77
104
  }
78
- if (normalizeWorkstationServer(state.server) !== normalizeWorkstationServer(this.server))
79
- throw new Error('RD belongs to a different workstation server');
105
+ if (this.host) {
106
+ if (await this.retirementRequired())
107
+ throw new NativeCommandError('unavailable');
108
+ return;
109
+ }
80
110
  if (this.provider && this.state?.artifact.sha512 === state.artifact.sha512 && this.state.session === state.session && this.state.display === state.display && this.session && await sessionAlive(this.session))
81
111
  return;
82
112
  this.provider = undefined;
@@ -91,11 +121,15 @@ export class LinuxDesktopProvider {
91
121
  session.environment.AXOM_DESKTOP_RUNTIME_DIR : path.join(runtimeRoot, session.identity);
92
122
  await privateDirectory(this.runtime);
93
123
  const executable = path.join(state.installation, 'bin/axom-desktop-keeper');
124
+ const originalHost = await LinuxDeviceHost.launch(this.directory, executable, this.environment());
125
+ this.host = originalHost;
126
+ const originalEnvironment = { ...this.environment(), AXOM_KEEPER_SOCKET: originalHost.grant.socket, AXOM_KEEPER_TOKEN: originalHost.grant.token };
94
127
  this.provider = new NativeProvider({ helperPath: executable, runtimeDir: this.runtime,
95
- environment: this.environment(), enumerate: () => this.capabilities(), validateSession: () => this.validate(session),
128
+ deviceAdmission: originalHost.admission, environment: originalEnvironment, enumerate: () => this.capabilities(), validateSession: () => this.validate(session),
96
129
  sessionHelperFactory: async (binding) => {
97
130
  await this.validate(session);
98
- const helper = await HelperProcess.launch(executable, { ...this.environment(), AXOM_KEEPER_TARGET: binding.displayId });
131
+ const helper = await HelperProcess.launch(executable, { ...originalEnvironment, AXOM_KEEPER_TARGET: binding.displayId,
132
+ AXOM_DEVICE_CONTEXT: originalHost.admission.context(binding.deviceGrant?.registrationId ?? '', binding.deviceGeneration, binding.sessionId, binding.deviceGrant) });
99
133
  return helper;
100
134
  } });
101
135
  process.env.AXOM_QUIC_BINARY = path.join(state.installation, 'bin/axom-quic');
@@ -197,19 +231,19 @@ export class LinuxDesktopProvider {
197
231
  throw error;
198
232
  }
199
233
  }
200
- async prepare(selection, binding) {
234
+ async prepare(selection, binding, current) {
201
235
  await this.initialize();
202
236
  if (!this.provider)
203
237
  throw new NativeCommandError('unavailable');
204
- return this.provider.prepare(selection, binding);
238
+ return this.provider.prepare(selection, binding, current);
205
239
  }
206
- async wallpaper(targetId) {
240
+ async wallpaper(targetId, pixels = { width: 1920, height: 1080 }) {
207
241
  await this.initialize();
208
242
  if (!this.usingLegacy)
209
243
  await this.validate();
210
244
  if (!this.provider)
211
245
  throw new NativeCommandError('unavailable');
212
- return this.provider.wallpaper(targetId);
246
+ return this.provider.wallpaper(targetId, pixels);
213
247
  }
214
248
  async recoverDisplay(binding, reservation) {
215
249
  await this.initialize();
@@ -4,13 +4,25 @@ import { randomUUID } from 'node:crypto';
4
4
  import { z } from 'zod';
5
5
  import { rdArtifactSchema } from './artifact.js';
6
6
  import { privateDirectory } from '../keepers/kit/paths.js';
7
- const selection = { schemaVersion: z.literal(1), server: z.string().url(),
7
+ const selection = { schemaVersion: z.literal(2), artifactSource: z.string().url(),
8
8
  session: z.enum(['auto', 'existing', 'virtual']), display: z.string().regex(/^:\d+(?:\.\d+)?$/).optional(),
9
9
  };
10
- export const rdStateSchema = z.discriminatedUnion('enabled', [
10
+ const currentRdStateSchema = z.discriminatedUnion('enabled', [
11
11
  z.strictObject({ ...selection, enabled: z.literal(true), installation: z.string().startsWith('/'), artifact: rdArtifactSchema }),
12
12
  z.strictObject({ ...selection, enabled: z.literal(false), installation: z.string().startsWith('/').optional(), artifact: rdArtifactSchema.optional() }),
13
13
  ]);
14
+ const legacySelection = { schemaVersion: z.literal(1), server: z.string().url(), session: selection.session, display: selection.display };
15
+ const legacyRdStateSchema = z.discriminatedUnion('enabled', [
16
+ z.strictObject({ ...legacySelection, enabled: z.literal(true), installation: z.string().startsWith('/'), artifact: rdArtifactSchema }),
17
+ z.strictObject({ ...legacySelection, enabled: z.literal(false), installation: z.string().startsWith('/').optional(), artifact: rdArtifactSchema.optional() }),
18
+ ]);
19
+ export const rdStateSchema = z.preprocess(value => {
20
+ const legacy = legacyRdStateSchema.safeParse(value);
21
+ if (!legacy.success)
22
+ return value;
23
+ const { server, ...state } = legacy.data;
24
+ return { ...state, schemaVersion: 2, artifactSource: server };
25
+ }, currentRdStateSchema);
14
26
  export async function readRdState(directory) {
15
27
  let file;
16
28
  try {
@@ -3,10 +3,9 @@ import path from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { execFile } from 'node:child_process';
5
5
  import { promisify } from 'node:util';
6
- import { installRd, rdArtifactSchema, rdPlatform, verifyRdInstallation } from './artifact.js';
6
+ import { installRd, rdArtifactSchema, rdPlatform, verifyRdInstallation, RD_DEVICE_HOST_PLATFORM } from './artifact.js';
7
7
  import { readRdState, writeRdState } from './state.js';
8
8
  import { assertLockOwnership, withPrivateLock } from '../control/locks.js';
9
- import { normalizeWorkstationServer } from '../control/serverIdentity.js';
10
9
  import { privateDirectory } from '../keepers/kit/paths.js';
11
10
  import { z } from 'zod';
12
11
  import { CLIENT_VERSION } from '../version.js';
@@ -34,9 +33,9 @@ async function adoptBundle(directory, server, options) {
34
33
  if (!bundle)
35
34
  return;
36
35
  const catalog = z.object({ schemaVersion: z.literal(1), artifacts: z.record(z.string(), rdArtifactSchema) }).parse(JSON.parse(await fs.readFile(bundle.manifest, 'utf8')));
37
- const artifact = catalog.artifacts['linux-x64-debian-13'];
36
+ const artifact = catalog.artifacts[RD_DEVICE_HOST_PLATFORM];
38
37
  if (!artifact || artifact.ipcVersion !== 2)
39
- throw new Error('Bundled RD requires the IPC 2 runtime');
38
+ throw new Error('Bundled RD requires the device-host platform and IPC 2 runtime');
40
39
  if (!bundle.display || !/^:\d+(?:\.\d+)?$/.test(bundle.display))
41
40
  throw new Error('Bundled RD requires a local X11 DISPLAY');
42
41
  if (!path.isAbsolute(bundle.root))
@@ -44,7 +43,7 @@ async function adoptBundle(directory, server, options) {
44
43
  await verifyRdInstallation(bundle.root, artifact);
45
44
  await selfTest(bundle.root, artifact, options);
46
45
  assertLockOwnership();
47
- await writeRdState(directory, { schemaVersion: 1, enabled: true, server, session: 'existing', display: bundle.display, installation: bundle.root, artifact });
46
+ await writeRdState(directory, { schemaVersion: 2, enabled: true, artifactSource: server, session: 'existing', display: bundle.display, installation: bundle.root, artifact });
48
47
  }
49
48
  function requireSupportedIpc(artifact) {
50
49
  if (artifact.ipcVersion !== 1 && artifact.ipcVersion !== 2)
@@ -73,8 +72,6 @@ export async function synchronizeRd(directory, server, options = {}) {
73
72
  if (!previous?.enabled)
74
73
  return;
75
74
  requireSupportedIpc(previous.artifact);
76
- if (normalizeWorkstationServer(previous.server) !== normalizeWorkstationServer(server))
77
- throw new Error('RD belongs to a different workstation server');
78
75
  }
79
76
  catch (error) {
80
77
  await writeStatus(directory, { state: 'blocked', reason: reason(error) });
@@ -95,7 +92,7 @@ export async function synchronizeRd(directory, server, options = {}) {
95
92
  let installed;
96
93
  try {
97
94
  const platform = options.platform ?? rdPlatform(await fs.readFile('/etc/os-release', 'utf8'), process.arch);
98
- installed = await (options.install ?? installRd)(directory, server, platform, options.fetch ?? globalThis.fetch);
95
+ installed = await (options.install ?? installRd)(directory, previous.artifactSource, platform, options.fetch ?? globalThis.fetch);
99
96
  requireSupportedIpc(installed.artifact);
100
97
  await verifyRdInstallation(installed.directory, installed.artifact);
101
98
  await selfTest(installed.directory, installed.artifact, options);