@playcraft/cli 0.1.58-beta → 0.1.59-beta
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-generation-provenance.js +1 -1
- package/dist/workspace-runtime/batch/generate.js +1 -1
- package/dist/workspace-runtime/batch/policy.js +71 -22
- package/dist/workspace-runtime/codex/app-server.js +277 -20
- package/dist/workspace-runtime/codex/generation-provenance-broker.js +17 -8
- package/dist/workspace-runtime/codex/loopback.js +2 -0
- package/dist/workspace-runtime/codex/model-broker.js +7 -4
- package/dist/workspace-runtime/serve.js +2 -1
- package/dist/workspace-runtime/server/directory-access.js +1 -1
- package/dist/workspace-runtime/server/dispatch.js +13 -6
- package/dist/workspace-runtime/server/http.js +29 -1
- package/dist/workspace-runtime/server/terminal-host.js +82 -1
- package/dist/workspace-runtime/server/workspace-storage.js +193 -0
- package/dist/workspace-runtime/workspaces/registry.js +44 -3
- package/dist/workspace-runtime/workspaces/storage-snapshot.js +235 -0
- package/package.json +3 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { lstat, mkdir, realpath, rm, writeFile } from 'node:fs/promises';
|
|
3
|
-
import { join, relative } from 'node:path';
|
|
3
|
+
import { dirname, join, relative } from 'node:path';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
5
|
import { isLocalWorkspaceGitAuthMode, sameWorkspaceRegistration, workspaceRegistrationSchema, batchSourceSnapshotSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
6
|
import { prepareWorkspaceCheckout } from './prepare.js';
|
|
@@ -163,7 +163,7 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
|
|
|
163
163
|
await prepareWorkspaceCheckout(state, binding, preparation, leaseFence);
|
|
164
164
|
root = await checkedDirectory(state, binding.workspaceId);
|
|
165
165
|
}
|
|
166
|
-
const
|
|
166
|
+
const gitIn = (cwd) => async (...args) => (await exec('git', ['-C', cwd, ...args], {
|
|
167
167
|
timeout: 5000,
|
|
168
168
|
maxBuffer: 64 * 1024,
|
|
169
169
|
env: {
|
|
@@ -173,8 +173,14 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
|
|
|
173
173
|
GIT_CONFIG_GLOBAL: '/dev/null',
|
|
174
174
|
},
|
|
175
175
|
})).stdout.trim();
|
|
176
|
+
const git = gitIn(root);
|
|
177
|
+
// The checkout, not the journal, decides whether the remote is still missing: the state volume is
|
|
178
|
+
// per generation and forgets a standalone→main promotion, while the checkout outlives it.
|
|
179
|
+
const needsRemote = binding.targetKind === 'main' && !preservedLocalBasic
|
|
180
|
+
&& (await git('remote').catch(() => undefined)) === '';
|
|
176
181
|
// Unfinished if it failed part way: the marker outlives the journal entry written below.
|
|
177
|
-
if (binding.targetKind === 'main' && !preservedLocalBasic
|
|
182
|
+
if (binding.targetKind === 'main' && !preservedLocalBasic
|
|
183
|
+
&& (needsRemote || remoteUpgrade || await lstat(attachMarker).then(() => true, () => false))) {
|
|
178
184
|
if (preparation?.mode !== 'clone' || !state.gitCredentials)
|
|
179
185
|
throw new WorkspaceContextError('workspace_git_gateway_unavailable');
|
|
180
186
|
// private
|
|
@@ -218,6 +224,10 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
|
|
|
218
224
|
throw error;
|
|
219
225
|
throw new WorkspaceContextError('workspace_not_prepared');
|
|
220
226
|
}
|
|
227
|
+
// Variants share basic's volume but have no registration step of their own, so a swap can leave
|
|
228
|
+
// them checked out, on the right branch, and without the remote their saves need.
|
|
229
|
+
if (!readOnlyBasic && !preservedLocalBasic)
|
|
230
|
+
await restoreDirectoryRemotes(binding, root, gitIn);
|
|
221
231
|
if (state.journal.destroyed())
|
|
222
232
|
throw new WorkspaceContextError('runtime_destroyed');
|
|
223
233
|
if (leaseFence < state.acceptedLeaseFence)
|
|
@@ -260,3 +270,34 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
|
|
|
260
270
|
return binding;
|
|
261
271
|
});
|
|
262
272
|
}
|
|
273
|
+
/**
|
|
274
|
+
* Variant checkouts outlive the journal that registered them: a sandbox swap keeps the files and the
|
|
275
|
+
* branch but drops the remote. Only an already-correct branch is repaired; rebuilding another one
|
|
276
|
+
* would discard the variant's own commits, so that case stays a checkout mismatch.
|
|
277
|
+
*/
|
|
278
|
+
async function restoreDirectoryRemotes(binding, root, gitIn) {
|
|
279
|
+
const repositoryUrl = binding.repositoryUrl;
|
|
280
|
+
if (!repositoryUrl)
|
|
281
|
+
return;
|
|
282
|
+
for (const directory of binding.directories ?? []) {
|
|
283
|
+
if (directory.directoryName === 'basic')
|
|
284
|
+
continue;
|
|
285
|
+
const checkout = join(dirname(root), directory.directoryName);
|
|
286
|
+
const stat = await lstat(checkout).catch(() => undefined);
|
|
287
|
+
if (!stat?.isDirectory() || stat.isSymbolicLink())
|
|
288
|
+
continue;
|
|
289
|
+
const git = gitIn(checkout);
|
|
290
|
+
if ((await git('symbolic-ref', '--short', 'HEAD').catch(() => undefined)) !== directory.branchName)
|
|
291
|
+
continue;
|
|
292
|
+
const origin = await git('config', '--get', 'remote.origin.url').catch(() => undefined);
|
|
293
|
+
if (origin === repositoryUrl)
|
|
294
|
+
continue;
|
|
295
|
+
try {
|
|
296
|
+
if (origin === undefined)
|
|
297
|
+
await git('remote', 'add', 'origin', repositoryUrl);
|
|
298
|
+
else
|
|
299
|
+
await git('remote', 'set-url', 'origin', repositoryUrl);
|
|
300
|
+
}
|
|
301
|
+
catch { /* The variant still opens; its next save reports the missing remote. */ }
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { constants, createReadStream, createWriteStream } from 'node:fs';
|
|
3
|
+
import { chmod, lstat, mkdir, mkdtemp, open, readlink, readdir, realpath, rm, symlink, utimes } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
6
|
+
import { Readable, Transform } from 'node:stream';
|
|
7
|
+
import { pipeline } from 'node:stream/promises';
|
|
8
|
+
import { createGunzip, createGzip } from 'node:zlib';
|
|
9
|
+
const MAGIC = Buffer.from('PLAYCRAFT-STORAGE-1\n');
|
|
10
|
+
export const STORAGE_SNAPSHOT_MAX_BYTES = 16 * 1024 * 1024 * 1024;
|
|
11
|
+
const MAX_ENTRIES = 250_000, MAX_MANIFEST_BYTES = 32 * 1024 * 1024;
|
|
12
|
+
const EXCLUDED = new Set(['node_modules', '.next', '.cache', '.turbo', '.vite', '.pnpm-store', '.playcraft.json']);
|
|
13
|
+
function inside(root, path) {
|
|
14
|
+
const part = relative(root, path);
|
|
15
|
+
return part === '' || (!isAbsolute(part) && part !== '..' && !part.startsWith(`..${sep}`));
|
|
16
|
+
}
|
|
17
|
+
function excluded(path) {
|
|
18
|
+
const parts = path.split('/');
|
|
19
|
+
return parts.some(name => EXCLUDED.has(name.toLowerCase()) || /^\.playcraft-credential-/.test(name));
|
|
20
|
+
}
|
|
21
|
+
function safeLink(root, parent, link) {
|
|
22
|
+
// Parent traversals may precede a target, but cannot follow a symlinkable path component.
|
|
23
|
+
// For example, `link/../outside` can escape even though its lexical path looks internal.
|
|
24
|
+
return Boolean(link) && !isAbsolute(link) && !/[\0\\]/.test(link) && /^(?:\.\.\/)*(?!.*(?:^|\/)\.\.(?:\/|$)).+$/.test(link) &&
|
|
25
|
+
inside(root, resolve(parent, link));
|
|
26
|
+
}
|
|
27
|
+
function same(before, after) {
|
|
28
|
+
return before.dev === after.dev && before.ino === after.ino && before.mode === after.mode && before.size === after.size &&
|
|
29
|
+
before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs;
|
|
30
|
+
}
|
|
31
|
+
async function scan(root, options) {
|
|
32
|
+
const entries = [], excludedRoots = await Promise.all((options.excludedRoots ?? []).map(path => realpath(path).catch(() => resolve(path))));
|
|
33
|
+
let bytes = 0;
|
|
34
|
+
async function walk(directory) {
|
|
35
|
+
options.signal?.throwIfAborted();
|
|
36
|
+
const before = await lstat(directory);
|
|
37
|
+
if (!before.isDirectory() || before.isSymbolicLink() || !inside(root, await realpath(directory)))
|
|
38
|
+
throw new Error('storage_snapshot_changed');
|
|
39
|
+
for (const name of (await readdir(directory)).sort()) {
|
|
40
|
+
const source = join(directory, name), path = relative(root, source).split(sep).join('/');
|
|
41
|
+
if (excluded(path) || excludedRoots.some(item => inside(item, source)))
|
|
42
|
+
continue;
|
|
43
|
+
if (Buffer.byteLength(path) > 65_535 || /[\0\\]/.test(path))
|
|
44
|
+
throw new Error('storage_snapshot_invalid_path');
|
|
45
|
+
const info = await lstat(source);
|
|
46
|
+
if (/(?:^|\/)\.git\/objects\/info\/alternates$/.test(path))
|
|
47
|
+
throw new Error('storage_snapshot_unsupported');
|
|
48
|
+
const type = info.isSymbolicLink() ? 'symlink' : info.isDirectory() ? 'directory' : info.isFile() ? 'file' : undefined;
|
|
49
|
+
if (!type || entries.length >= MAX_ENTRIES)
|
|
50
|
+
throw new Error('storage_snapshot_unsupported');
|
|
51
|
+
const entry = { path, source, info, type, mode: info.mode & 0o777, mtime: info.mtimeMs, size: type === 'file' ? info.size : 0 };
|
|
52
|
+
if (type === 'symlink') {
|
|
53
|
+
entry.link = await readlink(source);
|
|
54
|
+
if (!safeLink(root, dirname(source), entry.link))
|
|
55
|
+
throw new Error('storage_snapshot_unsafe_link');
|
|
56
|
+
}
|
|
57
|
+
else if (type === 'file' && /(?:^|\/)\.git$/.test(path)) {
|
|
58
|
+
if (info.size > 4096)
|
|
59
|
+
throw new Error('storage_snapshot_unsupported');
|
|
60
|
+
const handle = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
61
|
+
try {
|
|
62
|
+
const pointer = /^gitdir: ([^\r\n]+)\n?$/.exec(await handle.readFile('utf8'));
|
|
63
|
+
if (!pointer || !inside(root, resolve(directory, pointer[1])))
|
|
64
|
+
throw new Error('storage_snapshot_unsupported');
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
await handle.close();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
bytes += entry.size;
|
|
71
|
+
if (bytes > STORAGE_SNAPSHOT_MAX_BYTES)
|
|
72
|
+
throw new Error('storage_snapshot_too_large');
|
|
73
|
+
entries.push(entry);
|
|
74
|
+
if (type === 'directory')
|
|
75
|
+
await walk(source);
|
|
76
|
+
}
|
|
77
|
+
if (!same(before, await lstat(directory)))
|
|
78
|
+
throw new Error('storage_snapshot_changed');
|
|
79
|
+
}
|
|
80
|
+
await walk(root);
|
|
81
|
+
return entries;
|
|
82
|
+
}
|
|
83
|
+
export async function createStorageSnapshot(root, options = {}) {
|
|
84
|
+
const temporary = await mkdtemp(join(tmpdir(), 'playcraft-storage-'));
|
|
85
|
+
const cleanup = () => rm(temporary, { recursive: true, force: true });
|
|
86
|
+
try {
|
|
87
|
+
const canonical = await realpath(root), rootInfo = await lstat(canonical), entries = await scan(canonical, options);
|
|
88
|
+
const manifest = Buffer.from(JSON.stringify(entries.map(({ path, type, mode, mtime, size, link }) => ({ path, type, mode, mtime, size, ...(link === undefined ? {} : { link }) }))));
|
|
89
|
+
if (manifest.length > MAX_MANIFEST_BYTES)
|
|
90
|
+
throw new Error('storage_snapshot_too_large');
|
|
91
|
+
const length = Buffer.alloc(4);
|
|
92
|
+
length.writeUInt32BE(manifest.length);
|
|
93
|
+
async function* contents() {
|
|
94
|
+
yield MAGIC;
|
|
95
|
+
yield length;
|
|
96
|
+
yield manifest;
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
options.signal?.throwIfAborted();
|
|
99
|
+
if (!same(entry.info, await lstat(entry.source)))
|
|
100
|
+
throw new Error('storage_snapshot_changed');
|
|
101
|
+
if (entry.type !== 'file')
|
|
102
|
+
continue;
|
|
103
|
+
const file = await open(entry.source, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
104
|
+
try {
|
|
105
|
+
if (!same(entry.info, await file.stat()))
|
|
106
|
+
throw new Error('storage_snapshot_changed');
|
|
107
|
+
let bytes = 0;
|
|
108
|
+
for await (const chunk of file.createReadStream({ autoClose: false })) {
|
|
109
|
+
bytes += chunk.length;
|
|
110
|
+
if (bytes > entry.size)
|
|
111
|
+
throw new Error('storage_snapshot_changed');
|
|
112
|
+
yield chunk;
|
|
113
|
+
}
|
|
114
|
+
if (bytes !== entry.size)
|
|
115
|
+
throw new Error('storage_snapshot_changed');
|
|
116
|
+
if (!same(entry.info, await file.stat()))
|
|
117
|
+
throw new Error('storage_snapshot_changed');
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
await file.close();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const entry of entries)
|
|
124
|
+
if (!same(entry.info, await lstat(entry.source)))
|
|
125
|
+
throw new Error('storage_snapshot_changed');
|
|
126
|
+
if (!same(rootInfo, await lstat(canonical)))
|
|
127
|
+
throw new Error('storage_snapshot_changed');
|
|
128
|
+
}
|
|
129
|
+
const path = join(temporary, 'snapshot.gz'), hash = createHash('sha256');
|
|
130
|
+
let bytes = 0;
|
|
131
|
+
await pipeline(Readable.from(contents()), createGzip(), new Transform({ transform(chunk, _encoding, done) { bytes += chunk.length; hash.update(chunk); done(bytes > STORAGE_SNAPSHOT_MAX_BYTES ? new Error('storage_snapshot_too_large') : null, chunk); } }),
|
|
132
|
+
// private
|
|
133
|
+
createWriteStream(path, { flags: 'wx', mode: 0o600 }), { signal: options.signal });
|
|
134
|
+
return { path, bytes, sha256: hash.digest('hex'), cleanup };
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
await cleanup();
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** Extract only a validated manifest into a newly-created private directory; create links last. */
|
|
142
|
+
export async function extractStorageSnapshot(path, target, options = {}) {
|
|
143
|
+
const input = createReadStream(path), stream = input.pipe(createGunzip());
|
|
144
|
+
input.on('error', error => stream.destroy(error));
|
|
145
|
+
const iterator = stream[Symbol.asyncIterator]();
|
|
146
|
+
let chunk = Buffer.alloc(0), offset = 0, total = 0;
|
|
147
|
+
const read = async (length) => {
|
|
148
|
+
const result = Buffer.allocUnsafe(length);
|
|
149
|
+
let written = 0;
|
|
150
|
+
while (written < length) {
|
|
151
|
+
options.signal?.throwIfAborted();
|
|
152
|
+
if (offset === chunk.length) {
|
|
153
|
+
const next = await iterator.next();
|
|
154
|
+
if (next.done)
|
|
155
|
+
throw new Error('storage_snapshot_invalid');
|
|
156
|
+
chunk = next.value;
|
|
157
|
+
offset = 0;
|
|
158
|
+
total += chunk.length;
|
|
159
|
+
if (total > STORAGE_SNAPSHOT_MAX_BYTES + MAX_MANIFEST_BYTES)
|
|
160
|
+
throw new Error('storage_snapshot_too_large');
|
|
161
|
+
}
|
|
162
|
+
const count = Math.min(length - written, chunk.length - offset);
|
|
163
|
+
chunk.copy(result, written, offset, offset + count);
|
|
164
|
+
written += count;
|
|
165
|
+
offset += count;
|
|
166
|
+
}
|
|
167
|
+
return result;
|
|
168
|
+
};
|
|
169
|
+
try {
|
|
170
|
+
if (!(await read(MAGIC.length)).equals(MAGIC))
|
|
171
|
+
throw new Error('storage_snapshot_invalid');
|
|
172
|
+
const size = (await read(4)).readUInt32BE();
|
|
173
|
+
if (size > MAX_MANIFEST_BYTES)
|
|
174
|
+
throw new Error('storage_snapshot_too_large');
|
|
175
|
+
const entries = JSON.parse((await read(size)).toString('utf8'));
|
|
176
|
+
if (!Array.isArray(entries) || entries.length > MAX_ENTRIES)
|
|
177
|
+
throw new Error('storage_snapshot_invalid');
|
|
178
|
+
const known = new Map();
|
|
179
|
+
let bytes = 0;
|
|
180
|
+
for (const raw of entries) {
|
|
181
|
+
const entry = raw;
|
|
182
|
+
if (!entry || typeof entry.path !== 'string' || !entry.path || Buffer.byteLength(entry.path) > 65_535 || /[\0\\]/.test(entry.path) || entry.path.split('/').some(part => !part || part === '.' || part === '..') || isAbsolute(entry.path) || known.has(entry.path) ||
|
|
183
|
+
!['file', 'directory', 'symlink'].includes(entry.type) || !Number.isSafeInteger(entry.mode) || entry.mode < 0 || entry.mode > 0o777 || !Number.isFinite(entry.mtime) ||
|
|
184
|
+
!Number.isSafeInteger(entry.size) || entry.size < 0 || (entry.type !== 'file' && entry.size !== 0))
|
|
185
|
+
throw new Error('storage_snapshot_invalid');
|
|
186
|
+
const parent = dirname(entry.path);
|
|
187
|
+
if (parent !== '.' && known.get(parent)?.type !== 'directory')
|
|
188
|
+
throw new Error('storage_snapshot_invalid');
|
|
189
|
+
if (entry.type === 'symlink' && (typeof entry.link !== 'string' || !safeLink(target, join(target, parent), entry.link)))
|
|
190
|
+
throw new Error('storage_snapshot_unsafe_link');
|
|
191
|
+
bytes += entry.size;
|
|
192
|
+
if (bytes > STORAGE_SNAPSHOT_MAX_BYTES)
|
|
193
|
+
throw new Error('storage_snapshot_too_large');
|
|
194
|
+
known.set(entry.path, entry);
|
|
195
|
+
}
|
|
196
|
+
for (const entry of known.values()) {
|
|
197
|
+
const destination = join(target, entry.path);
|
|
198
|
+
// private
|
|
199
|
+
if (entry.type === 'directory')
|
|
200
|
+
await mkdir(destination, { mode: 0o700 });
|
|
201
|
+
if (entry.type !== 'file')
|
|
202
|
+
continue;
|
|
203
|
+
// private
|
|
204
|
+
const handle = await open(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
205
|
+
try {
|
|
206
|
+
let remaining = entry.size;
|
|
207
|
+
while (remaining > 0) {
|
|
208
|
+
const value = await read(Math.min(64 * 1024, remaining));
|
|
209
|
+
await handle.writeFile(value);
|
|
210
|
+
remaining -= value.length;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
await handle.close();
|
|
215
|
+
}
|
|
216
|
+
await chmod(destination, entry.mode);
|
|
217
|
+
await utimes(destination, entry.mtime / 1000, entry.mtime / 1000);
|
|
218
|
+
}
|
|
219
|
+
if (offset !== chunk.length || !(await iterator.next()).done)
|
|
220
|
+
throw new Error('storage_snapshot_invalid');
|
|
221
|
+
for (const entry of known.values())
|
|
222
|
+
if (entry.type === 'symlink')
|
|
223
|
+
await symlink(entry.link, join(target, entry.path));
|
|
224
|
+
for (const entry of [...known.values()].reverse())
|
|
225
|
+
if (entry.type === 'directory') {
|
|
226
|
+
await chmod(join(target, entry.path), entry.mode);
|
|
227
|
+
await utimes(join(target, entry.path), entry.mtime / 1000, entry.mtime / 1000);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
input.destroy();
|
|
232
|
+
stream.destroy();
|
|
233
|
+
await iterator.return?.();
|
|
234
|
+
}
|
|
235
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playcraft/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.59-beta",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -53,8 +53,8 @@
|
|
|
53
53
|
"@gltf-transform/core": "^4.3.0",
|
|
54
54
|
"@gltf-transform/extensions": "^4.3.0",
|
|
55
55
|
"@gltf-transform/functions": "^4.3.0",
|
|
56
|
-
"@playcraft/build": "0.1.
|
|
57
|
-
"@playcraft/common": "0.1.
|
|
56
|
+
"@playcraft/build": "0.1.59-beta",
|
|
57
|
+
"@playcraft/common": "0.1.59-beta",
|
|
58
58
|
"better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
|
|
59
59
|
"chokidar": "^4.0.3",
|
|
60
60
|
"commander": "^13.1.0",
|