@playcraft/cli 0.1.16 → 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.
@@ -9,15 +9,6 @@ function publicBase(value) {
9
9
  function append(base, path) {
10
10
  return new URL(`${base.toString().replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`);
11
11
  }
12
- /** Keep the deployment prefix when deriving a control endpoint from its callback address. */
13
- export function runtimeCallbackEndpoint(callbackUrl, path) {
14
- const base = publicBase(callbackUrl);
15
- base.pathname = base.pathname.replace(/\/runtime\/(?:callbacks|internal\/events)$/, '');
16
- // Older local runtimes used a root-level events callback.
17
- if (base.pathname === '/events')
18
- base.pathname = '';
19
- return append(base, path);
20
- }
21
12
  /** Accept either a public deployment base or its /api endpoint, without duplicating /api. */
22
13
  export function businessEndpoint(apiUrl, path) {
23
14
  const base = publicBase(apiUrl);
@@ -1,9 +1,10 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { createHash, randomUUID } from 'node:crypto';
3
3
  import { startLauncherProcess } from './launcher-client.js';
4
- import { readFile } from 'node:fs/promises';
4
+ import { chmod, mkdtemp, rm } from 'node:fs/promises';
5
5
  import { join } from 'node:path';
6
- import { writePlaycraftCodexConfig } from './config-toml.js';
6
+ import { tmpdir } from 'node:os';
7
+ import { portableCodexConfig, writePortableCodexCatalog } from './config-toml.js';
7
8
  import { createJsonRpcStdioClient } from './jsonrpc-stdio.js';
8
9
  import { projectNativeThread } from '@playcraft/common/cloud-workspace/runtime';
9
10
  import { collectedEvent, NativePreparationError } from './native-adapter.js';
@@ -38,18 +39,6 @@ function spawnCommand(bin) {
38
39
  }
39
40
  return { command: bin, prefix: [] };
40
41
  }
41
- async function readToken(options) {
42
- if (options.token?.trim())
43
- return options.token.trim();
44
- const fromEnv = process.env.PLAYCRAFT_MODEL_PROXY_TOKEN?.trim() || process.env.OPENAI_API_KEY?.trim() || '';
45
- if (fromEnv)
46
- return fromEnv;
47
- const tokenFile = options.tokenFile || process.env.PLAYCRAFT_MODEL_PROXY_TOKEN_FILE;
48
- if (tokenFile) {
49
- return (await readFile(tokenFile, 'utf8')).trim();
50
- }
51
- throw new Error('model_proxy_token_missing');
52
- }
53
42
  export function createCodexAppServerAdapter(options) {
54
43
  const launcherSocket = options.launcherSocket || process.env.PLAYCRAFT_LAUNCHER_SOCKET;
55
44
  const homeDir = options.homeDir || process.env.PLAYCRAFT_CODEX_HOME ||
@@ -64,6 +53,7 @@ export function createCodexAppServerAdapter(options) {
64
53
  let readyFlag = false;
65
54
  let closed = false;
66
55
  let startPromise;
56
+ let catalogDirectory;
67
57
  let skillsRestartFailed = false;
68
58
  let preparationQueue = Promise.resolve();
69
59
  let live;
@@ -107,24 +97,25 @@ export function createCodexAppServerAdapter(options) {
107
97
  startPromise ??= start();
108
98
  await startPromise;
109
99
  }
110
- async function threadConfig(cwd) {
100
+ async function threadConfig(cwd, modelConfigId, sessionId) {
111
101
  try {
112
- return await options.modelBroker?.threadConfig(cwd) ?? {};
102
+ return await options.modelBroker?.prepareThreadConfig(cwd, modelConfigId, sessionId)
103
+ ?? { modelConfigId: undefined, activate: () => { } };
113
104
  }
114
105
  catch (error) {
115
106
  const reason = error instanceof Error ? error.message : '';
116
- throw new NativePreparationError(['model_config_required', 'model_callback_insecure',
117
- 'model_credential_scope_mismatch', 'workspace_not_registered'].includes(reason)
107
+ throw new NativePreparationError(['model_config_required',
108
+ 'model_credential_scope_mismatch', 'model_config_denied', 'workspace_not_registered'].includes(reason)
118
109
  ? reason : 'model_credential_unavailable');
119
110
  }
120
111
  }
121
- async function lockedThreadConfig(cwd) {
122
- const modelConfig = await threadConfig(cwd);
112
+ async function lockedThreadConfig(cwd, selectedConfigId, sessionId) {
113
+ const { modelConfigId, activate, ...modelConfig } = await threadConfig(cwd, selectedConfigId, sessionId);
123
114
  // Single-workspace legacy runners retain their prior behaviour until explicitly adopted.
124
115
  if (await readProjectSkillsLockFile(cwd) === null) {
125
116
  if (options.config.workspaceMode === 'multi')
126
117
  throw new NativePreparationError('workspace_skills_unavailable');
127
- return { params: modelConfig, skillsHash: undefined, discoveryHash: undefined };
118
+ return { params: modelConfig, modelConfigId, activate, skillsHash: undefined, discoveryHash: undefined };
128
119
  }
129
120
  try {
130
121
  const frozen = await freezeWorkspaceSkills(cwd, join(options.config.stateDir, 'skill-artifacts'), options.loadSkillArtifact ? entry => options.loadSkillArtifact(cwd, entry) : undefined);
@@ -145,13 +136,13 @@ export function createCodexAppServerAdapter(options) {
145
136
  ...(frozen.skills.length ? [] : ['(No workspace skills are enabled.)']),
146
137
  ].join('\n');
147
138
  const discoveryHash = createHash('sha256').update(JSON.stringify(listing.skills.map(skill => skill.path).sort())).digest('hex');
148
- return { params: { ...modelConfig, config, developerInstructions }, skillsHash: frozen.lockHash, discoveryHash };
139
+ return { params: { ...modelConfig, config, developerInstructions }, modelConfigId, activate, skillsHash: frozen.lockHash, discoveryHash };
149
140
  }
150
141
  catch {
151
142
  throw new NativePreparationError('workspace_skills_unavailable');
152
143
  }
153
144
  }
154
- async function restartForSkills(threadId) {
145
+ async function restartForSkills(threadId, selectedConfigId) {
155
146
  if ([...threads.values()].some(thread => thread.turnActive) || approvals.size)
156
147
  throw new NativePreparationError('skills_reload_pending');
157
148
  const previous = live;
@@ -182,7 +173,7 @@ export function createCodexAppServerAdapter(options) {
182
173
  // Other workspaces resume lazily, so a damaged unrelated lock cannot block this workspace.
183
174
  const thread = threads.get(threadId);
184
175
  if (thread?.hasNativeHistory && thread.cwd)
185
- await resumePreparedThread(threadId, thread, await lockedThreadConfig(thread.cwd));
176
+ await resumePreparedThread(threadId, thread, await lockedThreadConfig(thread.cwd, selectedConfigId ?? thread.modelConfigId, thread.sessionId));
186
177
  }
187
178
  catch {
188
179
  readyFlag = false;
@@ -209,24 +200,33 @@ export function createCodexAppServerAdapter(options) {
209
200
  if (closed)
210
201
  throw new Error('codex_not_started');
211
202
  await options.modelBroker?.start();
212
- const token = options.modelBroker ? 'workspace-broker-managed' : await readToken(options);
213
203
  if (closed)
214
204
  throw new Error('codex_not_started');
215
205
  const bin = options.command || process.env.PLAYCRAFT_CODEX_BIN || (launcherSocket ? '/usr/local/bin/codex' : 'codex');
216
206
  const { command, prefix } = spawnCommand(bin);
207
+ const launchArgs = [...prefix, ...(options.args ?? ['app-server'])];
208
+ const catalogArgs = launchArgs.at(-1) === 'app-server' ? launchArgs.slice(0, -1) : launchArgs;
209
+ let catalogPath;
210
+ try {
211
+ // Only public model metadata is shared with the development identity.
212
+ // A private, control-owned directory keeps it outside writable project files.
213
+ catalogDirectory ??= await mkdtemp(join(tmpdir(), 'playcraft-codex-catalog-'));
214
+ await chmod(catalogDirectory, 0o755);
215
+ catalogPath = await writePortableCodexCatalog({ directory: catalogDirectory, command, args: catalogArgs });
216
+ }
217
+ catch {
218
+ throw new NativePreparationError('codex_model_catalog_unavailable');
219
+ }
220
+ if (closed)
221
+ throw new Error('codex_not_started');
217
222
  const args = [...prefix, ...(options.args ?? ['app-server'])];
218
223
  if (launcherSocket) {
219
- // Configuration travels as non-secret overrides: control cannot write dev's home.
224
+ // Thread model access comes from the broker. These overrides carry no proxy address or token.
220
225
  for (const [key, value] of Object.entries({
221
226
  model,
222
- model_provider: 'playcraft',
223
227
  approval_policy: 'never',
224
228
  sandbox_mode: 'danger-full-access',
225
- 'model_providers.playcraft.name': 'PlayCraft Model Proxy',
226
- 'model_providers.playcraft.base_url': options.config.modelProxyUrl.replace(/\/+$/, ''),
227
- 'model_providers.playcraft.env_key': 'OPENAI_API_KEY',
228
- 'model_providers.playcraft.wire_api': 'responses',
229
- 'model_providers.playcraft.requires_openai_auth': false,
229
+ ...portableCodexConfig(catalogPath),
230
230
  }))
231
231
  args.push('-c', `${key}=${JSON.stringify(value)}`);
232
232
  child = await startLauncherProcess({
@@ -237,16 +237,15 @@ export function createCodexAppServerAdapter(options) {
237
237
  // The launcher admits the dev home, not the shared /workspaces root.
238
238
  // Each thread supplies its registered workspace separately.
239
239
  cwd: options.config.workspaceMode === 'multi' ? '/home/playcraft-dev' : options.config.workspaceRoot,
240
- env: { CODEX_HOME: homeDir, OPENAI_API_KEY: token },
240
+ env: { CODEX_HOME: homeDir },
241
241
  });
242
242
  }
243
243
  else {
244
- await writePlaycraftCodexConfig({ homeDir, modelProxyUrl: options.config.modelProxyUrl, model });
245
244
  if (closed)
246
245
  throw new Error('codex_not_started');
247
246
  const local = spawn(command, args, {
248
247
  cwd: options.config.workspaceRoot,
249
- env: { ...process.env, ...options.env, CODEX_HOME: homeDir, OPENAI_API_KEY: token, PLAYCRAFT_SKIP_UPDATE_CHECK: '1' },
248
+ env: { ...process.env, ...options.env, CODEX_HOME: homeDir, PLAYCRAFT_SKIP_UPDATE_CHECK: '1' },
250
249
  stdio: ['pipe', 'pipe', 'pipe'],
251
250
  });
252
251
  await new Promise((resolve, reject) => { local.once('spawn', resolve); local.once('error', reject); });
@@ -389,18 +388,21 @@ export function createCodexAppServerAdapter(options) {
389
388
  if (!rpc)
390
389
  return false;
391
390
  try {
392
- const prepared = await lockedThreadConfig(input.cwd ?? '');
393
391
  const existing = threads.get(input.threadId);
394
392
  if (existing && existing.sessionId !== input.sessionId)
395
393
  return false;
394
+ if (existing?.turnActive)
395
+ return false;
396
+ const prepared = await lockedThreadConfig(input.cwd ?? '', input.modelConfigId, input.sessionId);
396
397
  if (existing?.needsRecreate)
397
398
  return false;
398
399
  if (existing?.loaded) {
399
400
  if (existing.skillsHash !== prepared.skillsHash || existing.discoveryHash !== prepared.discoveryHash) {
400
401
  if (!existing.hasNativeHistory)
401
402
  return false;
402
- await restartForSkills(input.threadId);
403
+ await restartForSkills(input.threadId, prepared.modelConfigId);
403
404
  }
405
+ prepared.activate();
404
406
  return threads.get(input.threadId)?.sessionId === input.sessionId;
405
407
  }
406
408
  const result = await rpc.request('thread/resume', { threadId: input.threadId, cwd: input.cwd,
@@ -408,8 +410,9 @@ export function createCodexAppServerAdapter(options) {
408
410
  const resumedId = result.thread?.id ?? result.threadId;
409
411
  if (resumedId !== input.threadId)
410
412
  return false;
413
+ prepared.activate();
411
414
  threads.set(input.threadId, { sessionId: input.sessionId, cwd: input.cwd, skillsHash: prepared.skillsHash,
412
- discoveryHash: prepared.discoveryHash, hasNativeHistory: true, loaded: true });
415
+ discoveryHash: prepared.discoveryHash, modelConfigId: prepared.modelConfigId, hasNativeHistory: true, loaded: true });
413
416
  return true;
414
417
  }
415
418
  catch {
@@ -452,7 +455,7 @@ export function createCodexAppServerAdapter(options) {
452
455
  await ensureStarted();
453
456
  if (!rpc)
454
457
  throw new Error('codex_not_started');
455
- const prepared = await lockedThreadConfig(input.cwd);
458
+ const prepared = await lockedThreadConfig(input.cwd, input.modelConfigId, input.sessionId);
456
459
  const result = await rpc.request('thread/start', {
457
460
  cwd: input.cwd,
458
461
  model,
@@ -461,9 +464,10 @@ export function createCodexAppServerAdapter(options) {
461
464
  const threadId = result.thread?.id;
462
465
  if (!threadId)
463
466
  throw new Error('native_receipt_lost');
467
+ prepared.activate();
464
468
  threads.set(threadId, { sessionId: input.sessionId, cwd: input.cwd, skillsHash: prepared.skillsHash,
465
- discoveryHash: prepared.discoveryHash, hasNativeHistory: false, loaded: true });
466
- return { threadId };
469
+ discoveryHash: prepared.discoveryHash, modelConfigId: prepared.modelConfigId, hasNativeHistory: false, loaded: true });
470
+ return { threadId, modelConfigId: prepared.modelConfigId };
467
471
  });
468
472
  },
469
473
  async startTurn(input) {
@@ -475,18 +479,26 @@ export function createCodexAppServerAdapter(options) {
475
479
  return { outcome: 'failed', error: 'unknown_thread' };
476
480
  }
477
481
  const thread = threads.get(input.threadId);
482
+ if (thread.turnActive)
483
+ return { outcome: 'failed', error: 'turn_already_active' };
478
484
  if (thread.needsRecreate)
479
485
  return { outcome: 'failed', error: 'workspace_skills_changed' };
480
- if (thread.skillsHash && thread.cwd) {
486
+ let selectedModel;
487
+ let modelConfigId = thread.modelConfigId;
488
+ let activateModel = () => { };
489
+ if (thread.cwd) {
481
490
  try {
482
- const prepared = await lockedThreadConfig(thread.cwd);
491
+ const prepared = await lockedThreadConfig(thread.cwd, input.modelConfigId ?? thread.modelConfigId, input.sessionId);
492
+ selectedModel = 'model' in prepared.params ? prepared.params.model : undefined;
493
+ modelConfigId = prepared.modelConfigId;
494
+ activateModel = prepared.activate;
483
495
  // Codex does not persist an empty thread rollout until its first turn.
484
- if (!thread.hasNativeHistory) {
496
+ if (thread.skillsHash && !thread.hasNativeHistory) {
485
497
  if (prepared.skillsHash !== thread.skillsHash || prepared.discoveryHash !== thread.discoveryHash)
486
498
  return { outcome: 'failed', error: 'workspace_skills_changed' };
487
499
  }
488
- else if (prepared.skillsHash !== thread.skillsHash || prepared.discoveryHash !== thread.discoveryHash) {
489
- await restartForSkills(input.threadId);
500
+ else if (thread.skillsHash && (prepared.skillsHash !== thread.skillsHash || prepared.discoveryHash !== thread.discoveryHash)) {
501
+ await restartForSkills(input.threadId, prepared.modelConfigId);
490
502
  }
491
503
  if (!thread.loaded)
492
504
  await resumePreparedThread(input.threadId, thread, prepared);
@@ -498,7 +510,9 @@ export function createCodexAppServerAdapter(options) {
498
510
  }
499
511
  }
500
512
  const requestId = rpc.reserveRequest();
501
- await input.persistReservation({ nativeRequestId: String(requestId) });
513
+ await input.persistReservation({ nativeRequestId: String(requestId), modelConfigId });
514
+ activateModel();
515
+ thread.modelConfigId = modelConfigId;
502
516
  if (input.simulateLostReceipt)
503
517
  return { outcome: 'uncertain', threadId: input.threadId };
504
518
  try {
@@ -506,7 +520,7 @@ export function createCodexAppServerAdapter(options) {
506
520
  const started = await rpc.sendReserved(requestId, 'turn/start', { threadId: input.threadId, input: [
507
521
  { type: 'text', text: input.text },
508
522
  ...(input.images ?? []).map((path) => ({ type: 'localImage', path })),
509
- ] }, turnTimeoutMs);
523
+ ], ...(selectedModel ? { model: selectedModel } : {}) }, turnTimeoutMs);
510
524
  const turnId = started.turn?.id;
511
525
  if (!turnId)
512
526
  return { outcome: 'uncertain', threadId: input.threadId };
@@ -514,9 +528,10 @@ export function createCodexAppServerAdapter(options) {
514
528
  if (acceptedThread) {
515
529
  acceptedThread.lastTurnId = turnId;
516
530
  acceptedThread.hasNativeHistory = true;
531
+ acceptedThread.modelConfigId = modelConfigId;
517
532
  acceptedThread.turnActive = acceptedThread.lastCompletedTurnId !== turnId;
518
533
  }
519
- return { outcome: 'accepted', threadId: input.threadId, turnId };
534
+ return { outcome: 'accepted', threadId: input.threadId, turnId, modelConfigId };
520
535
  }
521
536
  catch {
522
537
  return { outcome: 'uncertain', threadId: input.threadId };
@@ -534,6 +549,9 @@ export function createCodexAppServerAdapter(options) {
534
549
  return { outcome: 'uncertain' };
535
550
  try {
536
551
  await rpc.request('turn/interrupt', { threadId: input.threadId, turnId });
552
+ const cwd = threads.get(input.threadId)?.cwd;
553
+ if (cwd)
554
+ options.modelBroker?.cancelSession(cwd, input.sessionId);
537
555
  }
538
556
  catch {
539
557
  return { outcome: 'uncertain', turnId };
@@ -573,6 +591,8 @@ export function createCodexAppServerAdapter(options) {
573
591
  child = undefined;
574
592
  live = undefined;
575
593
  await options.modelBroker?.close();
594
+ if (catalogDirectory)
595
+ await rm(catalogDirectory, { recursive: true, force: true });
576
596
  },
577
597
  };
578
598
  }
@@ -1,8 +1,90 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
1
3
  import { mkdir, writeFile } from 'node:fs/promises';
2
4
  import { join } from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ const exec = promisify(execFile);
3
7
  function tomlString(value) {
4
8
  return JSON.stringify(value);
5
9
  }
10
+ /** Keep workspace turns portable between native Responses and function-tool conversion. */
11
+ export function portableCodexConfig(catalogPath) {
12
+ return {
13
+ web_search: 'disabled',
14
+ 'features.multi_agent': false,
15
+ 'features.goals': false,
16
+ 'features.apps': false,
17
+ 'features.plugins': false,
18
+ 'features.code_mode': false,
19
+ 'features.code_mode_host': false,
20
+ 'features.code_mode_only': false,
21
+ model_reasoning_summary: 'none',
22
+ ...(catalogPath ? { model_catalog_json: catalogPath } : {}),
23
+ };
24
+ }
25
+ /** Retain real model identities and limits while declaring this gateway's portable tools. */
26
+ export function portableCodexCatalog(raw) {
27
+ if (!raw ||
28
+ typeof raw !== 'object' ||
29
+ Array.isArray(raw) ||
30
+ !Array.isArray(raw.models)) {
31
+ throw new Error('codex_model_catalog_invalid');
32
+ }
33
+ const catalog = raw;
34
+ const models = catalog.models.map((value) => {
35
+ if (!value ||
36
+ typeof value !== 'object' ||
37
+ Array.isArray(value) ||
38
+ typeof value.slug !== 'string') {
39
+ throw new Error('codex_model_catalog_invalid');
40
+ }
41
+ return {
42
+ ...value,
43
+ tool_mode: null,
44
+ use_responses_lite: false,
45
+ apply_patch_tool_type: null,
46
+ multi_agent_version: null,
47
+ supports_search_tool: false,
48
+ experimental_supported_tools: [],
49
+ default_reasoning_level: null,
50
+ supported_reasoning_levels: [],
51
+ };
52
+ });
53
+ return { ...catalog, models };
54
+ }
55
+ /** The installed binary supplies its own schema; unsupported binaries fail before inference. */
56
+ export async function writePortableCodexCatalog(input) {
57
+ const command = input.command || process.env.PLAYCRAFT_CODEX_BIN || 'codex';
58
+ const prefix = input.args ?? [];
59
+ let body;
60
+ let version;
61
+ try {
62
+ const bundled = await exec(command, [...prefix, 'debug', 'models', '--bundled'], {
63
+ encoding: 'utf8',
64
+ timeout: 10_000,
65
+ maxBuffer: 16 * 1024 * 1024,
66
+ });
67
+ const metadata = await exec(command, [...prefix, '--version'], {
68
+ encoding: 'utf8',
69
+ timeout: 10_000,
70
+ maxBuffer: 64 * 1024,
71
+ });
72
+ version = metadata.stdout.trim();
73
+ body = JSON.stringify(portableCodexCatalog(JSON.parse(bundled.stdout)));
74
+ }
75
+ catch {
76
+ // Continuing with an unverified catalog can silently remove every coding tool.
77
+ throw new Error('codex_model_catalog_unavailable');
78
+ }
79
+ await mkdir(input.directory, { recursive: true });
80
+ const path = join(input.directory, 'portable-models.json');
81
+ await writeFile(path, body, { encoding: 'utf8', mode: 0o644 });
82
+ await writeFile(join(input.directory, 'portable-models.version.json'), JSON.stringify({
83
+ version,
84
+ sha256: createHash('sha256').update(body).digest('hex'),
85
+ }), { encoding: 'utf8', mode: 0o644 });
86
+ return path;
87
+ }
6
88
  export async function writePlaycraftCodexConfig(input) {
7
89
  await mkdir(input.homeDir, { recursive: true, mode: 0o700 });
8
90
  const path = join(input.homeDir, 'config.toml');
@@ -11,6 +93,7 @@ export async function writePlaycraftCodexConfig(input) {
11
93
  'model_provider = "playcraft"',
12
94
  'approval_policy = "never"',
13
95
  'sandbox_mode = "danger-full-access"',
96
+ ...Object.entries(portableCodexConfig(input.catalogPath)).map(([key, value]) => `${key} = ${JSON.stringify(value)}`),
14
97
  '',
15
98
  '[model_providers.playcraft]',
16
99
  'name = "PlayCraft Model Proxy"',
@@ -1,11 +1,11 @@
1
- import { randomBytes, timingSafeEqual } from 'node:crypto';
1
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
2
  import { createServer } from 'node:http';
3
3
  import { Readable } from 'node:stream';
4
4
  import { pipeline } from 'node:stream/promises';
5
5
  import { basename } from 'node:path';
6
6
  import { z } from 'zod';
7
7
  import { workspaceRoot } from '../workspaces/registry.js';
8
- import { runtimeCallbackEndpoint } from '../../utils/environment-endpoints.js';
8
+ import { PLATFORM_ROUTES, modelResponsesPath, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
9
9
  const credentialSchema = z.object({
10
10
  token: z.string().min(1), expiresAt: z.string().datetime(),
11
11
  configId: z.string().uuid(), model: z.string().min(1).max(128),
@@ -20,8 +20,10 @@ export class WorkspaceModelBroker {
20
20
  server;
21
21
  baseUrl = '';
22
22
  entries = new Map();
23
+ routes = new Map();
23
24
  pending = new Map();
24
25
  requests = new Set();
26
+ sessionRequests = new Map();
25
27
  constructor(state) {
26
28
  this.state = state;
27
29
  }
@@ -41,24 +43,47 @@ export class WorkspaceModelBroker {
41
43
  throw new Error('model_broker_unavailable');
42
44
  this.baseUrl = `http://127.0.0.1:${address.port}`;
43
45
  }
44
- async threadConfig(cwd) {
46
+ async threadConfig(cwd, modelConfigId, sessionId) {
47
+ const { activate, ...settings } = await this.prepareThreadConfig(cwd, modelConfigId, sessionId);
48
+ activate();
49
+ return settings;
50
+ }
51
+ async prepareThreadConfig(cwd, modelConfigId, sessionId) {
45
52
  const workspaceId = basename(cwd);
46
53
  if (!this.server || await workspaceRoot(this.state, workspaceId) !== cwd)
47
54
  throw new Error('workspace_not_registered');
48
- let entry = this.entries.get(workspaceId);
55
+ if (modelConfigId !== undefined && !z.string().uuid().safeParse(modelConfigId).success)
56
+ throw new Error('model_config_denied');
57
+ const routeKey = sessionId ? `${workspaceId}/${createHash('sha256').update(sessionId).digest('hex').slice(0, 32)}` : workspaceId;
58
+ let route = this.routes.get(routeKey);
59
+ const selected = modelConfigId ?? (route?.epoch === this.state.runtimeEpoch ? route.selected?.entry.modelConfigId : undefined);
60
+ const cacheKey = `${workspaceId}:${selected ?? 'default'}`;
61
+ let entry = this.entries.get(cacheKey) ?? [...this.entries.values()].find(item => item.workspaceId === workspaceId && item.epoch === this.state.runtimeEpoch && item.modelConfigId === selected);
49
62
  if (!entry || entry.epoch !== this.state.runtimeEpoch) {
50
- entry = { workspaceId, epoch: this.state.runtimeEpoch, capability: randomBytes(32).toString('base64url') };
51
- this.entries.set(workspaceId, entry);
63
+ entry = { workspaceId, epoch: this.state.runtimeEpoch, cacheKey, modelConfigId: selected };
64
+ this.entries.set(cacheKey, entry);
65
+ }
66
+ // Resolve current model metadata and permission at every native turn boundary.
67
+ const credential = await this.credential(entry, true);
68
+ if (!route || route.epoch !== this.state.runtimeEpoch) {
69
+ route = { workspaceId, epoch: this.state.runtimeEpoch, capability: randomBytes(32).toString('base64url') };
70
+ this.routes.set(routeKey, route);
52
71
  }
53
- const credential = await this.credential(entry);
72
+ // Each session pins its prepared model; shared credential renewal cannot change an active turn.
54
73
  return {
74
+ activate: () => {
75
+ if (!this.valid(entry) || this.routes.get(routeKey) !== route)
76
+ throw new Error('model_access_denied');
77
+ route.selected = { entry, model: credential.model };
78
+ },
55
79
  model: credential.model,
80
+ modelConfigId: credential.configId,
56
81
  modelProvider: 'playcraft_workspace',
57
82
  config: {
58
83
  'model_providers.playcraft_workspace': {
59
- name: 'PlayCraft Workspace', base_url: `${this.baseUrl}/${workspaceId}`,
84
+ name: 'PlayCraft Workspace', base_url: `${this.baseUrl}/${routeKey}`,
60
85
  wire_api: 'responses', requires_openai_auth: false,
61
- http_headers: { Authorization: `Bearer ${entry.capability}` },
86
+ http_headers: { Authorization: `Bearer ${route.capability}` },
62
87
  },
63
88
  },
64
89
  };
@@ -66,12 +91,12 @@ export class WorkspaceModelBroker {
66
91
  valid(entry) {
67
92
  return Boolean(this.server && !this.state.journal.destroyed()
68
93
  && this.state.runtimeEpoch === entry.epoch && this.state.journal.getWorkspace(entry.workspaceId)
69
- && this.entries.get(entry.workspaceId) === entry);
94
+ && this.entries.get(entry.cacheKey) === entry);
70
95
  }
71
- async credential(entry) {
96
+ async credential(entry, refresh = false) {
72
97
  if (!this.valid(entry))
73
98
  throw new Error('model_access_denied');
74
- if (entry.credential && Date.parse(entry.credential.expiresAt) > Date.now() + 60_000)
99
+ if (!refresh && entry.credential && Date.parse(entry.credential.expiresAt) > Date.now() + 60_000)
75
100
  return entry.credential;
76
101
  let pending = this.pending.get(entry);
77
102
  if (!pending) {
@@ -82,46 +107,54 @@ export class WorkspaceModelBroker {
82
107
  }
83
108
  async renew(entry) {
84
109
  const config = this.state.config;
85
- const endpoint = runtimeCallbackEndpoint(config.callbackUrl, '/runtime/internal/model-credentials');
86
- if (endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:'
87
- && ['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname)))
88
- throw new Error('model_callback_insecure');
110
+ const endpoint = platformEndpoint(config.publicBaseUrl, PLATFORM_ROUTES.modelCredentials);
89
111
  const response = await fetch(endpoint, {
90
112
  method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10_000),
91
113
  headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': config.environment },
92
114
  body: JSON.stringify({ runtimeId: config.runtimeId, generation: config.generation,
93
- runtimeEpoch: entry.epoch, workspaceId: entry.workspaceId }),
115
+ runtimeEpoch: entry.epoch, workspaceId: entry.workspaceId,
116
+ ...(entry.modelConfigId ? { modelConfigId: entry.modelConfigId } : {}) }),
94
117
  });
95
118
  if (!response.ok) {
96
119
  const error = await response.json().catch(() => ({}));
97
- throw new Error(error.reason === 'model_config_required' ? 'model_config_required' : 'model_credential_unavailable');
120
+ throw new Error(['model_config_required', 'model_config_denied'].includes(error.reason ?? '') ? error.reason : 'model_credential_unavailable');
98
121
  }
99
122
  const credential = credentialSchema.parse(await response.json());
100
123
  const claims = credential.claims;
101
124
  if (!this.valid(entry) || claims.workspaceId !== entry.workspaceId || claims.runtimeId !== config.runtimeId
102
125
  || claims.userId !== config.userId || claims.environment !== config.environment
103
126
  || claims.generation !== config.generation || claims.runtimeEpoch !== entry.epoch
127
+ || (entry.modelConfigId !== undefined && credential.configId !== entry.modelConfigId)
104
128
  || Date.parse(credential.expiresAt) <= Date.now() + 1000)
105
129
  throw new Error('model_credential_scope_mismatch');
130
+ entry.modelConfigId = credential.configId;
106
131
  entry.credential = credential;
107
132
  return credential;
108
133
  }
109
134
  async forward(request, response) {
110
135
  const abort = new AbortController();
111
136
  this.requests.add(abort);
137
+ let activeRoute;
112
138
  const cancel = () => abort.abort();
113
139
  response.once('close', cancel);
114
140
  try {
115
- const match = /^\/([0-9a-f-]{36})\/responses$/.exec(request.url ?? '');
116
- const entry = match && this.entries.get(match[1]);
141
+ const match = /^\/([0-9a-f-]{36}(?:\/[0-9a-f]{32})?)\/responses$/.exec(request.url ?? '');
142
+ const route = match ? this.routes.get(match[1]) : undefined;
143
+ const selected = route?.selected;
144
+ const entry = selected?.entry;
117
145
  const presented = Buffer.from(request.headers.authorization ?? '');
118
- const expected = Buffer.from(entry ? `Bearer ${entry.capability}` : '');
146
+ const expected = Buffer.from(route ? `Bearer ${route.capability}` : '');
119
147
  if (request.method !== 'POST' || !entry || !expected.length || presented.length !== expected.length
120
148
  || !timingSafeEqual(presented, expected) || !this.valid(entry)) {
121
149
  response.writeHead(403);
122
150
  response.end();
123
151
  return;
124
152
  }
153
+ activeRoute = route;
154
+ const active = this.sessionRequests.get(route) ?? new Set();
155
+ active.add(abort);
156
+ this.sessionRequests.set(route, active);
157
+ const credential = await this.credential(entry);
125
158
  const chunks = [];
126
159
  let size = 0;
127
160
  for await (const chunk of request) {
@@ -133,15 +166,22 @@ export class WorkspaceModelBroker {
133
166
  }
134
167
  chunks.push(Buffer.from(chunk));
135
168
  }
136
- const credential = await this.credential(entry);
137
169
  if (!this.valid(entry))
138
170
  throw new Error('model_access_denied');
171
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
172
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
173
+ response.writeHead(400);
174
+ response.end();
175
+ return;
176
+ }
177
+ // The browser selects a configuration, never an arbitrary billable upstream model.
178
+ body.model = selected.model;
139
179
  const expires = setTimeout(cancel, Math.max(0, Date.parse(credential.expiresAt) - Date.now()));
140
180
  try {
141
- const upstream = await fetch(`${this.state.config.modelProxyUrl.replace(/\/+$/, '')}/v1/${credential.configId}/responses`, {
181
+ const upstream = await fetch(platformEndpoint(this.state.config.publicBaseUrl, modelResponsesPath(credential.configId)), {
142
182
  method: 'POST', redirect: 'error', signal: abort.signal,
143
183
  headers: { authorization: `Bearer ${credential.token}`, 'content-type': 'application/json' },
144
- body: Buffer.concat(chunks),
184
+ body: JSON.stringify(body),
145
185
  });
146
186
  if (upstream.status === 401 || upstream.status === 403)
147
187
  entry.credential = undefined;
@@ -168,12 +208,27 @@ export class WorkspaceModelBroker {
168
208
  finally {
169
209
  response.off('close', cancel);
170
210
  this.requests.delete(abort);
211
+ if (activeRoute) {
212
+ const active = this.sessionRequests.get(activeRoute);
213
+ active?.delete(abort);
214
+ if (!active?.size)
215
+ this.sessionRequests.delete(activeRoute);
216
+ }
171
217
  }
172
218
  }
219
+ cancelSession(cwd, sessionId) {
220
+ const routeKey = `${basename(cwd)}/${createHash('sha256').update(sessionId).digest('hex').slice(0, 32)}`;
221
+ const route = this.routes.get(routeKey);
222
+ if (route)
223
+ for (const request of this.sessionRequests.get(route) ?? [])
224
+ request.abort();
225
+ }
173
226
  async close() {
174
227
  const server = this.server;
175
228
  this.server = undefined;
176
229
  this.entries.clear();
230
+ this.routes.clear();
231
+ this.sessionRequests.clear();
177
232
  for (const request of this.requests)
178
233
  request.abort();
179
234
  if (server) {
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { spawn } from 'node:child_process';
3
- import { runtimeCallbackEndpoint } from '../../utils/environment-endpoints.js';
3
+ import { platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
4
4
  import { constants } from 'node:fs';
5
5
  import { lstat, open, readdir, rm } from 'node:fs/promises';
6
6
  import { join } from 'node:path';
@@ -223,10 +223,7 @@ function git(cwd, args) {
223
223
  });
224
224
  }
225
225
  async function postControl(state, path, body) {
226
- const endpoint = runtimeCallbackEndpoint(state.config.callbackUrl, path);
227
- const local = endpoint.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname);
228
- if (endpoint.protocol !== 'https:' && !local)
229
- throw new PreviewFailure('preview_upload_failed');
226
+ const endpoint = platformEndpoint(state.config.publicBaseUrl, path);
230
227
  const response = await fetch(endpoint, {
231
228
  method: 'POST',
232
229
  redirect: 'error',
@@ -96,7 +96,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
96
96
  }
97
97
  catch (error) {
98
98
  const code = error instanceof Error ? error.message : '';
99
- state.nativeError = code === 'model_proxy_token_missing' ? code : 'codex_start_failed';
99
+ state.nativeError = code === 'codex_model_catalog_unavailable' ? code : 'codex_start_failed';
100
100
  console.error(`[workspace-runtime] ${state.nativeError}`);
101
101
  }
102
102
  const server = createServer((request, response) => {
@@ -180,11 +180,15 @@ function defaultNativeAdapter(state) {
180
180
  const config = state.config;
181
181
  if (process.env.PLAYCRAFT_NATIVE_ADAPTER === 'loopback')
182
182
  return createLoopbackNativeAdapter();
183
- const managed = config.workspaceMode === 'multi' && Boolean(process.env.PLAYCRAFT_LAUNCHER_SOCKET);
184
- if (process.env.PLAYCRAFT_NATIVE_ADAPTER === 'codex'
185
- || (!process.env.PLAYCRAFT_NATIVE_ADAPTER && managed)) {
186
- return createCodexAppServerAdapter({ config,
187
- ...(managed ? { modelBroker: new WorkspaceModelBroker(state) } : {}),
183
+ const hasLauncher = Boolean(process.env.PLAYCRAFT_LAUNCHER_SOCKET);
184
+ const multi = config.workspaceMode === 'multi';
185
+ if (process.env.PLAYCRAFT_NATIVE_ADAPTER === 'codex' && !multi && !hasLauncher) {
186
+ throw new Error('codex_requires_managed_launcher');
187
+ }
188
+ if (process.env.PLAYCRAFT_NATIVE_ADAPTER === 'codex' || (multi && hasLauncher)) {
189
+ return createCodexAppServerAdapter({
190
+ config,
191
+ ...(multi ? { modelBroker: new WorkspaceModelBroker(state) } : {}),
188
192
  loadSkillArtifact: async (cwd, entry) => {
189
193
  const id = basename(cwd);
190
194
  if (await workspaceRoot(state, id) !== cwd)
@@ -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,7 +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 { runtimeCallbackEndpoint } from '../../utils/environment-endpoints.js';
5
+ import { PLATFORM_ROUTES, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
6
6
  const responseSchema = z.object({
7
7
  token: z.string().min(1),
8
8
  apiUrl: z.string().url(),
@@ -37,11 +37,7 @@ export async function ensureCliCredentials(state) {
37
37
  async function renew(state, entry) {
38
38
  const config = state.config;
39
39
  const epoch = state.runtimeEpoch;
40
- const endpoint = runtimeCallbackEndpoint(config.callbackUrl, '/runtime/internal/cli-credentials');
41
- if (endpoint.protocol !== 'https:' &&
42
- !(endpoint.protocol === 'http:' &&
43
- ['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname)))
44
- throw new Error('cli_callback_insecure');
40
+ const endpoint = platformEndpoint(config.publicBaseUrl, PLATFORM_ROUTES.cliCredentials);
45
41
  try {
46
42
  const response = await fetch(endpoint, {
47
43
  method: 'POST',
@@ -8,7 +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 { runtimeCallbackEndpoint } from '../../utils/environment-endpoints.js';
11
+ import { PLATFORM_ROUTES, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
12
12
  const exec = promisify(execFile);
13
13
  /** Only this control process sees the callback credential. Development processes use the local socket. */
14
14
  export class WorkspaceGitCredentialBroker {
@@ -165,9 +165,7 @@ export class WorkspaceGitCredentialBroker {
165
165
  await this.direct.sweep(true);
166
166
  }
167
167
  async refresh(binding, key) {
168
- const endpoint = runtimeCallbackEndpoint(this.state.config.callbackUrl, '/runtime/internal/git-credentials');
169
- if (endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname)))
170
- throw new Error('git_broker_unavailable');
168
+ const endpoint = platformEndpoint(this.state.config.publicBaseUrl, PLATFORM_ROUTES.gitCredentials);
171
169
  const epoch = this.state.runtimeEpoch;
172
170
  const response = await fetch(endpoint, {
173
171
  method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10_000),
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { workspaceRoot } from './registry.js';
3
- import { runtimeCallbackEndpoint } from '../../utils/environment-endpoints.js';
3
+ import { PLATFORM_ROUTES, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
4
4
  const MAX_ARTIFACT_BYTES = 80 * 1024 * 1024;
5
5
  /** Requests an exact locked artifact using control identity; no storage credentials reach Codex. */
6
6
  export async function loadWorkspaceSkillArtifact(state, workspaceId, entry) {
@@ -8,9 +8,7 @@ export async function loadWorkspaceSkillArtifact(state, workspaceId, entry) {
8
8
  const epoch = state.runtimeEpoch;
9
9
  if (state.journal.destroyed())
10
10
  throw new Error('workspace_skills_unavailable');
11
- const endpoint = runtimeCallbackEndpoint(state.config.callbackUrl, '/runtime/internal/skill-artifacts');
12
- if (endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname)))
13
- throw new Error('workspace_skills_callback_insecure');
11
+ const endpoint = platformEndpoint(state.config.publicBaseUrl, PLATFORM_ROUTES.skillArtifacts);
14
12
  const response = await fetch(endpoint, {
15
13
  method: 'POST', redirect: 'error', signal: AbortSignal.timeout(30_000),
16
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.16",
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.16",
52
- "@playcraft/common": "^0.1.16",
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",