@playcraft/cli 0.1.11 → 0.1.12

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.
@@ -11,6 +11,11 @@ import { GitWorkspaceError, readWorkspaceGitDiff, readWorkspaceGitFile, readWork
11
11
  import { readWorkspaceParameters } from '../workspaces/parameters.js';
12
12
  import { readDependencies } from '../workspaces/dependencies.js';
13
13
  import { desktopHost, desktopRoute } from './desktop-host.js';
14
+ import { ensureCliCredentials } from '../workspaces/cli-credentials.js';
15
+ import { readAttachmentUpload, uploadWorkspaceAttachment } from '../workspaces/attachments.js';
16
+ import { readWorkspaceSkills } from '../workspaces/skills.js';
17
+ import { loadWorkspaceSkillArtifact } from '../workspaces/skill-artifact-loader.js';
18
+ import { join } from 'node:path';
14
19
  import { terminalHost } from './terminal-host.js';
15
20
  export async function handleRuntimeRequest(state, request, response) {
16
21
  const url = new URL(request.url ?? '/', 'http://127.0.0.1');
@@ -59,6 +64,8 @@ export async function handleRuntimeRequest(state, request, response) {
59
64
  return;
60
65
  }
61
66
  const binding = await registerWorkspace(state, parsed.data.binding, parsed.data.leaseFence, parsed.data.preparation);
67
+ if (parsed.data.cliCredentials)
68
+ await ensureCliCredentials(state);
62
69
  writeJson(response, 200, { workspaceId: binding.workspaceId, status: 'registered' });
63
70
  return;
64
71
  }
@@ -108,6 +115,19 @@ export async function handleRuntimeRequest(state, request, response) {
108
115
  await handleSourceEvents(state, request, response, url);
109
116
  return;
110
117
  }
118
+ if (request.method === 'POST' && url.pathname === '/runtime/attachments') {
119
+ if (writeDestroyed(state, response))
120
+ return;
121
+ const workspaceId = url.searchParams.get('workspaceId') ?? '';
122
+ const root = await workspaceRoot(state, workspaceId);
123
+ try {
124
+ writeJson(response, 200, await uploadWorkspaceAttachment(root, await readAttachmentUpload(request)));
125
+ }
126
+ catch {
127
+ writeJson(response, 400, { error: 'invalid_attachment' });
128
+ }
129
+ return;
130
+ }
111
131
  if (request.method === 'GET' && url.pathname === '/runtime/files') {
112
132
  if (writeDestroyed(state, response))
113
133
  return;
@@ -163,6 +183,21 @@ export async function handleRuntimeRequest(state, request, response) {
163
183
  writeJson(response, 200, readDependencies(state, workspaceId ?? 'workspace'));
164
184
  return;
165
185
  }
186
+ if (request.method === 'GET' && url.pathname === '/runtime/skills') {
187
+ if (writeDestroyed(state, response))
188
+ return;
189
+ const workspaceId = url.searchParams.get('workspaceId') ?? undefined;
190
+ try {
191
+ const view = await state.journal.exclusive(`workspace:${revisionScope(state, workspaceId) ?? 'single'}`, async () => readWorkspaceSkills(await workspaceRoot(state, workspaceId), join(state.config.stateDir, 'skill-artifacts'), entry => loadWorkspaceSkillArtifact(state, workspaceId ?? 'workspace', entry)));
192
+ writeJson(response, 200, view);
193
+ }
194
+ catch (error) {
195
+ if (error instanceof WorkspaceContextError)
196
+ throw error;
197
+ writeJson(response, 409, { error: error instanceof Error && error.message.startsWith('workspace_skills_') ? error.message : 'workspace_skills_unavailable' });
198
+ }
199
+ return;
200
+ }
166
201
  if (request.method === 'GET' && url.pathname === '/runtime/git/graph') {
167
202
  await writeGitRead(state, response, async () => readWorkspaceGitGraph(await workspaceRoot(state, url.searchParams.get('workspaceId') ?? undefined)));
168
203
  return;
@@ -0,0 +1,109 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { chmod, lstat, mkdir, open, readFile, writeFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { WORKSPACE_USER_UPLOADS_DIRECTORY, attachmentIdsSchema, attachmentUploadSchema, WORKSPACE_ATTACHMENT_MAX_BYTES, } from '@playcraft/common/cloud-workspace/runtime';
6
+ import { NativePreparationError } from '../codex/native-adapter.js';
7
+ import { excludeFromGit } from './dependencies.js';
8
+ async function attachmentRoot(root) {
9
+ // Reject redirected upload directories, including symlinks that stay inside the workspace.
10
+ let directory = root;
11
+ for (const part of WORKSPACE_USER_UPLOADS_DIRECTORY.split('/')) {
12
+ directory = join(directory, part);
13
+ await mkdir(directory, { mode: 0o755 }).catch((error) => {
14
+ if (error.code !== 'EEXIST')
15
+ throw error;
16
+ });
17
+ if (!(await lstat(directory)).isDirectory())
18
+ throw new Error('invalid_attachment');
19
+ await chmod(directory, 0o755);
20
+ }
21
+ return directory;
22
+ }
23
+ function imageMime(bytes) {
24
+ if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])))
25
+ return 'image/png';
26
+ if (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255)
27
+ return 'image/jpeg';
28
+ if (/^GIF8[79]a$/.test(bytes.subarray(0, 6).toString('ascii')))
29
+ return 'image/gif';
30
+ if (bytes.subarray(0, 4).toString() === 'RIFF' && bytes.subarray(8, 12).toString() === 'WEBP')
31
+ return 'image/webp';
32
+ return 'application/octet-stream';
33
+ }
34
+ export async function readAttachmentUpload(request) {
35
+ const chunks = [];
36
+ let size = 0;
37
+ for await (const chunk of request) {
38
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
39
+ size += bytes.length;
40
+ if (size > 4 * Math.ceil(WORKSPACE_ATTACHMENT_MAX_BYTES / 3) + 4096)
41
+ throw new Error('invalid_attachment');
42
+ chunks.push(bytes);
43
+ }
44
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
45
+ }
46
+ /** References belong to the workspace and are retained by its checkpoints. */
47
+ export async function uploadWorkspaceAttachment(root, body) {
48
+ const parsed = attachmentUploadSchema.parse(body);
49
+ const bytes = Buffer.from(parsed.content, 'base64');
50
+ if (bytes.length > WORKSPACE_ATTACHMENT_MAX_BYTES || bytes.toString('base64') !== parsed.content)
51
+ throw new Error('invalid_attachment');
52
+ const attachment = {
53
+ id: randomUUID(),
54
+ name: parsed.name,
55
+ size: bytes.length,
56
+ mimeType: imageMime(bytes),
57
+ };
58
+ await excludeFromGit(root, `/${WORKSPACE_USER_UPLOADS_DIRECTORY}/`);
59
+ const uploads = await attachmentRoot(root);
60
+ const directory = join(uploads, attachment.id);
61
+ const metadataDirectory = join(uploads, '.metadata');
62
+ await mkdir(metadataDirectory, { mode: 0o755 }).catch((error) => {
63
+ if (error.code !== 'EEXIST')
64
+ throw error;
65
+ });
66
+ if (!(await lstat(metadataDirectory)).isDirectory())
67
+ throw new Error('invalid_attachment');
68
+ // Codex can run as a different user from the sandbox control process.
69
+ await mkdir(directory, { mode: 0o755 });
70
+ await writeFile(join(directory, attachment.name), bytes, { flag: 'wx', mode: 0o644 });
71
+ await writeFile(join(metadataDirectory, `${attachment.id}.json`), JSON.stringify(attachment), {
72
+ flag: 'wx',
73
+ mode: 0o644,
74
+ });
75
+ return attachment;
76
+ }
77
+ export async function resolveWorkspaceAttachments(root, input) {
78
+ try {
79
+ const ids = attachmentIdsSchema.parse(input ?? []);
80
+ return await Promise.all(ids.map(async (id) => {
81
+ const uploads = await attachmentRoot(root);
82
+ const directory = join(uploads, id);
83
+ if (!(await lstat(directory)).isDirectory())
84
+ throw new Error('invalid_attachment');
85
+ await chmod(directory, 0o755);
86
+ if (!(await lstat(join(uploads, '.metadata'))).isDirectory())
87
+ throw new Error('invalid_attachment');
88
+ const metadata = JSON.parse(await readFile(join(uploads, '.metadata', `${id}.json`), 'utf8'));
89
+ attachmentUploadSchema.shape.name.parse(metadata.name);
90
+ if (metadata.id !== id)
91
+ throw new Error('invalid_attachment');
92
+ const path = join(directory, metadata.name);
93
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
94
+ try {
95
+ const stat = await handle.stat();
96
+ if (!stat.isFile() || stat.size > WORKSPACE_ATTACHMENT_MAX_BYTES)
97
+ throw new Error('invalid_attachment');
98
+ const bytes = await handle.readFile();
99
+ return { ...metadata, size: bytes.length, mimeType: imageMime(bytes), path };
100
+ }
101
+ finally {
102
+ await handle.close();
103
+ }
104
+ }));
105
+ }
106
+ catch {
107
+ throw new NativePreparationError('attachment_unavailable');
108
+ }
109
+ }
@@ -0,0 +1,114 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { z } from 'zod';
5
+ const responseSchema = z.object({
6
+ token: z.string().min(1),
7
+ apiUrl: z.string().url(),
8
+ expiresAt: z.string().datetime(),
9
+ claims: z.object({
10
+ kind: z.literal('cloud-runtime-cli'),
11
+ environment: z.string(),
12
+ userId: z.string(),
13
+ runtimeId: z.string(),
14
+ generation: z.number().int(),
15
+ runtimeEpoch: z.number().int(),
16
+ }),
17
+ });
18
+ const entries = new WeakMap();
19
+ /** The file is outside every checkout, shared by all workspaces owned by this sandbox's user. */
20
+ export async function ensureCliCredentials(state) {
21
+ let entry = entries.get(state);
22
+ if (!entry) {
23
+ entry = { closed: false, expiresAt: 0, epoch: -1 };
24
+ entries.set(state, entry);
25
+ }
26
+ if (entry.closed)
27
+ throw new Error('cli_credentials_unavailable');
28
+ if (entry.epoch === state.runtimeEpoch && entry.expiresAt > Date.now() + 60_000)
29
+ return;
30
+ if (!entry.pending)
31
+ entry.pending = renew(state, entry).finally(() => {
32
+ entry.pending = undefined;
33
+ });
34
+ await entry.pending;
35
+ }
36
+ async function renew(state, entry) {
37
+ const config = state.config;
38
+ const epoch = state.runtimeEpoch;
39
+ const endpoint = new URL('/runtime/internal/cli-credentials', config.callbackUrl);
40
+ if (endpoint.protocol !== 'https:' &&
41
+ !(endpoint.protocol === 'http:' &&
42
+ ['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname)))
43
+ throw new Error('cli_callback_insecure');
44
+ try {
45
+ const response = await fetch(endpoint, {
46
+ method: 'POST',
47
+ redirect: 'error',
48
+ signal: AbortSignal.timeout(10_000),
49
+ headers: {
50
+ authorization: `Bearer ${config.controlToken}`,
51
+ 'content-type': 'application/json',
52
+ 'playcraft-env': config.environment,
53
+ },
54
+ body: JSON.stringify({
55
+ runtimeId: config.runtimeId,
56
+ generation: config.generation,
57
+ runtimeEpoch: epoch,
58
+ }),
59
+ });
60
+ if (!response.ok) {
61
+ await response.body?.cancel();
62
+ throw new Error('cli_credentials_unavailable');
63
+ }
64
+ const credential = responseSchema.parse(await response.json());
65
+ const claims = credential.claims;
66
+ if (entry.closed ||
67
+ state.journal.destroyed() ||
68
+ epoch !== state.runtimeEpoch ||
69
+ claims.environment !== config.environment ||
70
+ claims.userId !== config.userId ||
71
+ claims.runtimeId !== config.runtimeId ||
72
+ claims.generation !== config.generation ||
73
+ claims.runtimeEpoch !== epoch ||
74
+ Date.parse(credential.expiresAt) <= Date.now() + 60_000)
75
+ throw new Error('cli_credential_scope_mismatch');
76
+ await mkdir(config.workspaceRoot, { recursive: true });
77
+ const temporary = join(config.workspaceRoot, `.playcraft-credential-${randomUUID()}.tmp`);
78
+ try {
79
+ await writeFile(temporary, JSON.stringify({
80
+ apiUrl: credential.apiUrl,
81
+ token: credential.token,
82
+ env: config.environment,
83
+ userId: config.userId,
84
+ expiresAt: credential.expiresAt,
85
+ }), { flag: 'wx', mode: 0o644 });
86
+ await rename(temporary, join(config.workspaceRoot, '.playcraft.json'));
87
+ }
88
+ finally {
89
+ await rm(temporary, { force: true });
90
+ }
91
+ entry.expiresAt = Date.parse(credential.expiresAt);
92
+ entry.epoch = epoch;
93
+ }
94
+ finally {
95
+ if (!entry.closed && !state.journal.destroyed()) {
96
+ if (entry.timer)
97
+ clearTimeout(entry.timer);
98
+ const wait = entry.expiresAt > Date.now() + 60_000 ? entry.expiresAt - Date.now() - 59_000 : 30_000;
99
+ entry.timer = setTimeout(() => void ensureCliCredentials(state).catch(() => undefined), wait);
100
+ entry.timer.unref();
101
+ }
102
+ }
103
+ }
104
+ export async function closeCliCredentials(state) {
105
+ const entry = entries.get(state);
106
+ if (entry) {
107
+ entry.closed = true;
108
+ if (entry.timer)
109
+ clearTimeout(entry.timer);
110
+ await entry.pending?.catch(() => undefined);
111
+ }
112
+ if (entry)
113
+ await rm(join(state.config.workspaceRoot, '.playcraft.json'), { force: true });
114
+ }
@@ -0,0 +1,32 @@
1
+ import { cp, mkdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { resolveCliPackageRoot } from '../../scaffold/compose.js';
4
+ import { syncLocalProjectSkills } from '../../project-skills/local-store.js';
5
+ export const EMPTY_WORKSPACE_DEFAULT_SKILLS = [
6
+ 'playcraft-skill-recommender',
7
+ 'playcraft-image-generation',
8
+ 'playcraft-audio-generation',
9
+ 'playcraft-image-processing',
10
+ 'playcraft-build-optimizer',
11
+ ];
12
+ /** Only called in a new, unpublished staging directory; existing workspaces are never scaffolded. */
13
+ export async function scaffoldEmptyWorkspace(root, skillCache) {
14
+ const cliRoot = resolveCliPackageRoot();
15
+ const template = join(cliRoot, 'project-templates', 'empty');
16
+ for (const [source, target] of [
17
+ ['gitignore', '.gitignore'],
18
+ ['AGENTS.md', 'AGENTS.md'],
19
+ ]) {
20
+ await cp(join(template, source), join(root, target), { force: false, errorOnExist: true });
21
+ }
22
+ const skills = join(root, '.agents', 'skills');
23
+ await mkdir(skills, { recursive: true });
24
+ for (const name of EMPTY_WORKSPACE_DEFAULT_SKILLS) {
25
+ await cp(join(cliRoot, 'project-template', '.claude', 'skills', name), join(skills, name), {
26
+ recursive: true,
27
+ force: false,
28
+ errorOnExist: true,
29
+ });
30
+ }
31
+ await syncLocalProjectSkills(root, skillCache);
32
+ }
@@ -1,6 +1,6 @@
1
1
  import { chmod, lstat, mkdir, readdir, readFile, readlink, realpath, rm, symlink, writeFile } from 'node:fs/promises';
2
2
  import { dirname, join, relative, resolve, sep } from 'node:path';
3
- import { fileSnapshotDigest, fileSnapshotManifestKey, fileSnapshotContentKey, nativeTranscriptKey, fileSnapshotManifestSchema, hashSnapshotBytes, isDisposableBackupPath, putObjectBytes, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
3
+ import { fileSnapshotDigest, fileSnapshotManifestKey, fileSnapshotContentKey, nativeTranscriptKey, fileSnapshotManifestSchema, hashSnapshotBytes, isDisposableBackupPath, isWorkspaceAttachmentPath, putObjectBytes, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
4
4
  import { UNPUSHED_BUNDLE, addBundleObjects, repositoryResolves } from './git.js';
5
5
  const SKIP_DIRECTORY_NAMES = new Set(['.git', 'node_modules']);
6
6
  /** Build output, caches and scratch folders, at any depth: regenerated, so not kept in storage. */
@@ -46,6 +46,8 @@ const UNSTORED_RUN_OUTPUT = [
46
46
  * does not remove them.
47
47
  */
48
48
  export function keptInStorage(path) {
49
+ if (isWorkspaceAttachmentPath(path))
50
+ return true;
49
51
  if (isDisposableBackupPath(path))
50
52
  return false;
51
53
  if (path.startsWith('.git/'))
@@ -161,7 +163,7 @@ export async function materializeRemoteSnapshot(root, entries) {
161
163
  await rm(target, { force: true });
162
164
  continue;
163
165
  }
164
- await mkdir(dirname(target), { recursive: true, mode: 0o700 });
166
+ await mkdir(dirname(target), { recursive: true, mode: isWorkspaceAttachmentPath(entry.path) ? 0o755 : 0o700 });
165
167
  if (entry.kind === 'symlink') {
166
168
  if (!entry.target)
167
169
  throw new Error('snapshot_mismatch');
@@ -1,6 +1,6 @@
1
1
  import { mkdir, readdir, readFile, realpath, rm, writeFile } from 'node:fs/promises';
2
2
  import { dirname, join, relative, resolve, sep } from 'node:path';
3
- import { hashWorkspaceFileContent, workspaceFileContentSchema, workspaceFileListSchema, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
3
+ import { hashWorkspaceFileContent, hashSnapshotBytes, isWorkspaceAttachmentPath, workspaceFileContentSchema, workspaceFileListSchema, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
4
4
  const SKIP_DIRECTORY_NAMES = new Set(['.git', 'node_modules']);
5
5
  export class InvalidWorkspacePathError extends Error {
6
6
  code = 'invalid_workspace_path';
@@ -71,11 +71,11 @@ export async function snapshotWorkspaceFiles(root, snapshotDir, revisions) {
71
71
  await rm(snapshotDir, { recursive: true, force: true });
72
72
  const records = [];
73
73
  for (const file of listed.files) {
74
- const content = await readWorkspaceFile(root, file.path, revisions);
74
+ const content = await readFile(await resolveWorkspaceFile(root, file.path));
75
75
  const target = join(snapshotDir, ...file.path.split('/'));
76
76
  await mkdir(dirname(target), { recursive: true, mode: 0o700 });
77
- await writeFile(target, content.content, { mode: 0o600 });
78
- records.push({ path: file.path, contentHash: file.contentHash, revision: file.revision });
77
+ await writeFile(target, content, { mode: 0o600 });
78
+ records.push({ path: file.path, contentHash: hashSnapshotBytes(content), revision: file.revision });
79
79
  }
80
80
  return records;
81
81
  }
@@ -83,16 +83,17 @@ export async function restoreWorkspaceFiles(root, snapshotDir, files) {
83
83
  const rootReal = await ensureWorkspaceRoot(root);
84
84
  const keep = new Set(files.map((item) => item.path));
85
85
  for (const relativePath of await walkWorkspaceFiles(rootReal, rootReal)) {
86
- if (!keep.has(relativePath)) {
86
+ if (!keep.has(relativePath) && !isWorkspaceAttachmentPath(relativePath)) {
87
87
  await rm(join(rootReal, ...relativePath.split('/')), { force: true });
88
88
  }
89
89
  }
90
90
  const next = {};
91
91
  for (const file of files) {
92
92
  const source = join(snapshotDir, ...file.path.split('/'));
93
- const content = await readFile(source, 'utf8');
94
- await writeWorkspaceFile(root, file.path, content);
95
- next[file.path] = { revision: file.revision, contentHash: hashWorkspaceFileContent(content) };
93
+ const content = await readFile(source);
94
+ const target = await resolveWorkspaceFile(root, file.path);
95
+ await writeFile(target, content, { mode: isWorkspaceAttachmentPath(file.path) ? 0o644 : 0o600 });
96
+ next[file.path] = { revision: file.revision, contentHash: hashSnapshotBytes(content) };
96
97
  }
97
98
  return next;
98
99
  }
@@ -1,4 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { excludeFromGit } from './dependencies.js';
2
3
  import { lstat, mkdir, readFile, realpath, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
3
4
  import { tmpdir } from 'node:os';
4
5
  import { basename, dirname, join, relative, resolve, sep } from 'node:path';
@@ -346,6 +347,7 @@ export async function ensureWorkspaceGit(root) {
346
347
  await git(rootReal, ['config', 'user.email', 'workspace@playcraft.local']);
347
348
  }
348
349
  await assertWorkspaceGitRoot(rootReal);
350
+ await excludeFromGit(rootReal, '/.tmp/user_uploads/');
349
351
  return rootReal;
350
352
  }
351
353
  async function gitMarkerExists(rootReal) {
@@ -4,6 +4,7 @@ import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs
4
4
  import { join } from 'node:path';
5
5
  import { isLocalWorkspaceGitAuthMode, isLocalWorkspaceGitEnvironment, matchWorkspaceGitGatewayCloneUrl, workspacePreparationSchema, workspaceRegistrationSchema, } from '@playcraft/common/cloud-workspace/runtime';
6
6
  import { WorkspaceContextError } from './context-error.js';
7
+ import { scaffoldEmptyWorkspace } from './empty-template.js';
7
8
  /** The caller holds the Workspace lock. Only an absent, never-published directory may be prepared. */
8
9
  export async function prepareWorkspaceCheckout(state, binding, input, leaseFence) {
9
10
  const preparation = workspacePreparationSchema.safeParse(input);
@@ -82,22 +83,31 @@ export async function prepareWorkspaceCheckout(state, binding, input, leaseFence
82
83
  try {
83
84
  if (standalone) {
84
85
  await runGit(['init', '--initial-branch=main', staging], env, deadline);
86
+ await scaffoldEmptyWorkspace(staging, join(state.config.stateDir, 'skill-artifacts'));
85
87
  }
86
88
  else {
89
+ const fork = binding.pendingFork;
90
+ const cloneBranch = fork?.sourceBranchName ?? binding.branchName;
87
91
  await runGit(['check-ref-format', '--branch', binding.branchName], env, deadline);
92
+ await runGit(['check-ref-format', '--branch', cloneBranch], env, deadline);
88
93
  await runGit([
89
94
  'clone',
90
95
  '--no-checkout',
91
96
  '--single-branch',
92
97
  '--branch',
93
- binding.branchName,
98
+ cloneBranch,
94
99
  '--',
95
100
  binding.repositoryUrl,
96
101
  staging,
97
102
  ], env, deadline);
98
103
  // Checkout never receives the temporary credential and never invokes repository hooks or global filters.
99
104
  clearCloneSecrets(env);
100
- await runGit(['-C', staging, 'checkout', '--force', binding.branchName], env, deadline);
105
+ if (fork) {
106
+ await runGit(['-C', staging, 'checkout', '-b', binding.branchName, fork.sourceCommit], env, deadline);
107
+ }
108
+ else {
109
+ await runGit(['-C', staging, 'checkout', '--force', binding.branchName], env, deadline);
110
+ }
101
111
  }
102
112
  assertCurrent();
103
113
  receipt.status = 'ready';
@@ -6,6 +6,8 @@ import { isLocalWorkspaceGitAuthMode, workspaceRegistrationSchema, } from '@play
6
6
  import { prepareWorkspaceCheckout } from './prepare.js';
7
7
  import { WorkspaceContextError } from './context-error.js';
8
8
  import { scheduleDependencyInstall } from './dependencies.js';
9
+ import { ensureWorkspaceSkills, readWorkspaceSkills } from './skills.js';
10
+ import { loadWorkspaceSkillArtifact } from './skill-artifact-loader.js';
9
11
  export { WorkspaceContextError } from './context-error.js';
10
12
  const exec = promisify(execFile);
11
13
  export function revisionScope(state, workspaceId) {
@@ -121,7 +123,17 @@ export async function registerWorkspace(state, input, leaseFence, preparation) {
121
123
  throw new WorkspaceContextError('workspace_git_gateway_unavailable');
122
124
  }
123
125
  }
126
+ try {
127
+ await ensureWorkspaceSkills(root, join(state.config.stateDir, 'skill-artifacts'));
128
+ }
129
+ catch {
130
+ throw new WorkspaceContextError('workspace_skills_unavailable');
131
+ }
124
132
  await state.journal.putWorkspace(binding);
133
+ try {
134
+ await readWorkspaceSkills(root, join(state.config.stateDir, 'skill-artifacts'), entry => loadWorkspaceSkillArtifact(state, binding.workspaceId, entry));
135
+ }
136
+ catch { /* Skills reads and native turns diagnose missing artifacts; files and source repair remain available. */ }
125
137
  // Runs in the background; every registration rechecks it, so reopening retries a failed install.
126
138
  void scheduleDependencyInstall(state, binding.workspaceId, root);
127
139
  return binding;
@@ -0,0 +1,47 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { workspaceRoot } from './registry.js';
3
+ const MAX_ARTIFACT_BYTES = 80 * 1024 * 1024;
4
+ /** Requests an exact locked artifact using control identity; no storage credentials reach Codex. */
5
+ export async function loadWorkspaceSkillArtifact(state, workspaceId, entry) {
6
+ await workspaceRoot(state, workspaceId);
7
+ const epoch = state.runtimeEpoch;
8
+ if (state.journal.destroyed())
9
+ throw new Error('workspace_skills_unavailable');
10
+ const endpoint = new URL('/runtime/internal/skill-artifacts', state.config.callbackUrl);
11
+ if (endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname)))
12
+ throw new Error('workspace_skills_callback_insecure');
13
+ const response = await fetch(endpoint, {
14
+ method: 'POST', redirect: 'error', signal: AbortSignal.timeout(30_000),
15
+ headers: { authorization: `Bearer ${state.config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': state.config.environment },
16
+ body: JSON.stringify({ runtimeId: state.config.runtimeId, generation: state.config.generation,
17
+ runtimeEpoch: epoch, workspaceId, entryKey: entry.entryKey, revision: entry.revision }),
18
+ });
19
+ if (!response.ok || !response.body)
20
+ throw new Error('workspace_skills_artifact_unavailable');
21
+ const reader = response.body.getReader(), chunks = [];
22
+ let total = 0;
23
+ try {
24
+ while (true) {
25
+ const chunk = await reader.read();
26
+ if (chunk.done)
27
+ break;
28
+ total += chunk.value.byteLength;
29
+ if (total > Math.ceil(MAX_ARTIFACT_BYTES * 4 / 3) + 4096)
30
+ throw new Error('workspace_skills_artifact_too_large');
31
+ chunks.push(chunk.value);
32
+ }
33
+ }
34
+ finally {
35
+ await reader.cancel().catch(() => undefined);
36
+ }
37
+ const result = JSON.parse(Buffer.concat(chunks).toString('utf8'));
38
+ if (typeof result.content !== 'string' || result.artifactHash !== entry.revision.artifactHash
39
+ || result.content.length > Math.ceil(MAX_ARTIFACT_BYTES * 4 / 3) || result.content.length % 4 !== 0 || /[^A-Za-z0-9+/=]/.test(result.content))
40
+ throw new Error('workspace_skills_digest_mismatch');
41
+ const archive = Buffer.from(result.content, 'base64');
42
+ if (archive.byteLength > MAX_ARTIFACT_BYTES || archive.toString('base64') !== result.content || createHash('sha256').update(archive).digest('hex') !== entry.revision.artifactHash)
43
+ throw new Error('workspace_skills_digest_mismatch');
44
+ if (state.journal.destroyed() || epoch !== state.runtimeEpoch)
45
+ throw new Error('workspace_skills_unavailable');
46
+ return archive;
47
+ }