@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.
- package/dist/utils/environment-endpoints.js +0 -9
- package/dist/workspace-runtime/codex/app-server.js +69 -65
- package/dist/workspace-runtime/codex/config-toml.js +83 -0
- package/dist/workspace-runtime/codex/model-broker.js +79 -24
- package/dist/workspace-runtime/previews/publish.js +2 -5
- package/dist/workspace-runtime/serve.js +10 -6
- package/dist/workspace-runtime/server/connectivity.js +201 -0
- package/dist/workspace-runtime/server/dispatch.js +83 -78
- package/dist/workspace-runtime/server/endpoints.js +3 -3
- package/dist/workspace-runtime/server/http.js +15 -1
- package/dist/workspace-runtime/workspaces/cli-credentials.js +2 -6
- package/dist/workspace-runtime/workspaces/file-snapshot.js +20 -161
- package/dist/workspace-runtime/workspaces/git-credential-broker.js +2 -4
- package/dist/workspace-runtime/workspaces/git.js +67 -81
- package/dist/workspace-runtime/workspaces/skill-artifact-loader.js +2 -4
- package/package.json +3 -3
- package/dist/workspace-runtime/codex/transcripts.js +0 -94
- package/dist/workspace-runtime/workspaces/checkpoint-saves.js +0 -79
|
@@ -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 {
|
|
4
|
+
import { chmod, mkdtemp, rm } from 'node:fs/promises';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
-
import {
|
|
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?.
|
|
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',
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
51
|
-
this.entries.set(
|
|
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
|
-
|
|
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}/${
|
|
84
|
+
name: 'PlayCraft Workspace', base_url: `${this.baseUrl}/${routeKey}`,
|
|
60
85
|
wire_api: 'responses', requires_openai_auth: false,
|
|
61
|
-
http_headers: { Authorization: `Bearer ${
|
|
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.
|
|
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 =
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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:
|
|
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) {
|