@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.
Files changed (89) hide show
  1. package/dist/commands/workspace.js +43 -0
  2. package/dist/utils/agent-generation-provenance.js +1 -1
  3. package/dist/workspace/messages.js +10 -0
  4. package/dist/workspace-runtime/batch/deliver.js +140 -80
  5. package/dist/workspace-runtime/batch/freeze.js +18 -6
  6. package/dist/workspace-runtime/batch/generate.js +68 -64
  7. package/dist/workspace-runtime/batch/policy.js +71 -22
  8. package/dist/workspace-runtime/batch/prepare.js +108 -93
  9. package/dist/workspace-runtime/batch/records.js +4 -0
  10. package/dist/workspace-runtime/batch/routes.js +10 -2
  11. package/dist/workspace-runtime/batch/source-candidate.js +2 -0
  12. package/dist/workspace-runtime/batch/source-capture.js +1 -0
  13. package/dist/workspace-runtime/batch/source-failure.js +2 -0
  14. package/dist/workspace-runtime/batch/source-publish.js +3 -0
  15. package/dist/workspace-runtime/batch/writer-proof.js +37 -0
  16. package/dist/workspace-runtime/codex/app-server.js +278 -20
  17. package/dist/workspace-runtime/codex/config-toml.js +4 -0
  18. package/dist/workspace-runtime/codex/delivery-tools.js +1 -1
  19. package/dist/workspace-runtime/codex/generation-provenance-broker.js +17 -8
  20. package/dist/workspace-runtime/codex/loopback.js +2 -0
  21. package/dist/workspace-runtime/codex/model-broker.js +7 -4
  22. package/dist/workspace-runtime/persistence/journal.js +34 -0
  23. package/dist/workspace-runtime/persistence/sqlite-worker.js +68 -6
  24. package/dist/workspace-runtime/previews/output.js +2 -0
  25. package/dist/workspace-runtime/previews/publish.js +20 -5
  26. package/dist/workspace-runtime/previews/runtime-preview.js +4 -0
  27. package/dist/workspace-runtime/processes/managed-writes.js +47 -5
  28. package/dist/workspace-runtime/serve.js +13 -3
  29. package/dist/workspace-runtime/server/accept-operation.js +22 -2
  30. package/dist/workspace-runtime/server/directory-access.js +414 -0
  31. package/dist/workspace-runtime/server/dispatch.js +74 -29
  32. package/dist/workspace-runtime/server/endpoints.js +1 -0
  33. package/dist/workspace-runtime/server/http.js +80 -10
  34. package/dist/workspace-runtime/server/operation-record.js +2 -0
  35. package/dist/workspace-runtime/server/terminal-host.js +124 -8
  36. package/dist/workspace-runtime/server/workspace-storage.js +193 -0
  37. package/dist/workspace-runtime/server/workspace-transfer.js +3 -1
  38. package/dist/workspace-runtime/workspaces/append-only-log.js +3 -0
  39. package/dist/workspace-runtime/workspaces/artifact-events.js +5 -2
  40. package/dist/workspace-runtime/workspaces/artifact-files.js +14 -8
  41. package/dist/workspace-runtime/workspaces/artifact-receipts.js +7 -25
  42. package/dist/workspace-runtime/workspaces/artifacts.js +4 -1
  43. package/dist/workspace-runtime/workspaces/asset-library-import.js +9 -4
  44. package/dist/workspace-runtime/workspaces/asset-publication-access.js +7 -9
  45. package/dist/workspace-runtime/workspaces/asset-transfer-publish.js +3 -0
  46. package/dist/workspace-runtime/workspaces/asset-transfer-storage.js +17 -2
  47. package/dist/workspace-runtime/workspaces/attachments.js +9 -0
  48. package/dist/workspace-runtime/workspaces/batch-commit.js +5 -3
  49. package/dist/workspace-runtime/workspaces/binary-write.js +12 -7
  50. package/dist/workspace-runtime/workspaces/cli-context.js +2 -0
  51. package/dist/workspace-runtime/workspaces/cli-credentials.js +1 -0
  52. package/dist/workspace-runtime/workspaces/delivery-build.js +30 -11
  53. package/dist/workspace-runtime/workspaces/delivery-store.js +2 -0
  54. package/dist/workspace-runtime/workspaces/dependencies.js +90 -70
  55. package/dist/workspace-runtime/workspaces/developer-command.js +12 -1
  56. package/dist/workspace-runtime/workspaces/directory-access.js +13 -18
  57. package/dist/workspace-runtime/workspaces/directory-attachments.js +8 -0
  58. package/dist/workspace-runtime/workspaces/directory-create.js +18 -5
  59. package/dist/workspace-runtime/workspaces/directory-maintenance.js +46 -35
  60. package/dist/workspace-runtime/workspaces/directory-mutations.js +8 -3
  61. package/dist/workspace-runtime/workspaces/files.js +4 -0
  62. package/dist/workspace-runtime/workspaces/git-credential-broker.js +3 -0
  63. package/dist/workspace-runtime/workspaces/git-direct-store.js +2 -0
  64. package/dist/workspace-runtime/workspaces/json5-edit.js +36 -0
  65. package/dist/workspace-runtime/workspaces/library-attachments.js +4 -0
  66. package/dist/workspace-runtime/workspaces/parameter-agent-protocol.js +2 -0
  67. package/dist/workspace-runtime/workspaces/parameter-agent-store.js +2 -0
  68. package/dist/workspace-runtime/workspaces/parameter-agent.js +40 -11
  69. package/dist/workspace-runtime/workspaces/parameter-assets.js +1 -0
  70. package/dist/workspace-runtime/workspaces/parameter-initialization-skill-transaction.js +141 -0
  71. package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +329 -0
  72. package/dist/workspace-runtime/workspaces/parameter-initialization.js +63 -0
  73. package/dist/workspace-runtime/workspaces/parameter-production.js +124 -0
  74. package/dist/workspace-runtime/workspaces/parameters.js +95 -3
  75. package/dist/workspace-runtime/workspaces/platform-export.js +5 -0
  76. package/dist/workspace-runtime/workspaces/prepare.js +4 -0
  77. package/dist/workspace-runtime/workspaces/registry.js +55 -3
  78. package/dist/workspace-runtime/workspaces/remix-references.js +5 -0
  79. package/dist/workspace-runtime/workspaces/shared-write.js +187 -0
  80. package/dist/workspace-runtime/workspaces/skills.js +14 -15
  81. package/dist/workspace-runtime/workspaces/storage-snapshot.js +235 -0
  82. package/dist/workspace-runtime/workspaces/transfer.js +4 -1
  83. package/dist/workspace-runtime/workspaces/workspace-shares.js +235 -0
  84. package/dist/workspace-runtime/workspaces/worktree-attachments.js +2 -0
  85. package/dist/workspace-runtime/workspaces/worktree-checkout.js +11 -6
  86. package/package.json +5 -4
  87. package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +32 -0
  88. package/project-template/.claude/skills/playcraft-runtime-parameters/references/platform-protocol.md +21 -0
  89. package/project-template/.claude/skills/playcraft-runtime-parameters/references/preview-integration.md +57 -0
@@ -100,6 +100,11 @@ export async function startPlatformExport(state, input) {
100
100
  clientRequestId: `${input.clientRequestId.slice(0, 80)}:export-save`,
101
101
  payload: {},
102
102
  });
103
+ if (['queued', 'dispatched', 'accepted'].includes(saved.status))
104
+ return {
105
+ status: 'pending', operationId: saved.operationId, stage: 'workspace.save', builds: [],
106
+ nextAction: 'Finish the current turn so the queued save can run. Do not poll or wait for it within this turn. In a later turn, call the delivery tool again to continue the export.',
107
+ };
103
108
  if (saved.status !== 'completed')
104
109
  throw new PlatformExportError('workspace_save_failed', saved.error);
105
110
  const status = await readWorkspaceGitStatus(target.root);
@@ -38,6 +38,7 @@ export async function prepareWorkspaceCheckout(state, binding, input, leaseFence
38
38
  workspaceDirectoryNameSchema.parse(directoryName);
39
39
  const target = join(workspace, directoryName);
40
40
  const receiptDir = join(state.config.stateDir, 'workspace-preparations');
41
+ // private
41
42
  await mkdir(receiptDir, { recursive: true, mode: 0o700 });
42
43
  const receiptPath = join(receiptDir, `${binding.workspaceId}${directoryName === 'basic' ? '' : `--${directoryName}`}.json`);
43
44
  let previous = await readReceipt(receiptPath, binding);
@@ -73,6 +74,7 @@ export async function prepareWorkspaceCheckout(state, binding, input, leaseFence
73
74
  // Persist ownership before creating/removing a staging directory, so a crash can be retried.
74
75
  await writeReceipt(receiptPath, receipt);
75
76
  await rm(staging, { recursive: true, force: true });
77
+ // private
76
78
  await mkdir(staging, { mode: 0o700 });
77
79
  const access = clone?.gitAccess;
78
80
  const credential = clone?.directAccess ?? access;
@@ -163,6 +165,7 @@ export async function prepareWorkspaceCheckout(state, binding, input, leaseFence
163
165
  if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(workspace) !== workspace)
164
166
  throw new WorkspaceContextError('invalid_workspace_directory');
165
167
  }
168
+ // shared
166
169
  await mkdir(workspace, { recursive: true, mode: 0o700 });
167
170
  await rename(staging, target);
168
171
  // The terminal and Codex run as the development user, so a checkout only the control user can
@@ -234,6 +237,7 @@ async function readReceipt(path, binding) {
234
237
  }
235
238
  async function writeReceipt(path, value) {
236
239
  const temp = `${path}.tmp`;
240
+ // private
237
241
  await writeFile(temp, `${JSON.stringify(value)}\n`, { mode: 0o600 });
238
242
  await rename(temp, path);
239
243
  }
@@ -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';
@@ -11,6 +11,7 @@ import { loadWorkspaceSkillArtifact, workspacePlatformSkillAccess } from './skil
11
11
  import { writeWorkspaceCliContext } from './cli-context.js';
12
12
  import { attachWorkspaceRemote } from './git.js';
13
13
  import { ensureWorkspaceArtifactInstructions } from './instructions.js';
14
+ import { repairWorkspaceShares } from './workspace-shares.js';
14
15
  export { WorkspaceContextError } from './context-error.js';
15
16
  const exec = promisify(execFile);
16
17
  export function revisionScope(state, workspaceId, directoryName = 'basic') {
@@ -128,6 +129,7 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
128
129
  throw new WorkspaceContextError('invalid_workspace_registration');
129
130
  // Production restores its assigned result from durable bytes, without touching basic.
130
131
  const parent = await realpath(state.config.workspaceRoot);
132
+ // shared
131
133
  await mkdir(join(parent, binding.workspaceId), { mode: 0o2770 }).catch(error => {
132
134
  if (error.code !== 'EEXIST')
133
135
  throw error;
@@ -141,6 +143,7 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
141
143
  // Registration publishes trusted identity. Only an explicit basic ensure restores the checkout.
142
144
  const parent = await realpath(state.config.workspaceRoot);
143
145
  const container = join(parent, binding.workspaceId);
146
+ // shared
144
147
  await mkdir(container, { mode: 0o2770 }).catch(error => { if (error.code !== 'EEXIST')
145
148
  throw error; });
146
149
  const registered = { ...binding, directories };
@@ -160,7 +163,7 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
160
163
  await prepareWorkspaceCheckout(state, binding, preparation, leaseFence);
161
164
  root = await checkedDirectory(state, binding.workspaceId);
162
165
  }
163
- const git = async (...args) => (await exec('git', ['-C', root, ...args], {
166
+ const gitIn = (cwd) => async (...args) => (await exec('git', ['-C', cwd, ...args], {
164
167
  timeout: 5000,
165
168
  maxBuffer: 64 * 1024,
166
169
  env: {
@@ -170,11 +173,19 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
170
173
  GIT_CONFIG_GLOBAL: '/dev/null',
171
174
  },
172
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)) === '';
173
181
  // Unfinished if it failed part way: the marker outlives the journal entry written below.
174
- if (binding.targetKind === 'main' && !preservedLocalBasic && (remoteUpgrade || await lstat(attachMarker).then(() => true, () => false))) {
182
+ if (binding.targetKind === 'main' && !preservedLocalBasic
183
+ && (needsRemote || remoteUpgrade || await lstat(attachMarker).then(() => true, () => false))) {
175
184
  if (preparation?.mode !== 'clone' || !state.gitCredentials)
176
185
  throw new WorkspaceContextError('workspace_git_gateway_unavailable');
186
+ // private
177
187
  await mkdir(join(state.config.stateDir, 'workspace-remote-attach'), { recursive: true, mode: 0o700 });
188
+ // private
178
189
  await writeFile(attachMarker, JSON.stringify({ repositoryUrl: binding.repositoryUrl, branchName: binding.branchName }), { mode: 0o600 });
179
190
  // The credential helper only serves a workspace the journal knows to be bound.
180
191
  await state.journal.putWorkspace({ ...binding, directories });
@@ -213,6 +224,10 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
213
224
  throw error;
214
225
  throw new WorkspaceContextError('workspace_not_prepared');
215
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);
216
231
  if (state.journal.destroyed())
217
232
  throw new WorkspaceContextError('runtime_destroyed');
218
233
  if (leaseFence < state.acceptedLeaseFence)
@@ -240,6 +255,12 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
240
255
  await writeWorkspaceCliContext(root, binding.workspaceId);
241
256
  await ensureWorkspaceArtifactInstructions(root);
242
257
  await state.journal.putWorkspace({ ...binding, directories });
258
+ // Registration is the one point every reused container reaches: the root repair is skipped
259
+ // when the binding did not change, so the shared subtrees repair themselves here (idempotent).
260
+ // Keep registration latency independent of workspace size; build and scan paths also repair.
261
+ void repairWorkspaceShares(state, root).catch(error => {
262
+ console.warn('[workspace-runtime] share repair failed:', error instanceof Error ? error.message : error);
263
+ });
243
264
  try {
244
265
  await readWorkspaceSkills(root, join(state.config.stateDir, 'skill-artifacts'), entry => loadWorkspaceSkillArtifact(state, binding.workspaceId, entry));
245
266
  }
@@ -249,3 +270,34 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
249
270
  return binding;
250
271
  });
251
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
+ }
@@ -24,6 +24,7 @@ async function safeDirectory(root, relative = '', create = false) {
24
24
  let current = root;
25
25
  for (const segment of relative.split('/').filter(Boolean)) {
26
26
  current = join(current, segment);
27
+ // shared
27
28
  if (create)
28
29
  await mkdir(current, { mode: 0o755 }).catch((error) => {
29
30
  if (error.code !== 'EEXIST')
@@ -255,9 +256,12 @@ async function importReference(root, directoryName, input, assertCurrent) {
255
256
  for (const file of files) {
256
257
  assertCurrent();
257
258
  await safeDirectory(stage, dirname(file.targetPath) === '.' ? '' : dirname(file.targetPath), true);
259
+ // shared
258
260
  await writeFile(join(stage, file.targetPath), file.bytes, { flag: 'wx', mode: 0o644 });
259
261
  }
262
+ // shared
260
263
  await writeFile(join(stage, RECEIPT), JSON.stringify(record), { flag: 'wx', mode: 0o644 });
264
+ // shared
261
265
  await writeFile(join(stage, ATTRIBUTES), SNAPSHOT_ATTRIBUTES, { flag: 'wx', mode: 0o644 });
262
266
  await chmod(stage, 0o755);
263
267
  assertCurrent();
@@ -276,6 +280,7 @@ async function importReference(root, directoryName, input, assertCurrent) {
276
280
  await safeDirectory(rootReal, '.playcraft', true);
277
281
  const temporary = join(rootReal, '.playcraft', `.remix-references-${randomUUID()}.tmp`);
278
282
  try {
283
+ // shared
279
284
  await writeFile(temporary, serialized, { flag: 'wx', mode: 0o644 });
280
285
  assertCurrent();
281
286
  await safeDirectory(rootReal, '.playcraft');
@@ -0,0 +1,187 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { chmod, chown, lstat, mkdir, open, rename, rm, } from 'node:fs/promises';
4
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
5
+ /**
6
+ * Cloud workspaces are shared by two Linux identities: the controller (`playcraft-control`) and the
7
+ * development user that runs agents and builds (`playcraft-dev`). Every inode either of them must
8
+ * read or write has to carry the development group plus group read/write (and setgid on
9
+ * directories, so new entries inherit that group).
10
+ *
11
+ * Only the owner of an inode can chmod/chown it, so a call that finds a foreign owner leaves the
12
+ * entry alone instead of failing the operation; `repairWorkspaceShares` re-runs the same shaping as
13
+ * the owning identity.
14
+ */
15
+ export const SHARED_DIRECTORY_MODE = 0o2770;
16
+ export const SHARED_FILE_MODE = 0o660;
17
+ const PERMISSION_BITS = 0o7777;
18
+ const OWNER_ONLY_ERRORS = new Set(['EPERM', 'EACCES']);
19
+ /** The development group, read from the workspace container that owns the tree. */
20
+ export async function workspaceGroup(workspaceRoot) {
21
+ const stat = await lstat(workspaceRoot);
22
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
23
+ throw new Error('invalid_workspace_directory');
24
+ }
25
+ return stat.gid;
26
+ }
27
+ function currentUid() {
28
+ return typeof process.getuid === 'function' ? process.getuid() : undefined;
29
+ }
30
+ /** Another identity owns this inode; only its owner can re-shape it. */
31
+ function canShape(owner) {
32
+ const current = currentUid();
33
+ return current === undefined || current === 0 || owner === current;
34
+ }
35
+ function isOwnerOnly(error) {
36
+ const code = error?.code;
37
+ return typeof code === 'string' && OWNER_ONLY_ERRORS.has(code);
38
+ }
39
+ async function shapePath(path, groupId, mode, union = false) {
40
+ const stat = await lstat(path);
41
+ if (stat.isSymbolicLink())
42
+ throw new Error('invalid_shared_path');
43
+ const desired = union ? (stat.mode & 0o777) | mode : mode;
44
+ if (stat.gid === groupId && (stat.mode & PERMISSION_BITS) === desired)
45
+ return false;
46
+ if (!canShape(stat.uid))
47
+ return false;
48
+ try {
49
+ if (stat.gid !== groupId)
50
+ await chown(path, stat.uid, groupId);
51
+ await chmod(path, desired);
52
+ return true;
53
+ }
54
+ catch (error) {
55
+ if (isOwnerOnly(error))
56
+ return false;
57
+ throw error;
58
+ }
59
+ }
60
+ async function shapeHandle(handle, groupId, mode) {
61
+ const stat = await handle.stat();
62
+ if (stat.gid === groupId && (stat.mode & PERMISSION_BITS) === mode)
63
+ return false;
64
+ if (!canShape(stat.uid))
65
+ return false;
66
+ try {
67
+ if (stat.gid !== groupId)
68
+ await handle.chown(stat.uid, groupId);
69
+ await handle.chmod(mode);
70
+ return true;
71
+ }
72
+ catch (error) {
73
+ if (isOwnerOnly(error))
74
+ return false;
75
+ throw error;
76
+ }
77
+ }
78
+ /** True when `target` is the root itself or lives below it. */
79
+ export function isInsideRoot(root, target) {
80
+ const base = resolve(root), path = resolve(target);
81
+ return path === base || path.startsWith(`${base}${sep}`);
82
+ }
83
+ /**
84
+ * Chain of directories below `root` down to `target`; empty when `target` is the root itself or
85
+ * outside it. The root is shared by checkout preparation, never as a side effect of a write.
86
+ */
87
+ function directoryChain(root, target) {
88
+ if (!isInsideRoot(root, target))
89
+ return [];
90
+ const parts = relative(resolve(root), resolve(target)).split(sep).filter(Boolean);
91
+ return parts.map((_, index) => resolve(root, ...parts.slice(0, index + 1)));
92
+ }
93
+ /**
94
+ * Shares one existing path with the development group. Returns whether the mode changed; an entry
95
+ * owned by the other identity is reported as unchanged because this process cannot re-shape it.
96
+ */
97
+ export async function shareIfNeeded(workspaceRoot, path) {
98
+ const groupId = await workspaceGroup(workspaceRoot);
99
+ const stat = await lstat(path);
100
+ if (stat.isSymbolicLink())
101
+ throw new Error('invalid_shared_path');
102
+ return shapePath(path, groupId, stat.isDirectory() ? SHARED_DIRECTORY_MODE : SHARED_FILE_MODE);
103
+ }
104
+ /**
105
+ * Shapes an existing path with an explicit group and mode. Used by the legacy helpers that receive
106
+ * the group from their caller (scratch trees and checkout preparation) so there is one implementation.
107
+ */
108
+ export async function sharePathWithGroup(path, groupId, mode, union = false) {
109
+ return shapePath(path, groupId, mode, union);
110
+ }
111
+ /** Shapes an already open file. Cross-owner failures are tolerated like `shareIfNeeded`. */
112
+ export async function shareHandleWithGroup(handle, groupId, mode) {
113
+ return shapeHandle(handle, groupId, mode);
114
+ }
115
+ /**
116
+ * Creates a directory tree both identities can use. Missing ancestors are created with the shared
117
+ * mode and every segment below `workspaceRoot` is re-checked, so a pre-existing private parent is
118
+ * repaired when this process owns it.
119
+ */
120
+ export async function ensureSharedDirectory(workspaceRoot, path) {
121
+ const root = resolve(workspaceRoot), target = resolve(path);
122
+ const groupId = await workspaceGroup(root);
123
+ const chain = directoryChain(root, target);
124
+ const shapes = isInsideRoot(root, target) ? chain : [target];
125
+ // Reject redirected ancestors before mkdir follows them.
126
+ for (const directory of chain) {
127
+ const stat = await lstat(directory).catch((error) => {
128
+ if (error.code === 'ENOENT')
129
+ return undefined;
130
+ throw error;
131
+ });
132
+ if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) {
133
+ throw new Error('invalid_shared_directory');
134
+ }
135
+ }
136
+ await mkdir(target, { recursive: true, mode: SHARED_DIRECTORY_MODE });
137
+ for (const directory of shapes) {
138
+ const stat = await lstat(directory);
139
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
140
+ throw new Error('invalid_shared_directory');
141
+ }
142
+ await shapePath(directory, groupId, SHARED_DIRECTORY_MODE);
143
+ }
144
+ return target;
145
+ }
146
+ /**
147
+ * Opens (optionally creating) a file the development group can read and write. The handle keeps
148
+ * the descriptor, so a concurrent `chmod` cannot redirect what is written.
149
+ */
150
+ export async function openSharedFile(workspaceRoot, path, flags) {
151
+ const groupId = await workspaceGroup(workspaceRoot);
152
+ const handle = await open(path, flags, SHARED_FILE_MODE);
153
+ try {
154
+ const stat = await handle.stat();
155
+ if (!stat.isFile())
156
+ throw new Error('invalid_shared_file');
157
+ await shapeHandle(handle, groupId, SHARED_FILE_MODE);
158
+ return handle;
159
+ }
160
+ catch (error) {
161
+ await handle.close().catch(() => undefined);
162
+ throw error;
163
+ }
164
+ }
165
+ /** Writes through a temporary file in the same directory, then renames, so readers never see a partial file. */
166
+ export async function writeSharedFile(workspaceRoot, path, data) {
167
+ const root = resolve(workspaceRoot), target = resolve(path);
168
+ if (isInsideRoot(root, target))
169
+ await ensureSharedDirectory(root, dirname(target));
170
+ const groupId = await workspaceGroup(root);
171
+ const temporary = join(dirname(target), `.${basename(target)}.${randomUUID()}.tmp`);
172
+ try {
173
+ const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, SHARED_FILE_MODE);
174
+ try {
175
+ await shapeHandle(handle, groupId, SHARED_FILE_MODE);
176
+ await handle.writeFile(data);
177
+ await handle.sync();
178
+ }
179
+ finally {
180
+ await handle.close();
181
+ }
182
+ await rename(temporary, target);
183
+ }
184
+ finally {
185
+ await rm(temporary, { force: true });
186
+ }
187
+ }
@@ -1,5 +1,5 @@
1
- import { createHash, randomUUID } from 'node:crypto';
2
- import { cp, lstat, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
1
+ import { createHash } from 'node:crypto';
2
+ import { cp, lstat, readFile, readdir, rm, writeFile } from 'node:fs/promises';
3
3
  import { basename, dirname, join } from 'node:path';
4
4
  import { load } from 'js-yaml';
5
5
  import { canonicalProjectSkillsJson, createCanonicalBundleArchive, effectiveProjectSkills, extractBundleArchive, inspectProjectSkills, readProjectSkillSource, readProjectSkillsLockFile, sourceTreeDigest, validateProjectSkillsSnapshot, } from '@playcraft/common/project-skills';
@@ -9,6 +9,7 @@ import { selectPlatformSkillSnapshot } from '../../project-skills/builtin-import
9
9
  import { EMPTY_WORKSPACE_REPOSITORY_SKILLS, EMPTY_WORKSPACE_PLATFORM_SKILLS } from './empty-template.js';
10
10
  import { excludeFromGit } from './dependencies.js';
11
11
  import { selectOptionalPlatformSkills } from './optional-platform-skills.js';
12
+ import { ensureSharedDirectory, writeSharedFile } from './shared-write.js';
12
13
  /** Runtime availability is determined by the locked metadata, independent of unsynced source edits. */
13
14
  export async function readWorkspaceSkillSnapshot(root) {
14
15
  const management = await lstat(join(root, '.playcraft'));
@@ -42,7 +43,7 @@ export async function ensureWorkspaceSkills(root, cache, access, options = {}) {
42
43
  continue;
43
44
  await safeDirectory(root, '.agents/skills');
44
45
  const source = `.agents/skills/${name}`;
45
- await mkdir(join(root, source), { mode: 0o755 });
46
+ await ensureSharedDirectory(root, join(root, source));
46
47
  added.add(source);
47
48
  const template = join(resolveCliPackageRoot(), 'project-template/.claude/skills', name);
48
49
  for (const child of await readdir(template))
@@ -61,12 +62,16 @@ async function safeDirectory(root, path) {
61
62
  let current = root;
62
63
  for (const segment of path.split('/')) {
63
64
  current = join(current, segment);
64
- await mkdir(current, { mode: 0o755 }).catch(error => { if (error.code !== 'EEXIST')
65
- throw error; });
66
- const stat = await lstat(current);
67
- if (!stat.isDirectory() || stat.isSymbolicLink())
65
+ // Validate every existing segment before creating anything, so a link never redirects the tree.
66
+ const stat = await lstat(current).catch((error) => {
67
+ if (error.code === 'ENOENT')
68
+ return undefined;
69
+ throw error;
70
+ });
71
+ if (stat && (!stat.isDirectory() || stat.isSymbolicLink()))
68
72
  throw new Error('workspace_skills_invalid');
69
73
  }
74
+ await ensureSharedDirectory(root, current);
70
75
  return current;
71
76
  }
72
77
  /** Cache misses may reconstruct only the exact locked repository bytes, never a newer source. */
@@ -111,14 +116,7 @@ async function lockedFiles(root, cache, entry, view, loadArtifact) {
111
116
  throw new Error('workspace_skills_invalid_bundle');
112
117
  if (entry.revision.kind === 'source' && sourceTreeDigest(extracted.files.map(file => ({ path: file.path, content: file.data }))) !== entry.sourceTreeHash)
113
118
  throw new Error('workspace_skills_digest_mismatch');
114
- const temporary = `${saved}.${randomUUID()}.tmp`;
115
- try {
116
- await writeFile(temporary, archive, { flag: 'wx', mode: 0o644 });
117
- await rename(temporary, saved);
118
- }
119
- finally {
120
- await rm(temporary, { force: true });
121
- }
119
+ await writeSharedFile(root, saved, archive);
122
120
  return extracted.files;
123
121
  }
124
122
  function metadata(entryKey, content) {
@@ -155,6 +153,7 @@ export async function freezeWorkspaceSkills(root, cache, loadArtifact) {
155
153
  const relative = join(entry.entryKey, file.path);
156
154
  expectedFiles.add(relative);
157
155
  await safeDirectory(generation, dirname(relative));
156
+ // shared
158
157
  await writeFile(target, file.data, { flag: 'wx', mode: 0o444 }).catch(async (error) => {
159
158
  if (error.code !== 'EEXIST')
160
159
  throw error;