@playcraft/cli 0.1.1 → 0.1.2

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.
@@ -6,7 +6,7 @@ import { join } from 'node:path';
6
6
  import { writePlaycraftCodexConfig } from './config-toml.js';
7
7
  import { createJsonRpcStdioClient } from './jsonrpc-stdio.js';
8
8
  import { projectNativeThread } from '@playcraft/common/cloud-workspace/runtime';
9
- import { collectedEvent } from './native-adapter.js';
9
+ import { collectedEvent, NativePreparationError } from './native-adapter.js';
10
10
  function asRecord(value) {
11
11
  return value && typeof value === 'object' && !Array.isArray(value)
12
12
  ? value
@@ -74,8 +74,20 @@ export function createCodexAppServerAdapter(options) {
74
74
  startPromise ??= start();
75
75
  await startPromise;
76
76
  }
77
+ async function threadConfig(cwd) {
78
+ try {
79
+ return await options.modelBroker?.threadConfig(cwd) ?? {};
80
+ }
81
+ catch (error) {
82
+ const reason = error instanceof Error ? error.message : '';
83
+ throw new NativePreparationError(['model_config_required', 'model_callback_insecure',
84
+ 'model_credential_scope_mismatch', 'workspace_not_registered'].includes(reason)
85
+ ? reason : 'model_credential_unavailable');
86
+ }
87
+ }
77
88
  async function start() {
78
- const token = await readToken(options);
89
+ await options.modelBroker?.start();
90
+ const token = options.modelBroker ? 'workspace-broker-managed' : await readToken(options);
79
91
  const bin = options.command || process.env.PLAYCRAFT_CODEX_BIN || (launcherSocket ? '/usr/local/bin/codex' : 'codex');
80
92
  const { command, prefix } = spawnCommand(bin);
81
93
  const args = [...prefix, ...(options.args ?? ['app-server'])];
@@ -98,7 +110,9 @@ export function createCodexAppServerAdapter(options) {
98
110
  processId: `codex-${randomUUID()}`,
99
111
  runtimeEpoch: executionEpoch,
100
112
  argv: [command, ...args],
101
- cwd: options.config.workspaceRoot,
113
+ // The launcher admits the dev home, not the shared /workspaces root.
114
+ // Each thread supplies its registered workspace separately.
115
+ cwd: options.config.workspaceMode === 'multi' ? '/home/playcraft-dev' : options.config.workspaceRoot,
102
116
  env: { CODEX_HOME: homeDir, OPENAI_API_KEY: token },
103
117
  });
104
118
  }
@@ -230,7 +244,8 @@ export function createCodexAppServerAdapter(options) {
230
244
  if (!rpc)
231
245
  return false;
232
246
  try {
233
- const result = await rpc.request('thread/resume', { threadId: input.threadId, cwd: input.cwd }, 30_000);
247
+ const result = await rpc.request('thread/resume', { threadId: input.threadId, cwd: input.cwd,
248
+ ...await threadConfig(input.cwd ?? '') }, 30_000);
234
249
  const resumedId = result.thread?.id ?? result.threadId;
235
250
  if (resumedId !== input.threadId)
236
251
  return false;
@@ -275,6 +290,7 @@ export function createCodexAppServerAdapter(options) {
275
290
  const result = await rpc.request('thread/start', {
276
291
  cwd: input.cwd,
277
292
  model,
293
+ ...await threadConfig(input.cwd),
278
294
  });
279
295
  const threadId = result.thread?.id;
280
296
  if (!threadId)
@@ -356,6 +372,7 @@ export function createCodexAppServerAdapter(options) {
356
372
  await current.stop();
357
373
  child = undefined;
358
374
  live = undefined;
375
+ await options.modelBroker?.close();
359
376
  },
360
377
  };
361
378
  }
@@ -0,0 +1,183 @@
1
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import { createServer } from 'node:http';
3
+ import { Readable } from 'node:stream';
4
+ import { pipeline } from 'node:stream/promises';
5
+ import { basename } from 'node:path';
6
+ import { z } from 'zod';
7
+ import { workspaceRoot } from '../workspaces/registry.js';
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
+ /** Keeps renewable execution credentials in control memory, outside Codex and project files. */
17
+ export class WorkspaceModelBroker {
18
+ state;
19
+ server;
20
+ baseUrl = '';
21
+ entries = new Map();
22
+ pending = new Map();
23
+ requests = new Set();
24
+ constructor(state) {
25
+ this.state = state;
26
+ }
27
+ async start() {
28
+ if (this.server)
29
+ return;
30
+ const server = createServer({ maxHeaderSize: 8192 }, (request, response) => {
31
+ void this.forward(request, response);
32
+ });
33
+ await new Promise((resolve, reject) => {
34
+ server.once('error', reject);
35
+ server.listen(0, '127.0.0.1', resolve);
36
+ });
37
+ this.server = server;
38
+ const address = server.address();
39
+ if (!address || typeof address === 'string')
40
+ throw new Error('model_broker_unavailable');
41
+ this.baseUrl = `http://127.0.0.1:${address.port}`;
42
+ }
43
+ async threadConfig(cwd) {
44
+ const workspaceId = basename(cwd);
45
+ if (!this.server || await workspaceRoot(this.state, workspaceId) !== cwd)
46
+ throw new Error('workspace_not_registered');
47
+ let entry = this.entries.get(workspaceId);
48
+ if (!entry || entry.epoch !== this.state.runtimeEpoch) {
49
+ entry = { workspaceId, epoch: this.state.runtimeEpoch, capability: randomBytes(32).toString('base64url') };
50
+ this.entries.set(workspaceId, entry);
51
+ }
52
+ const credential = await this.credential(entry);
53
+ return {
54
+ model: credential.model,
55
+ modelProvider: 'playcraft_workspace',
56
+ config: {
57
+ 'model_providers.playcraft_workspace': {
58
+ name: 'PlayCraft Workspace', base_url: `${this.baseUrl}/${workspaceId}`,
59
+ wire_api: 'responses', requires_openai_auth: false,
60
+ http_headers: { Authorization: `Bearer ${entry.capability}` },
61
+ },
62
+ },
63
+ };
64
+ }
65
+ valid(entry) {
66
+ return Boolean(this.server && !this.state.journal.destroyed()
67
+ && this.state.runtimeEpoch === entry.epoch && this.state.journal.getWorkspace(entry.workspaceId)
68
+ && this.entries.get(entry.workspaceId) === entry);
69
+ }
70
+ async credential(entry) {
71
+ if (!this.valid(entry))
72
+ throw new Error('model_access_denied');
73
+ if (entry.credential && Date.parse(entry.credential.expiresAt) > Date.now() + 60_000)
74
+ return entry.credential;
75
+ let pending = this.pending.get(entry);
76
+ if (!pending) {
77
+ pending = this.renew(entry).finally(() => this.pending.delete(entry));
78
+ this.pending.set(entry, pending);
79
+ }
80
+ return pending;
81
+ }
82
+ async renew(entry) {
83
+ const config = this.state.config;
84
+ const endpoint = new URL('/runtime/internal/model-credentials', config.callbackUrl);
85
+ if (endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:'
86
+ && ['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname)))
87
+ throw new Error('model_callback_insecure');
88
+ const response = await fetch(endpoint, {
89
+ method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10_000),
90
+ headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json' },
91
+ body: JSON.stringify({ runtimeId: config.runtimeId, generation: config.generation,
92
+ runtimeEpoch: entry.epoch, workspaceId: entry.workspaceId }),
93
+ });
94
+ if (!response.ok) {
95
+ const error = await response.json().catch(() => ({}));
96
+ throw new Error(error.reason === 'model_config_required' ? 'model_config_required' : 'model_credential_unavailable');
97
+ }
98
+ const credential = credentialSchema.parse(await response.json());
99
+ const claims = credential.claims;
100
+ if (!this.valid(entry) || claims.workspaceId !== entry.workspaceId || claims.runtimeId !== config.runtimeId
101
+ || claims.userId !== config.userId || claims.environment !== config.environment
102
+ || claims.generation !== config.generation || claims.runtimeEpoch !== entry.epoch
103
+ || Date.parse(credential.expiresAt) <= Date.now() + 1000)
104
+ throw new Error('model_credential_scope_mismatch');
105
+ entry.credential = credential;
106
+ return credential;
107
+ }
108
+ async forward(request, response) {
109
+ const abort = new AbortController();
110
+ this.requests.add(abort);
111
+ const cancel = () => abort.abort();
112
+ response.once('close', cancel);
113
+ try {
114
+ const match = /^\/([0-9a-f-]{36})\/responses$/.exec(request.url ?? '');
115
+ const entry = match && this.entries.get(match[1]);
116
+ const presented = Buffer.from(request.headers.authorization ?? '');
117
+ const expected = Buffer.from(entry ? `Bearer ${entry.capability}` : '');
118
+ if (request.method !== 'POST' || !entry || !expected.length || presented.length !== expected.length
119
+ || !timingSafeEqual(presented, expected) || !this.valid(entry)) {
120
+ response.writeHead(403);
121
+ response.end();
122
+ return;
123
+ }
124
+ const chunks = [];
125
+ let size = 0;
126
+ for await (const chunk of request) {
127
+ size += chunk.length;
128
+ if (size > 16 * 1024 * 1024) {
129
+ response.writeHead(413);
130
+ response.end();
131
+ return;
132
+ }
133
+ chunks.push(Buffer.from(chunk));
134
+ }
135
+ const credential = await this.credential(entry);
136
+ if (!this.valid(entry))
137
+ throw new Error('model_access_denied');
138
+ const expires = setTimeout(cancel, Math.max(0, Date.parse(credential.expiresAt) - Date.now()));
139
+ try {
140
+ const upstream = await fetch(`${this.state.config.modelProxyUrl.replace(/\/+$/, '')}/v1/${credential.configId}/responses`, {
141
+ method: 'POST', redirect: 'error', signal: abort.signal,
142
+ headers: { authorization: `Bearer ${credential.token}`, 'content-type': 'application/json' },
143
+ body: Buffer.concat(chunks),
144
+ });
145
+ if (upstream.status === 401 || upstream.status === 403)
146
+ entry.credential = undefined;
147
+ response.writeHead(upstream.status, {
148
+ 'content-type': upstream.headers.get('content-type') ?? 'application/json', 'cache-control': 'no-store',
149
+ });
150
+ if (upstream.body)
151
+ await pipeline(Readable.fromWeb(upstream.body), response);
152
+ else
153
+ response.end();
154
+ }
155
+ finally {
156
+ clearTimeout(expires);
157
+ }
158
+ }
159
+ catch {
160
+ if (!response.headersSent) {
161
+ response.writeHead(503, { 'content-type': 'application/json', 'cache-control': 'no-store' });
162
+ response.end(JSON.stringify({ error: { message: 'model_credential_unavailable', type: 'server_error' } }));
163
+ }
164
+ else
165
+ response.destroy();
166
+ }
167
+ finally {
168
+ response.off('close', cancel);
169
+ this.requests.delete(abort);
170
+ }
171
+ }
172
+ async close() {
173
+ const server = this.server;
174
+ this.server = undefined;
175
+ this.entries.clear();
176
+ for (const request of this.requests)
177
+ request.abort();
178
+ if (server) {
179
+ server.closeAllConnections();
180
+ await new Promise(resolve => server.close(() => resolve()));
181
+ }
182
+ }
183
+ }
@@ -1,3 +1,6 @@
1
+ /** Failure before a native request was sent; safe to report as failed instead of uncertain. */
2
+ export class NativePreparationError extends Error {
3
+ }
1
4
  export function nativeEvent(type, payload) {
2
5
  return { type, payload, occurredAt: new Date().toISOString() };
3
6
  }
@@ -13,6 +13,7 @@ import { handleExecutionGroupCrash } from './server/execution-group.js';
13
13
  import { RuntimeProcessLock } from './server/lock.js';
14
14
  import { probeGitBinary } from './workspaces/git.js';
15
15
  import { WorkspaceGitCredentialBroker } from './workspaces/git-credential-broker.js';
16
+ import { WorkspaceModelBroker } from './codex/model-broker.js';
16
17
  export async function startWorkspaceRuntime(config, options = {}) {
17
18
  process.env.PLAYCRAFT_SKIP_UPDATE_CHECK = '1';
18
19
  const negotiated = negotiateProtocol(config);
@@ -31,17 +32,17 @@ export async function startWorkspaceRuntime(config, options = {}) {
31
32
  journal = new RuntimeJournal(config.stateDir);
32
33
  await journal.load();
33
34
  await mkdir(config.workspaceRoot, { recursive: true, mode: 0o700 });
34
- const native = options.nativeAdapter ?? defaultNativeAdapter(config);
35
35
  const state = {
36
36
  config: { ...config, protocolMinor: negotiated.protocolMinor },
37
37
  journal,
38
38
  acceptedLeaseFence: journal.acceptedLeaseFence(),
39
39
  gitAvailable: await probeGitBinary(),
40
- native,
40
+ native: options.nativeAdapter,
41
41
  runtimeEpoch: Math.max(config.runtimeEpoch, journal.storedRuntimeEpoch() ?? 0),
42
42
  executionQueue: Promise.resolve(),
43
43
  instanceToken: randomUUID(),
44
44
  };
45
+ const native = state.native ??= defaultNativeAdapter(state);
45
46
  await journal.bindRuntime({
46
47
  runtimeId: config.runtimeId,
47
48
  generation: config.generation,
@@ -61,8 +62,10 @@ export async function startWorkspaceRuntime(config, options = {}) {
61
62
  if (group)
62
63
  await journal.noteExecutionGroup(group);
63
64
  }
64
- catch {
65
- /* conversation stays adapter_not_started until Codex handshake succeeds */
65
+ catch (error) {
66
+ const code = error instanceof Error ? error.message : '';
67
+ state.nativeError = code === 'model_proxy_token_missing' ? code : 'codex_start_failed';
68
+ console.error(`[workspace-runtime] ${state.nativeError}`);
66
69
  }
67
70
  const server = createServer((request, response) => {
68
71
  void handleRuntimeRequest(state, request, response);
@@ -135,11 +138,16 @@ function closeServer(server) {
135
138
  function formatHost(host) {
136
139
  return host.includes(':') ? `[${host}]` : host;
137
140
  }
138
- function defaultNativeAdapter(config) {
141
+ function defaultNativeAdapter(state) {
142
+ const config = state.config;
139
143
  if (process.env.PLAYCRAFT_NATIVE_ADAPTER === 'loopback')
140
144
  return createLoopbackNativeAdapter();
141
- if (process.env.PLAYCRAFT_NATIVE_ADAPTER === 'codex') {
142
- return createCodexAppServerAdapter({ config });
145
+ const managed = config.workspaceMode === 'multi' && Boolean(process.env.PLAYCRAFT_LAUNCHER_SOCKET);
146
+ if (process.env.PLAYCRAFT_NATIVE_ADAPTER === 'codex'
147
+ || (!process.env.PLAYCRAFT_NATIVE_ADAPTER && managed)) {
148
+ return createCodexAppServerAdapter({ config,
149
+ ...(managed ? { modelBroker: new WorkspaceModelBroker(state) } : {}),
150
+ });
143
151
  }
144
152
  return undefined;
145
153
  }
@@ -3,6 +3,7 @@ import { revisionScope, workspaceRoot, workspaceSessions } from '../workspaces/r
3
3
  import { join } from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { checkpointRecordSchema, fileWritePayloadSchema, gitCommitPayloadSchema, gitPushPayloadSchema, parameterPatchPayloadSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, } from '@playcraft/common/cloud-workspace/runtime';
6
+ import { NativePreparationError } from '../codex/native-adapter.js';
6
7
  import { disposeTerminalHost } from './terminal-host.js';
7
8
  import { captureWorkspaceTree, materializeSnapshot, publishFileSnapshot, } from '../workspaces/file-snapshot.js';
8
9
  import { InvalidWorkspacePathError, resolveWorkspaceFile, restoreWorkspaceFiles, snapshotWorkspaceFiles, writeWorkspaceFile, } from '../workspaces/files.js';
@@ -117,10 +118,19 @@ async function executeScopedOperation(state, scopeKey, operation, payload) {
117
118
  }
118
119
  async function executeCreate(state, scopeKey, operation) {
119
120
  const sessionId = required(operation.sessionId, 'sessionId');
120
- const started = await native(state).startThread({
121
- sessionId,
122
- cwd: await workspaceRoot(state, operation.workspaceId),
123
- });
121
+ let started;
122
+ try {
123
+ started = await native(state).startThread({
124
+ sessionId,
125
+ cwd: await workspaceRoot(state, operation.workspaceId),
126
+ });
127
+ }
128
+ catch (error) {
129
+ if (error instanceof NativePreparationError) {
130
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: error.message }));
131
+ }
132
+ throw error;
133
+ }
124
134
  if (!started.threadId) {
125
135
  return persist(state, scopeKey, transitionOperation(operation, 'uncertain', { error: 'native_receipt_lost' }));
126
136
  }
@@ -211,7 +211,7 @@ function handshake(state) {
211
211
  if (item.name === 'conversation') {
212
212
  if (conversationReady)
213
213
  return capability('conversation', true);
214
- return destroyed ? capability('conversation', false, 'runtime_destroyed') : item;
214
+ return capability('conversation', false, destroyed ? 'runtime_destroyed' : state.nativeError ?? 'adapter_not_started');
215
215
  }
216
216
  if (item.name === 'files') {
217
217
  return filesReady
@@ -543,7 +543,7 @@ function unavailableReason(state, kind) {
543
543
  return 'runtime_destroyed';
544
544
  if (kind === 'preview.publish')
545
545
  return 'capability_not_implemented';
546
- return 'adapter_not_started';
546
+ return state.nativeError ?? 'adapter_not_started';
547
547
  }
548
548
  function sessionHistoryPath(pathname) {
549
549
  const match = pathname.match(/^\/runtime\/workspaces\/([^/]+)\/sessions\/([^/]+)\/history$/);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
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.1",
52
- "@playcraft/common": "^0.1.1",
51
+ "@playcraft/build": "^0.1.2",
52
+ "@playcraft/common": "^0.1.2",
53
53
  "better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
54
54
  "chokidar": "^4.0.3",
55
55
  "commander": "^13.1.0",