@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.
- package/dist/utils/agent-api-client.js +26 -11
- package/dist/workspace-runtime/codex/app-server.js +234 -50
- package/dist/workspace-runtime/serve.js +12 -0
- package/dist/workspace-runtime/server/dispatch.js +53 -2
- package/dist/workspace-runtime/server/http.js +35 -0
- package/dist/workspace-runtime/workspaces/attachments.js +109 -0
- package/dist/workspace-runtime/workspaces/cli-credentials.js +114 -0
- package/dist/workspace-runtime/workspaces/empty-template.js +32 -0
- package/dist/workspace-runtime/workspaces/file-snapshot.js +4 -2
- package/dist/workspace-runtime/workspaces/files.js +9 -8
- package/dist/workspace-runtime/workspaces/git.js +2 -0
- package/dist/workspace-runtime/workspaces/prepare.js +12 -2
- package/dist/workspace-runtime/workspaces/registry.js +12 -0
- package/dist/workspace-runtime/workspaces/skill-artifact-loader.js +47 -0
- package/dist/workspace-runtime/workspaces/skills.js +179 -0
- package/package.json +3 -3
- package/project-templates/empty/AGENTS.md +23 -0
- package/project-templates/empty/gitignore +31 -0
|
@@ -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
|
|
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
|
|
78
|
-
records.push({ path: file.path, contentHash:
|
|
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
|
|
94
|
-
await
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|