@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.
- package/dist/commands/login.js +79 -42
- package/dist/config.js +59 -4
- package/dist/utils/agent-api-client.js +3 -2
- package/dist/utils/environment-endpoints.js +17 -0
- package/dist/workspace-runtime/codex/app-server.js +69 -49
- package/dist/workspace-runtime/codex/config-toml.js +83 -0
- package/dist/workspace-runtime/codex/model-broker.js +79 -23
- package/dist/workspace-runtime/previews/publish.js +2 -4
- package/dist/workspace-runtime/serve.js +10 -6
- package/dist/workspace-runtime/server/connectivity.js +201 -0
- package/dist/workspace-runtime/server/dispatch.js +32 -12
- package/dist/workspace-runtime/server/endpoints.js +3 -3
- package/dist/workspace-runtime/server/http.js +15 -1
- package/dist/workspace-runtime/workspaces/checkpoint-saves.js +2 -11
- package/dist/workspace-runtime/workspaces/cli-credentials.js +2 -5
- package/dist/workspace-runtime/workspaces/git-credential-broker.js +2 -3
- package/dist/workspace-runtime/workspaces/skill-artifact-loader.js +2 -3
- package/package.json +3 -3
|
@@ -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 {
|
|
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';
|
|
@@ -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?.
|
|
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',
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
50
|
-
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);
|
|
51
71
|
}
|
|
52
|
-
|
|
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}/${
|
|
84
|
+
name: 'PlayCraft Workspace', base_url: `${this.baseUrl}/${routeKey}`,
|
|
59
85
|
wire_api: 'responses', requires_openai_auth: false,
|
|
60
|
-
http_headers: { Authorization: `Bearer ${
|
|
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.
|
|
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 =
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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:
|
|
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 =
|
|
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 === '
|
|
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
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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)
|