@playcraft/cli 0.1.57-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/commands/workspace.js +43 -0
- package/dist/utils/agent-generation-provenance.js +1 -1
- package/dist/workspace/messages.js +10 -0
- package/dist/workspace-runtime/batch/deliver.js +140 -80
- package/dist/workspace-runtime/batch/freeze.js +18 -6
- package/dist/workspace-runtime/batch/generate.js +68 -64
- package/dist/workspace-runtime/batch/policy.js +71 -22
- package/dist/workspace-runtime/batch/prepare.js +108 -93
- package/dist/workspace-runtime/batch/records.js +4 -0
- package/dist/workspace-runtime/batch/routes.js +10 -2
- package/dist/workspace-runtime/batch/source-candidate.js +2 -0
- package/dist/workspace-runtime/batch/source-capture.js +1 -0
- package/dist/workspace-runtime/batch/source-failure.js +2 -0
- package/dist/workspace-runtime/batch/source-publish.js +3 -0
- package/dist/workspace-runtime/batch/writer-proof.js +37 -0
- package/dist/workspace-runtime/codex/app-server.js +278 -20
- package/dist/workspace-runtime/codex/config-toml.js +4 -0
- package/dist/workspace-runtime/codex/delivery-tools.js +1 -1
- 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/persistence/journal.js +34 -0
- package/dist/workspace-runtime/persistence/sqlite-worker.js +68 -6
- package/dist/workspace-runtime/previews/output.js +2 -0
- package/dist/workspace-runtime/previews/publish.js +20 -5
- package/dist/workspace-runtime/previews/runtime-preview.js +4 -0
- package/dist/workspace-runtime/processes/managed-writes.js +47 -5
- package/dist/workspace-runtime/serve.js +13 -3
- package/dist/workspace-runtime/server/accept-operation.js +22 -2
- package/dist/workspace-runtime/server/directory-access.js +414 -0
- package/dist/workspace-runtime/server/dispatch.js +74 -29
- package/dist/workspace-runtime/server/endpoints.js +1 -0
- package/dist/workspace-runtime/server/http.js +80 -10
- package/dist/workspace-runtime/server/operation-record.js +2 -0
- package/dist/workspace-runtime/server/terminal-host.js +124 -8
- package/dist/workspace-runtime/server/workspace-storage.js +193 -0
- package/dist/workspace-runtime/server/workspace-transfer.js +3 -1
- package/dist/workspace-runtime/workspaces/append-only-log.js +3 -0
- package/dist/workspace-runtime/workspaces/artifact-events.js +5 -2
- package/dist/workspace-runtime/workspaces/artifact-files.js +14 -8
- package/dist/workspace-runtime/workspaces/artifact-receipts.js +7 -25
- package/dist/workspace-runtime/workspaces/artifacts.js +4 -1
- package/dist/workspace-runtime/workspaces/asset-library-import.js +9 -4
- package/dist/workspace-runtime/workspaces/asset-publication-access.js +7 -9
- package/dist/workspace-runtime/workspaces/asset-transfer-publish.js +3 -0
- package/dist/workspace-runtime/workspaces/asset-transfer-storage.js +17 -2
- package/dist/workspace-runtime/workspaces/attachments.js +9 -0
- package/dist/workspace-runtime/workspaces/batch-commit.js +5 -3
- package/dist/workspace-runtime/workspaces/binary-write.js +12 -7
- package/dist/workspace-runtime/workspaces/cli-context.js +2 -0
- package/dist/workspace-runtime/workspaces/cli-credentials.js +1 -0
- package/dist/workspace-runtime/workspaces/delivery-build.js +30 -11
- package/dist/workspace-runtime/workspaces/delivery-store.js +2 -0
- package/dist/workspace-runtime/workspaces/dependencies.js +90 -70
- package/dist/workspace-runtime/workspaces/developer-command.js +12 -1
- package/dist/workspace-runtime/workspaces/directory-access.js +13 -18
- package/dist/workspace-runtime/workspaces/directory-attachments.js +8 -0
- package/dist/workspace-runtime/workspaces/directory-create.js +18 -5
- package/dist/workspace-runtime/workspaces/directory-maintenance.js +46 -35
- package/dist/workspace-runtime/workspaces/directory-mutations.js +8 -3
- package/dist/workspace-runtime/workspaces/files.js +4 -0
- package/dist/workspace-runtime/workspaces/git-credential-broker.js +3 -0
- package/dist/workspace-runtime/workspaces/git-direct-store.js +2 -0
- package/dist/workspace-runtime/workspaces/json5-edit.js +36 -0
- package/dist/workspace-runtime/workspaces/library-attachments.js +4 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-protocol.js +2 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-store.js +2 -0
- package/dist/workspace-runtime/workspaces/parameter-agent.js +40 -11
- package/dist/workspace-runtime/workspaces/parameter-assets.js +1 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization-skill-transaction.js +141 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +329 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization.js +63 -0
- package/dist/workspace-runtime/workspaces/parameter-production.js +124 -0
- package/dist/workspace-runtime/workspaces/parameters.js +95 -3
- package/dist/workspace-runtime/workspaces/platform-export.js +5 -0
- package/dist/workspace-runtime/workspaces/prepare.js +4 -0
- package/dist/workspace-runtime/workspaces/registry.js +55 -3
- package/dist/workspace-runtime/workspaces/remix-references.js +5 -0
- package/dist/workspace-runtime/workspaces/shared-write.js +187 -0
- package/dist/workspace-runtime/workspaces/skills.js +14 -15
- package/dist/workspace-runtime/workspaces/storage-snapshot.js +235 -0
- package/dist/workspace-runtime/workspaces/transfer.js +4 -1
- package/dist/workspace-runtime/workspaces/workspace-shares.js +235 -0
- package/dist/workspace-runtime/workspaces/worktree-attachments.js +2 -0
- package/dist/workspace-runtime/workspaces/worktree-checkout.js +11 -6
- package/package.json +5 -4
- package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +32 -0
- package/project-template/.claude/skills/playcraft-runtime-parameters/references/platform-protocol.md +21 -0
- package/project-template/.claude/skills/playcraft-runtime-parameters/references/preview-integration.md +57 -0
|
@@ -6,6 +6,7 @@ import sharp from 'sharp';
|
|
|
6
6
|
import { WORKSPACE_ARTIFACT_OUTPUT_DIRECTORY, isWorkspaceArtifactOutputPath, isWorkspaceArtifactOutputVersion, workspaceArtifactContentQuerySchema, workspaceArtifactListSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
7
7
|
import { artifactBundleDigest, artifactDirectory, artifactFormat, artifactPath, artifactResources, snapshotArtifactBundle, snapshotArtifactFile } from './artifact-files.js';
|
|
8
8
|
import { ARTIFACT_RECEIPTS_DIRECTORY, readArtifactReceipt } from './artifact-receipts.js';
|
|
9
|
+
import { ensureSharedDirectory } from './shared-write.js';
|
|
9
10
|
import { validateWorkspacePlayable } from '../previews/finalize.js';
|
|
10
11
|
import { PREVIEW_HTML_MAX_BYTES, WORKSPACE_PLAYABLE_OUTPUT_PATH } from '@playcraft/common/cloud-workspace/runtime';
|
|
11
12
|
const serial = new Map();
|
|
@@ -35,6 +36,7 @@ export class WorkspaceArtifactStore {
|
|
|
35
36
|
}
|
|
36
37
|
get scopeId() { return this.directoryName === 'basic' ? this.workspaceId : `${this.workspaceId}:${this.directoryName}`; }
|
|
37
38
|
async directory() {
|
|
39
|
+
// private
|
|
38
40
|
await mkdir(this.stateDir, { recursive: true, mode: 0o700 });
|
|
39
41
|
return artifactDirectory(this.stateDir, `artifacts/${digest(this.scopeId)}`);
|
|
40
42
|
}
|
|
@@ -64,6 +66,7 @@ export class WorkspaceArtifactStore {
|
|
|
64
66
|
}
|
|
65
67
|
async write(directory, catalog) {
|
|
66
68
|
const temporary = join(directory, `${randomUUID()}.tmp`);
|
|
69
|
+
// private
|
|
67
70
|
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
68
71
|
try {
|
|
69
72
|
await handle.writeFile(JSON.stringify(catalog));
|
|
@@ -281,7 +284,7 @@ export class WorkspaceArtifactStore {
|
|
|
281
284
|
const target = join(root, ARTIFACT_RECEIPTS_DIRECTORY);
|
|
282
285
|
if (!(await lstat(target)).isDirectory())
|
|
283
286
|
return [];
|
|
284
|
-
directory = await
|
|
287
|
+
directory = await ensureSharedDirectory(root, target);
|
|
285
288
|
}
|
|
286
289
|
catch {
|
|
287
290
|
return [];
|
|
@@ -5,6 +5,7 @@ import { basename, dirname, extname, join, relative } from 'node:path';
|
|
|
5
5
|
import { BINARY_WORKSPACE_FILE_MAX_BYTES, WORKSPACE_LIBRARY_ATTACHMENT_MAX_BYTES, WORKSPACE_LIBRARY_ATTACHMENT_MAX_FILES, workspaceAssetLibraryImportPayloadSchema, workspaceAssetLibraryImportResultSchema, workspaceAssetLibraryImportTransportSchema, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
6
|
import { observeWorkspaceBinaryFile, writeWorkspaceBinaryFile } from './binary-write.js';
|
|
7
7
|
import { shareWorkspaceAssetDirectory } from './asset-publication-access.js';
|
|
8
|
+
import { ensureSharedDirectory } from './shared-write.js';
|
|
8
9
|
const RESERVED = new Set(['.git', 'node_modules', '.playcraft-temp', '.tmp']);
|
|
9
10
|
const digest = (value) => createHash('sha256').update(value).digest('hex');
|
|
10
11
|
const missing = (error) => error?.code === 'ENOENT';
|
|
@@ -23,6 +24,7 @@ async function safeDirectory(root, path, create = false) {
|
|
|
23
24
|
let current = root;
|
|
24
25
|
for (const part of path.split('/').filter(Boolean)) {
|
|
25
26
|
current = join(current, part);
|
|
27
|
+
// private
|
|
26
28
|
if (create)
|
|
27
29
|
await mkdir(current, { mode: 0o700 }).catch((error) => {
|
|
28
30
|
if (error.code !== 'EEXIST')
|
|
@@ -47,8 +49,7 @@ async function createPublicDirectory(root, path) {
|
|
|
47
49
|
if (!missing(error))
|
|
48
50
|
throw error;
|
|
49
51
|
await safeDirectory(root, parent);
|
|
50
|
-
await
|
|
51
|
-
await shareWorkspaceAssetDirectory(root, join(root, current));
|
|
52
|
+
await ensureSharedDirectory(root, join(root, current));
|
|
52
53
|
await safeDirectory(root, current);
|
|
53
54
|
}
|
|
54
55
|
}
|
|
@@ -127,6 +128,7 @@ async function syncDirectory(path) {
|
|
|
127
128
|
async function saveTransaction(root, transaction, value) {
|
|
128
129
|
await safeDirectory(root, relative(root, transaction));
|
|
129
130
|
const temporary = join(transaction, 'manifest.next');
|
|
131
|
+
// private
|
|
130
132
|
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, 0o600);
|
|
131
133
|
try {
|
|
132
134
|
await handle.writeFile(JSON.stringify(value));
|
|
@@ -181,6 +183,8 @@ export async function recoverInterruptedLibraryImports(root) {
|
|
|
181
183
|
return result;
|
|
182
184
|
throw error;
|
|
183
185
|
}
|
|
186
|
+
// The shared scratch root is where both identities create their own private transactions.
|
|
187
|
+
await ensureSharedDirectory(rootReal, scratch);
|
|
184
188
|
for (const entry of await readdir(scratch, { withFileTypes: true })) {
|
|
185
189
|
if (!entry.isDirectory() || !entry.name.startsWith('library-import-'))
|
|
186
190
|
continue;
|
|
@@ -239,9 +243,10 @@ async function publishBundle(root, target, asset, replace, assertCurrent, operat
|
|
|
239
243
|
if (previous && (!replace || !previous.isDirectory() || previous.isSymbolicLink()))
|
|
240
244
|
throw new AssetLibraryImportError('content_conflict');
|
|
241
245
|
const originalHash = previous ? await directorySnapshot(join(root, target)) : undefined;
|
|
242
|
-
const scratch = await
|
|
246
|
+
const scratch = await ensureSharedDirectory(root, join(root, '.playcraft-temp'));
|
|
243
247
|
const transaction = await mkdtemp(join(scratch, 'library-import-'));
|
|
244
248
|
const staged = join(transaction, 'incoming'), backup = join(transaction, 'previous');
|
|
249
|
+
// private
|
|
245
250
|
await mkdir(staged, { mode: 0o700 });
|
|
246
251
|
let moved = false, published = false, preserve = false;
|
|
247
252
|
let reservation;
|
|
@@ -271,7 +276,7 @@ async function publishBundle(root, target, asset, replace, assertCurrent, operat
|
|
|
271
276
|
}
|
|
272
277
|
else if (current)
|
|
273
278
|
throw new AssetLibraryImportError('content_conflict');
|
|
274
|
-
await
|
|
279
|
+
await ensureSharedDirectory(root, join(root, target));
|
|
275
280
|
reservation = await lstat(join(root, target));
|
|
276
281
|
await saveTransaction(root, transaction, { ...manifest, phase: 'reserved', reservation: { dev: reservation.dev, ino: reservation.ino } });
|
|
277
282
|
await assertCurrent();
|
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
import { constants } from 'node:fs';
|
|
2
2
|
import { lstat, open } from 'node:fs/promises';
|
|
3
|
+
import { SHARED_DIRECTORY_MODE, SHARED_FILE_MODE, shareHandleWithGroup, } from './shared-write.js';
|
|
4
|
+
/**
|
|
5
|
+
* Published workspace assets belong to the development group so both identities can read and edit
|
|
6
|
+
* them. Private transfers, chunks, transaction records and backups never use these helpers.
|
|
7
|
+
*/
|
|
3
8
|
async function shareWithWorkspaceGroup(root, handle, directory) {
|
|
4
9
|
const workspace = await lstat(root), target = await handle.stat();
|
|
5
10
|
if (!workspace.isDirectory() || workspace.isSymbolicLink()
|
|
6
11
|
|| (directory ? !target.isDirectory() : !target.isFile() || target.nlink !== 1))
|
|
7
12
|
throw new Error('invalid_workspace_asset');
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
if (target.gid === workspace.gid && (target.mode & 0o7777) === shared)
|
|
11
|
-
return;
|
|
12
|
-
if (target.gid !== workspace.gid)
|
|
13
|
-
await handle.chown(target.uid, workspace.gid);
|
|
14
|
-
// Match directory-access.ts: the development group can read and edit public project files.
|
|
15
|
-
// Private transfers, chunks, transaction records and backups never use these helpers.
|
|
16
|
-
await handle.chmod(shared);
|
|
13
|
+
// Already shared or owned by the other identity: shaping is a no-op, never a cross-owner EPERM.
|
|
14
|
+
await shareHandleWithGroup(handle, workspace.gid, directory ? SHARED_DIRECTORY_MODE : SHARED_FILE_MODE);
|
|
17
15
|
}
|
|
18
16
|
export async function shareWorkspaceAssetFile(root, handle) {
|
|
19
17
|
await shareWithWorkspaceGroup(root, handle, false);
|
|
@@ -40,6 +40,7 @@ async function assemble(transfer) {
|
|
|
40
40
|
const staged = [];
|
|
41
41
|
for (const [index, file] of transfer.record.manifest.files.entries()) {
|
|
42
42
|
const destination = join(transfer.directory, 'incoming', String(index));
|
|
43
|
+
// private
|
|
43
44
|
const output = await open(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
44
45
|
const hash = createHash('sha256');
|
|
45
46
|
try {
|
|
@@ -120,6 +121,7 @@ async function createDirectories(transfer, path) {
|
|
|
120
121
|
if (!assetTransferMissing(error))
|
|
121
122
|
throw error;
|
|
122
123
|
await safeAssetTransferDirectory(transfer.root, dirname(directory) === '.' ? '' : dirname(directory));
|
|
124
|
+
// private
|
|
123
125
|
await mkdir(join(transfer.root, directory), { mode: 0o700 });
|
|
124
126
|
await shareWorkspaceAssetDirectory(transfer.root, join(transfer.root, directory));
|
|
125
127
|
const stat = await lstat(await safeAssetTransferDirectory(transfer.root, directory));
|
|
@@ -146,6 +148,7 @@ async function ensureDirectoryMarkers(transfer) {
|
|
|
146
148
|
const parent = await safeAssetTransferDirectory(transfer.root, path);
|
|
147
149
|
const marker = join(parent, '.gitkeep');
|
|
148
150
|
try {
|
|
151
|
+
// private
|
|
149
152
|
const handle = await open(marker, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
150
153
|
try {
|
|
151
154
|
await shareWorkspaceAssetFile(transfer.root, handle);
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
3
|
import { link, lstat, mkdir, open, readdir, realpath, rename, rm, unlink } from 'node:fs/promises';
|
|
4
|
-
import { join } from 'node:path';
|
|
4
|
+
import { join, relative } from 'node:path';
|
|
5
5
|
import { ASSET_TRANSFER_BODY_MAX_BYTES, ASSET_TRANSFER_CHUNK_BYTES, ASSET_TRANSFER_FILE_MAX_BYTES, assetBundleReceiptSchema, assetTransferChunkSchema, assetTransferManifestSchema, assetTransferStatusSchema, hashAssetTransferManifest, isAssetBundleReceiptForManifest, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
|
+
import { ensureSharedDirectory } from './shared-write.js';
|
|
6
7
|
export class AssetTransferError extends Error {
|
|
7
8
|
code;
|
|
8
9
|
status;
|
|
@@ -27,9 +28,19 @@ export async function safeAssetTransferDirectory(root, path = '', create = false
|
|
|
27
28
|
if (part === '.' || part === '..' || /[\\:\x00-\x1f\x7f]/.test(part))
|
|
28
29
|
failPath();
|
|
29
30
|
current = join(current, part);
|
|
30
|
-
|
|
31
|
+
// The scratch root is shared by both identities; each transaction below it stays private.
|
|
32
|
+
if (create && relative(root, current) === '.playcraft-temp') {
|
|
33
|
+
await ensureSharedDirectory(root, current).catch(error => {
|
|
34
|
+
if (error.message === 'invalid_shared_directory')
|
|
35
|
+
failPath();
|
|
36
|
+
throw error;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
else if (create) {
|
|
40
|
+
// private
|
|
31
41
|
await mkdir(current, { mode: 0o700 }).catch(error => { if (error.code !== 'EEXIST')
|
|
32
42
|
throw error; });
|
|
43
|
+
}
|
|
33
44
|
const stat = await lstat(current);
|
|
34
45
|
if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(current) !== current)
|
|
35
46
|
failPath();
|
|
@@ -93,6 +104,7 @@ export async function hashAssetTransferFile(path, limit = ASSET_TRANSFER_FILE_MA
|
|
|
93
104
|
export async function saveAssetTransfer(transfer) {
|
|
94
105
|
await safeAssetTransferDirectory(transfer.directory);
|
|
95
106
|
const temporary = join(transfer.directory, `record-${randomUUID()}.tmp`);
|
|
107
|
+
// private
|
|
96
108
|
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
97
109
|
try {
|
|
98
110
|
await handle.writeFile(JSON.stringify(transfer.record));
|
|
@@ -194,6 +206,7 @@ export async function withAssetTransfer(root, scope, id, action) {
|
|
|
194
206
|
}
|
|
195
207
|
async function saveProgress(transfer, files) {
|
|
196
208
|
const temporary = join(transfer.directory, `progress-${randomUUID()}.tmp`);
|
|
209
|
+
// private
|
|
197
210
|
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
198
211
|
try {
|
|
199
212
|
await handle.writeFile(JSON.stringify(files));
|
|
@@ -292,6 +305,7 @@ async function createAssetTransferUnlocked(root, scope, input) {
|
|
|
292
305
|
const directory = await safeAssetTransferDirectory(rootReal, `.playcraft-temp/asset-transfers/${manifest.transferId}`, true);
|
|
293
306
|
const record = { version: 1, createdAt: Date.now(), scope, manifest, manifestHash: hashAssetTransferManifest(manifest), status: 'uploading' };
|
|
294
307
|
const temporary = join(directory, `create-${randomUUID()}.tmp`);
|
|
308
|
+
// private
|
|
295
309
|
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
296
310
|
try {
|
|
297
311
|
await handle.writeFile(JSON.stringify(record));
|
|
@@ -366,6 +380,7 @@ export async function putAssetTransferChunk(root, scope, id, input) {
|
|
|
366
380
|
throw error;
|
|
367
381
|
}
|
|
368
382
|
const temporary = join(directory, `chunk-${randomUUID()}.tmp`);
|
|
383
|
+
// private
|
|
369
384
|
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
370
385
|
try {
|
|
371
386
|
await handle.writeFile(bytes);
|
|
@@ -13,6 +13,7 @@ async function attachmentRoot(root) {
|
|
|
13
13
|
let directory = root;
|
|
14
14
|
for (const part of WORKSPACE_USER_UPLOADS_DIRECTORY.split('/')) {
|
|
15
15
|
directory = join(directory, part);
|
|
16
|
+
// shared
|
|
16
17
|
await mkdir(directory, { mode: 0o755 }).catch((error) => {
|
|
17
18
|
if (error.code !== 'EEXIST')
|
|
18
19
|
throw error;
|
|
@@ -62,6 +63,7 @@ export async function uploadWorkspaceAttachment(root, body) {
|
|
|
62
63
|
const uploads = await attachmentRoot(root);
|
|
63
64
|
const directory = join(uploads, attachment.id);
|
|
64
65
|
const metadataDirectory = join(uploads, '.metadata');
|
|
66
|
+
// shared
|
|
65
67
|
await mkdir(metadataDirectory, { mode: 0o755 }).catch((error) => {
|
|
66
68
|
if (error.code !== 'EEXIST')
|
|
67
69
|
throw error;
|
|
@@ -69,8 +71,11 @@ export async function uploadWorkspaceAttachment(root, body) {
|
|
|
69
71
|
if (!(await lstat(metadataDirectory)).isDirectory())
|
|
70
72
|
throw new Error('invalid_attachment');
|
|
71
73
|
// Codex can run as a different user from the sandbox control process.
|
|
74
|
+
// shared
|
|
72
75
|
await mkdir(directory, { mode: 0o755 });
|
|
76
|
+
// shared
|
|
73
77
|
await writeFile(join(directory, attachment.name), bytes, { flag: 'wx', mode: 0o644 });
|
|
78
|
+
// shared
|
|
74
79
|
await writeFile(join(metadataDirectory, `${attachment.id}.json`), JSON.stringify(attachment), {
|
|
75
80
|
flag: 'wx',
|
|
76
81
|
mode: 0o644,
|
|
@@ -88,11 +93,14 @@ export async function uploadWorkspaceVideoAttachment(root, request, query) {
|
|
|
88
93
|
await excludeFromGit(root, `/${WORKSPACE_USER_UPLOADS_DIRECTORY}/`);
|
|
89
94
|
const uploads = await attachmentRoot(root);
|
|
90
95
|
const id = randomUUID(), directory = join(uploads, id), metadataDirectory = join(uploads, '.metadata');
|
|
96
|
+
// shared
|
|
91
97
|
await mkdir(metadataDirectory, { mode: 0o755 }).catch((error) => { if (error.code !== 'EEXIST')
|
|
92
98
|
throw error; });
|
|
93
99
|
if (!(await lstat(metadataDirectory)).isDirectory())
|
|
94
100
|
throw new Error('invalid_attachment');
|
|
101
|
+
// shared
|
|
95
102
|
await mkdir(directory, { mode: 0o755 });
|
|
103
|
+
// shared
|
|
96
104
|
const handle = await open(join(directory, name), constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | constants.O_NOFOLLOW, 0o644);
|
|
97
105
|
try {
|
|
98
106
|
let size = 0;
|
|
@@ -112,6 +120,7 @@ export async function uploadWorkspaceVideoAttachment(root, request, query) {
|
|
|
112
120
|
if (contentType !== media.mimeType && !(contentType === 'video/quicktime' && media.mimeType === 'video/mp4'))
|
|
113
121
|
throw new Error('invalid_attachment');
|
|
114
122
|
const attachment = { id, name, size, ...media, sha256: await hashAttachment(handle) };
|
|
123
|
+
// shared
|
|
115
124
|
await writeFile(join(metadataDirectory, `${id}.json`), JSON.stringify(attachment), { flag: 'wx', mode: 0o644 });
|
|
116
125
|
return attachment;
|
|
117
126
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
|
-
import { lstat, mkdir, open, realpath, rm
|
|
4
|
+
import { lstat, mkdir, open, realpath, rm } from 'node:fs/promises';
|
|
5
5
|
import { dirname, isAbsolute, join, relative, sep } from 'node:path';
|
|
6
6
|
import { promisify } from 'node:util';
|
|
7
7
|
import { batchCommitSourceVersion } from '@playcraft/common/cloud-workspace/runtime';
|
|
8
8
|
import { validateProjectSkillsSnapshot } from '@playcraft/common/project-skills';
|
|
9
|
+
import { ensureSharedDirectory, writeSharedFile } from './shared-write.js';
|
|
9
10
|
const execute = promisify(execFile);
|
|
10
11
|
const ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
11
12
|
const COMMIT = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
|
|
@@ -131,8 +132,9 @@ export async function checkoutBatchCommit(input) {
|
|
|
131
132
|
if (await lstat(target).catch(() => undefined))
|
|
132
133
|
continue;
|
|
133
134
|
const body = await readArtifact(root, path);
|
|
134
|
-
|
|
135
|
-
await
|
|
135
|
+
// The checkout is handed to the development identity, so its archives must be group readable.
|
|
136
|
+
await ensureSharedDirectory(input.destination, dirname(target));
|
|
137
|
+
await writeSharedFile(input.destination, target, body);
|
|
136
138
|
}
|
|
137
139
|
}
|
|
138
140
|
catch (cause) {
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
|
-
import { link, lstat,
|
|
3
|
+
import { link, lstat, open, realpath, rename, unlink } from 'node:fs/promises';
|
|
4
4
|
import { basename, join } from 'node:path';
|
|
5
5
|
import { fileBinaryWritePayloadSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
6
|
import { artifactPath } from './artifact-files.js';
|
|
7
7
|
import { sampleDiskFile } from './disk-snapshot.js';
|
|
8
8
|
import { InvalidWorkspacePathError } from './files.js';
|
|
9
|
-
import {
|
|
9
|
+
import { shareWorkspaceAssetFile } from './asset-publication-access.js';
|
|
10
|
+
import { ensureSharedDirectory } from './shared-write.js';
|
|
10
11
|
export class BinaryWorkspaceWriteError extends Error {
|
|
11
12
|
code;
|
|
12
13
|
constructor(code) {
|
|
@@ -45,14 +46,17 @@ export async function writeWorkspaceBinaryFile(root, input) {
|
|
|
45
46
|
throw new InvalidWorkspacePathError(payload.path);
|
|
46
47
|
for (const part of parts.slice(0, -1)) {
|
|
47
48
|
parent = join(parent, part);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
const existing = await lstat(parent).catch((error) => {
|
|
50
|
+
if (error.code === 'ENOENT')
|
|
51
|
+
return undefined;
|
|
52
|
+
throw error;
|
|
53
|
+
});
|
|
54
|
+
// Only new parents are shared; an existing directory keeps whatever access its owner chose.
|
|
55
|
+
if (!existing)
|
|
56
|
+
await ensureSharedDirectory(rootReal, parent);
|
|
51
57
|
const stat = await lstat(parent);
|
|
52
58
|
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
53
59
|
throw new InvalidWorkspacePathError(payload.path);
|
|
54
|
-
if (created)
|
|
55
|
-
await shareWorkspaceAssetDirectory(rootReal, parent);
|
|
56
60
|
}
|
|
57
61
|
const parentStat = await lstat(parent);
|
|
58
62
|
const target = join(parent, basename(payload.path));
|
|
@@ -61,6 +65,7 @@ export async function writeWorkspaceBinaryFile(root, input) {
|
|
|
61
65
|
let staged = false;
|
|
62
66
|
try {
|
|
63
67
|
await verifyPreconditions(rootReal, payload);
|
|
68
|
+
// shared
|
|
64
69
|
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
65
70
|
staged = true;
|
|
66
71
|
try {
|
|
@@ -5,6 +5,7 @@ import { excludeFromGit } from './dependencies.js';
|
|
|
5
5
|
/** Non-secret routing hint; the business service still authorizes the selected workspace. */
|
|
6
6
|
export async function writeWorkspaceCliContext(root, workspaceId) {
|
|
7
7
|
const management = join(root, '.playcraft');
|
|
8
|
+
// shared
|
|
8
9
|
await mkdir(management, { mode: 0o755 }).catch(error => { if (error.code !== 'EEXIST')
|
|
9
10
|
throw error; });
|
|
10
11
|
const stat = await lstat(management);
|
|
@@ -12,6 +13,7 @@ export async function writeWorkspaceCliContext(root, workspaceId) {
|
|
|
12
13
|
throw new Error('workspace_skills_invalid');
|
|
13
14
|
const temporary = join(management, `workspace-context.${randomUUID()}.tmp`);
|
|
14
15
|
try {
|
|
16
|
+
// shared
|
|
15
17
|
await writeFile(temporary, `${JSON.stringify({ schemaVersion: 'playcraft.workspace-context.v1', workspaceId })}\n`, { flag: 'wx', mode: 0o644 });
|
|
16
18
|
await rename(temporary, join(management, 'workspace-context.json'));
|
|
17
19
|
await excludeFromGit(root, '/.playcraft/workspace-context.json');
|
|
@@ -76,6 +76,7 @@ async function renew(state, entry) {
|
|
|
76
76
|
await mkdir(config.workspaceRoot, { recursive: true });
|
|
77
77
|
const temporary = join(config.workspaceRoot, `.playcraft-credential-${randomUUID()}.tmp`);
|
|
78
78
|
try {
|
|
79
|
+
// private
|
|
79
80
|
await writeFile(temporary, JSON.stringify({
|
|
80
81
|
apiUrl: credential.apiUrl,
|
|
81
82
|
token: credential.token,
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
|
-
import { copyFile, cp,
|
|
3
|
+
import { copyFile, cp, lstat, mkdir, mkdtemp, readdir, readlink, rename, rm, stat, symlink } from 'node:fs/promises';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { basename, dirname, join, relative, sep } from 'node:path';
|
|
6
6
|
import { createPlatformAdapter, getChannelRuleProfile, } from '@playcraft/build';
|
|
7
7
|
import { DELIVERY_QUEUE_LIMIT, isWorkspaceArtifactOutputPath, } from '@playcraft/common/cloud-workspace/runtime';
|
|
8
|
-
import { workspaceRoot } from './registry.js';
|
|
8
|
+
import { revisionScope, workspaceRoot } from './registry.js';
|
|
9
9
|
import { excludeFromGit, ensureWorkspaceDependencies } from './dependencies.js';
|
|
10
10
|
import { listWorkspaceBuildInputs } from './git.js';
|
|
11
11
|
import { runDeveloperCommand } from './developer-command.js';
|
|
12
12
|
import { openScratchToDevelopment } from './directory-access.js';
|
|
13
|
+
import { ensureSharedDirectory } from './shared-write.js';
|
|
14
|
+
import { driftScope, permissionDriftFromOutput, repairWorkspaceShares } from './workspace-shares.js';
|
|
13
15
|
import { DeliveryStore } from './delivery-store.js';
|
|
14
16
|
import { DeliveryValidationError, validateDeliveryArtifact } from './delivery.js';
|
|
15
17
|
export const DELIVERY_BUILD_TIMEOUT_MS = 10 * 60_000;
|
|
@@ -196,7 +198,7 @@ async function copyToDist(root, staging, channel, format) {
|
|
|
196
198
|
throw new DeliveryBuildError('delivery_output_missing');
|
|
197
199
|
const target = join(root, relative);
|
|
198
200
|
const directory = dirname(target);
|
|
199
|
-
await
|
|
201
|
+
await ensureSharedDirectory(root, directory);
|
|
200
202
|
const temporary = join(directory, `.${basename(file)}.${randomUUID()}.tmp`);
|
|
201
203
|
try {
|
|
202
204
|
await copyFile(join(staging, file), temporary);
|
|
@@ -249,9 +251,8 @@ async function copyInput(root, destination, path) {
|
|
|
249
251
|
* caches, logs and local outputs stay behind. Without a repository the whole tree is copied.
|
|
250
252
|
*/
|
|
251
253
|
export async function isolateWorkspaceForBuild(root, destination) {
|
|
252
|
-
await mkdir(destination, { recursive: true });
|
|
253
254
|
// The build runs as the development identity and writes its scratch folder inside this copy.
|
|
254
|
-
await
|
|
255
|
+
await ensureSharedDirectory(root, destination);
|
|
255
256
|
const modules = join(root, 'node_modules');
|
|
256
257
|
const modulesEntry = await lstat(modules).catch(() => undefined);
|
|
257
258
|
if (modulesEntry?.isDirectory() || modulesEntry?.isSymbolicLink()) {
|
|
@@ -319,11 +320,10 @@ async function executeBuild(state, input, controller) {
|
|
|
319
320
|
await openScratchToDevelopment(scratch, (await lstat(root)).gid);
|
|
320
321
|
staging = join(scratch, 'output');
|
|
321
322
|
const project = join(scratch, 'project');
|
|
322
|
-
await
|
|
323
|
-
await chmod(staging, 0o2770);
|
|
323
|
+
await ensureSharedDirectory(root, staging);
|
|
324
324
|
await excludeFromGit(root, `/${STAGING_DIRECTORY}/`);
|
|
325
325
|
await excludeFromGit(root, '/dist/');
|
|
326
|
-
const dependencies = await ensureWorkspaceDependencies(state, input.workspaceId, root);
|
|
326
|
+
const dependencies = await ensureWorkspaceDependencies(state, revisionScope(state, input.workspaceId, input.directoryName) ?? input.workspaceId, root);
|
|
327
327
|
if (!dependencies.ok) {
|
|
328
328
|
if (dependencies.logTail) {
|
|
329
329
|
await store.appendLog(input.buildId, `\n${dependencies.logTail}\n`);
|
|
@@ -338,7 +338,8 @@ async function executeBuild(state, input, controller) {
|
|
|
338
338
|
}
|
|
339
339
|
const planned = process.env.PLAYCRAFT_DELIVERY_BUILD_COMMAND || 'playcraft build';
|
|
340
340
|
const command = `umask 002 && NO_COLOR=1 CI=1 ${planned} . --platform ${input.payload.channel} --format ${input.payload.format} --output ${shellQuote(staging)} --mode full`;
|
|
341
|
-
|
|
341
|
+
let outputTail = '';
|
|
342
|
+
const runBuild = () => runDeveloperCommand(state, {
|
|
342
343
|
processId: `delivery-${input.buildId}`,
|
|
343
344
|
cwd: project,
|
|
344
345
|
command,
|
|
@@ -348,10 +349,28 @@ async function executeBuild(state, input, controller) {
|
|
|
348
349
|
const text = String(chunk);
|
|
349
350
|
if (!text)
|
|
350
351
|
return;
|
|
352
|
+
outputTail = (outputTail + text).slice(-64 * 1024);
|
|
351
353
|
logChain = logChain.then(() => store.appendLog(input.buildId, text)).catch(() => undefined);
|
|
352
354
|
},
|
|
353
355
|
});
|
|
354
|
-
|
|
356
|
+
let outcome = await runBuild();
|
|
357
|
+
let permissionDrift = false;
|
|
358
|
+
if (outcome.code !== 0 && !outcome.canceled && !outcome.timedOut) {
|
|
359
|
+
// The build runs as the development identity; an entry only that identity owns repairs its
|
|
360
|
+
// own shared subtree, then the build is retried exactly once.
|
|
361
|
+
const drift = permissionDriftFromOutput(outputTail);
|
|
362
|
+
if (drift) {
|
|
363
|
+
permissionDrift = true;
|
|
364
|
+
await repairWorkspaceShares(state, root, await driftScope(root, drift.path)).catch(() => undefined);
|
|
365
|
+
outcome = await runBuild();
|
|
366
|
+
permissionDrift = outcome.code !== 0;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (outcome.code === undefined) {
|
|
370
|
+
status = 'failed';
|
|
371
|
+
error = 'delivery_process_unknown';
|
|
372
|
+
}
|
|
373
|
+
else if (outcome.canceled) {
|
|
355
374
|
status = 'canceled';
|
|
356
375
|
error = 'delivery_canceled';
|
|
357
376
|
}
|
|
@@ -361,7 +380,7 @@ async function executeBuild(state, input, controller) {
|
|
|
361
380
|
}
|
|
362
381
|
else if (outcome.code !== 0) {
|
|
363
382
|
status = 'failed';
|
|
364
|
-
error = 'delivery_build_failed';
|
|
383
|
+
error = permissionDrift ? 'workspace_permission_drift' : 'delivery_build_failed';
|
|
365
384
|
}
|
|
366
385
|
else {
|
|
367
386
|
const output = await copyToDist(root, staging, input.payload.channel, input.payload.format);
|
|
@@ -24,6 +24,7 @@ export class DeliveryStore {
|
|
|
24
24
|
this.directory = workspaceDirectory(stateDir, workspaceId);
|
|
25
25
|
}
|
|
26
26
|
async ready() {
|
|
27
|
+
// private
|
|
27
28
|
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
28
29
|
return this.directory;
|
|
29
30
|
}
|
|
@@ -42,6 +43,7 @@ export class DeliveryStore {
|
|
|
42
43
|
async writeJson(path, value) {
|
|
43
44
|
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
44
45
|
try {
|
|
46
|
+
// private
|
|
45
47
|
await writeFile(temporary, JSON.stringify(value), { mode: 0o600 });
|
|
46
48
|
await rename(temporary, path);
|
|
47
49
|
}
|