@playcraft/cli 0.1.15 → 0.1.17

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.
@@ -0,0 +1,201 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { z } from 'zod';
3
+ import { PLATFORM_ROUTES, modelConnectivityPath, platformEndpoint, } from '@playcraft/common/cloud-workspace/runtime';
4
+ const PROBE_TIMEOUT_MS = 4_000;
5
+ const CACHE_MS = 15_000;
6
+ const CREDENTIAL_RENEW_BEFORE_MS = 30_000;
7
+ const MAX_RESPONSE_BYTES = 16 * 1024;
8
+ const credentialSchema = z.object({
9
+ token: z.string().min(1), expiresAt: z.string().datetime(),
10
+ configId: z.string().uuid(), model: z.string().min(1).max(128),
11
+ claims: z.object({
12
+ environment: z.string(), userId: z.string(), workspaceId: z.string(), runtimeId: z.string(),
13
+ generation: z.number().int(), runtimeEpoch: z.number().int(),
14
+ }),
15
+ });
16
+ const caches = new WeakMap();
17
+ const ready = () => ({ status: 'ready' });
18
+ const unavailable = (reason) => ({ status: 'unavailable', reason });
19
+ function snapshot(state, workspaceId) {
20
+ let nativeReady = false;
21
+ try {
22
+ nativeReady = Boolean(state.native?.ready());
23
+ }
24
+ catch { /* A failed adapter stays unavailable. */ }
25
+ const { publicBaseUrl, controlToken, environment, userId, runtimeId, generation } = state.config;
26
+ return { publicBaseUrl, controlToken, environment, userId, runtimeId, generation,
27
+ runtimeEpoch: state.runtimeEpoch, workspaceId, nativeReady, destroyed: state.journal.destroyed(),
28
+ registered: state.config.workspaceMode !== 'multi' || Boolean(state.journal.getWorkspace(workspaceId)) };
29
+ }
30
+ function safeEndpoint(value) {
31
+ const url = new URL(value);
32
+ if (url.username || url.password || url.search || url.hash || /[%\\]/.test(url.pathname)
33
+ || (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) {
34
+ throw new Error('insecure_endpoint');
35
+ }
36
+ return url;
37
+ }
38
+ async function responseJson(response) {
39
+ const reader = response.body?.getReader();
40
+ if (!reader)
41
+ throw new Error('invalid_probe_response');
42
+ const chunks = [];
43
+ let size = 0;
44
+ try {
45
+ while (true) {
46
+ const item = await reader.read();
47
+ if (item.done)
48
+ break;
49
+ size += item.value.byteLength;
50
+ if (size > MAX_RESPONSE_BYTES)
51
+ throw new Error('invalid_probe_response');
52
+ chunks.push(item.value);
53
+ }
54
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
55
+ }
56
+ finally {
57
+ await reader.cancel().catch(() => undefined);
58
+ reader.releaseLock();
59
+ }
60
+ }
61
+ async function requestProbe(target, init, credentials = false) {
62
+ const signal = AbortSignal.timeout(PROBE_TIMEOUT_MS);
63
+ try {
64
+ const response = await fetch(target, { ...init, redirect: 'error', signal });
65
+ if (!response.ok) {
66
+ const offline = response.headers.has('ngrok-error-code');
67
+ const rejected = response.status === 401 || response.status === 403;
68
+ let reason = rejected ? 'endpoint_rejected' : offline ? 'endpoint_offline' : 'endpoint_unreachable';
69
+ if (credentials && !offline && !rejected) {
70
+ const body = await responseJson(response).catch(() => undefined);
71
+ if (signal.aborted)
72
+ return { state: unavailable('endpoint_timeout') };
73
+ if (body && typeof body === 'object' && 'reason' in body && body.reason === 'model_config_required')
74
+ reason = 'model_config_required';
75
+ }
76
+ else
77
+ await response.body?.cancel().catch(() => undefined);
78
+ return { state: unavailable(reason) };
79
+ }
80
+ let body;
81
+ try {
82
+ body = await responseJson(response);
83
+ }
84
+ catch {
85
+ return { state: unavailable(signal.aborted ? 'endpoint_timeout' : 'invalid_probe_response') };
86
+ }
87
+ return { state: ready(), body };
88
+ }
89
+ catch {
90
+ return { state: unavailable(signal.aborted ? 'endpoint_timeout' : 'endpoint_unreachable') };
91
+ }
92
+ }
93
+ function echoMatches(body, expected) {
94
+ return Boolean(body && typeof body === 'object' && !Array.isArray(body)
95
+ && Object.entries(expected).every(([key, value]) => body[key] === value));
96
+ }
97
+ function scope(config) {
98
+ return { runtimeId: config.runtimeId, environment: config.environment, workspaceId: config.workspaceId,
99
+ generation: config.generation, runtimeEpoch: config.runtimeEpoch };
100
+ }
101
+ function entrance(config, route) {
102
+ return safeEndpoint(platformEndpoint(config.publicBaseUrl, route));
103
+ }
104
+ async function callbackProbe(config) {
105
+ if (!config.publicBaseUrl)
106
+ return unavailable('endpoint_not_configured');
107
+ let endpoint;
108
+ try {
109
+ endpoint = entrance(config, PLATFORM_ROUTES.eventsProbe);
110
+ }
111
+ catch {
112
+ return unavailable('insecure_endpoint');
113
+ }
114
+ const nonce = randomUUID();
115
+ const response = await requestProbe(endpoint, {
116
+ method: 'POST', headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': config.environment },
117
+ body: JSON.stringify({ runtimeId: config.runtimeId, generation: config.generation,
118
+ runtimeEpoch: config.runtimeEpoch, workspaceId: config.workspaceId, nonce }),
119
+ });
120
+ if (response.state.status !== 'ready')
121
+ return response.state;
122
+ return echoMatches(response.body, { service: 'playcraft-runtime', ...scope(config), nonce }) ? ready() : unavailable('invalid_probe_response');
123
+ }
124
+ async function modelProxyProbe(config, entry) {
125
+ if (!config.publicBaseUrl)
126
+ return unavailable('endpoint_not_configured');
127
+ let credentialEndpoint;
128
+ try {
129
+ credentialEndpoint = entrance(config, PLATFORM_ROUTES.modelCredentials);
130
+ entrance(config, PLATFORM_ROUTES.modelProxy);
131
+ }
132
+ catch {
133
+ return unavailable('insecure_endpoint');
134
+ }
135
+ if (entry.credential && Date.parse(entry.credential.expiresAt) <= Date.now() + CREDENTIAL_RENEW_BEFORE_MS)
136
+ entry.credential = undefined;
137
+ if (!entry.credential) {
138
+ const response = await requestProbe(credentialEndpoint, {
139
+ method: 'POST', headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': config.environment },
140
+ body: JSON.stringify({ runtimeId: config.runtimeId, generation: config.generation, runtimeEpoch: config.runtimeEpoch, workspaceId: config.workspaceId }),
141
+ }, true);
142
+ if (response.state.status !== 'ready')
143
+ return response.state;
144
+ const parsed = credentialSchema.safeParse(response.body);
145
+ if (!parsed.success || !echoMatches(parsed.data.claims, { ...scope(config), userId: config.userId })
146
+ || Date.parse(parsed.data.expiresAt) <= Date.now() + 1_000)
147
+ return unavailable('invalid_probe_response');
148
+ entry.credential = parsed.data;
149
+ }
150
+ const credential = entry.credential;
151
+ const nonce = randomUUID();
152
+ const target = new URL(platformEndpoint(config.publicBaseUrl, modelConnectivityPath(credential.configId)));
153
+ target.searchParams.set('nonce', nonce);
154
+ const result = await requestProbe(target, { method: 'GET', headers: { authorization: `Bearer ${credential.token}`, 'playcraft-env': config.environment } });
155
+ if (result.state.reason === 'endpoint_rejected')
156
+ entry.credential = undefined;
157
+ if (result.state.status !== 'ready')
158
+ return result.state;
159
+ return echoMatches(result.body, { service: 'playcraft-model-proxy', ...scope(config), configId: credential.configId, nonce })
160
+ ? ready() : unavailable('invalid_probe_response');
161
+ }
162
+ async function probe(config, entry) {
163
+ if (config.destroyed || !config.registered) {
164
+ const state = unavailable(config.destroyed ? 'runtime_destroyed' : 'workspace_not_registered');
165
+ return { ...scope(config), checkedAt: new Date().toISOString(), callback: state, modelProxy: state, conversation: state };
166
+ }
167
+ const [callback, modelProxy] = await Promise.all([callbackProbe(config), modelProxyProbe(config, entry)]);
168
+ const conversation = !config.nativeReady ? unavailable('adapter_not_started')
169
+ : callback.status !== 'ready' ? unavailable('callback_unavailable')
170
+ : modelProxy.status !== 'ready' ? unavailable('model_proxy_unavailable') : ready();
171
+ return { ...scope(config), checkedAt: new Date().toISOString(), callback, modelProxy, conversation };
172
+ }
173
+ /** Check the configured services without starting a thread or generating a model response. */
174
+ export function probeRuntimeConnectivity(state, workspaceId) {
175
+ const config = snapshot(state, workspaceId);
176
+ const key = JSON.stringify(config);
177
+ let cache = caches.get(state);
178
+ if (!cache) {
179
+ cache = new Map();
180
+ caches.set(state, cache);
181
+ }
182
+ const previous = cache.get(workspaceId);
183
+ if (previous?.key === key) {
184
+ if (previous.pending)
185
+ return previous.pending;
186
+ if (previous.value && previous.expiresAt > Date.now())
187
+ return Promise.resolve(previous.value);
188
+ }
189
+ const entry = previous?.key === key ? previous : { key, expiresAt: 0 };
190
+ cache.set(workspaceId, entry);
191
+ if (cache.size > 256)
192
+ cache.delete(cache.keys().next().value);
193
+ entry.pending = probe(config, entry).then((value) => {
194
+ if (JSON.stringify(snapshot(state, workspaceId)) !== key)
195
+ return probeRuntimeConnectivity(state, workspaceId);
196
+ entry.value = value;
197
+ entry.expiresAt = Date.now() + CACHE_MS;
198
+ return value;
199
+ }).finally(() => { entry.pending = undefined; });
200
+ return entry.pending;
201
+ }
@@ -2,7 +2,8 @@ import { withObservedFile } from '../workspaces/revisions.js';
2
2
  import { revisionScope, workspaceRoot, workspaceSessions } from '../workspaces/registry.js';
3
3
  import { join } from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
- import { checkpointRecordSchema, fileWritePayloadSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, parameterPatchPayloadSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, workspaceSkillsUpdatePayloadSchema, } from '@playcraft/common/cloud-workspace/runtime';
5
+ import { z } from 'zod';
6
+ import { PLATFORM_ROUTES, platformEndpoint, checkpointRecordSchema, fileWritePayloadSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, parameterPatchPayloadSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, workspaceSkillsUpdatePayloadSchema, } from '@playcraft/common/cloud-workspace/runtime';
6
7
  import { closeCliCredentials } from '../workspaces/cli-credentials.js';
7
8
  import { resolveWorkspaceAttachments } from '../workspaces/attachments.js';
8
9
  import { readWorkspaceSkillSnapshot, readWorkspaceSkills } from '../workspaces/skills.js';
@@ -123,7 +124,7 @@ async function executeScopedOperation(state, scopeKey, operation, payload) {
123
124
  }
124
125
  try {
125
126
  if (operation.kind === 'session.create') {
126
- return executeCreate(state, scopeKey, current);
127
+ return executeCreate(state, scopeKey, current, payload);
127
128
  }
128
129
  if (operation.kind === 'session.turn') {
129
130
  return executeTurn(state, scopeKey, current, payload);
@@ -171,13 +172,17 @@ async function executeSkillsUpdate(state, scopeKey, operation, payload) {
171
172
  }));
172
173
  }
173
174
  }
174
- async function executeCreate(state, scopeKey, operation) {
175
+ async function executeCreate(state, scopeKey, operation, payload) {
175
176
  const sessionId = required(operation.sessionId, 'sessionId');
177
+ const selected = z.string().uuid().optional().safeParse(asRecord(payload).modelConfigId);
178
+ if (!selected.success)
179
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'model_config_denied' }));
176
180
  let started;
177
181
  try {
178
182
  started = await native(state).startThread({
179
183
  sessionId,
180
184
  cwd: await workspaceRoot(state, operation.workspaceId),
185
+ modelConfigId: selected.data,
181
186
  });
182
187
  }
183
188
  catch (error) {
@@ -195,10 +200,12 @@ async function executeCreate(state, scopeKey, operation) {
195
200
  threadId: started.threadId,
196
201
  cwd: await workspaceRoot(state, operation.workspaceId),
197
202
  recoverability: 'native',
203
+ modelConfigId: started.modelConfigId,
198
204
  });
199
205
  const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted', {
200
206
  nativeRequestId: `thread/start:${sessionId}`,
201
207
  nativeThreadId: started.threadId,
208
+ modelConfigId: started.modelConfigId,
202
209
  }));
203
210
  await recordNativeEvents(state, accepted, [
204
211
  { type: 'session.created', payload: { sessionId, threadId: started.threadId }, occurredAt: new Date().toISOString() },
@@ -218,6 +225,9 @@ async function executeTurn(state, scopeKey, operation, payload) {
218
225
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'turn_already_active' }));
219
226
  }
220
227
  const body = asRecord(payload);
228
+ const selected = z.string().uuid().optional().safeParse(body.modelConfigId);
229
+ if (!selected.success)
230
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'model_config_denied' }));
221
231
  let attachments;
222
232
  try {
223
233
  attachments = await resolveWorkspaceAttachments(await workspaceRoot(state, operation.workspaceId), body.attachmentIds);
@@ -233,15 +243,17 @@ async function executeTurn(state, scopeKey, operation, payload) {
233
243
  text: String(body.text ?? '') + attachmentText,
234
244
  images: attachments.filter((file) => file.mimeType.startsWith('image/')).map((file) => file.path),
235
245
  clientRequestId: operation.clientRequestId,
246
+ modelConfigId: selected.data ?? binding.modelConfigId,
236
247
  simulateLostReceipt: body.simulateLostReceipt === true,
237
- persistReservation: async ({ nativeRequestId }) => {
248
+ persistReservation: async ({ nativeRequestId, modelConfigId }) => {
238
249
  await persist(state, scopeKey, {
239
250
  ...operation,
240
251
  nativeRequestId,
241
252
  nativeThreadId: binding.threadId,
253
+ modelConfigId,
242
254
  updatedAt: new Date().toISOString(),
243
255
  });
244
- await state.journal.putSession({ ...binding, turnStatus: 'starting' });
256
+ await state.journal.putSession({ ...binding, modelConfigId, turnStatus: 'starting' });
245
257
  },
246
258
  });
247
259
  const reserved = state.journal.getOperation(scopeKey) ?? operation;
@@ -354,6 +366,7 @@ async function executeCheckpoint(state, scopeKey, operation, payload) {
354
366
  const sessions = workspaceSessions(state, operation.workspaceId).map((item) => ({
355
367
  sessionId: item.sessionId,
356
368
  nativeThreadId: item.threadId,
369
+ modelConfigId: item.modelConfigId,
357
370
  recoverability: 'history_only',
358
371
  }));
359
372
  const checkpoint = checkpointRecordSchema.parse({
@@ -385,7 +398,7 @@ async function executeCheckpoint(state, scopeKey, operation, payload) {
385
398
  git: standalone ? 'full' : 'local',
386
399
  });
387
400
  // Transcripts only matter where another instance can read them back.
388
- const transcripts = state.config.fileObjectStoreUrl ? await captureTranscripts(state, operation.workspaceId) : [];
401
+ const transcripts = state.config.checkpointObjects ? await captureTranscripts(state, operation.workspaceId) : [];
389
402
  const digest = liveTreeDigest(tree, transcripts);
390
403
  if (!requested && tree.status === 'complete') {
391
404
  const stillActive = await activeManagedWrites(state, workspaceId);
@@ -396,14 +409,14 @@ async function executeCheckpoint(state, scopeKey, operation, payload) {
396
409
  return finishUnchanged(state, scopeKey, operation, previous);
397
410
  }
398
411
  }
399
- if (state.config.fileObjectStoreUrl) {
412
+ if (state.config.checkpointObjects) {
400
413
  const stillActive = await activeManagedWrites(state, workspaceId);
401
414
  if (stillActive.length > 0) {
402
415
  return finishBestEffort(state, scopeKey, operation, checkpointId, workspaceId, createdAt, stillActive.map((item) => item.source));
403
416
  }
404
417
  try {
405
418
  published = await publishFileSnapshot({
406
- baseUrl: state.config.fileObjectStoreUrl,
419
+ baseUrl: platformEndpoint(state.config.publicBaseUrl, PLATFORM_ROUTES.checkpointStore),
407
420
  snapshotId: checkpointId,
408
421
  workspaceId,
409
422
  generation: state.config.generation,
@@ -411,7 +424,7 @@ async function executeCheckpoint(state, scopeKey, operation, payload) {
411
424
  native: transcripts,
412
425
  uploaded: workspaceSaves(state, workspaceId).uploaded,
413
426
  uploadedTranscripts: workspaceSaves(state, workspaceId).transcripts,
414
- headers: objectStoreHeaders(state, state.config.fileObjectStoreUrl),
427
+ headers: objectStoreHeaders(state),
415
428
  });
416
429
  }
417
430
  catch {
@@ -421,6 +434,7 @@ async function executeCheckpoint(state, scopeKey, operation, payload) {
421
434
  const sessions = workspaceSessions(state, operation.workspaceId).map((item) => ({
422
435
  sessionId: item.sessionId,
423
436
  nativeThreadId: item.threadId,
437
+ modelConfigId: item.modelConfigId,
424
438
  recoverability: 'history_only',
425
439
  }));
426
440
  const checkpoint = checkpointRecordSchema.parse({
@@ -513,13 +527,14 @@ async function executeRestore(state, scopeKey, operation, payload) {
513
527
  if (!checkpoint.nativeRecoverable || threadReplaced) {
514
528
  return {
515
529
  ...item,
530
+ modelConfigId: snap.modelConfigId ?? item.modelConfigId,
516
531
  threadId,
517
532
  recoverability: restarted ? 'history_only' : item.recoverability,
518
533
  };
519
534
  }
520
535
  if (restarted)
521
- return { ...item, threadId, recoverability: 'history_only' };
522
- return { ...item, threadId: snap.nativeThreadId ?? item.threadId, recoverability: 'native' };
536
+ return { ...item, modelConfigId: snap.modelConfigId ?? item.modelConfigId, threadId, recoverability: 'history_only' };
537
+ return { ...item, modelConfigId: snap.modelConfigId ?? item.modelConfigId, threadId: snap.nativeThreadId ?? item.threadId, recoverability: 'native' };
523
538
  });
524
539
  for (const snap of checkpoint.sessions) {
525
540
  if (!next.some((item) => item.sessionId === snap.sessionId)) {
@@ -527,6 +542,7 @@ async function executeRestore(state, scopeKey, operation, payload) {
527
542
  sessionId: snap.sessionId,
528
543
  workspaceId: operation.workspaceId,
529
544
  threadId: snap.nativeThreadId ?? `thread_${snap.sessionId}`,
545
+ modelConfigId: snap.modelConfigId,
530
546
  cwd,
531
547
  recoverability: checkpoint.nativeRecoverable && !restarted ? 'native' : 'history_only',
532
548
  });
@@ -545,6 +561,7 @@ async function executeRestore(state, scopeKey, operation, payload) {
545
561
  threadId,
546
562
  sessionId: snap.sessionId,
547
563
  cwd: found.cwd,
564
+ modelConfigId: found.modelConfigId,
548
565
  });
549
566
  if (!continued || found.threadId !== threadId)
550
567
  continue;
@@ -944,12 +961,14 @@ async function captureNativeSessions(state, workspaceId) {
944
961
  ? {
945
962
  sessionId: item.sessionId,
946
963
  nativeThreadId: item.threadId,
964
+ modelConfigId: item.modelConfigId,
947
965
  nativeDurableTurnId: durable,
948
966
  recoverability: 'native',
949
967
  }
950
968
  : {
951
969
  sessionId: item.sessionId,
952
970
  nativeThreadId: item.threadId,
971
+ modelConfigId: item.modelConfigId,
953
972
  recoverability: 'history_only',
954
973
  });
955
974
  }
@@ -984,6 +1003,7 @@ async function finishBestEffort(state, scopeKey, operation, checkpointId, worksp
984
1003
  const sessions = workspaceSessions(state, operation.workspaceId).map((item) => ({
985
1004
  sessionId: item.sessionId,
986
1005
  nativeThreadId: item.threadId,
1006
+ modelConfigId: item.modelConfigId,
987
1007
  recoverability: 'history_only',
988
1008
  }));
989
1009
  const checkpoint = checkpointRecordSchema.parse({
@@ -1074,7 +1094,7 @@ function nativeIds(params) {
1074
1094
  };
1075
1095
  }
1076
1096
  async function notifyCallback(state, events) {
1077
- await fetch(state.config.callbackUrl, {
1097
+ await fetch(platformEndpoint(state.config.publicBaseUrl, PLATFORM_ROUTES.events), {
1078
1098
  method: 'POST',
1079
1099
  headers: {
1080
1100
  authorization: `Bearer ${state.config.controlToken}`,
@@ -1,10 +1,10 @@
1
1
  import { chmod, rename, writeFile } from 'node:fs/promises';
2
2
  import { applySandboxEndpointPatch } from '@playcraft/common/cloud-workspace/runtime';
3
- /** Persist the new addresses, then publish them to the running process. A restart is not required. */
3
+ /** Persist the new public entrance, then publish it to the running process. A restart is not required. */
4
4
  export async function applyRuntimeEndpointPatch(state, patch) {
5
5
  const next = applySandboxEndpointPatch(state.config, patch);
6
- const changed = next.callbackUrl !== state.config.callbackUrl || next.modelProxyUrl !== state.config.modelProxyUrl;
7
- const result = { changed, callbackUrl: next.callbackUrl, modelProxyUrl: next.modelProxyUrl };
6
+ const changed = next.publicBaseUrl !== state.config.publicBaseUrl;
7
+ const result = { changed, publicBaseUrl: next.publicBaseUrl };
8
8
  if (!changed)
9
9
  return result;
10
10
  if (!state.configPath) {
@@ -1,7 +1,7 @@
1
1
  import { readObservedFile, withObservedFile } from '../workspaces/revisions.js';
2
2
  import { registerWorkspace, revisionScope, workspaceRoot, WorkspaceContextError } from '../workspaces/registry.js';
3
3
  import { randomUUID } from 'node:crypto';
4
- import { workspaceRegistrationRequestSchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, gitFileRefSchema, hashOperationPayload, instanceRebuildRequestSchema, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, resolveIdempotentAccept, nativeHistoryReadSchema, runtimeError, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
4
+ import { workspaceRegistrationRequestSchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, gitFileRefSchema, hashOperationPayload, instanceRebuildRequestSchema, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, resolveIdempotentAccept, nativeHistoryReadSchema, runtimeError, runtimeConnectivityDocumentSchema, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
5
5
  import { controlTokenMatches, extractControlToken } from './auth.js';
6
6
  import { applyRuntimeEndpointPatch } from './endpoints.js';
7
7
  import { canExecuteKind, executeOperation } from './dispatch.js';
@@ -17,6 +17,7 @@ import { readWorkspaceSkills } from '../workspaces/skills.js';
17
17
  import { loadWorkspaceSkillArtifact } from '../workspaces/skill-artifact-loader.js';
18
18
  import { join } from 'node:path';
19
19
  import { terminalHost } from './terminal-host.js';
20
+ import { probeRuntimeConnectivity } from './connectivity.js';
20
21
  export async function handleRuntimeRequest(state, request, response) {
21
22
  const url = new URL(request.url ?? '/', 'http://127.0.0.1');
22
23
  const access = authorizeRuntime(state, request);
@@ -33,6 +34,19 @@ export async function handleRuntimeRequest(state, request, response) {
33
34
  writeJson(response, 200, { status: 'ok', handshake: handshake(state) });
34
35
  return;
35
36
  }
37
+ if (request.method === 'GET' && url.pathname === '/runtime/connectivity') {
38
+ if (writeDestroyed(state, response))
39
+ return;
40
+ const parsed = runtimeConnectivityDocumentSchema.shape.workspaceId.safeParse(url.searchParams.get('workspaceId'));
41
+ if (!parsed.success) {
42
+ writeJson(response, 400, { error: 'invalid_workspace' });
43
+ return;
44
+ }
45
+ const workspaceId = parsed.data;
46
+ await workspaceRoot(state, workspaceId);
47
+ writeJson(response, 200, await probeRuntimeConnectivity(state, workspaceId));
48
+ return;
49
+ }
36
50
  if (request.method === 'GET' && url.pathname === '/runtime/identity') {
37
51
  const { runtimeId, userId, environment, generation } = state.config;
38
52
  writeJson(response, 200, {
@@ -58,17 +58,8 @@ export async function rememberFileCheckpoint(state, workspaceId, saved) {
58
58
  await rm(join(state.config.stateDir, 'file-checkpoints', checkpointId), { recursive: true, force: true }).catch(() => undefined);
59
59
  }
60
60
  }
61
- /**
62
- * The control credential goes only to Runtime's own object endpoint, never to another store.
63
- */
64
- export function objectStoreHeaders(state, storeUrl) {
65
- try {
66
- if (new URL(storeUrl).origin !== new URL(state.config.callbackUrl).origin)
67
- return {};
68
- }
69
- catch {
70
- return {};
71
- }
61
+ /** Checkpoint uploads always go to this deployment, so they always carry the control credential. */
62
+ export function objectStoreHeaders(state) {
72
63
  return {
73
64
  authorization: `Bearer ${state.config.controlToken}`,
74
65
  'playcraft-env': state.config.environment,
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { z } from 'zod';
5
+ import { PLATFORM_ROUTES, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
5
6
  const responseSchema = z.object({
6
7
  token: z.string().min(1),
7
8
  apiUrl: z.string().url(),
@@ -36,11 +37,7 @@ export async function ensureCliCredentials(state) {
36
37
  async function renew(state, entry) {
37
38
  const config = state.config;
38
39
  const epoch = state.runtimeEpoch;
39
- const endpoint = new URL('/runtime/internal/cli-credentials', config.callbackUrl);
40
- if (endpoint.protocol !== 'https:' &&
41
- !(endpoint.protocol === 'http:' &&
42
- ['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname)))
43
- throw new Error('cli_callback_insecure');
40
+ const endpoint = platformEndpoint(config.publicBaseUrl, PLATFORM_ROUTES.cliCredentials);
44
41
  try {
45
42
  const response = await fetch(endpoint, {
46
43
  method: 'POST',
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url';
8
8
  import { promisify } from 'node:util';
9
9
  import { isLocalWorkspaceGitAuthMode, isLocalWorkspaceGitEnvironment, matchWorkspaceGitGatewayCloneUrl, workspacePreparationSchema } from '@playcraft/common/cloud-workspace/runtime';
10
10
  import { WorkspaceGitDirectStore } from './git-direct-store.js';
11
+ import { PLATFORM_ROUTES, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
11
12
  const exec = promisify(execFile);
12
13
  /** Only this control process sees the callback credential. Development processes use the local socket. */
13
14
  export class WorkspaceGitCredentialBroker {
@@ -164,9 +165,7 @@ export class WorkspaceGitCredentialBroker {
164
165
  await this.direct.sweep(true);
165
166
  }
166
167
  async refresh(binding, key) {
167
- const endpoint = new URL('/runtime/internal/git-credentials', this.state.config.callbackUrl);
168
- if (endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname)))
169
- throw new Error('git_broker_unavailable');
168
+ const endpoint = platformEndpoint(this.state.config.publicBaseUrl, PLATFORM_ROUTES.gitCredentials);
170
169
  const epoch = this.state.runtimeEpoch;
171
170
  const response = await fetch(endpoint, {
172
171
  method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10_000),
@@ -1,5 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { workspaceRoot } from './registry.js';
3
+ import { PLATFORM_ROUTES, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
3
4
  const MAX_ARTIFACT_BYTES = 80 * 1024 * 1024;
4
5
  /** Requests an exact locked artifact using control identity; no storage credentials reach Codex. */
5
6
  export async function loadWorkspaceSkillArtifact(state, workspaceId, entry) {
@@ -7,9 +8,7 @@ export async function loadWorkspaceSkillArtifact(state, workspaceId, entry) {
7
8
  const epoch = state.runtimeEpoch;
8
9
  if (state.journal.destroyed())
9
10
  throw new Error('workspace_skills_unavailable');
10
- const endpoint = new URL('/runtime/internal/skill-artifacts', state.config.callbackUrl);
11
- if (endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname)))
12
- throw new Error('workspace_skills_callback_insecure');
11
+ const endpoint = platformEndpoint(state.config.publicBaseUrl, PLATFORM_ROUTES.skillArtifacts);
13
12
  const response = await fetch(endpoint, {
14
13
  method: 'POST', redirect: 'error', signal: AbortSignal.timeout(30_000),
15
14
  headers: { authorization: `Bearer ${state.config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': state.config.environment },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -48,8 +48,8 @@
48
48
  "@gltf-transform/core": "^4.3.0",
49
49
  "@gltf-transform/extensions": "^4.3.0",
50
50
  "@gltf-transform/functions": "^4.3.0",
51
- "@playcraft/build": "^0.1.15",
52
- "@playcraft/common": "^0.1.15",
51
+ "@playcraft/build": "^0.1.17",
52
+ "@playcraft/common": "^0.1.17",
53
53
  "better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
54
54
  "chokidar": "^4.0.3",
55
55
  "commander": "^13.1.0",