@playcraft/cli 0.1.16 → 0.1.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/utils/environment-endpoints.js +0 -9
- 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 -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 +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 -6
- package/dist/workspace-runtime/workspaces/git-credential-broker.js +2 -4
- package/dist/workspace-runtime/workspaces/skill-artifact-loader.js +2 -4
- package/package.json +3 -3
|
@@ -9,15 +9,6 @@ function publicBase(value) {
|
|
|
9
9
|
function append(base, path) {
|
|
10
10
|
return new URL(`${base.toString().replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`);
|
|
11
11
|
}
|
|
12
|
-
/** Keep the deployment prefix when deriving a control endpoint from its callback address. */
|
|
13
|
-
export function runtimeCallbackEndpoint(callbackUrl, path) {
|
|
14
|
-
const base = publicBase(callbackUrl);
|
|
15
|
-
base.pathname = base.pathname.replace(/\/runtime\/(?:callbacks|internal\/events)$/, '');
|
|
16
|
-
// Older local runtimes used a root-level events callback.
|
|
17
|
-
if (base.pathname === '/events')
|
|
18
|
-
base.pathname = '';
|
|
19
|
-
return append(base, path);
|
|
20
|
-
}
|
|
21
12
|
/** Accept either a public deployment base or its /api endpoint, without duplicating /api. */
|
|
22
13
|
export function businessEndpoint(apiUrl, path) {
|
|
23
14
|
const base = publicBase(apiUrl);
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { createHash, randomUUID } from 'node:crypto';
|
|
3
3
|
import { startLauncherProcess } from './launcher-client.js';
|
|
4
|
-
import {
|
|
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,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) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
-
import {
|
|
3
|
+
import { platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
4
|
import { constants } from 'node:fs';
|
|
5
5
|
import { lstat, open, readdir, rm } from 'node:fs/promises';
|
|
6
6
|
import { join } from 'node:path';
|
|
@@ -223,10 +223,7 @@ function git(cwd, args) {
|
|
|
223
223
|
});
|
|
224
224
|
}
|
|
225
225
|
async function postControl(state, path, body) {
|
|
226
|
-
const endpoint =
|
|
227
|
-
const local = endpoint.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname);
|
|
228
|
-
if (endpoint.protocol !== 'https:' && !local)
|
|
229
|
-
throw new PreviewFailure('preview_upload_failed');
|
|
226
|
+
const endpoint = platformEndpoint(state.config.publicBaseUrl, path);
|
|
230
227
|
const response = await fetch(endpoint, {
|
|
231
228
|
method: 'POST',
|
|
232
229
|
redirect: 'error',
|
|
@@ -96,7 +96,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
96
96
|
}
|
|
97
97
|
catch (error) {
|
|
98
98
|
const code = error instanceof Error ? error.message : '';
|
|
99
|
-
state.nativeError = code === '
|
|
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)
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { PLATFORM_ROUTES, modelConnectivityPath, platformEndpoint, } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
|
+
const PROBE_TIMEOUT_MS = 4_000;
|
|
5
|
+
const CACHE_MS = 15_000;
|
|
6
|
+
const CREDENTIAL_RENEW_BEFORE_MS = 30_000;
|
|
7
|
+
const MAX_RESPONSE_BYTES = 16 * 1024;
|
|
8
|
+
const credentialSchema = z.object({
|
|
9
|
+
token: z.string().min(1), expiresAt: z.string().datetime(),
|
|
10
|
+
configId: z.string().uuid(), model: z.string().min(1).max(128),
|
|
11
|
+
claims: z.object({
|
|
12
|
+
environment: z.string(), userId: z.string(), workspaceId: z.string(), runtimeId: z.string(),
|
|
13
|
+
generation: z.number().int(), runtimeEpoch: z.number().int(),
|
|
14
|
+
}),
|
|
15
|
+
});
|
|
16
|
+
const caches = new WeakMap();
|
|
17
|
+
const ready = () => ({ status: 'ready' });
|
|
18
|
+
const unavailable = (reason) => ({ status: 'unavailable', reason });
|
|
19
|
+
function snapshot(state, workspaceId) {
|
|
20
|
+
let nativeReady = false;
|
|
21
|
+
try {
|
|
22
|
+
nativeReady = Boolean(state.native?.ready());
|
|
23
|
+
}
|
|
24
|
+
catch { /* A failed adapter stays unavailable. */ }
|
|
25
|
+
const { publicBaseUrl, controlToken, environment, userId, runtimeId, generation } = state.config;
|
|
26
|
+
return { publicBaseUrl, controlToken, environment, userId, runtimeId, generation,
|
|
27
|
+
runtimeEpoch: state.runtimeEpoch, workspaceId, nativeReady, destroyed: state.journal.destroyed(),
|
|
28
|
+
registered: state.config.workspaceMode !== 'multi' || Boolean(state.journal.getWorkspace(workspaceId)) };
|
|
29
|
+
}
|
|
30
|
+
function safeEndpoint(value) {
|
|
31
|
+
const url = new URL(value);
|
|
32
|
+
if (url.username || url.password || url.search || url.hash || /[%\\]/.test(url.pathname)
|
|
33
|
+
|| (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) {
|
|
34
|
+
throw new Error('insecure_endpoint');
|
|
35
|
+
}
|
|
36
|
+
return url;
|
|
37
|
+
}
|
|
38
|
+
async function responseJson(response) {
|
|
39
|
+
const reader = response.body?.getReader();
|
|
40
|
+
if (!reader)
|
|
41
|
+
throw new Error('invalid_probe_response');
|
|
42
|
+
const chunks = [];
|
|
43
|
+
let size = 0;
|
|
44
|
+
try {
|
|
45
|
+
while (true) {
|
|
46
|
+
const item = await reader.read();
|
|
47
|
+
if (item.done)
|
|
48
|
+
break;
|
|
49
|
+
size += item.value.byteLength;
|
|
50
|
+
if (size > MAX_RESPONSE_BYTES)
|
|
51
|
+
throw new Error('invalid_probe_response');
|
|
52
|
+
chunks.push(item.value);
|
|
53
|
+
}
|
|
54
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
await reader.cancel().catch(() => undefined);
|
|
58
|
+
reader.releaseLock();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function requestProbe(target, init, credentials = false) {
|
|
62
|
+
const signal = AbortSignal.timeout(PROBE_TIMEOUT_MS);
|
|
63
|
+
try {
|
|
64
|
+
const response = await fetch(target, { ...init, redirect: 'error', signal });
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
const offline = response.headers.has('ngrok-error-code');
|
|
67
|
+
const rejected = response.status === 401 || response.status === 403;
|
|
68
|
+
let reason = rejected ? 'endpoint_rejected' : offline ? 'endpoint_offline' : 'endpoint_unreachable';
|
|
69
|
+
if (credentials && !offline && !rejected) {
|
|
70
|
+
const body = await responseJson(response).catch(() => undefined);
|
|
71
|
+
if (signal.aborted)
|
|
72
|
+
return { state: unavailable('endpoint_timeout') };
|
|
73
|
+
if (body && typeof body === 'object' && 'reason' in body && body.reason === 'model_config_required')
|
|
74
|
+
reason = 'model_config_required';
|
|
75
|
+
}
|
|
76
|
+
else
|
|
77
|
+
await response.body?.cancel().catch(() => undefined);
|
|
78
|
+
return { state: unavailable(reason) };
|
|
79
|
+
}
|
|
80
|
+
let body;
|
|
81
|
+
try {
|
|
82
|
+
body = await responseJson(response);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return { state: unavailable(signal.aborted ? 'endpoint_timeout' : 'invalid_probe_response') };
|
|
86
|
+
}
|
|
87
|
+
return { state: ready(), body };
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return { state: unavailable(signal.aborted ? 'endpoint_timeout' : 'endpoint_unreachable') };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function echoMatches(body, expected) {
|
|
94
|
+
return Boolean(body && typeof body === 'object' && !Array.isArray(body)
|
|
95
|
+
&& Object.entries(expected).every(([key, value]) => body[key] === value));
|
|
96
|
+
}
|
|
97
|
+
function scope(config) {
|
|
98
|
+
return { runtimeId: config.runtimeId, environment: config.environment, workspaceId: config.workspaceId,
|
|
99
|
+
generation: config.generation, runtimeEpoch: config.runtimeEpoch };
|
|
100
|
+
}
|
|
101
|
+
function entrance(config, route) {
|
|
102
|
+
return safeEndpoint(platformEndpoint(config.publicBaseUrl, route));
|
|
103
|
+
}
|
|
104
|
+
async function callbackProbe(config) {
|
|
105
|
+
if (!config.publicBaseUrl)
|
|
106
|
+
return unavailable('endpoint_not_configured');
|
|
107
|
+
let endpoint;
|
|
108
|
+
try {
|
|
109
|
+
endpoint = entrance(config, PLATFORM_ROUTES.eventsProbe);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return unavailable('insecure_endpoint');
|
|
113
|
+
}
|
|
114
|
+
const nonce = randomUUID();
|
|
115
|
+
const response = await requestProbe(endpoint, {
|
|
116
|
+
method: 'POST', headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': config.environment },
|
|
117
|
+
body: JSON.stringify({ runtimeId: config.runtimeId, generation: config.generation,
|
|
118
|
+
runtimeEpoch: config.runtimeEpoch, workspaceId: config.workspaceId, nonce }),
|
|
119
|
+
});
|
|
120
|
+
if (response.state.status !== 'ready')
|
|
121
|
+
return response.state;
|
|
122
|
+
return echoMatches(response.body, { service: 'playcraft-runtime', ...scope(config), nonce }) ? ready() : unavailable('invalid_probe_response');
|
|
123
|
+
}
|
|
124
|
+
async function modelProxyProbe(config, entry) {
|
|
125
|
+
if (!config.publicBaseUrl)
|
|
126
|
+
return unavailable('endpoint_not_configured');
|
|
127
|
+
let credentialEndpoint;
|
|
128
|
+
try {
|
|
129
|
+
credentialEndpoint = entrance(config, PLATFORM_ROUTES.modelCredentials);
|
|
130
|
+
entrance(config, PLATFORM_ROUTES.modelProxy);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return unavailable('insecure_endpoint');
|
|
134
|
+
}
|
|
135
|
+
if (entry.credential && Date.parse(entry.credential.expiresAt) <= Date.now() + CREDENTIAL_RENEW_BEFORE_MS)
|
|
136
|
+
entry.credential = undefined;
|
|
137
|
+
if (!entry.credential) {
|
|
138
|
+
const response = await requestProbe(credentialEndpoint, {
|
|
139
|
+
method: 'POST', headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': config.environment },
|
|
140
|
+
body: JSON.stringify({ runtimeId: config.runtimeId, generation: config.generation, runtimeEpoch: config.runtimeEpoch, workspaceId: config.workspaceId }),
|
|
141
|
+
}, true);
|
|
142
|
+
if (response.state.status !== 'ready')
|
|
143
|
+
return response.state;
|
|
144
|
+
const parsed = credentialSchema.safeParse(response.body);
|
|
145
|
+
if (!parsed.success || !echoMatches(parsed.data.claims, { ...scope(config), userId: config.userId })
|
|
146
|
+
|| Date.parse(parsed.data.expiresAt) <= Date.now() + 1_000)
|
|
147
|
+
return unavailable('invalid_probe_response');
|
|
148
|
+
entry.credential = parsed.data;
|
|
149
|
+
}
|
|
150
|
+
const credential = entry.credential;
|
|
151
|
+
const nonce = randomUUID();
|
|
152
|
+
const target = new URL(platformEndpoint(config.publicBaseUrl, modelConnectivityPath(credential.configId)));
|
|
153
|
+
target.searchParams.set('nonce', nonce);
|
|
154
|
+
const result = await requestProbe(target, { method: 'GET', headers: { authorization: `Bearer ${credential.token}`, 'playcraft-env': config.environment } });
|
|
155
|
+
if (result.state.reason === 'endpoint_rejected')
|
|
156
|
+
entry.credential = undefined;
|
|
157
|
+
if (result.state.status !== 'ready')
|
|
158
|
+
return result.state;
|
|
159
|
+
return echoMatches(result.body, { service: 'playcraft-model-proxy', ...scope(config), configId: credential.configId, nonce })
|
|
160
|
+
? ready() : unavailable('invalid_probe_response');
|
|
161
|
+
}
|
|
162
|
+
async function probe(config, entry) {
|
|
163
|
+
if (config.destroyed || !config.registered) {
|
|
164
|
+
const state = unavailable(config.destroyed ? 'runtime_destroyed' : 'workspace_not_registered');
|
|
165
|
+
return { ...scope(config), checkedAt: new Date().toISOString(), callback: state, modelProxy: state, conversation: state };
|
|
166
|
+
}
|
|
167
|
+
const [callback, modelProxy] = await Promise.all([callbackProbe(config), modelProxyProbe(config, entry)]);
|
|
168
|
+
const conversation = !config.nativeReady ? unavailable('adapter_not_started')
|
|
169
|
+
: callback.status !== 'ready' ? unavailable('callback_unavailable')
|
|
170
|
+
: modelProxy.status !== 'ready' ? unavailable('model_proxy_unavailable') : ready();
|
|
171
|
+
return { ...scope(config), checkedAt: new Date().toISOString(), callback, modelProxy, conversation };
|
|
172
|
+
}
|
|
173
|
+
/** Check the configured services without starting a thread or generating a model response. */
|
|
174
|
+
export function probeRuntimeConnectivity(state, workspaceId) {
|
|
175
|
+
const config = snapshot(state, workspaceId);
|
|
176
|
+
const key = JSON.stringify(config);
|
|
177
|
+
let cache = caches.get(state);
|
|
178
|
+
if (!cache) {
|
|
179
|
+
cache = new Map();
|
|
180
|
+
caches.set(state, cache);
|
|
181
|
+
}
|
|
182
|
+
const previous = cache.get(workspaceId);
|
|
183
|
+
if (previous?.key === key) {
|
|
184
|
+
if (previous.pending)
|
|
185
|
+
return previous.pending;
|
|
186
|
+
if (previous.value && previous.expiresAt > Date.now())
|
|
187
|
+
return Promise.resolve(previous.value);
|
|
188
|
+
}
|
|
189
|
+
const entry = previous?.key === key ? previous : { key, expiresAt: 0 };
|
|
190
|
+
cache.set(workspaceId, entry);
|
|
191
|
+
if (cache.size > 256)
|
|
192
|
+
cache.delete(cache.keys().next().value);
|
|
193
|
+
entry.pending = probe(config, entry).then((value) => {
|
|
194
|
+
if (JSON.stringify(snapshot(state, workspaceId)) !== key)
|
|
195
|
+
return probeRuntimeConnectivity(state, workspaceId);
|
|
196
|
+
entry.value = value;
|
|
197
|
+
entry.expiresAt = Date.now() + CACHE_MS;
|
|
198
|
+
return value;
|
|
199
|
+
}).finally(() => { entry.pending = undefined; });
|
|
200
|
+
return entry.pending;
|
|
201
|
+
}
|
|
@@ -2,7 +2,8 @@ import { withObservedFile } from '../workspaces/revisions.js';
|
|
|
2
2
|
import { revisionScope, workspaceRoot, workspaceSessions } from '../workspaces/registry.js';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
|
-
import {
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { PLATFORM_ROUTES, platformEndpoint, checkpointRecordSchema, fileWritePayloadSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, parameterPatchPayloadSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, workspaceSkillsUpdatePayloadSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
7
|
import { closeCliCredentials } from '../workspaces/cli-credentials.js';
|
|
7
8
|
import { resolveWorkspaceAttachments } from '../workspaces/attachments.js';
|
|
8
9
|
import { readWorkspaceSkillSnapshot, readWorkspaceSkills } from '../workspaces/skills.js';
|
|
@@ -123,7 +124,7 @@ async function executeScopedOperation(state, scopeKey, operation, payload) {
|
|
|
123
124
|
}
|
|
124
125
|
try {
|
|
125
126
|
if (operation.kind === 'session.create') {
|
|
126
|
-
return executeCreate(state, scopeKey, current);
|
|
127
|
+
return executeCreate(state, scopeKey, current, payload);
|
|
127
128
|
}
|
|
128
129
|
if (operation.kind === 'session.turn') {
|
|
129
130
|
return executeTurn(state, scopeKey, current, payload);
|
|
@@ -171,13 +172,17 @@ async function executeSkillsUpdate(state, scopeKey, operation, payload) {
|
|
|
171
172
|
}));
|
|
172
173
|
}
|
|
173
174
|
}
|
|
174
|
-
async function executeCreate(state, scopeKey, operation) {
|
|
175
|
+
async function executeCreate(state, scopeKey, operation, payload) {
|
|
175
176
|
const sessionId = required(operation.sessionId, 'sessionId');
|
|
177
|
+
const selected = z.string().uuid().optional().safeParse(asRecord(payload).modelConfigId);
|
|
178
|
+
if (!selected.success)
|
|
179
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'model_config_denied' }));
|
|
176
180
|
let started;
|
|
177
181
|
try {
|
|
178
182
|
started = await native(state).startThread({
|
|
179
183
|
sessionId,
|
|
180
184
|
cwd: await workspaceRoot(state, operation.workspaceId),
|
|
185
|
+
modelConfigId: selected.data,
|
|
181
186
|
});
|
|
182
187
|
}
|
|
183
188
|
catch (error) {
|
|
@@ -195,10 +200,12 @@ async function executeCreate(state, scopeKey, operation) {
|
|
|
195
200
|
threadId: started.threadId,
|
|
196
201
|
cwd: await workspaceRoot(state, operation.workspaceId),
|
|
197
202
|
recoverability: 'native',
|
|
203
|
+
modelConfigId: started.modelConfigId,
|
|
198
204
|
});
|
|
199
205
|
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted', {
|
|
200
206
|
nativeRequestId: `thread/start:${sessionId}`,
|
|
201
207
|
nativeThreadId: started.threadId,
|
|
208
|
+
modelConfigId: started.modelConfigId,
|
|
202
209
|
}));
|
|
203
210
|
await recordNativeEvents(state, accepted, [
|
|
204
211
|
{ type: 'session.created', payload: { sessionId, threadId: started.threadId }, occurredAt: new Date().toISOString() },
|
|
@@ -218,6 +225,9 @@ async function executeTurn(state, scopeKey, operation, payload) {
|
|
|
218
225
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'turn_already_active' }));
|
|
219
226
|
}
|
|
220
227
|
const body = asRecord(payload);
|
|
228
|
+
const selected = z.string().uuid().optional().safeParse(body.modelConfigId);
|
|
229
|
+
if (!selected.success)
|
|
230
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'model_config_denied' }));
|
|
221
231
|
let attachments;
|
|
222
232
|
try {
|
|
223
233
|
attachments = await resolveWorkspaceAttachments(await workspaceRoot(state, operation.workspaceId), body.attachmentIds);
|
|
@@ -233,15 +243,17 @@ async function executeTurn(state, scopeKey, operation, payload) {
|
|
|
233
243
|
text: String(body.text ?? '') + attachmentText,
|
|
234
244
|
images: attachments.filter((file) => file.mimeType.startsWith('image/')).map((file) => file.path),
|
|
235
245
|
clientRequestId: operation.clientRequestId,
|
|
246
|
+
modelConfigId: selected.data ?? binding.modelConfigId,
|
|
236
247
|
simulateLostReceipt: body.simulateLostReceipt === true,
|
|
237
|
-
persistReservation: async ({ nativeRequestId }) => {
|
|
248
|
+
persistReservation: async ({ nativeRequestId, modelConfigId }) => {
|
|
238
249
|
await persist(state, scopeKey, {
|
|
239
250
|
...operation,
|
|
240
251
|
nativeRequestId,
|
|
241
252
|
nativeThreadId: binding.threadId,
|
|
253
|
+
modelConfigId,
|
|
242
254
|
updatedAt: new Date().toISOString(),
|
|
243
255
|
});
|
|
244
|
-
await state.journal.putSession({ ...binding, turnStatus: 'starting' });
|
|
256
|
+
await state.journal.putSession({ ...binding, modelConfigId, turnStatus: 'starting' });
|
|
245
257
|
},
|
|
246
258
|
});
|
|
247
259
|
const reserved = state.journal.getOperation(scopeKey) ?? operation;
|
|
@@ -354,6 +366,7 @@ async function executeCheckpoint(state, scopeKey, operation, payload) {
|
|
|
354
366
|
const sessions = workspaceSessions(state, operation.workspaceId).map((item) => ({
|
|
355
367
|
sessionId: item.sessionId,
|
|
356
368
|
nativeThreadId: item.threadId,
|
|
369
|
+
modelConfigId: item.modelConfigId,
|
|
357
370
|
recoverability: 'history_only',
|
|
358
371
|
}));
|
|
359
372
|
const checkpoint = checkpointRecordSchema.parse({
|
|
@@ -385,7 +398,7 @@ async function executeCheckpoint(state, scopeKey, operation, payload) {
|
|
|
385
398
|
git: standalone ? 'full' : 'local',
|
|
386
399
|
});
|
|
387
400
|
// Transcripts only matter where another instance can read them back.
|
|
388
|
-
const transcripts = state.config.
|
|
401
|
+
const transcripts = state.config.checkpointObjects ? await captureTranscripts(state, operation.workspaceId) : [];
|
|
389
402
|
const digest = liveTreeDigest(tree, transcripts);
|
|
390
403
|
if (!requested && tree.status === 'complete') {
|
|
391
404
|
const stillActive = await activeManagedWrites(state, workspaceId);
|
|
@@ -396,14 +409,14 @@ async function executeCheckpoint(state, scopeKey, operation, payload) {
|
|
|
396
409
|
return finishUnchanged(state, scopeKey, operation, previous);
|
|
397
410
|
}
|
|
398
411
|
}
|
|
399
|
-
if (state.config.
|
|
412
|
+
if (state.config.checkpointObjects) {
|
|
400
413
|
const stillActive = await activeManagedWrites(state, workspaceId);
|
|
401
414
|
if (stillActive.length > 0) {
|
|
402
415
|
return finishBestEffort(state, scopeKey, operation, checkpointId, workspaceId, createdAt, stillActive.map((item) => item.source));
|
|
403
416
|
}
|
|
404
417
|
try {
|
|
405
418
|
published = await publishFileSnapshot({
|
|
406
|
-
baseUrl: state.config.
|
|
419
|
+
baseUrl: platformEndpoint(state.config.publicBaseUrl, PLATFORM_ROUTES.checkpointStore),
|
|
407
420
|
snapshotId: checkpointId,
|
|
408
421
|
workspaceId,
|
|
409
422
|
generation: state.config.generation,
|
|
@@ -411,7 +424,7 @@ async function executeCheckpoint(state, scopeKey, operation, payload) {
|
|
|
411
424
|
native: transcripts,
|
|
412
425
|
uploaded: workspaceSaves(state, workspaceId).uploaded,
|
|
413
426
|
uploadedTranscripts: workspaceSaves(state, workspaceId).transcripts,
|
|
414
|
-
headers: objectStoreHeaders(state
|
|
427
|
+
headers: objectStoreHeaders(state),
|
|
415
428
|
});
|
|
416
429
|
}
|
|
417
430
|
catch {
|
|
@@ -421,6 +434,7 @@ async function executeCheckpoint(state, scopeKey, operation, payload) {
|
|
|
421
434
|
const sessions = workspaceSessions(state, operation.workspaceId).map((item) => ({
|
|
422
435
|
sessionId: item.sessionId,
|
|
423
436
|
nativeThreadId: item.threadId,
|
|
437
|
+
modelConfigId: item.modelConfigId,
|
|
424
438
|
recoverability: 'history_only',
|
|
425
439
|
}));
|
|
426
440
|
const checkpoint = checkpointRecordSchema.parse({
|
|
@@ -513,13 +527,14 @@ async function executeRestore(state, scopeKey, operation, payload) {
|
|
|
513
527
|
if (!checkpoint.nativeRecoverable || threadReplaced) {
|
|
514
528
|
return {
|
|
515
529
|
...item,
|
|
530
|
+
modelConfigId: snap.modelConfigId ?? item.modelConfigId,
|
|
516
531
|
threadId,
|
|
517
532
|
recoverability: restarted ? 'history_only' : item.recoverability,
|
|
518
533
|
};
|
|
519
534
|
}
|
|
520
535
|
if (restarted)
|
|
521
|
-
return { ...item, threadId, recoverability: 'history_only' };
|
|
522
|
-
return { ...item, threadId: snap.nativeThreadId ?? item.threadId, recoverability: 'native' };
|
|
536
|
+
return { ...item, modelConfigId: snap.modelConfigId ?? item.modelConfigId, threadId, recoverability: 'history_only' };
|
|
537
|
+
return { ...item, modelConfigId: snap.modelConfigId ?? item.modelConfigId, threadId: snap.nativeThreadId ?? item.threadId, recoverability: 'native' };
|
|
523
538
|
});
|
|
524
539
|
for (const snap of checkpoint.sessions) {
|
|
525
540
|
if (!next.some((item) => item.sessionId === snap.sessionId)) {
|
|
@@ -527,6 +542,7 @@ async function executeRestore(state, scopeKey, operation, payload) {
|
|
|
527
542
|
sessionId: snap.sessionId,
|
|
528
543
|
workspaceId: operation.workspaceId,
|
|
529
544
|
threadId: snap.nativeThreadId ?? `thread_${snap.sessionId}`,
|
|
545
|
+
modelConfigId: snap.modelConfigId,
|
|
530
546
|
cwd,
|
|
531
547
|
recoverability: checkpoint.nativeRecoverable && !restarted ? 'native' : 'history_only',
|
|
532
548
|
});
|
|
@@ -545,6 +561,7 @@ async function executeRestore(state, scopeKey, operation, payload) {
|
|
|
545
561
|
threadId,
|
|
546
562
|
sessionId: snap.sessionId,
|
|
547
563
|
cwd: found.cwd,
|
|
564
|
+
modelConfigId: found.modelConfigId,
|
|
548
565
|
});
|
|
549
566
|
if (!continued || found.threadId !== threadId)
|
|
550
567
|
continue;
|
|
@@ -944,12 +961,14 @@ async function captureNativeSessions(state, workspaceId) {
|
|
|
944
961
|
? {
|
|
945
962
|
sessionId: item.sessionId,
|
|
946
963
|
nativeThreadId: item.threadId,
|
|
964
|
+
modelConfigId: item.modelConfigId,
|
|
947
965
|
nativeDurableTurnId: durable,
|
|
948
966
|
recoverability: 'native',
|
|
949
967
|
}
|
|
950
968
|
: {
|
|
951
969
|
sessionId: item.sessionId,
|
|
952
970
|
nativeThreadId: item.threadId,
|
|
971
|
+
modelConfigId: item.modelConfigId,
|
|
953
972
|
recoverability: 'history_only',
|
|
954
973
|
});
|
|
955
974
|
}
|
|
@@ -984,6 +1003,7 @@ async function finishBestEffort(state, scopeKey, operation, checkpointId, worksp
|
|
|
984
1003
|
const sessions = workspaceSessions(state, operation.workspaceId).map((item) => ({
|
|
985
1004
|
sessionId: item.sessionId,
|
|
986
1005
|
nativeThreadId: item.threadId,
|
|
1006
|
+
modelConfigId: item.modelConfigId,
|
|
987
1007
|
recoverability: 'history_only',
|
|
988
1008
|
}));
|
|
989
1009
|
const checkpoint = checkpointRecordSchema.parse({
|
|
@@ -1074,7 +1094,7 @@ function nativeIds(params) {
|
|
|
1074
1094
|
};
|
|
1075
1095
|
}
|
|
1076
1096
|
async function notifyCallback(state, events) {
|
|
1077
|
-
await fetch(state.config.
|
|
1097
|
+
await fetch(platformEndpoint(state.config.publicBaseUrl, PLATFORM_ROUTES.events), {
|
|
1078
1098
|
method: 'POST',
|
|
1079
1099
|
headers: {
|
|
1080
1100
|
authorization: `Bearer ${state.config.controlToken}`,
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { chmod, rename, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { applySandboxEndpointPatch } from '@playcraft/common/cloud-workspace/runtime';
|
|
3
|
-
/** Persist the new
|
|
3
|
+
/** Persist the new public entrance, then publish it to the running process. A restart is not required. */
|
|
4
4
|
export async function applyRuntimeEndpointPatch(state, patch) {
|
|
5
5
|
const next = applySandboxEndpointPatch(state.config, patch);
|
|
6
|
-
const changed = next.
|
|
7
|
-
const result = { changed,
|
|
6
|
+
const changed = next.publicBaseUrl !== state.config.publicBaseUrl;
|
|
7
|
+
const result = { changed, publicBaseUrl: next.publicBaseUrl };
|
|
8
8
|
if (!changed)
|
|
9
9
|
return result;
|
|
10
10
|
if (!state.configPath) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readObservedFile, withObservedFile } from '../workspaces/revisions.js';
|
|
2
2
|
import { registerWorkspace, revisionScope, workspaceRoot, WorkspaceContextError } from '../workspaces/registry.js';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
|
-
import { workspaceRegistrationRequestSchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, gitFileRefSchema, hashOperationPayload, instanceRebuildRequestSchema, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, resolveIdempotentAccept, nativeHistoryReadSchema, runtimeError, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
|
+
import { workspaceRegistrationRequestSchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, gitFileRefSchema, hashOperationPayload, instanceRebuildRequestSchema, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, resolveIdempotentAccept, nativeHistoryReadSchema, runtimeError, runtimeConnectivityDocumentSchema, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
5
|
import { controlTokenMatches, extractControlToken } from './auth.js';
|
|
6
6
|
import { applyRuntimeEndpointPatch } from './endpoints.js';
|
|
7
7
|
import { canExecuteKind, executeOperation } from './dispatch.js';
|
|
@@ -17,6 +17,7 @@ import { readWorkspaceSkills } from '../workspaces/skills.js';
|
|
|
17
17
|
import { loadWorkspaceSkillArtifact } from '../workspaces/skill-artifact-loader.js';
|
|
18
18
|
import { join } from 'node:path';
|
|
19
19
|
import { terminalHost } from './terminal-host.js';
|
|
20
|
+
import { probeRuntimeConnectivity } from './connectivity.js';
|
|
20
21
|
export async function handleRuntimeRequest(state, request, response) {
|
|
21
22
|
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
22
23
|
const access = authorizeRuntime(state, request);
|
|
@@ -33,6 +34,19 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
33
34
|
writeJson(response, 200, { status: 'ok', handshake: handshake(state) });
|
|
34
35
|
return;
|
|
35
36
|
}
|
|
37
|
+
if (request.method === 'GET' && url.pathname === '/runtime/connectivity') {
|
|
38
|
+
if (writeDestroyed(state, response))
|
|
39
|
+
return;
|
|
40
|
+
const parsed = runtimeConnectivityDocumentSchema.shape.workspaceId.safeParse(url.searchParams.get('workspaceId'));
|
|
41
|
+
if (!parsed.success) {
|
|
42
|
+
writeJson(response, 400, { error: 'invalid_workspace' });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const workspaceId = parsed.data;
|
|
46
|
+
await workspaceRoot(state, workspaceId);
|
|
47
|
+
writeJson(response, 200, await probeRuntimeConnectivity(state, workspaceId));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
36
50
|
if (request.method === 'GET' && url.pathname === '/runtime/identity') {
|
|
37
51
|
const { runtimeId, userId, environment, generation } = state.config;
|
|
38
52
|
writeJson(response, 200, {
|
|
@@ -58,17 +58,8 @@ export async function rememberFileCheckpoint(state, workspaceId, saved) {
|
|
|
58
58
|
await rm(join(state.config.stateDir, 'file-checkpoints', checkpointId), { recursive: true, force: true }).catch(() => undefined);
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
|
-
/**
|
|
62
|
-
|
|
63
|
-
*/
|
|
64
|
-
export function objectStoreHeaders(state, storeUrl) {
|
|
65
|
-
try {
|
|
66
|
-
if (new URL(storeUrl).origin !== new URL(state.config.callbackUrl).origin)
|
|
67
|
-
return {};
|
|
68
|
-
}
|
|
69
|
-
catch {
|
|
70
|
-
return {};
|
|
71
|
-
}
|
|
61
|
+
/** Checkpoint uploads always go to this deployment, so they always carry the control credential. */
|
|
62
|
+
export function objectStoreHeaders(state) {
|
|
72
63
|
return {
|
|
73
64
|
authorization: `Bearer ${state.config.controlToken}`,
|
|
74
65
|
'playcraft-env': state.config.environment,
|
|
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
-
import {
|
|
5
|
+
import { PLATFORM_ROUTES, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
6
|
const responseSchema = z.object({
|
|
7
7
|
token: z.string().min(1),
|
|
8
8
|
apiUrl: z.string().url(),
|
|
@@ -37,11 +37,7 @@ export async function ensureCliCredentials(state) {
|
|
|
37
37
|
async function renew(state, entry) {
|
|
38
38
|
const config = state.config;
|
|
39
39
|
const epoch = state.runtimeEpoch;
|
|
40
|
-
const endpoint =
|
|
41
|
-
if (endpoint.protocol !== 'https:' &&
|
|
42
|
-
!(endpoint.protocol === 'http:' &&
|
|
43
|
-
['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname)))
|
|
44
|
-
throw new Error('cli_callback_insecure');
|
|
40
|
+
const endpoint = platformEndpoint(config.publicBaseUrl, PLATFORM_ROUTES.cliCredentials);
|
|
45
41
|
try {
|
|
46
42
|
const response = await fetch(endpoint, {
|
|
47
43
|
method: 'POST',
|
|
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
8
8
|
import { promisify } from 'node:util';
|
|
9
9
|
import { isLocalWorkspaceGitAuthMode, isLocalWorkspaceGitEnvironment, matchWorkspaceGitGatewayCloneUrl, workspacePreparationSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
10
10
|
import { WorkspaceGitDirectStore } from './git-direct-store.js';
|
|
11
|
-
import {
|
|
11
|
+
import { PLATFORM_ROUTES, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
|
|
12
12
|
const exec = promisify(execFile);
|
|
13
13
|
/** Only this control process sees the callback credential. Development processes use the local socket. */
|
|
14
14
|
export class WorkspaceGitCredentialBroker {
|
|
@@ -165,9 +165,7 @@ export class WorkspaceGitCredentialBroker {
|
|
|
165
165
|
await this.direct.sweep(true);
|
|
166
166
|
}
|
|
167
167
|
async refresh(binding, key) {
|
|
168
|
-
const endpoint =
|
|
169
|
-
if (endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname)))
|
|
170
|
-
throw new Error('git_broker_unavailable');
|
|
168
|
+
const endpoint = platformEndpoint(this.state.config.publicBaseUrl, PLATFORM_ROUTES.gitCredentials);
|
|
171
169
|
const epoch = this.state.runtimeEpoch;
|
|
172
170
|
const response = await fetch(endpoint, {
|
|
173
171
|
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10_000),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { workspaceRoot } from './registry.js';
|
|
3
|
-
import {
|
|
3
|
+
import { PLATFORM_ROUTES, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
4
|
const MAX_ARTIFACT_BYTES = 80 * 1024 * 1024;
|
|
5
5
|
/** Requests an exact locked artifact using control identity; no storage credentials reach Codex. */
|
|
6
6
|
export async function loadWorkspaceSkillArtifact(state, workspaceId, entry) {
|
|
@@ -8,9 +8,7 @@ export async function loadWorkspaceSkillArtifact(state, workspaceId, entry) {
|
|
|
8
8
|
const epoch = state.runtimeEpoch;
|
|
9
9
|
if (state.journal.destroyed())
|
|
10
10
|
throw new Error('workspace_skills_unavailable');
|
|
11
|
-
const endpoint =
|
|
12
|
-
if (endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname)))
|
|
13
|
-
throw new Error('workspace_skills_callback_insecure');
|
|
11
|
+
const endpoint = platformEndpoint(state.config.publicBaseUrl, PLATFORM_ROUTES.skillArtifacts);
|
|
14
12
|
const response = await fetch(endpoint, {
|
|
15
13
|
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(30_000),
|
|
16
14
|
headers: { authorization: `Bearer ${state.config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': state.config.environment },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playcraft/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.17",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
"@gltf-transform/core": "^4.3.0",
|
|
49
49
|
"@gltf-transform/extensions": "^4.3.0",
|
|
50
50
|
"@gltf-transform/functions": "^4.3.0",
|
|
51
|
-
"@playcraft/build": "^0.1.
|
|
52
|
-
"@playcraft/common": "^0.1.
|
|
51
|
+
"@playcraft/build": "^0.1.17",
|
|
52
|
+
"@playcraft/common": "^0.1.17",
|
|
53
53
|
"better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
|
|
54
54
|
"chokidar": "^4.0.3",
|
|
55
55
|
"commander": "^13.1.0",
|