@playcraft/cli 0.1.56-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.
- package/dist/assets/client.js +6 -4
- package/dist/commands/tools-3d.js +15 -0
- package/dist/commands/tools-generation.js +3 -2
- package/dist/utils/agent-api-client.js +31 -8
- package/dist/utils/agent-generation-provenance.js +45 -0
- package/dist/utils/generation-workspace-scope.js +50 -0
- package/dist/utils/image-generation-retry.js +7 -0
- package/dist/utils/tool-operation-journal.js +34 -4
- package/dist/workspace-runtime/batch/prepare.js +9 -52
- package/dist/workspace-runtime/codex/delivery-tools.js +7 -5
- package/dist/workspace-runtime/codex/generation-provenance-broker.js +80 -0
- package/dist/workspace-runtime/codex/launcher-client.js +18 -0
- package/dist/workspace-runtime/codex/model-broker.js +23 -4
- package/dist/workspace-runtime/serve.js +8 -30
- package/dist/workspace-runtime/workspaces/dependencies.js +24 -10
- package/dist/workspace-runtime/workspaces/platform-export.js +185 -0
- package/package.json +3 -3
- package/project-template-v2/.playcraft/skills.lock.json +15 -8
- package/templates/preview-runtime/v1/README.md +4 -0
- package/templates/preview-runtime/v1/manifest.json +6 -6
- package/templates/preview-runtime/v1/runtime.mjs +368 -22
- package/templates/preview-runtime/v1/types/adapters.d.ts +4 -1
- package/templates/preview-runtime/v1/types/preview.d.ts +1 -0
- package/templates/preview-runtime/v1/types/protocol.d.ts +19 -0
- package/templates/preview-runtime/v1/types/runtime.d.ts +2 -1
package/dist/assets/client.js
CHANGED
|
@@ -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
|
|
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, ...(
|
|
133
|
+
await client.download(assetContentRoute({ ...scope, taskId, ...(directory ? { fileId: file.fileId } : {}), ...(versionId ? { versionId } : {}) }), temporary);
|
|
132
134
|
await verifyFile(temporary, file);
|
|
133
|
-
paths.push(
|
|
135
|
+
paths.push(directory ? join(destination, file.path) : destination);
|
|
134
136
|
}
|
|
135
|
-
if (
|
|
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;
|
|
@@ -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 =
|
|
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-
|
|
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
|
|
89
|
-
if (
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
245
|
-
if (plan === 'install') {
|
|
246
|
-
|
|
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: '
|
|
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
|
|
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
|
-
|| !/^[
|
|
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();
|