@playcraft/cli 0.1.15 → 0.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,10 +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 { PLATFORM_ROUTES, modelResponsesPath, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
8
9
  const credentialSchema = z.object({
9
10
  token: z.string().min(1), expiresAt: z.string().datetime(),
10
11
  configId: z.string().uuid(), model: z.string().min(1).max(128),
@@ -19,8 +20,10 @@ export class WorkspaceModelBroker {
19
20
  server;
20
21
  baseUrl = '';
21
22
  entries = new Map();
23
+ routes = new Map();
22
24
  pending = new Map();
23
25
  requests = new Set();
26
+ sessionRequests = new Map();
24
27
  constructor(state) {
25
28
  this.state = state;
26
29
  }
@@ -40,24 +43,47 @@ export class WorkspaceModelBroker {
40
43
  throw new Error('model_broker_unavailable');
41
44
  this.baseUrl = `http://127.0.0.1:${address.port}`;
42
45
  }
43
- 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) {
44
52
  const workspaceId = basename(cwd);
45
53
  if (!this.server || await workspaceRoot(this.state, workspaceId) !== cwd)
46
54
  throw new Error('workspace_not_registered');
47
- 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);
48
62
  if (!entry || entry.epoch !== this.state.runtimeEpoch) {
49
- entry = { workspaceId, epoch: this.state.runtimeEpoch, capability: randomBytes(32).toString('base64url') };
50
- 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);
51
71
  }
52
- const credential = await this.credential(entry);
72
+ // Each session pins its prepared model; shared credential renewal cannot change an active turn.
53
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
+ },
54
79
  model: credential.model,
80
+ modelConfigId: credential.configId,
55
81
  modelProvider: 'playcraft_workspace',
56
82
  config: {
57
83
  'model_providers.playcraft_workspace': {
58
- name: 'PlayCraft Workspace', base_url: `${this.baseUrl}/${workspaceId}`,
84
+ name: 'PlayCraft Workspace', base_url: `${this.baseUrl}/${routeKey}`,
59
85
  wire_api: 'responses', requires_openai_auth: false,
60
- http_headers: { Authorization: `Bearer ${entry.capability}` },
86
+ http_headers: { Authorization: `Bearer ${route.capability}` },
61
87
  },
62
88
  },
63
89
  };
@@ -65,12 +91,12 @@ export class WorkspaceModelBroker {
65
91
  valid(entry) {
66
92
  return Boolean(this.server && !this.state.journal.destroyed()
67
93
  && this.state.runtimeEpoch === entry.epoch && this.state.journal.getWorkspace(entry.workspaceId)
68
- && this.entries.get(entry.workspaceId) === entry);
94
+ && this.entries.get(entry.cacheKey) === entry);
69
95
  }
70
- async credential(entry) {
96
+ async credential(entry, refresh = false) {
71
97
  if (!this.valid(entry))
72
98
  throw new Error('model_access_denied');
73
- 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)
74
100
  return entry.credential;
75
101
  let pending = this.pending.get(entry);
76
102
  if (!pending) {
@@ -81,46 +107,54 @@ export class WorkspaceModelBroker {
81
107
  }
82
108
  async renew(entry) {
83
109
  const config = this.state.config;
84
- const endpoint = new URL('/runtime/internal/model-credentials', config.callbackUrl);
85
- if (endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:'
86
- && ['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname)))
87
- throw new Error('model_callback_insecure');
110
+ const endpoint = platformEndpoint(config.publicBaseUrl, PLATFORM_ROUTES.modelCredentials);
88
111
  const response = await fetch(endpoint, {
89
112
  method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10_000),
90
113
  headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': config.environment },
91
114
  body: JSON.stringify({ runtimeId: config.runtimeId, generation: config.generation,
92
- runtimeEpoch: entry.epoch, workspaceId: entry.workspaceId }),
115
+ runtimeEpoch: entry.epoch, workspaceId: entry.workspaceId,
116
+ ...(entry.modelConfigId ? { modelConfigId: entry.modelConfigId } : {}) }),
93
117
  });
94
118
  if (!response.ok) {
95
119
  const error = await response.json().catch(() => ({}));
96
- 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');
97
121
  }
98
122
  const credential = credentialSchema.parse(await response.json());
99
123
  const claims = credential.claims;
100
124
  if (!this.valid(entry) || claims.workspaceId !== entry.workspaceId || claims.runtimeId !== config.runtimeId
101
125
  || claims.userId !== config.userId || claims.environment !== config.environment
102
126
  || claims.generation !== config.generation || claims.runtimeEpoch !== entry.epoch
127
+ || (entry.modelConfigId !== undefined && credential.configId !== entry.modelConfigId)
103
128
  || Date.parse(credential.expiresAt) <= Date.now() + 1000)
104
129
  throw new Error('model_credential_scope_mismatch');
130
+ entry.modelConfigId = credential.configId;
105
131
  entry.credential = credential;
106
132
  return credential;
107
133
  }
108
134
  async forward(request, response) {
109
135
  const abort = new AbortController();
110
136
  this.requests.add(abort);
137
+ let activeRoute;
111
138
  const cancel = () => abort.abort();
112
139
  response.once('close', cancel);
113
140
  try {
114
- const match = /^\/([0-9a-f-]{36})\/responses$/.exec(request.url ?? '');
115
- 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;
116
145
  const presented = Buffer.from(request.headers.authorization ?? '');
117
- const expected = Buffer.from(entry ? `Bearer ${entry.capability}` : '');
146
+ const expected = Buffer.from(route ? `Bearer ${route.capability}` : '');
118
147
  if (request.method !== 'POST' || !entry || !expected.length || presented.length !== expected.length
119
148
  || !timingSafeEqual(presented, expected) || !this.valid(entry)) {
120
149
  response.writeHead(403);
121
150
  response.end();
122
151
  return;
123
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);
124
158
  const chunks = [];
125
159
  let size = 0;
126
160
  for await (const chunk of request) {
@@ -132,15 +166,22 @@ export class WorkspaceModelBroker {
132
166
  }
133
167
  chunks.push(Buffer.from(chunk));
134
168
  }
135
- const credential = await this.credential(entry);
136
169
  if (!this.valid(entry))
137
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;
138
179
  const expires = setTimeout(cancel, Math.max(0, Date.parse(credential.expiresAt) - Date.now()));
139
180
  try {
140
- 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)), {
141
182
  method: 'POST', redirect: 'error', signal: abort.signal,
142
183
  headers: { authorization: `Bearer ${credential.token}`, 'content-type': 'application/json' },
143
- body: Buffer.concat(chunks),
184
+ body: JSON.stringify(body),
144
185
  });
145
186
  if (upstream.status === 401 || upstream.status === 403)
146
187
  entry.credential = undefined;
@@ -167,12 +208,27 @@ export class WorkspaceModelBroker {
167
208
  finally {
168
209
  response.off('close', cancel);
169
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
+ }
170
217
  }
171
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
+ }
172
226
  async close() {
173
227
  const server = this.server;
174
228
  this.server = undefined;
175
229
  this.entries.clear();
230
+ this.routes.clear();
231
+ this.sessionRequests.clear();
176
232
  for (const request of this.requests)
177
233
  request.abort();
178
234
  if (server) {
@@ -1,5 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { spawn } from 'node:child_process';
3
+ import { platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
3
4
  import { constants } from 'node:fs';
4
5
  import { lstat, open, readdir, rm } from 'node:fs/promises';
5
6
  import { join } from 'node:path';
@@ -222,10 +223,7 @@ function git(cwd, args) {
222
223
  });
223
224
  }
224
225
  async function postControl(state, path, body) {
225
- const endpoint = new URL(path, state.config.callbackUrl);
226
- const local = endpoint.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname);
227
- if (endpoint.protocol !== 'https:' && !local)
228
- throw new PreviewFailure('preview_upload_failed');
226
+ const endpoint = platformEndpoint(state.config.publicBaseUrl, path);
229
227
  const response = await fetch(endpoint, {
230
228
  method: 'POST',
231
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)