@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,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { audioConfiguration, audioMode } from './audio.js';
3
3
  import { lobbyRequestSchema, lobbyPlanSchema } from './lobby.js';
4
+ import { deviceAcquisitionSchema, deviceOwnershipCommandSchema, deviceOwnershipEventSchema } from './ownershipProtocol.js';
4
5
  export const DESKTOP_MAX_JSON_BYTES = 48 * 1024;
5
6
  export class DesktopProtocolError extends Error {
6
7
  code = 'invalid_request';
@@ -39,6 +40,7 @@ const backend = z.strictObject({ id: bounded, kind: z.enum(['software', 'hardwar
39
40
  formats: z.array(encoderFormat).min(1).max(2).refine(a => distinct(a.map(f => f.format))),
40
41
  });
41
42
  export const desktopCapabilitiesSchema = z.strictObject({
43
+ deviceOwnershipProtocol: z.literal(1).optional(),
42
44
  cursorEmbedded: z.boolean().optional(), cursorVisibility: z.boolean().optional(), lobby: z.boolean().optional(),
43
45
  protocols, providerId: bounded, providerVersion: bounded, configured: z.boolean(),
44
46
  permission: z.enum(['granted', 'required', 'unknown', 'not_applicable']), health: z.enum(['ready', 'unavailable']), reason: code.nullable(),
@@ -87,7 +89,7 @@ const feedback = z.strictObject({ epoch: u32, receivedId: int(), decodedId: int(
87
89
  const resizeResult = z.strictObject({ commandId: id, width: axis, height: axis, displayRevision: u32, outcome: z.enum(['applied', 'rolled_back', 'failed']), dpi: dpi.optional(), captureRegion: captureRegion.optional() });
88
90
  export const desktopErrorCodeSchema = z.enum(['unavailable', 'unknown_capability', 'unsupported_protocol', 'unsupported_client',
89
91
  'permission_required', 'busy', 'conflict', 'invalid_request', 'rate_limited', 'startup_timeout', 'encoder_failed', 'decoder_failed',
90
- 'resize_failed', 'transport_failed', 'authorization_lost', 'takeover_lost']);
92
+ 'resize_failed', 'transport_failed', 'authorization_lost', 'takeover_lost', 'upgrade_required']);
91
93
  const state = z.enum(['REQUESTED', 'PREPARING', 'NEGOTIATING', 'STARTING', 'ACTIVE', 'RECONFIGURING', 'SUSPENDED', 'RECONNECTING', 'FAILED', 'CLOSED']);
92
94
  const clipboardChunk = { controlGeneration: positive, requestId: id, revision: int().default(0), offset: int(0, 1048576).default(0),
93
95
  total: int(0, 1048576).default(0), data: z.string().max(10924).default('') };
@@ -153,15 +155,21 @@ const sessionRequest = z.strictObject({ requestId: id, displayId: id, viewer,
153
155
  viewport: z.strictObject({ widthDp: z.number().positive().max(16384), heightDp: z.number().positive().max(16384),
154
156
  deviceScale: z.number().min(0.5).max(8), orientation: z.enum(['landscape', 'portrait']) }),
155
157
  resolution: z.discriminatedUnion('mode', [z.strictObject({ mode: z.literal('fit') }), z.strictObject({ mode: z.literal('fixed'), width: axis, height: axis })]),
156
- quality, takeover: z.boolean().default(false), lobbyId: id.optional(), planId: id.optional(), mediaTransport: z.enum(['quic', 'wss']).optional() })
157
- .refine(request => (request.lobbyId == null) === (request.planId == null) && (request.lobbyId == null) === (request.mediaTransport == null));
158
+ quality, takeover: z.boolean().default(false), lobbyId: id.optional(), planId: id.optional(), mediaTransport: z.enum(['quic', 'wss']).optional(),
159
+ ownershipProtocol: z.literal(1).optional(), expectedOwnershipRevision: int().optional(), expectedDeviceGeneration: bounded.optional(), deviceAcquisition: deviceAcquisitionSchema.optional() })
160
+ .refine(request => (request.lobbyId == null) === (request.planId == null) && (request.lobbyId == null) === (request.mediaTransport == null) &&
161
+ (request.deviceAcquisition?.kind !== 'takeover' || request.takeover && request.ownershipProtocol === 1 && request.expectedOwnershipRevision != null) &&
162
+ (request.expectedOwnershipRevision == null) === (request.expectedDeviceGeneration == null) &&
163
+ (request.expectedOwnershipRevision == null || request.ownershipProtocol === 1 && request.takeover));
158
164
  const desktopManagementSchema = z.discriminatedUnion('t', [
165
+ deviceOwnershipCommandSchema,
166
+ deviceOwnershipEventSchema,
159
167
  z.strictObject({ t: z.literal('desktop_lobby'), commandId: id, lobbyId: id, deviceGeneration: bounded, request: z.lazy(() => lobbyRequestSchema),
160
168
  wssProbeGrant: z.string().regex(/^[A-Za-z0-9_-]{43}$/).nullish(), quicProbeGrant: z.string().regex(/^[A-Za-z0-9_-]{43}$/).nullish(),
161
169
  quic: z.strictObject({ port: int(1, 65535), certificate: z.string().min(64).max(2048) }).nullish() }),
162
170
  z.strictObject({ t: z.literal('desktop_background'), commandId: id, lobbyId: id, deviceGeneration: bounded,
163
171
  planId: id, revision: positive, targetId: id, grant: z.string().regex(/^[A-Za-z0-9_-]{43}$/),
164
- cachedVersion: z.string().regex(/^[A-Za-z0-9_-]{1,128}$/).nullish() }),
172
+ cachedVersion: z.string().regex(/^[A-Za-z0-9_-]{1,128}$/).nullish(), pixelWidth: int(2, 4096), pixelHeight: int(2, 4096) }),
165
173
  z.strictObject({ t: z.literal('desktop_lobby_result'), commandId: id, lobbyId: id, deviceGeneration: bounded,
166
174
  plan: z.lazy(() => lobbyPlanSchema).nullable(), code: desktopErrorCodeSchema.nullable() }),
167
175
  z.strictObject({ t: z.literal('workstation_capabilities'), ...capabilityReportSchema.shape }),
@@ -171,12 +179,13 @@ const desktopManagementSchema = z.discriminatedUnion('t', [
171
179
  candidate: z.boolean().optional(), mediaTransport: z.enum(['quic', 'wss']).optional() }),
172
180
  z.strictObject({ t: z.literal('desktop_prepare_result'), commandId: id, sessionId: id,
173
181
  state: z.enum(['bound', 'failed']), code: desktopErrorCodeSchema.nullable() }),
174
- z.strictObject({ t: z.literal('desktop_cancel'), commandId: id, sessionId: id, controlGeneration: positive, code: desktopErrorCodeSchema }),
182
+ z.strictObject({ t: z.literal('desktop_cancel'), commandId: id, sessionId: id, controlGeneration: positive, code: desktopErrorCodeSchema, requireIsolation: z.boolean().optional() }),
183
+ z.strictObject({ t: z.literal('desktop_isolation_result'), commandId: id, sessionId: id, deviceGeneration: bounded, controlGeneration: positive, isolated: z.boolean() }),
175
184
  z.strictObject({ t: z.literal('desktop_session_state'), sessionId: id, controlGeneration: positive, state, code: desktopErrorCodeSchema.nullable() }),
176
185
  ]);
177
186
  export function decodeDesktopManagementFrame(source, from) {
178
187
  const frame = checked(desktopManagementSchema, source);
179
- const sender = ['desktop_lobby_result', 'workstation_capabilities', 'desktop_prepare_result', 'desktop_session_state'].includes(frame.t) ? 'device' : 'server';
188
+ const sender = ['desktop_ownership_result', 'desktop_isolation_result', 'desktop_lobby_result', 'workstation_capabilities', 'desktop_prepare_result', 'desktop_session_state'].includes(frame.t) ? 'device' : 'server';
180
189
  if (from && from !== sender)
181
190
  return invalid();
182
191
  return frame;
@@ -14,6 +14,7 @@ const diagnosticCodes = new Set(['invalid_request', 'unavailable', 'conflict', '
14
14
  /** One authority owner and bounded management work queue; receive/heartbeat never await native I/O. */
15
15
  export class DesktopSessionSupervisor {
16
16
  options;
17
+ get activeCount() { return Number(!!this.active && !this.active.retired) + this.candidates.size; }
17
18
  generation;
18
19
  active;
19
20
  candidates = new Map();
@@ -28,6 +29,7 @@ export class DesktopSessionSupervisor {
28
29
  backgroundTail = Promise.resolve();
29
30
  backgroundJobs = 0;
30
31
  cleanupJobs = 0;
32
+ retainedSessions = new Map();
31
33
  maintenance;
32
34
  constructor(options) {
33
35
  this.options = options;
@@ -90,8 +92,28 @@ export class DesktopSessionSupervisor {
90
92
  return this.planLobby(frame);
91
93
  if (frame.t === 'desktop_prepare')
92
94
  return this.prepare(frame);
95
+ if (frame.requireIsolation)
96
+ return this.isolate(frame, generation);
93
97
  return this.cancel(frame.sessionId, { commandId: frame.commandId, deviceGeneration: generation, controlGeneration: frame.controlGeneration });
94
98
  }
99
+ async isolate(frame, generation) {
100
+ const key = `${generation}:${frame.sessionId}:${frame.controlGeneration}`;
101
+ const retained = this.retainedSessions.get(key);
102
+ let isolated = false;
103
+ if (retained) {
104
+ this.retire(retained);
105
+ try {
106
+ await this.cleanup(retained);
107
+ isolated = true;
108
+ }
109
+ catch { }
110
+ }
111
+ if (this.generation === generation && !this.closed)
112
+ this.options.report(generation, {
113
+ t: 'desktop_isolation_result', commandId: frame.commandId, sessionId: frame.sessionId,
114
+ deviceGeneration: generation, controlGeneration: frame.controlGeneration, isolated,
115
+ });
116
+ }
95
117
  background(frame) {
96
118
  if (this.closed || frame.deviceGeneration !== this.generation || this.backgroundJobs >= 16)
97
119
  return Promise.resolve();
@@ -103,7 +125,7 @@ export class DesktopSessionSupervisor {
103
125
  if (!current() || !this.options.sockets.uploadWallpaper)
104
126
  return;
105
127
  try {
106
- const wallpaper = this.options.provider.wallpaper ? await bounded(this.options.provider.wallpaper(frame.targetId), 8000) : null;
128
+ const wallpaper = this.options.provider.wallpaper ? await bounded(this.options.provider.wallpaper(frame.targetId, { width: frame.pixelWidth, height: frame.pixelHeight }), 8000) : null;
107
129
  if (current())
108
130
  await this.options.sockets.uploadWallpaper(frame.grant, wallpaper, wallpaper?.asset.version === frame.cachedVersion);
109
131
  }
@@ -184,12 +206,30 @@ export class DesktopSessionSupervisor {
184
206
  this.tail = next.catch(() => { }).finally(() => { this.pending--; });
185
207
  return next;
186
208
  }
209
+ preparationCurrent(binding) {
210
+ if (this.options.preparationCurrent)
211
+ return this.options.preparationCurrent(binding);
212
+ let begun = false;
213
+ return (beginEffect = false) => {
214
+ if (this.closed || binding.deviceGeneration !== this.generation || this.options.controllerCurrent?.(binding) === false)
215
+ throw new NativeCommandError('control_lost');
216
+ if (!begun && this.options.newWorkAllowed?.() === false)
217
+ return false;
218
+ if (beginEffect)
219
+ begun = true;
220
+ return true;
221
+ };
222
+ }
187
223
  prepare(command) {
188
224
  if (this.maintenance)
189
225
  return this.maintenance.catch(() => { }).then(() => this.prepare(command));
190
226
  decodeDesktopManagementFrame(JSON.stringify({ ...command, t: 'desktop_prepare' }), 'server');
191
227
  if (this.closed || command.deviceGeneration !== this.generation)
192
228
  return Promise.resolve();
229
+ if (this.options.newWorkAllowed?.() === false) {
230
+ this.options.report(command.deviceGeneration, { t: 'desktop_prepare_result', commandId: command.commandId, sessionId: command.sessionId, state: 'failed', code: 'busy' });
231
+ return Promise.resolve();
232
+ }
193
233
  mediaRequire(this.pending < 16);
194
234
  const key = `${command.deviceGeneration}:${command.commandId}`, serialized = canonicalDesktopJson(command);
195
235
  const previousCommand = this.commands.get(key);
@@ -201,8 +241,12 @@ export class DesktopSessionSupervisor {
201
241
  this.commands.set(key, serialized);
202
242
  mediaRequire(!command.candidate || this.candidates.size < 8);
203
243
  const previous = command.candidate ? undefined : this.active;
204
- if (previous)
244
+ const openingCurrent = previous ? this.preparationCurrent(command) : () => this.options.newWorkAllowed?.() !== false;
245
+ if (previous) {
246
+ if (!openingCurrent(true))
247
+ return Promise.resolve();
205
248
  this.retire(previous);
249
+ }
206
250
  const s = { diagnosticId: ++this.diagnosticId, diagnosticCount: 0, rateDiagnosticCount: 0, lastRateDiagnosticAt: -Infinity,
207
251
  performanceDiagnosticCount: 0, lastPerformanceDiagnosticAt: -Infinity, diagnosticKey: randomBytes(16).toString('hex'), motionEncodeMs: 0,
208
252
  binding: { ...command }, abort: new AbortController(), retired: false, viewerReady: false, starting: false,
@@ -210,6 +254,8 @@ export class DesktopSessionSupervisor {
210
254
  controller: new DesktopRateController({ fpsCeiling: 30, maxFps: 30 }), rateBusy: false, capturePaused: false, lastFeedbackBytes: 0,
211
255
  activeBytes: 0, queuedAt: 0, lastPacketAt: performance.now(), receivedFrame: 0, firstCompleteFrame: 0, flow: new DesktopMediaFlowWindow(), delivery: new DesktopDeliveryDelay(), packetLoop: false, mediaWake: new Set(), nativeCreditDirty: false, nativeDisposed: { motion: 0, cursor: 0, exact: 0 }, configAdmittedEpoch: 0, recovery: [],
212
256
  overviewBudget: new DesktopOverviewBudget(), overviewSequence: 0, overviewInitialPolls: 0, challenges: new Map(), rtts: [], rtt: 0, nextPing: performance.now() + 10000 };
257
+ mediaRequire(this.retainedSessions.size < 1024);
258
+ this.retainedSessions.set(`${command.deviceGeneration}:${command.sessionId}:${command.controlGeneration}`, s);
213
259
  if (command.candidate)
214
260
  this.candidates.set(s.binding.sessionId, s);
215
261
  else
@@ -219,6 +265,12 @@ export class DesktopSessionSupervisor {
219
265
  await this.cleanup(previous);
220
266
  if (!this.current(s))
221
267
  return;
268
+ if (!openingCurrent()) {
269
+ this.options.report(command.deviceGeneration, { t: 'desktop_prepare_result', commandId: command.commandId, sessionId: command.sessionId, state: 'failed', code: 'busy' });
270
+ this.retire(s);
271
+ await this.cleanup(s);
272
+ return;
273
+ }
222
274
  mediaRequire(!this.failedCleanup);
223
275
  try {
224
276
  // Discovery is side-effect free and independent of the authenticated socket attach.
@@ -280,7 +332,8 @@ export class DesktopSessionSupervisor {
280
332
  }
281
333
  current(s) {
282
334
  return !this.closed && !s.retired &&
283
- (this.active === s || this.candidates.get(s.binding.sessionId) === s) && this.generation === s.binding.deviceGeneration;
335
+ (this.active === s || this.candidates.get(s.binding.sessionId) === s) && this.generation === s.binding.deviceGeneration &&
336
+ (s.binding.candidate || this.options.controllerCurrent?.(s.binding) !== false);
284
337
  }
285
338
  diagnostic(s, origin, error, packet, command) {
286
339
  // At most 16 fixed-shape records per session, before recovery resets its state.
@@ -527,6 +580,10 @@ export class DesktopSessionSupervisor {
527
580
  }
528
581
  return;
529
582
  }
583
+ if (message.type === 'select' && this.options.newWorkAllowed?.() === false) {
584
+ await this.send(s, { v: 1, type: 'error', sessionId: s.binding.sessionId, payload: { code: 'busy', retryable: true } });
585
+ return;
586
+ }
530
587
  if ('commandId' in message.payload && message.type !== 'audio_ready' && !message.type.startsWith('motion_cut_') && !(resumeAudio && message.type === 'audio_select')) {
531
588
  const id = message.payload.commandId, body = canonicalDesktopJson(message);
532
589
  const previous = s.commands.get(id);
@@ -573,24 +630,60 @@ export class DesktopSessionSupervisor {
573
630
  return;
574
631
  }
575
632
  case 'select': {
576
- if (this.candidates.get(s.binding.sessionId) === s)
577
- await this.enqueue(async () => {
578
- mediaRequire(this.current(s));
579
- const previous = this.active;
580
- if (previous) {
581
- this.retire(previous);
582
- await this.cleanup(previous);
583
- }
584
- mediaRequire(this.current(s) && !this.failedCleanup);
585
- this.candidates.delete(s.binding.sessionId);
586
- this.active = s;
587
- });
633
+ const operationCurrent = this.preparationCurrent(s.binding);
634
+ const busy = async () => {
635
+ if (s.commands.get(message.payload.commandId) === canonicalDesktopJson(message))
636
+ s.commands.delete(message.payload.commandId);
637
+ if (this.current(s))
638
+ await this.send(s, { v: 1, type: 'error', sessionId: s.binding.sessionId, payload: { code: 'busy', retryable: true } });
639
+ };
640
+ if (this.candidates.get(s.binding.sessionId) === s && !await this.enqueue(async () => {
641
+ mediaRequire(this.current(s));
642
+ if (!operationCurrent())
643
+ return false;
644
+ const previous = this.active;
645
+ if (previous) {
646
+ if (!operationCurrent(true))
647
+ return false;
648
+ this.retire(previous);
649
+ await this.cleanup(previous);
650
+ }
651
+ mediaRequire(this.current(s) && !this.failedCleanup);
652
+ if (!operationCurrent())
653
+ return false;
654
+ this.candidates.delete(s.binding.sessionId);
655
+ this.active = s;
656
+ s.binding.candidate = false;
657
+ return true;
658
+ })) {
659
+ await busy();
660
+ return;
661
+ }
662
+ if (!operationCurrent()) {
663
+ await busy();
664
+ return;
665
+ }
588
666
  mediaRequire(!s.backend && !s.selection);
589
667
  s.selection = message.payload;
590
668
  s.configuredCommand = message.payload.commandId;
591
669
  s.controller = new DesktopRateController({ fpsCeiling: message.payload.fpsCeiling, maxFps: message.payload.fpsCeiling });
592
- s.preparation = this.options.provider.prepare(message.payload, s.binding);
593
- const backend = await s.preparation;
670
+ s.preparation = this.options.provider.prepare(message.payload, s.binding, operationCurrent);
671
+ let backend;
672
+ try {
673
+ backend = await s.preparation;
674
+ }
675
+ catch (error) {
676
+ if (error instanceof NativeCommandError && error.code === 'busy') {
677
+ if (s.selection === message.payload) {
678
+ s.selection = undefined;
679
+ s.configuredCommand = undefined;
680
+ s.preparation = undefined;
681
+ }
682
+ await busy();
683
+ return;
684
+ }
685
+ throw error;
686
+ }
594
687
  if (!this.current(s)) {
595
688
  await backend.stop();
596
689
  return;
@@ -1,17 +1,37 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { wallpaperAssetSchema } from './lobby.js';
3
+ export const WALLPAPER_MAX_BYTES = 2 * 1024 * 1024;
4
+ export const WALLPAPER_MAX_JSON = 3 * 1024 * 1024;
3
5
  export function decodeWallpaper(text) {
4
- if (Buffer.byteLength(text) > 100_000)
6
+ if (Buffer.byteLength(text) > WALLPAPER_MAX_JSON)
5
7
  throw new Error('wallpaper_unavailable');
6
8
  const result = JSON.parse(text);
7
9
  const asset = wallpaperAssetSchema.parse(result.asset);
8
- if (typeof result.jpeg !== 'string' || result.jpeg.length > 87384)
10
+ if (typeof result.image !== 'string' || result.image.length > Math.ceil(WALLPAPER_MAX_BYTES / 3) * 4)
9
11
  throw new Error('wallpaper_unavailable');
10
- const jpeg = Buffer.from(result.jpeg, 'base64');
11
- if (jpeg.length < 4 || jpeg.length > 65536 || jpeg.toString('base64') !== result.jpeg || jpeg[0] !== 255 || jpeg[1] !== 216)
12
+ const bytes = Buffer.from(result.image, 'base64');
13
+ if (bytes.length < 20 || bytes.length > WALLPAPER_MAX_BYTES || bytes.toString('base64') !== result.image ||
14
+ bytes.toString('ascii', 0, 4) !== 'RIFF' || bytes.toString('ascii', 8, 12) !== 'WEBP' || bytes.readUInt32LE(4) + 8 !== bytes.length || asset.mimeType !== 'image/webp')
12
15
  throw new Error('wallpaper_unavailable');
13
- const version = createHash('sha256').update(JSON.stringify(asset)).update(jpeg).digest('hex');
14
- return { asset: { ...asset, version }, jpeg };
16
+ let dimensions;
17
+ for (let offset = 12; offset + 8 <= bytes.length;) {
18
+ const kind = bytes.toString('ascii', offset, offset + 4), size = bytes.readUInt32LE(offset + 4), data = offset + 8;
19
+ if (size > bytes.length - data || kind === 'ANIM' || kind === 'ANMF')
20
+ throw new Error('wallpaper_unavailable');
21
+ if (kind === 'VP8X' && (size !== 10 || (bytes[data] & 2)))
22
+ throw new Error('wallpaper_unavailable');
23
+ if (kind === 'VP8 ' && size >= 10 && !(bytes[data] & 1) && bytes[data + 3] === 0x9d && bytes[data + 4] === 1 && bytes[data + 5] === 0x2a)
24
+ dimensions = [bytes.readUInt16LE(data + 6) & 0x3fff, bytes.readUInt16LE(data + 8) & 0x3fff];
25
+ if (kind === 'VP8L' && size >= 5 && bytes[data] === 0x2f) {
26
+ const bits = bytes.readUInt32LE(data + 1);
27
+ dimensions = [(bits & 0x3fff) + 1, ((bits >>> 14) & 0x3fff) + 1];
28
+ }
29
+ offset = data + size + (size & 1);
30
+ }
31
+ if (!dimensions || dimensions[0] !== asset.width || dimensions[1] !== asset.height)
32
+ throw new Error('wallpaper_unavailable');
33
+ const version = createHash('sha256').update(JSON.stringify(asset)).update(bytes).digest('hex');
34
+ return { asset: { ...asset, version }, bytes };
15
35
  }
16
36
  export class XfceWallpaperReader {
17
37
  execute;
@@ -12,6 +12,7 @@
12
12
  import { createHash, randomUUID } from 'node:crypto';
13
13
  import { KeeperFrameTooLargeError, KeeperNotConnectedError, KeeperReplacementRequiredError, KeeperRequestError } from '../keepers/kit/client.js';
14
14
  import { KeeperLaunchError } from '../keepers/terminal/client.js';
15
+ import { RegistrationOwnershipError } from '../runtime/ownership.js';
15
16
  import { EXEC_CAPABILITY, EXEC_SIGNALS, EXEC_TERMINAL_TTL_MS, ExecErrorCode } from '../keepers/terminal/protocol.js';
16
17
  import { execArgv, execCwd, execDeadline, execEnvironment } from './command.js';
17
18
  import { DurableExec, EXEC_KILL_GRACE_MS } from './durable.js';
@@ -289,6 +290,8 @@ export class ExecPolicy {
289
290
  hello = (await keeper.ensure()).hello;
290
291
  }
291
292
  catch (error) {
293
+ if (error instanceof RegistrationOwnershipError)
294
+ throw error;
292
295
  // A repeat may be running in a keeper-terminal this daemon cannot reach right now: running it here too could run it twice.
293
296
  if (how.repeat) {
294
297
  this.log(`exec ${req.id}: repeated request while keeper-terminal is unreachable: ${messageOf(error)}`);
@@ -323,6 +326,8 @@ export class ExecPolicy {
323
326
  return { kind: 'durable', exec: DurableExec.follow(keeper, descriptor, { outlivesChannel, ...(wire ? { wire } : {}) }, this.durableOptions()) };
324
327
  }
325
328
  catch (error) {
329
+ if (error instanceof RegistrationOwnershipError)
330
+ throw error;
326
331
  // Another keeper-terminal generation owns an exec of this id (spec §3.1: two generations may coexist): it exists, so it is
327
332
  // never started again here and never run in this process.
328
333
  if (errorCode(error) === ExecErrorCode.foreign) {
@@ -78,6 +78,14 @@ export class FilesHostClient {
78
78
  return 0;
79
79
  return this.lastStatus.uploads + this.lastStatus.encodes;
80
80
  }
81
+ /** Removal must distinguish an idle host from one that has not supplied a current activity sample. */
82
+ activitySnapshot() {
83
+ return {
84
+ known: this.stopped ? !this.child && !this.spawning : !!this.child && this.hostUp && !this.retiring
85
+ && !!this.lastStatus && Date.now() - this.lastStatusAt <= this.timings.hungPinMs,
86
+ transfers: this.activity() + this.sent.size + this.waiting,
87
+ };
88
+ }
81
89
  /** Process id of the current host (diagnostics and tests). */
82
90
  get pid() { return this.child?.pid; }
83
91
  get isReady() { return this.ready; }
@@ -392,7 +400,8 @@ export class FilesHostClient {
392
400
  // Past the grace, stop feeding it: new requests wait for the restarted host while the ones in flight finish.
393
401
  if (overdue)
394
402
  this.ready = false;
395
- if (this.sent.size === 0 && (overdue || (this.lastStatus?.uploads ?? 0) === 0)) {
403
+ if (this.sent.size === 0 && (overdue || !!this.lastStatus
404
+ && this.lastStatus.uploads === 0 && this.lastStatus.encodes === 0 && this.lastStatus.inFlight === 0)) {
396
405
  this.restartRequestedAt = null;
397
406
  this.log('[files-host] workspaces changed; restarting');
398
407
  void this.shutdownChild(true);
@@ -19,6 +19,7 @@ let tools;
19
19
  let initializing = false;
20
20
  let closing;
21
21
  const inFlight = new Map();
22
+ const operations = new Set();
22
23
  function send(message) {
23
24
  if (!process.connected)
24
25
  return;
@@ -64,7 +65,7 @@ function run(request) {
64
65
  inFlight.set(request.id, controller);
65
66
  const deadline = request.deadlineAt === null ? undefined : setTimeout(() => controller.abort(), Math.max(0, request.deadlineAt - Date.now()));
66
67
  const current = tools;
67
- void (async () => {
68
+ const operation = (async () => {
68
69
  let reply;
69
70
  try {
70
71
  // runFileFrame never throws: errors (and cancellation) come back as the result frame's `error`.
@@ -89,6 +90,8 @@ function run(request) {
89
90
  status();
90
91
  send(reply);
91
92
  })();
93
+ operations.add(operation);
94
+ void operation.finally(() => operations.delete(operation));
92
95
  }
93
96
  /** Refuses new work, lets running calls finish within a budget (a hung IO cannot hold the exit forever), then exits. */
94
97
  function shutdown(code) {
@@ -96,7 +99,11 @@ function shutdown(code) {
96
99
  for (const controller of inFlight.values())
97
100
  controller.abort();
98
101
  const budget = new Promise(resolve => { setTimeout(resolve, DISPOSE_BUDGET_MS).unref(); });
99
- await Promise.race([tools?.dispose().catch(() => { }), budget]);
102
+ const dispose = async () => {
103
+ await Promise.allSettled([...operations]);
104
+ await tools?.dispose();
105
+ };
106
+ await Promise.race([dispose().catch(() => { }), budget]);
100
107
  process.exit(code);
101
108
  })();
102
109
  return closing;
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import fs from 'node:fs/promises';
17
17
  import path from 'node:path';
18
+ import { assertRegistrationOwner } from './runtime/ownership.js';
18
19
  const SUFFIX = '.jsonl';
19
20
  function parseEntry(line) {
20
21
  try {
@@ -58,7 +59,7 @@ export async function listLegacyJournalRefs(dir) {
58
59
  return refs.sort();
59
60
  }
60
61
  /** Every readable entry of `sessionRef`'s journal, in file order (unreadable lines skipped). A missing file is empty. */
61
- export async function readLegacyJournal(dir, sessionRef) {
62
+ export async function readLegacyJournal(dir, sessionRef, ownership) {
62
63
  let text;
63
64
  try {
64
65
  text = await fs.readFile(fileOf(dir, sessionRef), 'utf8');
@@ -73,8 +74,10 @@ export async function readLegacyJournal(dir, sessionRef) {
73
74
  if (line.length === 0)
74
75
  continue;
75
76
  const entry = parseEntry(line);
76
- if (entry)
77
+ if (entry) {
78
+ assertRegistrationOwner(JSON.parse(line), ownership, { directory: dir, kind: 'journal' });
77
79
  entries.push(entry);
80
+ }
78
81
  }
79
82
  return entries;
80
83
  }
@@ -1,6 +1,7 @@
1
1
  // Daemon-side: a typed `acp.*` client for keeper-acp (launch through KeeperLauncher, one long-lived KeeperClient per generation).
2
2
  // Exempt from the keeper-side import guard (it runs in the daemon), though it imports nothing but the kit and this keeper's protocol.
3
3
  import { KeeperClient } from '../kit/client.js';
4
+ import { assertRegistrationOwner, RegistrationOwnershipError } from '../../runtime/ownership.js';
4
5
  import { protocolWindow } from '../kit/protocol.js';
5
6
  import { ACP_PROTOCOL_VERSION, acpStream, } from './protocol.js';
6
7
  /** keeper-acp could not be reached (launch, connect or hello failed): the request was never sent. */
@@ -23,7 +24,7 @@ export class AcpKeeperClient {
23
24
  }
24
25
  get daemonProtocol() { return this.options.daemonProtocol ?? protocolWindow(ACP_PROTOCOL_VERSION); }
25
26
  clientFor(paths) {
26
- if (this.client && this.clientGen === paths.gen)
27
+ if (this.client && this.client.state !== 'closed' && this.clientGen === paths.gen)
27
28
  return this.client;
28
29
  this.client?.close();
29
30
  this.client = new KeeperClient({
@@ -48,6 +49,7 @@ export class AcpKeeperClient {
48
49
  get gen() { return this.options.gen; }
49
50
  /** The connected client of the current build, launching keeper-acp only when needed (a `gen`-bound client only connects). */
50
51
  async ensure() {
52
+ this.options.launcher.assertOwnership?.('acp', this.clientGen ?? this.options.gen);
51
53
  const current = this.client;
52
54
  if (current && current.state === 'ready')
53
55
  return current;
@@ -64,14 +66,43 @@ export class AcpKeeperClient {
64
66
  return client;
65
67
  }
66
68
  async call(method, params, options = {}) {
69
+ this.options.launcher.assertOwnership?.('acp', this.clientGen ?? this.options.gen);
67
70
  let client;
68
71
  try {
69
72
  client = await this.ensure();
70
73
  }
71
74
  catch (error) {
75
+ if (error instanceof RegistrationOwnershipError)
76
+ throw error;
72
77
  throw new AcpKeeperUnavailableError(error);
73
78
  }
74
- return client.request(method, params, options);
79
+ const ownership = this.options.launcher.ownership;
80
+ const input = params;
81
+ if (ownership && typeof input.sessionKey === 'string') {
82
+ const listed = await client.request('acp.list', {});
83
+ const existing = listed.sessions.find(session => session.sessionKey === input.sessionKey);
84
+ if (existing) {
85
+ if (this.options.launcher.assertSessionOwnership)
86
+ this.options.launcher.assertSessionOwnership(existing.label, this.clientGen);
87
+ else
88
+ assertRegistrationOwner(existing.label, ownership);
89
+ }
90
+ else if (method !== 'acp.spawn')
91
+ throw new RegistrationOwnershipError('Registration session owner is unknown');
92
+ }
93
+ if (ownership && method === 'acp.spawn')
94
+ params = { ...input, label: { ...input.label, registrationId: ownership.registrationId } };
95
+ const result = await client.request(method, params, options);
96
+ if (ownership && method === 'acp.list') {
97
+ const listed = result;
98
+ for (const session of listed.sessions) {
99
+ if (this.options.launcher.assertSessionOwnership)
100
+ this.options.launcher.assertSessionOwnership(session.label, this.clientGen);
101
+ else
102
+ assertRegistrationOwner(session.label, ownership);
103
+ }
104
+ }
105
+ return result;
75
106
  }
76
107
  spawn(params, options = {}) { return this.call('acp.spawn', params, options); }
77
108
  list() { return this.call('acp.list', {}); }
@@ -87,13 +118,18 @@ export class AcpKeeperClient {
87
118
  release(sessionKey) { return this.call('acp.release', { sessionKey }); }
88
119
  activity() { return this.call('keeper.activity', {}); }
89
120
  async subscribe(sessionKey, stream, handler, options) {
121
+ this.options.launcher.assertOwnership?.('acp', this.clientGen ?? this.options.gen);
90
122
  let client;
91
123
  try {
92
124
  client = await this.ensure();
93
125
  }
94
126
  catch (error) {
127
+ if (error instanceof RegistrationOwnershipError)
128
+ throw error;
95
129
  throw new AcpKeeperUnavailableError(error);
96
130
  }
131
+ if (this.options.launcher.ownership)
132
+ await this.attach(sessionKey);
97
133
  return client.subscribe(acpStream(stream, sessionKey), handler, options);
98
134
  }
99
135
  /** Drops the connection (keeper-acp and its adapters keep running). */
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { opendirSync, readFileSync, lstatSync, statSync } from 'node:fs';
3
3
  import { join, relative, sep } from 'node:path';
4
4
  /**
5
5
  * The keeper buildVersion: sha256 over the bundle bytes plus the sorted list of
@@ -14,22 +14,46 @@ import { join, relative, sep } from 'node:path';
14
14
  * unchanged keeper code does not change it.
15
15
  */
16
16
  const sha256 = (data) => createHash('sha256').update(data).digest('hex');
17
- function walkFiles(dir) {
17
+ function walkFiles(dir, limits) {
18
18
  const out = [];
19
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
20
- const full = join(dir, entry.name);
21
- if (entry.isDirectory())
22
- out.push(...walkFiles(full));
23
- else
24
- out.push(full);
25
- }
19
+ let entries = 0;
20
+ let bytes = 0;
21
+ const walk = (directory, depth) => {
22
+ if (limits && depth > limits.maxDepth)
23
+ throw Error('Native bundle depth limit');
24
+ const handle = opendirSync(directory, { bufferSize: 32 });
25
+ try {
26
+ for (;;) {
27
+ const entry = handle.readSync();
28
+ if (!entry)
29
+ break;
30
+ if (limits && ++entries > limits.maxEntries)
31
+ throw Error('Native bundle entry limit');
32
+ const full = join(directory, entry.name);
33
+ if (entry.isDirectory())
34
+ walk(full, depth + 1);
35
+ else {
36
+ if (limits?.maxBytes !== undefined) {
37
+ bytes += lstatSync(full).size;
38
+ if (bytes > limits.maxBytes)
39
+ throw Error('Native bundle byte limit');
40
+ }
41
+ out.push(full);
42
+ }
43
+ }
44
+ }
45
+ finally {
46
+ handle.closeSync();
47
+ }
48
+ };
49
+ walk(dir, 0);
26
50
  return out;
27
51
  }
28
52
  /** Relative (posix) paths of the native files carried next to a bundle, sorted. */
29
- export function nativeFilesOf(kindDir) {
53
+ export function nativeFilesOf(kindDir, limits) {
30
54
  let files;
31
55
  try {
32
- files = walkFiles(join(kindDir, 'node_modules'));
56
+ files = walkFiles(join(kindDir, 'node_modules'), limits);
33
57
  }
34
58
  catch (error) {
35
59
  if (error.code === 'ENOENT')
@@ -24,7 +24,7 @@ export class KeeperRequestTimeoutError extends Error {
24
24
  }
25
25
  export class KeeperNotConnectedError extends Error {
26
26
  code = 'not_connected';
27
- constructor(message = 'Keeper is not connected') { super(message); this.name = 'KeeperNotConnectedError'; }
27
+ constructor(message = 'Keeper is not connected', options) { super(message, options); this.name = 'KeeperNotConnectedError'; }
28
28
  }
29
29
  export class KeeperClosedError extends Error {
30
30
  code = 'closed';
@@ -154,7 +154,7 @@ function connectLink(socketPath, limits, timeoutMs, onEvt, onClose) {
154
154
  const timer = setTimeout(() => fail(new KeeperNotConnectedError('Timed out connecting to the keeper')), timeoutMs);
155
155
  const fail = (error) => { if (settled)
156
156
  return; settled = true; clearTimeout(timer); socket.destroy(); reject(error); };
157
- socket.once('error', (error) => fail(new KeeperNotConnectedError(error.message)));
157
+ socket.once('error', (error) => fail(new KeeperNotConnectedError(error.message, { cause: error })));
158
158
  socket.once('connect', () => {
159
159
  if (settled)
160
160
  return;