@playcraft/cli 0.1.0 → 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.
@@ -1,3 +1,4 @@
1
+ import { projectNativeThread } from '@playcraft/common/cloud-workspace/runtime';
1
2
  import { collectedEvent } from './native-adapter.js';
2
3
  /** In-process Codex stand-in. Not the App Server; used to prove the operation/event loop. */
3
4
  export function createLoopbackNativeAdapter() {
@@ -22,7 +23,7 @@ export function createLoopbackNativeAdapter() {
22
23
  },
23
24
  async startThread(input) {
24
25
  const threadId = `thread_${input.sessionId}`;
25
- threads.set(threadId, { sessionId: input.sessionId });
26
+ threads.set(threadId, { sessionId: input.sessionId, items: [] });
26
27
  return { threadId };
27
28
  },
28
29
  async startTurn(input) {
@@ -40,6 +41,18 @@ export function createLoopbackNativeAdapter() {
40
41
  turnStarts += 1;
41
42
  const turnId = `turn_${input.sessionId}_${input.clientRequestId}`;
42
43
  thread.turnId = turnId;
44
+ thread.items.push({
45
+ type: 'userMessage',
46
+ id: `user_${turnId}`,
47
+ content: [{ type: 'text', text: input.text }],
48
+ });
49
+ if (!input.text.includes('[approval]')) {
50
+ thread.items.push({
51
+ type: 'agentMessage',
52
+ id: `agent_${turnId}`,
53
+ text: `loopback:${input.text}`,
54
+ });
55
+ }
43
56
  queueMicrotask(() => {
44
57
  emit(collectedEvent('notification', 'item/agentMessage/delta', {
45
58
  threadId: input.threadId,
@@ -104,7 +117,24 @@ export function createLoopbackNativeAdapter() {
104
117
  turn: { id: thread?.[1].turnId, status: 'completed' },
105
118
  }));
106
119
  });
107
- return { outcome: 'accepted', approvalId: input.approvalId, turnId: thread?.[1].turnId };
120
+ const found = thread?.[1];
121
+ if (found) {
122
+ found.items.push({
123
+ type: 'agentMessage',
124
+ id: `agent_${found.turnId ?? input.approvalId}`,
125
+ text: 'after-approval',
126
+ });
127
+ }
128
+ return { outcome: 'accepted', approvalId: input.approvalId, turnId: found?.turnId };
129
+ },
130
+ async readThread(input) {
131
+ const thread = threads.get(input.threadId);
132
+ if (!thread || thread.sessionId !== input.sessionId)
133
+ return undefined;
134
+ return projectNativeThread(input.threadId, {
135
+ id: input.threadId,
136
+ turns: [{ items: thread.items }],
137
+ });
108
138
  },
109
139
  };
110
140
  }
@@ -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
  }
@@ -12,6 +12,8 @@ import { disposeTerminalHost, terminalHost } from './server/terminal-host.js';
12
12
  import { handleExecutionGroupCrash } from './server/execution-group.js';
13
13
  import { RuntimeProcessLock } from './server/lock.js';
14
14
  import { probeGitBinary } from './workspaces/git.js';
15
+ import { WorkspaceGitCredentialBroker } from './workspaces/git-credential-broker.js';
16
+ import { WorkspaceModelBroker } from './codex/model-broker.js';
15
17
  export async function startWorkspaceRuntime(config, options = {}) {
16
18
  process.env.PLAYCRAFT_SKIP_UPDATE_CHECK = '1';
17
19
  const negotiated = negotiateProtocol(config);
@@ -25,26 +27,31 @@ export async function startWorkspaceRuntime(config, options = {}) {
25
27
  const lock = new RuntimeProcessLock(config.stateDir);
26
28
  lock.acquire();
27
29
  let journal;
30
+ let gitCredentials;
28
31
  try {
29
32
  journal = new RuntimeJournal(config.stateDir);
30
33
  await journal.load();
31
34
  await mkdir(config.workspaceRoot, { recursive: true, mode: 0o700 });
32
- const native = options.nativeAdapter ?? defaultNativeAdapter(config);
33
35
  const state = {
34
36
  config: { ...config, protocolMinor: negotiated.protocolMinor },
35
37
  journal,
36
38
  acceptedLeaseFence: journal.acceptedLeaseFence(),
37
39
  gitAvailable: await probeGitBinary(),
38
- native,
40
+ native: options.nativeAdapter,
39
41
  runtimeEpoch: Math.max(config.runtimeEpoch, journal.storedRuntimeEpoch() ?? 0),
40
42
  executionQueue: Promise.resolve(),
41
43
  instanceToken: randomUUID(),
42
44
  };
45
+ const native = state.native ??= defaultNativeAdapter(state);
43
46
  await journal.bindRuntime({
44
47
  runtimeId: config.runtimeId,
45
48
  generation: config.generation,
46
49
  runtimeEpoch: state.runtimeEpoch,
47
50
  });
51
+ if (config.workspaceMode === 'multi') {
52
+ gitCredentials = state.gitCredentials = new WorkspaceGitCredentialBroker(state);
53
+ await gitCredentials.start();
54
+ }
48
55
  attachNativeCollector(state);
49
56
  native?.onUnexpectedExit?.((event) => {
50
57
  void handleExecutionGroupCrash(state, event.pid);
@@ -55,8 +62,10 @@ export async function startWorkspaceRuntime(config, options = {}) {
55
62
  if (group)
56
63
  await journal.noteExecutionGroup(group);
57
64
  }
58
- catch {
59
- /* 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}`);
60
69
  }
61
70
  const server = createServer((request, response) => {
62
71
  void handleRuntimeRequest(state, request, response);
@@ -79,6 +88,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
79
88
  state,
80
89
  url: `http://${formatHost(config.listen.host)}:${port}`,
81
90
  async close() {
91
+ await gitCredentials?.close();
82
92
  disposeDesktopHost(state);
83
93
  disposeTerminalHost(state);
84
94
  await closeServer(server);
@@ -94,6 +104,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
94
104
  };
95
105
  }
96
106
  catch (error) {
107
+ await gitCredentials?.close().catch(() => undefined);
97
108
  await journal?.close().catch(() => undefined);
98
109
  lock.release();
99
110
  throw error;
@@ -127,11 +138,16 @@ function closeServer(server) {
127
138
  function formatHost(host) {
128
139
  return host.includes(':') ? `[${host}]` : host;
129
140
  }
130
- function defaultNativeAdapter(config) {
141
+ function defaultNativeAdapter(state) {
142
+ const config = state.config;
131
143
  if (process.env.PLAYCRAFT_NATIVE_ADAPTER === 'loopback')
132
144
  return createLoopbackNativeAdapter();
133
- if (process.env.PLAYCRAFT_NATIVE_ADAPTER === 'codex') {
134
- 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
+ });
135
151
  }
136
152
  return undefined;
137
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
  }
@@ -469,6 +479,7 @@ async function executeRestore(state, scopeKey, operation, payload) {
469
479
  }
470
480
  async function executeDestroy(state, scopeKey, operation) {
471
481
  await state.journal.markDestroyed();
482
+ await state.gitCredentials?.destroy();
472
483
  disposeTerminalHost(state);
473
484
  try {
474
485
  await state.native?.close?.();
@@ -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, hashOperationPayload, instanceRebuildRequestSchema, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, resolveIdempotentAccept, runtimeError, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
4
+ import { workspaceRegistrationRequestSchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, hashOperationPayload, instanceRebuildRequestSchema, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, resolveIdempotentAccept, nativeHistoryReadSchema, runtimeError, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
5
5
  import { controlTokenMatches, extractControlToken } from './auth.js';
6
6
  import { canExecuteKind, executeOperation } from './dispatch.js';
7
7
  import { InvalidWorkspacePathError, listWorkspaceFiles, } from '../workspaces/files.js';
@@ -64,6 +64,11 @@ export async function handleRuntimeRequest(state, request, response) {
64
64
  writeJson(response, 200, handshake(state));
65
65
  return;
66
66
  }
67
+ const historyPath = sessionHistoryPath(url.pathname);
68
+ if (request.method === 'GET' && historyPath) {
69
+ await handleSessionHistory(state, response, historyPath.workspaceId, historyPath.sessionId);
70
+ return;
71
+ }
67
72
  if (isUnimplementedWorkbenchPath(url.pathname)) {
68
73
  if (writeDestroyed(state, response))
69
74
  return;
@@ -206,7 +211,7 @@ function handshake(state) {
206
211
  if (item.name === 'conversation') {
207
212
  if (conversationReady)
208
213
  return capability('conversation', true);
209
- return destroyed ? capability('conversation', false, 'runtime_destroyed') : item;
214
+ return capability('conversation', false, destroyed ? 'runtime_destroyed' : state.nativeError ?? 'adapter_not_started');
210
215
  }
211
216
  if (item.name === 'files') {
212
217
  return filesReady
@@ -538,11 +543,69 @@ function unavailableReason(state, kind) {
538
543
  return 'runtime_destroyed';
539
544
  if (kind === 'preview.publish')
540
545
  return 'capability_not_implemented';
541
- return 'adapter_not_started';
546
+ return state.nativeError ?? 'adapter_not_started';
547
+ }
548
+ function sessionHistoryPath(pathname) {
549
+ const match = pathname.match(/^\/runtime\/workspaces\/([^/]+)\/sessions\/([^/]+)\/history$/);
550
+ if (!match?.[1] || !match[2])
551
+ return undefined;
552
+ try {
553
+ return {
554
+ workspaceId: decodeURIComponent(match[1]),
555
+ sessionId: decodeURIComponent(match[2]),
556
+ };
557
+ }
558
+ catch {
559
+ return undefined;
560
+ }
542
561
  }
543
562
  function isUnimplementedWorkbenchPath(pathname) {
544
563
  const parts = pathname.split('/').filter(Boolean);
545
- return parts[0] === 'runtime' && (parts.includes('previews') || parts.includes('history'));
564
+ return parts[0] === 'runtime' && parts.includes('previews');
565
+ }
566
+ async function handleSessionHistory(state, response, workspaceId, sessionId) {
567
+ if (writeDestroyed(state, response))
568
+ return;
569
+ const session = state.journal.getSession(sessionId);
570
+ if (!session || (session.workspaceId && session.workspaceId !== workspaceId)) {
571
+ writeJson(response, 404, { error: 'session_not_found' });
572
+ return;
573
+ }
574
+ if (state.config.workspaceMode === 'multi' && session.workspaceId !== workspaceId) {
575
+ writeJson(response, 404, { error: 'session_not_found' });
576
+ return;
577
+ }
578
+ const readThread = state.native?.readThread;
579
+ if (!readThread) {
580
+ writeError(response, runtimeError('capability_unavailable', 'native_history_unavailable'));
581
+ return;
582
+ }
583
+ let read;
584
+ try {
585
+ read = await readThread({ threadId: session.threadId, sessionId });
586
+ }
587
+ catch {
588
+ writeError(response, runtimeError('capability_unavailable', 'native_history_unavailable'));
589
+ return;
590
+ }
591
+ if (!read || read.threadId !== session.threadId) {
592
+ writeError(response, runtimeError('capability_unavailable', 'native_history_unavailable'));
593
+ return;
594
+ }
595
+ const body = nativeHistoryReadSchema.safeParse({
596
+ source: 'native',
597
+ sessionId,
598
+ workspaceId,
599
+ threadId: read.threadId,
600
+ ...(read.threadName ? { threadName: read.threadName } : {}),
601
+ truncated: read.truncated,
602
+ items: read.items,
603
+ });
604
+ if (!body.success) {
605
+ writeError(response, runtimeError('capability_unavailable', 'native_history_unavailable'));
606
+ return;
607
+ }
608
+ writeJson(response, 200, body.data);
546
609
  }
547
610
  async function readObservedParameters(state, workspaceId) {
548
611
  const root = await workspaceRoot(state, workspaceId);