@playcraft/cli 0.1.55-beta → 0.1.57-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 (43) hide show
  1. package/dist/assets/client.js +6 -4
  2. package/dist/commands/build.js +38 -0
  3. package/dist/commands/tools-3d.js +15 -0
  4. package/dist/commands/tools-generation.js +3 -2
  5. package/dist/utils/agent-api-client.js +31 -8
  6. package/dist/utils/agent-generation-provenance.js +45 -0
  7. package/dist/utils/generation-workspace-scope.js +50 -0
  8. package/dist/utils/image-generation-retry.js +7 -0
  9. package/dist/utils/tool-operation-journal.js +34 -4
  10. package/dist/workspace-runtime/batch/prepare.js +9 -52
  11. package/dist/workspace-runtime/codex/delivery-tools.js +7 -5
  12. package/dist/workspace-runtime/codex/generation-provenance-broker.js +80 -0
  13. package/dist/workspace-runtime/codex/launcher-client.js +18 -0
  14. package/dist/workspace-runtime/codex/model-broker.js +23 -4
  15. package/dist/workspace-runtime/previews/finalize.js +64 -0
  16. package/dist/workspace-runtime/previews/publish.js +31 -8
  17. package/dist/workspace-runtime/serve.js +8 -30
  18. package/dist/workspace-runtime/server/http.js +5 -5
  19. package/dist/workspace-runtime/workspaces/artifact-events.js +136 -23
  20. package/dist/workspace-runtime/workspaces/artifact-files.js +3 -1
  21. package/dist/workspace-runtime/workspaces/artifact-receipts.js +27 -4
  22. package/dist/workspace-runtime/workspaces/artifacts.js +115 -17
  23. package/dist/workspace-runtime/workspaces/asset-publication-access.js +5 -1
  24. package/dist/workspace-runtime/workspaces/delivery-build.js +97 -7
  25. package/dist/workspace-runtime/workspaces/dependencies.js +24 -10
  26. package/dist/workspace-runtime/workspaces/directory-access.js +13 -0
  27. package/dist/workspace-runtime/workspaces/git.js +13 -0
  28. package/dist/workspace-runtime/workspaces/parameter-assets.js +38 -15
  29. package/dist/workspace-runtime/workspaces/parameters.js +61 -21
  30. package/dist/workspace-runtime/workspaces/platform-export.js +185 -0
  31. package/dist/workspace-runtime/workspaces/theme-asset-index.js +123 -13
  32. package/package.json +3 -3
  33. package/project-template-v2/.playcraft/skills.lock.json +15 -8
  34. package/project-templates/empty/AGENTS.md +1 -1
  35. package/templates/preview-runtime/v1/README.md +12 -0
  36. package/templates/preview-runtime/v1/manifest.json +8 -8
  37. package/templates/preview-runtime/v1/runtime.d.mts +1 -1
  38. package/templates/preview-runtime/v1/runtime.d.ts +1 -1
  39. package/templates/preview-runtime/v1/runtime.mjs +591 -54
  40. package/templates/preview-runtime/v1/types/adapters.d.ts +6 -1
  41. package/templates/preview-runtime/v1/types/preview.d.ts +2 -0
  42. package/templates/preview-runtime/v1/types/protocol.d.ts +56 -0
  43. package/templates/preview-runtime/v1/types/runtime.d.ts +2 -1
@@ -120,7 +120,9 @@ export async function downloadAssetTask(client, scope, taskId, output, versionId
120
120
  const staging = await mkdtemp(join(dirname(destination), '.playcraft-asset-'));
121
121
  let createdDestination = false;
122
122
  try {
123
- const files = bundle?.files ?? [{ ...entry, path: 'output', fileId: undefined }];
123
+ const multiple = !bundle && !versionId && Boolean(task.outputs);
124
+ const directory = Boolean(bundle || multiple);
125
+ const files = bundle?.files ?? (multiple ? task.outputs.map(file => ({ ...file, path: file.name })) : [{ ...entry, path: 'output', fileId: undefined }]);
124
126
  const paths = [];
125
127
  for (const file of files) {
126
128
  const temporary = resolve(staging, file.path);
@@ -128,11 +130,11 @@ export async function downloadAssetTask(client, scope, taskId, output, versionId
128
130
  if (!inside || inside === '..' || inside.startsWith(`..${sep}`))
129
131
  throw new Error(t('invalidContent'));
130
132
  await mkdir(dirname(temporary), { recursive: true });
131
- await client.download(assetContentRoute({ ...scope, taskId, ...(bundle ? { fileId: file.fileId } : {}), ...(versionId ? { versionId } : {}) }), temporary);
133
+ await client.download(assetContentRoute({ ...scope, taskId, ...(directory ? { fileId: file.fileId } : {}), ...(versionId ? { versionId } : {}) }), temporary);
132
134
  await verifyFile(temporary, file);
133
- paths.push(bundle ? join(destination, file.path) : destination);
135
+ paths.push(directory ? join(destination, file.path) : destination);
134
136
  }
135
- if (bundle) {
137
+ if (directory) {
136
138
  // mkdir is exclusive; an existing directory or symbolic link cannot be replaced.
137
139
  await mkdir(destination);
138
140
  createdDestination = true;
@@ -6,6 +6,10 @@ import { BaseBuilder, ViteBuilder, PlayableBuilder, PlayableAnalyzer, EngineDete
6
6
  import { loadBuildConfig } from '../build-config.js';
7
7
  import { cleanupBuildTempDirectory, createBuildTempDirectory } from './build-temp.js';
8
8
  import inquirer from 'inquirer';
9
+ import { tmpdir } from 'node:os';
10
+ import { generatedWorkspace } from '../workspace-runtime/workspaces/artifact-receipts.js';
11
+ import { isolateWorkspaceForBuild } from '../workspace-runtime/workspaces/delivery-build.js';
12
+ import { finalizeWorkspacePlayable, workspacePlayableSignature } from '../workspace-runtime/previews/finalize.js';
9
13
  /**
10
14
  * 类型守卫:检查对象是否符合 PlayableScriptsConfig 类型
11
15
  */
@@ -245,6 +249,38 @@ async function analyzeBaseBuild(baseBuildDir, ammoReplacement) {
245
249
  export async function buildCommand(projectPath, options) {
246
250
  // 解析项目路径 - 在 spinner 之前执行以便调试
247
251
  const resolvedProjectPath = path.resolve(projectPath);
252
+ const workspace = options.workspaceArtifact !== false && process.env.PLAYCRAFT_BUILD_ISOLATED !== '1' && options.mode === 'full' && options.platform === 'playcraft'
253
+ && (!options.format || options.format === 'html') ? await generatedWorkspace(resolvedProjectPath) : undefined;
254
+ const requestedOutput = options.output ? path.resolve(options.output) : path.join(resolvedProjectPath, 'dist');
255
+ const canonicalOutput = [path.join(resolvedProjectPath, 'dist'), path.join(resolvedProjectPath, 'dist/playable')].includes(requestedOutput);
256
+ if (workspace?.root === resolvedProjectPath) {
257
+ const prefix = requestedOutput.endsWith(path.sep) ? requestedOutput : `${requestedOutput}${path.sep}`;
258
+ if (requestedOutput === workspace.root || workspace.root.startsWith(prefix))
259
+ throw new Error('invalid_build_output');
260
+ const scratch = await fs.mkdtemp(path.join(tmpdir(), 'playcraft-workspace-build-'));
261
+ try {
262
+ const signature = canonicalOutput ? await workspacePlayableSignature(workspace.root) : undefined;
263
+ const project = path.join(scratch, 'project'), output = path.join(scratch, 'output');
264
+ await isolateWorkspaceForBuild(workspace.root, project);
265
+ await buildCommand(project, { ...options, workspaceArtifact: false, output });
266
+ if (canonicalOutput) {
267
+ const entries = await fs.readdir(output, { withFileTypes: true });
268
+ if (entries.length !== 1 || !entries[0].isFile() || !entries[0].name.endsWith('.html'))
269
+ throw new Error('preview_not_single_file');
270
+ await finalizeWorkspacePlayable(workspace.root, await fs.readFile(path.join(output, entries[0].name)), 'playcraft build', { expectedSignature: signature });
271
+ }
272
+ else {
273
+ if (options.clean === true)
274
+ await fs.rm(requestedOutput, { recursive: true, force: true });
275
+ await fs.cp(output, requestedOutput, { recursive: true });
276
+ }
277
+ console.log(canonicalOutput ? path.join(workspace.root, 'dist/playable/index.html') : requestedOutput);
278
+ return;
279
+ }
280
+ finally {
281
+ await fs.rm(scratch, { recursive: true, force: true });
282
+ }
283
+ }
248
284
  console.log(`[DEBUG] projectPath: ${projectPath}`);
249
285
  console.log(`[DEBUG] resolvedProjectPath: ${resolvedProjectPath}`);
250
286
  console.log(`[DEBUG] cwd: ${process.cwd()}`);
@@ -982,6 +1018,8 @@ export async function buildCommand(projectPath, options) {
982
1018
  if (error.stack) {
983
1019
  console.error(pc.dim(error.stack));
984
1020
  }
1021
+ if (options.workspaceArtifact === false)
1022
+ throw error;
985
1023
  process.exit(1);
986
1024
  }
987
1025
  }
@@ -46,6 +46,7 @@ export function register3DCommands(tools) {
46
46
  .description('Generate a 3D model from a text prompt or input image (uses HunyuanTo3D or configured provider)')
47
47
  .option('--prompt <text>', 'Text description of the 3D model to generate')
48
48
  .option('--input <path>', 'Input image (local path or URL) for image-to-3D generation')
49
+ .option('--view-image <view=path>', 'Additional local reference for hy-3d-3.1: left|right|back|top|bottom|left_front|right_front; repeat for multiple views', (value, previous) => [...previous, value], [])
49
50
  .requiredOption('--output <path>', 'Output model file path')
50
51
  .option('--model <ref>', 'Model ref in provider/model-id format (e.g. tencent-cloud/hy-3d-3.1)')
51
52
  .option('--format <fmt>', 'Output format: glb|obj|stl|fbx|usdz (default: glb)', 'glb')
@@ -77,6 +78,20 @@ export function register3DCommands(tools) {
77
78
  payload.enablePBR = true;
78
79
  if (opts.polygonType)
79
80
  payload.polygonType = opts.polygonType;
81
+ if (opts.viewImage.length) {
82
+ const views = new Set(['left', 'right', 'back', 'top', 'bottom', 'left_front', 'right_front']);
83
+ const seen = new Set();
84
+ if (!opts.input || opts.viewImage.length > 7 || opts.model && opts.model !== 'tencent-cloud/hy-3d-3.1')
85
+ throw new Error('--view-image requires --input and hy-3d-3.1 (at most 7 unique views)');
86
+ payload.multiViewImages = opts.viewImage.map((value) => {
87
+ const separator = value.indexOf('='), view = value.slice(0, separator), imagePath = value.slice(separator + 1);
88
+ if (separator < 1 || !imagePath || !views.has(view) || seen.has(view))
89
+ throw new Error('--view-image expects a unique view=path, e.g. back=./back.png');
90
+ seen.add(view);
91
+ return { view: view,
92
+ imageBase64: readFileSync(imagePath).toString('base64'), mimeType: mimeTypeForImagePath(imagePath) };
93
+ });
94
+ }
80
95
  if (opts.input) {
81
96
  let inputImageBase64;
82
97
  let inputImageMimeType = opts.inputMime;
@@ -6,6 +6,7 @@ import { captureGeneratedArtifact } from '../workspace-runtime/workspaces/artifa
6
6
  import { resolveImageBackgroundRequirement, validateImageDeliveryOptions } from '@playcraft/common/prefab';
7
7
  import { assertImageAssetOutputPaths, readImageAssetContext } from '../utils/image-asset-context.js';
8
8
  import { AgentApiClient } from '../utils/agent-api-client.js';
9
+ import { isUnsafeImageRetryError } from '../utils/image-generation-retry.js';
9
10
  import { MAX_REFERENCE_IMAGES, collectReferenceImagePaths, parseImageTargetFieldPath, collectReferenceImagePayloads, sniffImageExtension, extensionForImageMime, resolveImageOutputPath, handleError, raceWithTimeout, } from './tools-utils.js';
10
11
  function isMasterCompositeStripRequest(opts) {
11
12
  if (opts.aspectRatio === '45:16')
@@ -285,7 +286,7 @@ export function registerGenerationCommands(tools) {
285
286
  lastError = e;
286
287
  const msg = e instanceof Error ? e.message : String(e);
287
288
  const isTimeout = msg.includes('timed out');
288
- const cannotRetry = /source_transparency_invalid|output_transparency_invalid|transparency_unsupported|"retryable"\s*:\s*false/.test(msg);
289
+ const cannotRetry = isUnsafeImageRetryError(e);
289
290
  if (isTimeout || cannotRetry) {
290
291
  console.error(`Attempt ${attempt + 1} stopped: ${msg}`);
291
292
  break;
@@ -433,7 +434,7 @@ export function registerGenerationCommands(tools) {
433
434
  tools.command('generate-sfx')
434
435
  .description('AI 生成音效(SFX),使用 ElevenLabs Sound Effects')
435
436
  .requiredOption('--prompt <text>', '音效描述,仅英文,如 "crisp UI click, short tail, no voice"')
436
- .option('--duration <seconds>', '时长(秒,0.5-30)', parseFloat)
437
+ .option('--duration <seconds>', '时长(秒,0.5-22;省略时由供应商自动决定)', parseFloat)
437
438
  .option('--loop', '生成可循环的音效', false)
438
439
  .requiredOption('--output <path>', '保存路径,如 ./assets/audio/click.mp3')
439
440
  .action(async (opts) => {
@@ -9,8 +9,10 @@ import { homedir } from 'os';
9
9
  import { loadGlobalConfig } from '../config.js';
10
10
  import { DEFAULT_PLAYCRAFT_CLOUD_BASE } from '../commands/login.js';
11
11
  import { AGENT_TOOL_OPERATION_PROTOCOL, AGENT_TOOL_OPERATION_HEADERS, AGENT_TOOL_OPERATION_CAPABILITIES_PATH, } from '@playcraft/common/agent-tools';
12
- import { beginToolOperation } from './tool-operation-journal.js';
12
+ import { beginToolOperation, findToolOperation } from './tool-operation-journal.js';
13
13
  import { businessEndpoint } from './environment-endpoints.js';
14
+ import { cloudRuntimeCredentialHint, isWorkspaceGenerationPath, withGenerationWorkspaceScope } from './generation-workspace-scope.js';
15
+ import { agentGenerationProvenanceHeaders } from './agent-generation-provenance.js';
14
16
  import { redactDiagnosticText } from './error-diagnostics.js';
15
17
  import { AccountActivationError, parseErrorPayload, pendingAccountActivationError, } from '../auth/account-activation.js';
16
18
  /**
@@ -37,6 +39,7 @@ export class AgentApiClient {
37
39
  token;
38
40
  credentialFile;
39
41
  playcraftEnv;
42
+ cloudRuntimeCredential;
40
43
  constructor() {
41
44
  const localConfig = AgentApiClient.loadLocalConfig();
42
45
  const globalConfig = loadGlobalConfig();
@@ -59,6 +62,7 @@ export class AgentApiClient {
59
62
  this.token = token;
60
63
  this.credentialFile = localConfig.credentialFile;
61
64
  this.playcraftEnv = (process.env.PLAYCRAFT_ENV || localConfig.env || '').trim();
65
+ this.cloudRuntimeCredential = cloudRuntimeCredentialHint(token, token === localConfig.token ? localConfig : undefined);
62
66
  }
63
67
  /** 根据 token 类型构造鉴权 headers */
64
68
  authHeaders() {
@@ -85,12 +89,26 @@ export class AgentApiClient {
85
89
  }
86
90
  async post(path, body, options) {
87
91
  const url = this.toolsRequestUrl(path);
88
- const serializedBody = JSON.stringify(body);
89
- if (serializedBody === undefined)
92
+ const originalBody = JSON.stringify(body);
93
+ if (originalBody === undefined)
90
94
  throw new Error('Tool request body is required');
91
95
  const recoverId = options?.requestId ?? process.env.PLAYCRAFT_TOOL_REQUEST_ID;
96
+ const newRequest = options?.newRequest ?? process.env.PLAYCRAFT_TOOL_NEW_REQUEST === '1';
97
+ const originalReceipt = recoverId !== undefined && !newRequest && isWorkspaceGenerationPath(path)
98
+ ? findToolOperation(url.pathname, originalBody, recoverId) : undefined;
99
+ let serializedBody = originalReceipt ? originalBody : JSON.stringify(await withGenerationWorkspaceScope(path, body, this.cloudRuntimeCredential));
100
+ if (serializedBody === undefined)
101
+ throw new Error('Tool request body is required');
102
+ if (serializedBody !== originalBody && !newRequest) {
103
+ const previous = findToolOperation(url.pathname, originalBody, recoverId);
104
+ if (previous && recoverId !== undefined)
105
+ serializedBody = originalBody;
106
+ else if (previous)
107
+ throw new Error(`Previous unscoped tool request ${previous.requestId} is still unconfirmed. ` +
108
+ `Rerun this command with PLAYCRAFT_TOOL_REQUEST_ID=${previous.requestId} to read its settled receipt before starting a workspace generation. No generation was sent.`);
109
+ }
92
110
  const operation = beginToolOperation(url.pathname, serializedBody, recoverId, {
93
- newRequest: options?.newRequest ?? process.env.PLAYCRAFT_TOOL_NEW_REQUEST === '1',
111
+ newRequest,
94
112
  });
95
113
  const unknownOutcome = `Tool request ${operation.requestId}: the outcome is unknown. Rerun the same command to ` +
96
114
  `continue this request: it returns the result if the request finished, or finishes it if ` +
@@ -135,6 +153,7 @@ export class AgentApiClient {
135
153
  headers: {
136
154
  'Content-Type': 'application/json',
137
155
  ...this.authHeaders(),
156
+ ...await agentGenerationProvenanceHeaders(path, serializedBody, operation.requestId, this.cloudRuntimeCredential, options?.signal),
138
157
  [AGENT_TOOL_OPERATION_HEADERS.protocol]: AGENT_TOOL_OPERATION_PROTOCOL,
139
158
  [AGENT_TOOL_OPERATION_HEADERS.requestId]: operation.requestId,
140
159
  ...(operation.settledOnly ? { [AGENT_TOOL_OPERATION_HEADERS.settledOnly]: 'true' } : {}),
@@ -163,11 +182,15 @@ export class AgentApiClient {
163
182
  const pending = pendingAccountActivationError(res.status, parseErrorPayload(text));
164
183
  if (pending)
165
184
  throw pending;
185
+ if (isWorkspaceGenerationPath(path) && res.headers.get('x-playcraft-generation-outcome') === 'failed') {
186
+ operation.reject();
187
+ throw new ToolRequestError(`API ${res.status}: ${text}`);
188
+ }
166
189
  const code = errorCode(text);
167
- if (res.status === 409 && code === 'business_operation_in_progress')
190
+ if (res.status === 409 && (code === 'business_operation_in_progress' || code === 'asset_generation_in_progress'))
168
191
  throw new ToolRequestError(`API 409: ${text}\nTool request ${operation.requestId} is still running. Rerun the ` +
169
192
  'same command later to get its result.');
170
- if (res.status === 409 && code === 'business_operation_requires_reconciliation')
193
+ if (res.status === 409 && (code === 'business_operation_requires_reconciliation' || code === 'asset_generation_outcome_unknown'))
171
194
  throw new ToolRequestError(recoverId !== undefined
172
195
  ? // A receipt query never runs the request, even for a tool that can be safely redone.
173
196
  `API 409: ${text}\nTool request ${operation.requestId} has no settled result yet. ` +
@@ -331,8 +354,8 @@ function responseErrorReason(text) {
331
354
  }
332
355
  function errorCode(text) {
333
356
  try {
334
- const message = JSON.parse(text).message;
335
- return typeof message === 'string' ? message : undefined;
357
+ const payload = JSON.parse(text);
358
+ return typeof payload.code === 'string' ? payload.code : typeof payload.message === 'string' ? payload.message : undefined;
336
359
  }
337
360
  catch {
338
361
  return undefined;
@@ -0,0 +1,45 @@
1
+ import { hashOperationPayload, AGENT_GENERATION_PROVENANCE_HEADER } from '@playcraft/common/cloud-workspace/runtime';
2
+ import { isWorkspaceGenerationPath } from './generation-workspace-scope.js';
3
+ /** Attribution is optional; only the controlled local broker can attest an execution. */
4
+ export async function agentGenerationProvenanceHeaders(path, body, requestId, cloudRuntime, signal) {
5
+ if (!cloudRuntime)
6
+ return {};
7
+ const normalized = path.startsWith('/') ? path : `/${path}`;
8
+ let payload;
9
+ try {
10
+ payload = JSON.parse(body);
11
+ }
12
+ catch {
13
+ return {};
14
+ }
15
+ const assetCreate = normalized === '/workspace-assets' && payload?.action === 'create';
16
+ if (!isWorkspaceGenerationPath(normalized) && !assetCreate)
17
+ return {};
18
+ const endpoint = process.env.PLAYCRAFT_AGENT_GENERATION_ENDPOINT;
19
+ const capability = process.env.PLAYCRAFT_AGENT_GENERATION_CAPABILITY;
20
+ if (!endpoint || !capability || capability.length > 128 || endpoint.length > 1024)
21
+ return {};
22
+ try {
23
+ const url = new URL(endpoint);
24
+ if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || !url.port || url.username || url.password
25
+ || url.search || url.hash || !/\/[0-9a-f-]{36}\/[0-9a-f]{32}\/generation-provenance$/.test(url.pathname))
26
+ return {};
27
+ const response = await fetch(url, {
28
+ method: 'POST', redirect: 'error',
29
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000),
30
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${capability}` },
31
+ body: JSON.stringify({ toolPath: `/agent/tools${normalized}`,
32
+ toolRequestId: assetCreate ? payload.clientRequestId : requestId, payloadHash: hashOperationPayload(payload) }),
33
+ });
34
+ if (!response.ok) {
35
+ await response.body?.cancel();
36
+ return {};
37
+ }
38
+ const value = await response.json();
39
+ return typeof value.proof === 'string' && value.proof.length > 0 && value.proof.length <= 8192
40
+ ? { [AGENT_GENERATION_PROVENANCE_HEADER]: value.proof } : {};
41
+ }
42
+ catch {
43
+ return {};
44
+ }
45
+ }
@@ -0,0 +1,50 @@
1
+ import { basename, dirname } from 'node:path';
2
+ import { workspaceDirectoryRegistrationSchema } from '@playcraft/common/cloud-workspace/runtime';
3
+ import { generatedWorkspace } from '../workspace-runtime/workspaces/artifact-receipts.js';
4
+ const generationPaths = new Set([
5
+ '/generate-image', '/generate-sfx', '/generate-bgm', '/generate-3d',
6
+ '/generate-texture', '/generate-motion', '/generate-avatar',
7
+ ]);
8
+ const workspaceScopeSchema = workspaceDirectoryRegistrationSchema.pick({ workspaceId: true, directoryName: true });
9
+ export function isWorkspaceGenerationPath(path) {
10
+ return generationPaths.has(path.startsWith('/') ? path : `/${path}`);
11
+ }
12
+ /** Credential metadata only selects routing; the business service still authenticates and authorizes it. */
13
+ export function cloudRuntimeCredentialHint(token, metadata) {
14
+ if (token.startsWith('playcraft_pat_'))
15
+ return false;
16
+ if (metadata?.kind === 'cloud-runtime-cli' || typeof metadata?.runtimeId === 'string' && metadata.runtimeId.trim())
17
+ return true;
18
+ const parts = token.split('.');
19
+ if (parts.length !== 3 || parts[1].length > 16 * 1024)
20
+ return false;
21
+ try {
22
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
23
+ return Boolean(payload && typeof payload === 'object' && 'kind' in payload && payload.kind === 'cloud-runtime-cli');
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ /** Add the current engineering checkout before the durable operation hashes its request. */
30
+ export async function withGenerationWorkspaceScope(path, body, requiresWorkspace, cwd = process.cwd()) {
31
+ if (!isWorkspaceGenerationPath(path))
32
+ return body;
33
+ const context = await generatedWorkspace(cwd);
34
+ if (!context) {
35
+ if (requiresWorkspace)
36
+ throw new Error('Cloud workspace generation requires a current engineering directory; run this command from basic or a remix directory. No generation was sent.');
37
+ return body;
38
+ }
39
+ const parsed = workspaceScopeSchema.safeParse({ workspaceId: context.workspaceId, directoryName: basename(context.root) });
40
+ if (!parsed.success || basename(dirname(context.root)) !== context.workspaceId)
41
+ throw new Error('Cloud workspace generation requires a valid basic or remix checkout under its workspace. No generation was sent.');
42
+ if (!body || typeof body !== 'object' || Array.isArray(body))
43
+ throw new Error('Tool generation request must be an object');
44
+ if ('workspace' in body && body.workspace !== undefined) {
45
+ const explicit = workspaceScopeSchema.safeParse(body.workspace);
46
+ if (!explicit.success || explicit.data.workspaceId !== parsed.data.workspaceId || explicit.data.directoryName !== parsed.data.directoryName)
47
+ throw new Error('Tool generation workspace does not match the current engineering directory. No generation was sent.');
48
+ }
49
+ return { ...body, workspace: parsed.data };
50
+ }
@@ -0,0 +1,7 @@
1
+ /** An uncertain generation may still be billed; retrying must never create another operation. */
2
+ export function isUnsafeImageRetryError(error) {
3
+ const message = error instanceof Error ? error.message : String(error);
4
+ return /source_transparency_invalid|output_transparency_invalid|transparency_unsupported|"retryable"\s*:\s*false/.test(message)
5
+ || /business_operation_(?:in_progress|requires_reconciliation)|asset_generation_(?:in_progress|outcome_unknown)/.test(message)
6
+ || /outcome is unknown|outcome remains unconfirmed|still unconfirmed|needs reconciliation|no settled result|settled result is not available/i.test(message);
7
+ }
@@ -97,9 +97,40 @@ export function beginToolOperation(path, serializedBody, recoverId, options = {}
97
97
  release: () => inFlight.delete(requestId),
98
98
  };
99
99
  }
100
- function findUnconfirmed(directory, path, inputHash) {
100
+ /** Read an existing identity without creating an intent or changing its receipt state. */
101
+ export function findToolOperation(path, serializedBody, requestId) {
102
+ const directory = process.env.PLAYCRAFT_TOOL_REQUEST_DIR ?? join(homedir(), '.playcraft', 'tool-requests');
103
+ if (!isAbsolute(directory))
104
+ throw new Error('PLAYCRAFT_TOOL_REQUEST_DIR must be an absolute path');
105
+ const inputHash = createHash('sha256').update(serializedBody).digest('hex');
106
+ if (requestId === undefined)
107
+ return findUnconfirmed(directory, path, inputHash, true);
108
+ if (!isAgentToolRequestId(requestId))
109
+ return undefined;
110
+ try {
111
+ const entry = JSON.parse(readFileSync(join(directory, `${requestId.toLowerCase()}.json`), 'utf8'));
112
+ if (entry.version === 1 && entry.requestId === requestId.toLowerCase() && entry.path === path && entry.inputHash === inputHash
113
+ && ['unconfirmed', 'completed', 'rejected'].includes(entry.state))
114
+ return entry;
115
+ }
116
+ catch (error) {
117
+ if (error.code !== 'ENOENT')
118
+ throw error;
119
+ }
120
+ return undefined;
121
+ }
122
+ function findUnconfirmed(directory, path, inputHash, includeActive = false) {
101
123
  let latest;
102
- for (const name of readdirSync(directory)) {
124
+ let files;
125
+ try {
126
+ files = readdirSync(directory);
127
+ }
128
+ catch (error) {
129
+ if (error.code === 'ENOENT')
130
+ return undefined;
131
+ throw error;
132
+ }
133
+ for (const name of files) {
103
134
  if (!name.endsWith('.json'))
104
135
  continue;
105
136
  try {
@@ -112,8 +143,7 @@ function findUnconfirmed(directory, path, inputHash) {
112
143
  (!latest || entry.updatedAt > latest.updatedAt) &&
113
144
  // Still being sent, here or by another live process: that run is not interrupted.
114
145
  // Checked last, so only this command's candidates cost a process probe.
115
- !inFlight.has(entry.requestId) &&
116
- !holderAlive(entry.holder))
146
+ (includeActive || !inFlight.has(entry.requestId) && !holderAlive(entry.holder)))
117
147
  latest = entry;
118
148
  }
119
149
  catch {
@@ -5,23 +5,12 @@ import { randomUUID } from 'node:crypto';
5
5
  import { sandboxBatchMemberPrepareSchema, } from '@playcraft/common/cloud-workspace/runtime';
6
6
  import { registerWorkspaceDirectory } from '../workspaces/directories.js';
7
7
  import { workspaceContainerRoot } from '../workspaces/registry.js';
8
+ import { dependencyInstallCommand, planInstall } from '../workspaces/dependencies.js';
8
9
  import { copyCredentialHelpers, gitRead, gitWrite } from './git.js';
9
10
  import { restoreArchive } from './source-capture.js';
10
11
  import { groupExited, runProcessGroup } from './processes.js';
11
12
  import { filesystemObjectStore, memberStatus, readMember, readSnapshot, readStopMarker, serializeRun, sourceCacheDir, writeMember, } from './records.js';
12
13
  const BRANCH = /^remix\/draft-[a-f0-9]{16}$/;
13
- const LOCKS = [
14
- 'pnpm-lock.yaml',
15
- 'package-lock.json',
16
- 'npm-shrinkwrap.json',
17
- 'yarn.lock',
18
- 'bun.lock',
19
- 'bun.lockb',
20
- ];
21
- export function frozenInstallCommand() {
22
- return (process.env.PLAYCRAFT_BATCH_INSTALL_COMMAND ??
23
- 'pnpm install --frozen-lockfile --store-dir /var/cache/playcraft/pnpm-store --ignore-scripts');
24
- }
25
14
  function hashCommand(value) {
26
15
  return createHash('sha256').update(JSON.stringify(value)).digest('hex');
27
16
  }
@@ -117,43 +106,6 @@ async function resumeBranch(repositoryUrl, branchName, commit, destination, sour
117
106
  }
118
107
  await gitWrite(destination, ['checkout', '-B', branchName, commit]);
119
108
  }
120
- export async function assertMemberDependencies(root) {
121
- let manifest;
122
- try {
123
- manifest = await readFile(join(root, 'package.json'), 'utf8');
124
- }
125
- catch (error) {
126
- if (error.code === 'ENOENT')
127
- return 'skip';
128
- throw error;
129
- }
130
- let names = [];
131
- try {
132
- const pkg = JSON.parse(manifest);
133
- names = [...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {})];
134
- }
135
- catch {
136
- throw new Error('dependency_lock_mismatch');
137
- }
138
- const present = [];
139
- for (const lock of LOCKS)
140
- if (await lstat(join(root, lock)).catch(() => undefined))
141
- present.push(lock);
142
- if (present.length > 1)
143
- throw new Error('dependency_locks_conflict');
144
- if (present.length === 0)
145
- return 'skip';
146
- if (present[0] !== 'pnpm-lock.yaml')
147
- throw new Error('dependency_lock_mismatch');
148
- const lock = await readFile(join(root, 'pnpm-lock.yaml'), 'utf8');
149
- if (names.some((name) => !lock.includes(`${name}:`) &&
150
- !lock.includes(`/${name}@`) &&
151
- !lock.includes(`'${name}'`) &&
152
- !lock.includes(`"${name}"`))) {
153
- throw new Error('dependency_lock_mismatch');
154
- }
155
- return 'install';
156
- }
157
109
  export async function prepareBatchMember(state, body, options) {
158
110
  if (!body ||
159
111
  typeof body !== 'object' ||
@@ -241,9 +193,14 @@ export async function prepareBatchMember(state, body, options) {
241
193
  branchName: command.branchName,
242
194
  sourceCommit: command.resumeFromCommit ?? snapshot.commitHash,
243
195
  }, state.acceptedLeaseFence);
244
- const plan = await assertMemberDependencies(root);
245
- if (plan === 'install') {
246
- const installed = await runProcessGroup(root, frozenInstallCommand(), 'install', async (process) => {
196
+ const plan = await planInstall(root);
197
+ if (plan.kind === 'install') {
198
+ // Keep control-user installs script-free. pnpm validates the frozen manifest and lock.
199
+ const frozen = `${dependencyInstallCommand(state, { frozenLockfile: true })} --ignore-scripts`;
200
+ const installCommand = process.env.PLAYCRAFT_BATCH_INSTALL_COMMAND ?? (plan.hasLockfile
201
+ ? frozen
202
+ : `${dependencyInstallCommand(state)} --lockfile-only --ignore-scripts && ${frozen}`);
203
+ const installed = await runProcessGroup(root, installCommand, 'install', async (process) => {
247
204
  record = await writeMember(state, {
248
205
  ...record,
249
206
  processes: [...record.processes, process],
@@ -5,7 +5,7 @@ export const WORKSPACE_DELIVERY_TOOLS = [
5
5
  {
6
6
  type: 'function',
7
7
  name: 'playcraft_build_delivery',
8
- description: 'Start one formal channel delivery build. Returns build identifiers immediately; poll playcraft_get_delivery for status and validation results. This writes a delivery record under dist/delivery/.',
8
+ description: 'Export channel packages through the PlayCraft platform. This first saves the workspace (commit and push), then exports that commit; each channel reuses the options of its latest export on this branch, or the defaults when it has none. A workspace without a project first gets one, with a private repository, as its first save would. Returns build ids immediately; poll playcraft_get_delivery for status, checks and failure details.',
9
9
  inputSchema: {
10
10
  type: 'object',
11
11
  properties: {
@@ -24,7 +24,7 @@ export const WORKSPACE_DELIVERY_TOOLS = [
24
24
  {
25
25
  type: 'function',
26
26
  name: 'playcraft_get_delivery',
27
- description: 'Read the latest delivery build, or one build by id, including status, validation summary, error and a truncated build log.',
27
+ description: 'Read one export by build id, or the latest export of a channel (or of any channel) on this branch: status, current stage while running, channel check findings, and for a failed export the failed stage, command, exit code and the end of its stderr/stdout.',
28
28
  inputSchema: {
29
29
  type: 'object',
30
30
  properties: {
@@ -109,8 +109,9 @@ export class WorkspaceDeliveryTools {
109
109
  await this.reject(client, requestId, 'invalid_delivery_tool_arguments');
110
110
  return;
111
111
  }
112
+ // Sandbox builds are UUIDs; platform exports use the build queue's ids.
112
113
  if (input.buildId !== undefined && (typeof input.buildId !== 'string'
113
- || !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(input.buildId))) {
114
+ || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(input.buildId))) {
114
115
  await this.reject(client, requestId, 'invalid_delivery_tool_arguments');
115
116
  return;
116
117
  }
@@ -138,7 +139,8 @@ export class WorkspaceDeliveryTools {
138
139
  }
139
140
  }
140
141
  function errorReason(cause) {
141
- if (cause instanceof Error && 'code' in cause && typeof cause.code === 'string')
142
- return cause.code;
142
+ if (cause instanceof Error && 'code' in cause && typeof cause.code === 'string') {
143
+ return cause.message.startsWith(cause.code) ? cause.message.slice(0, 500) : cause.code;
144
+ }
143
145
  return 'delivery_tool_failed';
144
146
  }
@@ -0,0 +1,80 @@
1
+ import { z } from 'zod';
2
+ import { agentGenerationProvenanceRequestSchema, PLATFORM_ROUTES, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
3
+ import { prepareLauncherOrigin, verifyLauncherOrigin } from './launcher-client.js';
4
+ const requestSchema = agentGenerationProvenanceRequestSchema.pick({ toolPath: true, toolRequestId: true, payloadHash: true });
5
+ /** Only an unambiguous, newly launched shell in a controlled turn receives attribution. */
6
+ export class WorkspaceGenerationProvenanceBroker {
7
+ state;
8
+ constructor(state) {
9
+ this.state = state;
10
+ }
11
+ async prepare() {
12
+ const socketPath = process.env.PLAYCRAFT_LAUNCHER_SOCKET;
13
+ const root = this.state.native?.executionProcess?.();
14
+ return socketPath && root ? prepareLauncherOrigin({ socketPath, rootPid: root.pid, runtimeEpoch: this.state.runtimeEpoch }) : undefined;
15
+ }
16
+ operation(context) {
17
+ const active = this.state.journal.listOperations().map(item => item.operation).filter(operation => ['session.turn', 'session.review', 'session.compact'].includes(operation.kind)
18
+ && ['dispatched', 'accepted', 'uncertain'].includes(operation.status)
19
+ && operation.generation === this.state.config.generation && operation.runtimeEpoch === this.state.runtimeEpoch);
20
+ if (active.length !== 1)
21
+ return undefined;
22
+ const operation = active[0];
23
+ const session = this.state.journal.getSession(context.sessionId);
24
+ if (operation.kind !== 'session.turn' || operation.sessionId !== context.sessionId || operation.clientRequestId !== context.clientRequestId
25
+ || !operation.nativeThreadId || !operation.nativeTurnId || !operation.workspaceId || !operation.directoryName
26
+ || !session || session.threadId !== operation.nativeThreadId || session.activeTurnId !== operation.nativeTurnId
27
+ || session.cwd !== context.cwd || !['running', 'waiting_for_user'].includes(session.turnStatus ?? ''))
28
+ return undefined;
29
+ return operation;
30
+ }
31
+ async forward(request, response, context, valid) {
32
+ const socketPath = process.env.PLAYCRAFT_LAUNCHER_SOCKET;
33
+ const deny = () => { response.writeHead(403, { 'cache-control': 'no-store' }); response.end(); };
34
+ try {
35
+ const operation = this.operation(context);
36
+ if (!valid() || !socketPath || !context.boundaryId || !operation || request.socket.remoteAddress !== '127.0.0.1'
37
+ || !request.socket.localPort || !request.socket.remotePort)
38
+ return deny();
39
+ let text = '';
40
+ for await (const chunk of request) {
41
+ text += chunk.toString();
42
+ if (text.length > 2048)
43
+ return deny();
44
+ }
45
+ const parsed = requestSchema.safeParse(JSON.parse(text));
46
+ if (!parsed.success || !await verifyLauncherOrigin({ socketPath, boundaryId: context.boundaryId,
47
+ runtimeEpoch: this.state.runtimeEpoch, serverPort: request.socket.localPort, clientPort: request.socket.remotePort, cwd: context.cwd }))
48
+ return deny();
49
+ // The socket lookup may take time. Never attest a turn which ended or changed meanwhile.
50
+ if (!valid() || this.operation(context)?.operationId !== operation.operationId)
51
+ return deny();
52
+ const config = this.state.config;
53
+ const input = agentGenerationProvenanceRequestSchema.parse({ ...parsed.data,
54
+ runtimeId: config.runtimeId, generation: config.generation, runtimeEpoch: this.state.runtimeEpoch,
55
+ workspaceId: operation.workspaceId, directoryName: operation.directoryName,
56
+ sessionId: context.sessionId, sessionClientRequestId: context.clientRequestId,
57
+ nativeThreadId: operation.nativeThreadId, nativeTurnId: operation.nativeTurnId });
58
+ const upstream = await fetch(platformEndpoint(config.publicBaseUrl, PLATFORM_ROUTES.agentGenerationProvenance), {
59
+ method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10_000),
60
+ headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': config.environment },
61
+ body: JSON.stringify(input),
62
+ });
63
+ if (!upstream.ok) {
64
+ await upstream.body?.cancel();
65
+ return deny();
66
+ }
67
+ const value = z.object({ proof: z.string().min(1).max(8192) }).strict().parse(await upstream.json());
68
+ if (!valid() || this.operation(context)?.operationId !== operation.operationId)
69
+ return deny();
70
+ response.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
71
+ response.end(JSON.stringify(value));
72
+ }
73
+ catch {
74
+ if (!response.headersSent)
75
+ deny();
76
+ else
77
+ response.destroy();
78
+ }
79
+ }
80
+ }
@@ -33,6 +33,24 @@ function request(socketPath, message) {
33
33
  });
34
34
  });
35
35
  }
36
+ /** The private launcher owns the boundary; development shells never receive its identity. */
37
+ export async function prepareLauncherOrigin(input) {
38
+ try {
39
+ const reply = await request(input.socketPath, { action: 'origin-boundary', rootPid: input.rootPid, runtimeEpoch: input.runtimeEpoch });
40
+ return typeof reply.boundaryId === 'string' ? reply.boundaryId : undefined;
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ }
46
+ export async function verifyLauncherOrigin(input) {
47
+ try {
48
+ return (await request(input.socketPath, { ...input, action: 'origin-verify', socketPath: undefined })).verified === true;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
36
54
  export async function startLauncherProcess(input) {
37
55
  const socket = createConnection(input.socketPath);
38
56
  const stdout = new PassThrough();