@playcraft/cli 0.1.16 → 0.1.18

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,13 +1,13 @@
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';
10
- import { exportTranscript } from './transcripts.js';
11
11
  import { readProjectSkillsLockFile } from '@playcraft/common/project-skills';
12
12
  import { freezeWorkspaceSkills } from '../workspaces/skills.js';
13
13
  function asRecord(value) {
@@ -38,18 +38,6 @@ function spawnCommand(bin) {
38
38
  }
39
39
  return { command: bin, prefix: [] };
40
40
  }
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
41
  export function createCodexAppServerAdapter(options) {
54
42
  const launcherSocket = options.launcherSocket || process.env.PLAYCRAFT_LAUNCHER_SOCKET;
55
43
  const homeDir = options.homeDir || process.env.PLAYCRAFT_CODEX_HOME ||
@@ -64,6 +52,7 @@ export function createCodexAppServerAdapter(options) {
64
52
  let readyFlag = false;
65
53
  let closed = false;
66
54
  let startPromise;
55
+ let catalogDirectory;
67
56
  let skillsRestartFailed = false;
68
57
  let preparationQueue = Promise.resolve();
69
58
  let live;
@@ -85,18 +74,6 @@ export function createCodexAppServerAdapter(options) {
85
74
  release();
86
75
  }
87
76
  }
88
- function transcriptHome() {
89
- if (!launcherSocket)
90
- return { homeDir };
91
- return {
92
- homeDir,
93
- launcher: {
94
- socketPath: launcherSocket,
95
- runtimeEpoch: executionEpoch,
96
- cwd: options.config.workspaceMode === 'multi' ? '/home/playcraft-dev' : options.config.workspaceRoot,
97
- },
98
- };
99
- }
100
77
  async function ensureStarted() {
101
78
  if (closed)
102
79
  throw new Error('codex_not_started');
@@ -107,24 +84,25 @@ export function createCodexAppServerAdapter(options) {
107
84
  startPromise ??= start();
108
85
  await startPromise;
109
86
  }
110
- async function threadConfig(cwd) {
87
+ async function threadConfig(cwd, modelConfigId, sessionId) {
111
88
  try {
112
- return await options.modelBroker?.threadConfig(cwd) ?? {};
89
+ return await options.modelBroker?.prepareThreadConfig(cwd, modelConfigId, sessionId)
90
+ ?? { modelConfigId: undefined, activate: () => { } };
113
91
  }
114
92
  catch (error) {
115
93
  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)
94
+ throw new NativePreparationError(['model_config_required',
95
+ 'model_credential_scope_mismatch', 'model_config_denied', 'workspace_not_registered'].includes(reason)
118
96
  ? reason : 'model_credential_unavailable');
119
97
  }
120
98
  }
121
- async function lockedThreadConfig(cwd) {
122
- const modelConfig = await threadConfig(cwd);
99
+ async function lockedThreadConfig(cwd, selectedConfigId, sessionId) {
100
+ const { modelConfigId, activate, ...modelConfig } = await threadConfig(cwd, selectedConfigId, sessionId);
123
101
  // Single-workspace legacy runners retain their prior behaviour until explicitly adopted.
124
102
  if (await readProjectSkillsLockFile(cwd) === null) {
125
103
  if (options.config.workspaceMode === 'multi')
126
104
  throw new NativePreparationError('workspace_skills_unavailable');
127
- return { params: modelConfig, skillsHash: undefined, discoveryHash: undefined };
105
+ return { params: modelConfig, modelConfigId, activate, skillsHash: undefined, discoveryHash: undefined };
128
106
  }
129
107
  try {
130
108
  const frozen = await freezeWorkspaceSkills(cwd, join(options.config.stateDir, 'skill-artifacts'), options.loadSkillArtifact ? entry => options.loadSkillArtifact(cwd, entry) : undefined);
@@ -145,13 +123,13 @@ export function createCodexAppServerAdapter(options) {
145
123
  ...(frozen.skills.length ? [] : ['(No workspace skills are enabled.)']),
146
124
  ].join('\n');
147
125
  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 };
126
+ return { params: { ...modelConfig, config, developerInstructions }, modelConfigId, activate, skillsHash: frozen.lockHash, discoveryHash };
149
127
  }
150
128
  catch {
151
129
  throw new NativePreparationError('workspace_skills_unavailable');
152
130
  }
153
131
  }
154
- async function restartForSkills(threadId) {
132
+ async function restartForSkills(threadId, selectedConfigId) {
155
133
  if ([...threads.values()].some(thread => thread.turnActive) || approvals.size)
156
134
  throw new NativePreparationError('skills_reload_pending');
157
135
  const previous = live;
@@ -182,7 +160,7 @@ export function createCodexAppServerAdapter(options) {
182
160
  // Other workspaces resume lazily, so a damaged unrelated lock cannot block this workspace.
183
161
  const thread = threads.get(threadId);
184
162
  if (thread?.hasNativeHistory && thread.cwd)
185
- await resumePreparedThread(threadId, thread, await lockedThreadConfig(thread.cwd));
163
+ await resumePreparedThread(threadId, thread, await lockedThreadConfig(thread.cwd, selectedConfigId ?? thread.modelConfigId, thread.sessionId));
186
164
  }
187
165
  catch {
188
166
  readyFlag = false;
@@ -209,24 +187,33 @@ export function createCodexAppServerAdapter(options) {
209
187
  if (closed)
210
188
  throw new Error('codex_not_started');
211
189
  await options.modelBroker?.start();
212
- const token = options.modelBroker ? 'workspace-broker-managed' : await readToken(options);
213
190
  if (closed)
214
191
  throw new Error('codex_not_started');
215
192
  const bin = options.command || process.env.PLAYCRAFT_CODEX_BIN || (launcherSocket ? '/usr/local/bin/codex' : 'codex');
216
193
  const { command, prefix } = spawnCommand(bin);
194
+ const launchArgs = [...prefix, ...(options.args ?? ['app-server'])];
195
+ const catalogArgs = launchArgs.at(-1) === 'app-server' ? launchArgs.slice(0, -1) : launchArgs;
196
+ let catalogPath;
197
+ try {
198
+ // Only public model metadata is shared with the development identity.
199
+ // A private, control-owned directory keeps it outside writable project files.
200
+ catalogDirectory ??= await mkdtemp(join(tmpdir(), 'playcraft-codex-catalog-'));
201
+ await chmod(catalogDirectory, 0o755);
202
+ catalogPath = await writePortableCodexCatalog({ directory: catalogDirectory, command, args: catalogArgs });
203
+ }
204
+ catch {
205
+ throw new NativePreparationError('codex_model_catalog_unavailable');
206
+ }
207
+ if (closed)
208
+ throw new Error('codex_not_started');
217
209
  const args = [...prefix, ...(options.args ?? ['app-server'])];
218
210
  if (launcherSocket) {
219
- // Configuration travels as non-secret overrides: control cannot write dev's home.
211
+ // Thread model access comes from the broker. These overrides carry no proxy address or token.
220
212
  for (const [key, value] of Object.entries({
221
213
  model,
222
- model_provider: 'playcraft',
223
214
  approval_policy: 'never',
224
215
  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,
216
+ ...portableCodexConfig(catalogPath),
230
217
  }))
231
218
  args.push('-c', `${key}=${JSON.stringify(value)}`);
232
219
  child = await startLauncherProcess({
@@ -237,16 +224,15 @@ export function createCodexAppServerAdapter(options) {
237
224
  // The launcher admits the dev home, not the shared /workspaces root.
238
225
  // Each thread supplies its registered workspace separately.
239
226
  cwd: options.config.workspaceMode === 'multi' ? '/home/playcraft-dev' : options.config.workspaceRoot,
240
- env: { CODEX_HOME: homeDir, OPENAI_API_KEY: token },
227
+ env: { CODEX_HOME: homeDir },
241
228
  });
242
229
  }
243
230
  else {
244
- await writePlaycraftCodexConfig({ homeDir, modelProxyUrl: options.config.modelProxyUrl, model });
245
231
  if (closed)
246
232
  throw new Error('codex_not_started');
247
233
  const local = spawn(command, args, {
248
234
  cwd: options.config.workspaceRoot,
249
- env: { ...process.env, ...options.env, CODEX_HOME: homeDir, OPENAI_API_KEY: token, PLAYCRAFT_SKIP_UPDATE_CHECK: '1' },
235
+ env: { ...process.env, ...options.env, CODEX_HOME: homeDir, PLAYCRAFT_SKIP_UPDATE_CHECK: '1' },
250
236
  stdio: ['pipe', 'pipe', 'pipe'],
251
237
  });
252
238
  await new Promise((resolve, reject) => { local.once('spawn', resolve); local.once('error', reject); });
@@ -389,18 +375,21 @@ export function createCodexAppServerAdapter(options) {
389
375
  if (!rpc)
390
376
  return false;
391
377
  try {
392
- const prepared = await lockedThreadConfig(input.cwd ?? '');
393
378
  const existing = threads.get(input.threadId);
394
379
  if (existing && existing.sessionId !== input.sessionId)
395
380
  return false;
381
+ if (existing?.turnActive)
382
+ return false;
383
+ const prepared = await lockedThreadConfig(input.cwd ?? '', input.modelConfigId, input.sessionId);
396
384
  if (existing?.needsRecreate)
397
385
  return false;
398
386
  if (existing?.loaded) {
399
387
  if (existing.skillsHash !== prepared.skillsHash || existing.discoveryHash !== prepared.discoveryHash) {
400
388
  if (!existing.hasNativeHistory)
401
389
  return false;
402
- await restartForSkills(input.threadId);
390
+ await restartForSkills(input.threadId, prepared.modelConfigId);
403
391
  }
392
+ prepared.activate();
404
393
  return threads.get(input.threadId)?.sessionId === input.sessionId;
405
394
  }
406
395
  const result = await rpc.request('thread/resume', { threadId: input.threadId, cwd: input.cwd,
@@ -408,8 +397,9 @@ export function createCodexAppServerAdapter(options) {
408
397
  const resumedId = result.thread?.id ?? result.threadId;
409
398
  if (resumedId !== input.threadId)
410
399
  return false;
400
+ prepared.activate();
411
401
  threads.set(input.threadId, { sessionId: input.sessionId, cwd: input.cwd, skillsHash: prepared.skillsHash,
412
- discoveryHash: prepared.discoveryHash, hasNativeHistory: true, loaded: true });
402
+ discoveryHash: prepared.discoveryHash, modelConfigId: prepared.modelConfigId, hasNativeHistory: true, loaded: true });
413
403
  return true;
414
404
  }
415
405
  catch {
@@ -433,9 +423,6 @@ export function createCodexAppServerAdapter(options) {
433
423
  return undefined;
434
424
  }
435
425
  },
436
- exportThread(input) {
437
- return exportTranscript(transcriptHome(), input.threadId);
438
- },
439
426
  async captureNativeCheckpoint(input) {
440
427
  const thread = threads.get(input.threadId);
441
428
  if (!thread || thread.sessionId !== input.sessionId)
@@ -452,7 +439,7 @@ export function createCodexAppServerAdapter(options) {
452
439
  await ensureStarted();
453
440
  if (!rpc)
454
441
  throw new Error('codex_not_started');
455
- const prepared = await lockedThreadConfig(input.cwd);
442
+ const prepared = await lockedThreadConfig(input.cwd, input.modelConfigId, input.sessionId);
456
443
  const result = await rpc.request('thread/start', {
457
444
  cwd: input.cwd,
458
445
  model,
@@ -461,9 +448,10 @@ export function createCodexAppServerAdapter(options) {
461
448
  const threadId = result.thread?.id;
462
449
  if (!threadId)
463
450
  throw new Error('native_receipt_lost');
451
+ prepared.activate();
464
452
  threads.set(threadId, { sessionId: input.sessionId, cwd: input.cwd, skillsHash: prepared.skillsHash,
465
- discoveryHash: prepared.discoveryHash, hasNativeHistory: false, loaded: true });
466
- return { threadId };
453
+ discoveryHash: prepared.discoveryHash, modelConfigId: prepared.modelConfigId, hasNativeHistory: false, loaded: true });
454
+ return { threadId, modelConfigId: prepared.modelConfigId };
467
455
  });
468
456
  },
469
457
  async startTurn(input) {
@@ -475,18 +463,26 @@ export function createCodexAppServerAdapter(options) {
475
463
  return { outcome: 'failed', error: 'unknown_thread' };
476
464
  }
477
465
  const thread = threads.get(input.threadId);
466
+ if (thread.turnActive)
467
+ return { outcome: 'failed', error: 'turn_already_active' };
478
468
  if (thread.needsRecreate)
479
469
  return { outcome: 'failed', error: 'workspace_skills_changed' };
480
- if (thread.skillsHash && thread.cwd) {
470
+ let selectedModel;
471
+ let modelConfigId = thread.modelConfigId;
472
+ let activateModel = () => { };
473
+ if (thread.cwd) {
481
474
  try {
482
- const prepared = await lockedThreadConfig(thread.cwd);
475
+ const prepared = await lockedThreadConfig(thread.cwd, input.modelConfigId ?? thread.modelConfigId, input.sessionId);
476
+ selectedModel = 'model' in prepared.params ? prepared.params.model : undefined;
477
+ modelConfigId = prepared.modelConfigId;
478
+ activateModel = prepared.activate;
483
479
  // Codex does not persist an empty thread rollout until its first turn.
484
- if (!thread.hasNativeHistory) {
480
+ if (thread.skillsHash && !thread.hasNativeHistory) {
485
481
  if (prepared.skillsHash !== thread.skillsHash || prepared.discoveryHash !== thread.discoveryHash)
486
482
  return { outcome: 'failed', error: 'workspace_skills_changed' };
487
483
  }
488
- else if (prepared.skillsHash !== thread.skillsHash || prepared.discoveryHash !== thread.discoveryHash) {
489
- await restartForSkills(input.threadId);
484
+ else if (thread.skillsHash && (prepared.skillsHash !== thread.skillsHash || prepared.discoveryHash !== thread.discoveryHash)) {
485
+ await restartForSkills(input.threadId, prepared.modelConfigId);
490
486
  }
491
487
  if (!thread.loaded)
492
488
  await resumePreparedThread(input.threadId, thread, prepared);
@@ -498,7 +494,9 @@ export function createCodexAppServerAdapter(options) {
498
494
  }
499
495
  }
500
496
  const requestId = rpc.reserveRequest();
501
- await input.persistReservation({ nativeRequestId: String(requestId) });
497
+ await input.persistReservation({ nativeRequestId: String(requestId), modelConfigId });
498
+ activateModel();
499
+ thread.modelConfigId = modelConfigId;
502
500
  if (input.simulateLostReceipt)
503
501
  return { outcome: 'uncertain', threadId: input.threadId };
504
502
  try {
@@ -506,7 +504,7 @@ export function createCodexAppServerAdapter(options) {
506
504
  const started = await rpc.sendReserved(requestId, 'turn/start', { threadId: input.threadId, input: [
507
505
  { type: 'text', text: input.text },
508
506
  ...(input.images ?? []).map((path) => ({ type: 'localImage', path })),
509
- ] }, turnTimeoutMs);
507
+ ], ...(selectedModel ? { model: selectedModel } : {}) }, turnTimeoutMs);
510
508
  const turnId = started.turn?.id;
511
509
  if (!turnId)
512
510
  return { outcome: 'uncertain', threadId: input.threadId };
@@ -514,9 +512,10 @@ export function createCodexAppServerAdapter(options) {
514
512
  if (acceptedThread) {
515
513
  acceptedThread.lastTurnId = turnId;
516
514
  acceptedThread.hasNativeHistory = true;
515
+ acceptedThread.modelConfigId = modelConfigId;
517
516
  acceptedThread.turnActive = acceptedThread.lastCompletedTurnId !== turnId;
518
517
  }
519
- return { outcome: 'accepted', threadId: input.threadId, turnId };
518
+ return { outcome: 'accepted', threadId: input.threadId, turnId, modelConfigId };
520
519
  }
521
520
  catch {
522
521
  return { outcome: 'uncertain', threadId: input.threadId };
@@ -534,6 +533,9 @@ export function createCodexAppServerAdapter(options) {
534
533
  return { outcome: 'uncertain' };
535
534
  try {
536
535
  await rpc.request('turn/interrupt', { threadId: input.threadId, turnId });
536
+ const cwd = threads.get(input.threadId)?.cwd;
537
+ if (cwd)
538
+ options.modelBroker?.cancelSession(cwd, input.sessionId);
537
539
  }
538
540
  catch {
539
541
  return { outcome: 'uncertain', turnId };
@@ -573,6 +575,8 @@ export function createCodexAppServerAdapter(options) {
573
575
  child = undefined;
574
576
  live = undefined;
575
577
  await options.modelBroker?.close();
578
+ if (catalogDirectory)
579
+ await rm(catalogDirectory, { recursive: true, force: true });
576
580
  },
577
581
  };
578
582
  }
@@ -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) {