@topline-build/harness-mcp 0.5.3 → 0.6.0
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/CHANGELOG.md +6 -0
- package/README.md +6 -0
- package/dist/build-session-tools.d.ts +9 -0
- package/dist/build-session-tools.js +63 -0
- package/dist/index.d.ts +32 -6
- package/dist/index.js +180 -11
- package/dist/source-checkout.d.ts +10 -0
- package/dist/source-checkout.js +146 -0
- package/docs/generated/build-mcp-manifest.json +1718 -2
- package/docs/generated/build-mcp-reference.md +1904 -1
- package/package.json +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,12 @@ All notable customer-visible Build MCP changes are recorded here. A source chang
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.6.0 - pending publication
|
|
8
|
+
|
|
9
|
+
- Add server-owned external build sessions, guarded artifact-private SQL, and full acceptance/reviewer forwarding.
|
|
10
|
+
- Check out exact artifact source and materialize selected assets through authenticated same-account transfers with hash, revision, archive and local-path checks.
|
|
11
|
+
- Bind local draft create/deploy to the existing session identity, checked-out version, inspected source hash and stable operation keys. Additional asset, secret-metadata and data-verification scopes require explicit consent.
|
|
12
|
+
|
|
7
13
|
## 0.5.3 - pending publication
|
|
8
14
|
|
|
9
15
|
- Add tenant-scoped Storyboard state, guarded edit, and exact-draft publication tools to Build MCP.
|
package/README.md
CHANGED
|
@@ -28,6 +28,12 @@ Storyboard authoring uses separate `storyboard:read` and `storyboard:write` OAut
|
|
|
28
28
|
|
|
29
29
|
## Typical workflow
|
|
30
30
|
|
|
31
|
+
The 0.6.0 source adds session authoring; npm publication and a compatible customer API rollout are separate prerequisites. Start `platform_start_build_session` with an existing artifact's exact version or a new app manifest, a stable idempotency key and a Topline operation budget. External client model charges stay separate. For existing apps, `platform_checkout_artifact_app` writes exact saved source into a new local directory and returns its version, commit and source hash. Never reconstruct missing source from its manifest.
|
|
32
|
+
|
|
33
|
+
Keep Build and Workspace as separate same-account connections. Workspace supplies catalog, SQL and trusted calculations through `data:read`. Reusable assets, approved secret metadata, private table writes, validation and finite artifact data verification each require their separately consented Build scopes: `harness:assets:read`, `harness:secrets:metadata:read`, `harness:artifact:tables:write`, `harness:artifact:validate`, and `harness:artifact:data:verify`. The defaults remain the existing artifact scopes; request an expanded set explicitly and reconnect. Secret discovery returns approved references only, never values.
|
|
34
|
+
|
|
35
|
+
In session mode, `platform_deploy_artifact_app` requires the session ID, its exact current artifact version, a stable idempotency key, the session/checkout `inspectedSourceHash` and a fresh local `expectedBundleSha256`. The former pins the previously inspected remote source; the latter pins the local upload. Recover pending deployment and reviewer operations with their original IDs. Unknown work is never resubmitted by a status read. Add and run acceptance checks, prepare validation and request the independent review for the exact deployed revision. Approved output stays a private draft for an operator to publish.
|
|
36
|
+
|
|
31
37
|
1. Use `platform_init_artifact_app` to create a starter, or point at an existing artifact app.
|
|
32
38
|
2. Use `platform_inspect_artifact_app` to check local package boundaries and record its bundle SHA-256.
|
|
33
39
|
3. Use `platform_deploy_artifact_app` with that SHA-256 to create or update a private draft.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ListToolsResult, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
+
type Annotate = (name: string, policy: {
|
|
3
|
+
readOnly: boolean;
|
|
4
|
+
idempotent?: boolean;
|
|
5
|
+
openWorld?: boolean;
|
|
6
|
+
}) => ToolAnnotations;
|
|
7
|
+
export declare function externalBuildHelperTools(annotate: Annotate): ListToolsResult["tools"];
|
|
8
|
+
export declare const EXTERNAL_BUILD_FORWARD_TOOLS: Set<string>;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const id = { type: "string", format: "uuid" };
|
|
2
|
+
const requestKey = { type: "string", minLength: 1, maxLength: 128, pattern: "^[A-Za-z0-9._:-]+$" };
|
|
3
|
+
const session = { sessionId: id };
|
|
4
|
+
const mutation = { ...session, artifactVersionId: id, idempotencyKey: requestKey };
|
|
5
|
+
const deployed = { ...mutation, deploymentRecordId: id };
|
|
6
|
+
export function externalBuildHelperTools(annotate) {
|
|
7
|
+
const tool = (name, description, properties, required, readOnly = false) => ({
|
|
8
|
+
name, description, annotations: annotate(name, { readOnly, idempotent: true, openWorld: true }),
|
|
9
|
+
inputSchema: { type: "object", additionalProperties: false, properties, required },
|
|
10
|
+
// Recovery adds a oneOf branch for {ok:false,error:...}; successful objects
|
|
11
|
+
// may omit ok, but must never match that branch too.
|
|
12
|
+
outputSchema: { type: "object", properties: { ok: { const: true } } }
|
|
13
|
+
});
|
|
14
|
+
return [
|
|
15
|
+
tool("platform_capability_status", "Read the connected Build product's current available, denied, unsupported and unconfigured tool families, contract version and helper requirement. Reconnect explicitly for newly consented scopes.", {}, [], true),
|
|
16
|
+
tool("platform_start_build_session", "Start external authoring with a private artifact identity, current version and platform-operation budget. Launches no internal coder. Existing artifact starts require its current version; new app starts require a manifest. Optional own conversation selects exact operator-approved secret references.", {
|
|
17
|
+
idempotencyKey: requestKey, artifactId: id, artifactVersionId: id, manifest: { type: "object" }, projectId: id, owningConversationId: id,
|
|
18
|
+
budgetCents: { type: "integer", minimum: 0, maximum: 100000 }, brief: { type: "string", minLength: 3, maxLength: 4000 }
|
|
19
|
+
}, ["idempotencyKey", "budgetCents", "brief"]),
|
|
20
|
+
tool("platform_read_build_session", "Read the owned build session and original pending operations. Does not start another coder or deployment.", session, ["sessionId"], true),
|
|
21
|
+
tool("platform_close_build_session", "Stop new authoring and request pending-operation cancellation. Unknown effects retain their reservation; cancellation does not undo committed writes.", session, ["sessionId"]),
|
|
22
|
+
tool("platform_run_artifact_workspace_sql", "Run guarded private artifact SQL using the exact session namespace, version and retry key. CREATE TABLE/ADD COLUMN require documentation. Enforced non-admin writes remain denied; unknown writes are never replayed.", {
|
|
23
|
+
...mutation, sql: { type: "string", minLength: 1, maxLength: 20000 }, purpose: { type: "string", minLength: 3, maxLength: 500 },
|
|
24
|
+
params: { type: "object", additionalProperties: true }, maxRows: { type: "integer", minimum: 1, maximum: 5000 }, documentation: { type: "object" }, columnDocumentation: { type: "object" }
|
|
25
|
+
}, [...Object.keys(mutation), "sql", "purpose"]),
|
|
26
|
+
tool("platform_checkout_artifact_app", "Download exact saved app source through the owned session, verify archive hash/version/commit and extract regular source files into a new local directory. Missing source is never reconstructed.", {
|
|
27
|
+
...session, targetDir: { type: "string", minLength: 1 }
|
|
28
|
+
}, ["sessionId", "targetDir"]),
|
|
29
|
+
tool("platform_list_asset_folders", "List the session's permitted reusable asset folders.", session, ["sessionId"], true),
|
|
30
|
+
tool("platform_list_assets", "List permitted reusable asset metadata with bounded pagination; excluded/deleted attachments are omitted.", {
|
|
31
|
+
...session, query: { type: "string", maxLength: 200 }, folderId: id, limit: { type: "integer", minimum: 1, maximum: 500 }, offset: { type: "integer", minimum: 0 }
|
|
32
|
+
}, ["sessionId"], true),
|
|
33
|
+
tool("platform_materialize_assets", "Download exactly selected approved files into a new local directory. Verify each authenticated transfer's hash and size; source and attachment budgets remain separate.", {
|
|
34
|
+
...session, assetIds: { type: "array", minItems: 1, maxItems: 50, uniqueItems: true, items: id }, targetDir: { type: "string", minLength: 1 }
|
|
35
|
+
}, ["sessionId", "assetIds", "targetDir"]),
|
|
36
|
+
tool("platform_list_build_secrets", "Read only exact operator-approved binding references for this session. Values are never retrieved. Authoring detach does not prove deployed runtime revocation.", session, ["sessionId"], true),
|
|
37
|
+
tool("platform_add_acceptance_check", "Add a required acceptance check bound to this build session and deployed revision. A semantic control query requires separate Build data-verification consent.", {
|
|
38
|
+
...mutation, description: { type: "string", minLength: 3, maxLength: 1000 }
|
|
39
|
+
}, [...Object.keys(mutation), "description"]),
|
|
40
|
+
tool("platform_run_acceptance_checks", "Run required acceptance checks for the exact current draft revision through the shared service.", mutation, Object.keys(mutation)),
|
|
41
|
+
tool("platform_record_acceptance_review", "Record the builder's acceptance review without impersonating the independent validator or removing required checks.", {
|
|
42
|
+
...mutation, checkId: id, status: { type: "string", enum: ["passed", "failed"] }, summary: { type: "string", minLength: 3, maxLength: 2000 }
|
|
43
|
+
}, [...Object.keys(mutation), "checkId", "status", "summary"]),
|
|
44
|
+
tool("platform_prepare_artifact_validation", "Prepare validation evidence for the exact private draft revision; missing required checks block readiness.", { ...session, artifactVersionId: id, deploymentRecordId: id }, ["sessionId", "artifactVersionId", "deploymentRecordId"], true),
|
|
45
|
+
tool("platform_request_artifact_validation", "Request the independent reviewer for the exact deployment and bounded evidence. Returns durable operation status; unavailable infrastructure leaves operator review required, never approval.", {
|
|
46
|
+
...deployed, summary: { type: "string", minLength: 3, maxLength: 4000 }, waitMs: { type: "integer", minimum: 0, maximum: 1000 },
|
|
47
|
+
buildOk: { type: "boolean" }, typecheckOk: { type: "boolean" }, docImpact: { type: "string", enum: ["material", "none"] },
|
|
48
|
+
docImpactNotes: { type: "string", minLength: 12, maxLength: 2000 }, visualReview: { type: "object", additionalProperties: true }, dataValidation: { type: "object", additionalProperties: true }
|
|
49
|
+
}, [...Object.keys(deployed), "summary", "docImpact", "docImpactNotes", "visualReview"]),
|
|
50
|
+
tool("platform_artifact_validation_status", "Read the original validation result and pending prompts without starting another reviewer or deploy. Current authorization is rechecked before evidence delivery.", {
|
|
51
|
+
...session, operationId: id
|
|
52
|
+
}, ["sessionId", "operationId"], true),
|
|
53
|
+
tool("platform_answer_operator_prompt", "Answer the exact saved operator prompt on the original review operation. Appends guidance and reopens the same eligible session; cannot approve validation or widen the saved grant.", {
|
|
54
|
+
...session, operationId: id, promptId: id, idempotencyKey: requestKey, message: { type: "string", minLength: 3, maxLength: 4000 }
|
|
55
|
+
}, ["sessionId", "operationId", "promptId", "idempotencyKey", "message"])
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
export const EXTERNAL_BUILD_FORWARD_TOOLS = new Set([
|
|
59
|
+
"platform_capability_status",
|
|
60
|
+
"platform_start_build_session", "platform_read_build_session", "platform_close_build_session", "platform_run_artifact_workspace_sql",
|
|
61
|
+
"platform_list_asset_folders", "platform_list_assets", "platform_list_build_secrets", "platform_add_acceptance_check", "platform_run_acceptance_checks",
|
|
62
|
+
"platform_record_acceptance_review", "platform_prepare_artifact_validation", "platform_request_artifact_validation", "platform_artifact_validation_status", "platform_answer_operator_prompt"
|
|
63
|
+
]);
|
package/dist/index.d.ts
CHANGED
|
@@ -32,7 +32,7 @@ export interface PackedArtifactAppBundle {
|
|
|
32
32
|
type ToolProgressReporter = (progress: number, message: string) => void | Promise<void>;
|
|
33
33
|
export declare const DEFAULT_MCP_PROTOCOL_VERSION = "2025-11-25";
|
|
34
34
|
export declare const BUILD_MCP_SERVER_NAME = "topline-build-stdio";
|
|
35
|
-
export declare const HARNESS_MCP_VERSION = "0.
|
|
35
|
+
export declare const HARNESS_MCP_VERSION = "0.6.0";
|
|
36
36
|
export declare const DEFAULT_OAUTH_SCOPES: readonly string[];
|
|
37
37
|
export interface BuildMcpSetupOption {
|
|
38
38
|
readonly flag: string;
|
|
@@ -52,11 +52,6 @@ export declare function callHarnessTool(opts: HarnessMcpStdioOptions, name: stri
|
|
|
52
52
|
onProgress?: ToolProgressReporter;
|
|
53
53
|
}): Promise<CallToolResult>;
|
|
54
54
|
export declare function deployArtifactApp(opts: HarnessMcpStdioOptions, input: Record<string, unknown>, onProgress?: ToolProgressReporter): Promise<{
|
|
55
|
-
ok: boolean;
|
|
56
|
-
artifactId: string;
|
|
57
|
-
artifactVersionId: string;
|
|
58
|
-
manifestId: string | null;
|
|
59
|
-
clientRequestId: string;
|
|
60
55
|
operationId: string | null;
|
|
61
56
|
bundle: {
|
|
62
57
|
fileCount: number;
|
|
@@ -66,6 +61,37 @@ export declare function deployArtifactApp(opts: HarnessMcpStdioOptions, input: R
|
|
|
66
61
|
};
|
|
67
62
|
create: unknown;
|
|
68
63
|
deploy: unknown;
|
|
64
|
+
sessionId?: string | undefined;
|
|
65
|
+
ok: boolean;
|
|
66
|
+
artifactId: string;
|
|
67
|
+
artifactVersionId: string;
|
|
68
|
+
manifestId: string | null;
|
|
69
|
+
clientRequestId: string;
|
|
70
|
+
}>;
|
|
71
|
+
export declare function checkoutArtifactApp(opts: HarnessMcpStdioOptions, input: Record<string, unknown>): Promise<{
|
|
72
|
+
ok: boolean;
|
|
73
|
+
sessionId: string;
|
|
74
|
+
artifactId: unknown;
|
|
75
|
+
artifactVersionId: string;
|
|
76
|
+
commitSha: string;
|
|
77
|
+
sourceHash: string;
|
|
78
|
+
appDir: string;
|
|
79
|
+
platformAppDir: unknown;
|
|
80
|
+
fileCount: number;
|
|
81
|
+
byteCount: number;
|
|
82
|
+
}>;
|
|
83
|
+
export declare function materializeBuildAssets(opts: HarnessMcpStdioOptions, input: Record<string, unknown>): Promise<{
|
|
84
|
+
ok: boolean;
|
|
85
|
+
sessionId: string;
|
|
86
|
+
directory: string;
|
|
87
|
+
byteCount: number;
|
|
88
|
+
files: {
|
|
89
|
+
id: string;
|
|
90
|
+
file: string;
|
|
91
|
+
size: number;
|
|
92
|
+
sha256: unknown;
|
|
93
|
+
mime: unknown;
|
|
94
|
+
}[];
|
|
69
95
|
}>;
|
|
70
96
|
export declare function inspectArtifactApp(opts: HarnessMcpStdioOptions, input: Record<string, unknown>): Promise<{
|
|
71
97
|
ok: boolean;
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
|
-
import { mkdir, mkdtemp, readdir, readFile, realpath, lstat, writeFile, rm, rename } from "node:fs/promises";
|
|
4
|
+
import { cp, mkdir, mkdtemp, readdir, readFile, realpath, lstat, writeFile, rm, rename } from "node:fs/promises";
|
|
5
5
|
import { createServer } from "node:http";
|
|
6
6
|
import os from "node:os";
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { gzipSync } from "node:zlib";
|
|
9
|
+
import { externalBuildHelperTools, EXTERNAL_BUILD_FORWARD_TOOLS } from "./build-session-tools.js";
|
|
10
|
+
import { boundedTransfer, extractArtifactSourceArchive, MAX_SOURCE_ARCHIVE_BYTES, safeSourcePath } from "./source-checkout.js";
|
|
9
11
|
import { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js";
|
|
10
12
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
13
|
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";
|
|
@@ -13,7 +15,7 @@ import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } fr
|
|
|
13
15
|
export const DEFAULT_MCP_PROTOCOL_VERSION = "2025-11-25";
|
|
14
16
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
15
17
|
export const BUILD_MCP_SERVER_NAME = "topline-build-stdio";
|
|
16
|
-
export const HARNESS_MCP_VERSION = "0.
|
|
18
|
+
export const HARNESS_MCP_VERSION = "0.6.0";
|
|
17
19
|
export const DEFAULT_OAUTH_SCOPES = Object.freeze(["harness:artifact:read", "harness:artifact:write", "harness:artifact:deploy", "harness:artifact:delete"]);
|
|
18
20
|
export const BUILD_MCP_SETUP_OPTIONS = Object.freeze([
|
|
19
21
|
{ flag: "--url", env: "TOPLINE_API_URL", aliases: ["PLATFORM_API_URL", "OVERSITE_API_URL"], required: true, description: "Topline tenant base URL. HTTPS is required except for loopback development." },
|
|
@@ -161,6 +163,12 @@ export async function callHarnessTool(opts, name, args, hooks) {
|
|
|
161
163
|
case "platform_checkout_design_system":
|
|
162
164
|
result = toolResult(await checkoutDesignSystem(opts, input));
|
|
163
165
|
break;
|
|
166
|
+
case "platform_checkout_artifact_app":
|
|
167
|
+
result = toolResult(await checkoutArtifactApp(opts, input));
|
|
168
|
+
break;
|
|
169
|
+
case "platform_materialize_assets":
|
|
170
|
+
result = toolResult(await materializeBuildAssets(opts, input));
|
|
171
|
+
break;
|
|
164
172
|
case "platform_submit_design_system_draft":
|
|
165
173
|
result = toolResult(await submitDesignSystemDraft(opts, input));
|
|
166
174
|
break;
|
|
@@ -189,6 +197,12 @@ export async function callHarnessTool(opts, name, args, hooks) {
|
|
|
189
197
|
result = toolResult(await initArtifactApp(opts, input));
|
|
190
198
|
break;
|
|
191
199
|
default:
|
|
200
|
+
if (EXTERNAL_BUILD_FORWARD_TOOLS.has(name)) {
|
|
201
|
+
if (mcpSurface(opts) !== "build")
|
|
202
|
+
throw new Error("external_session_requires_build_surface");
|
|
203
|
+
result = toolResult(await callHostedBuildTool(opts, name, input));
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
192
206
|
throw new McpError(ErrorCode.InvalidParams, `Unknown tool: ${name}`);
|
|
193
207
|
}
|
|
194
208
|
const outputError = tool.outputSchema
|
|
@@ -203,7 +217,8 @@ export async function deployArtifactApp(opts, input, onProgress) {
|
|
|
203
217
|
await assertAuthFileOutsideAppDir(opts.authFile, absoluteAppDir);
|
|
204
218
|
const workspaceAppDir = workspaceRelativeAppDir(opts.workspaceRoot, absoluteAppDir);
|
|
205
219
|
const manifest = await readManifest(absoluteAppDir);
|
|
206
|
-
const
|
|
220
|
+
const suppliedPlatformAppDir = optionalString(input, "platformAppDir");
|
|
221
|
+
const platformAppDir = suppliedPlatformAppDir ? (suppliedPlatformAppDir === "." ? "." : validateArchivePrefix(suppliedPlatformAppDir)) : logicalArtifactAppDir(workspaceAppDir, manifest);
|
|
207
222
|
const clientRequestId = optionalString(input, "requestId") ?? createHash("sha256")
|
|
208
223
|
.update(JSON.stringify({ apiUrl: platformBaseUrl(opts), appDir: platformAppDir, manifestId: manifest.id ?? null }))
|
|
209
224
|
.digest("hex");
|
|
@@ -213,18 +228,39 @@ export async function deployArtifactApp(opts, input, onProgress) {
|
|
|
213
228
|
if (expectedBundleSha256 && bundle.sha256 !== expectedBundleSha256) {
|
|
214
229
|
throw new Error("artifact_app_bundle_changed_after_inspection");
|
|
215
230
|
}
|
|
231
|
+
const sessionId = optionalString(input, "sessionId");
|
|
232
|
+
const sessionVersion = optionalString(input, "artifactVersionId");
|
|
233
|
+
const idempotencyKey = optionalString(input, "idempotencyKey");
|
|
234
|
+
const inspectedSourceHash = optionalString(input, "inspectedSourceHash");
|
|
235
|
+
let sessionArtifactId;
|
|
236
|
+
if (sessionId) {
|
|
237
|
+
if (mcpSurface(opts) !== "build")
|
|
238
|
+
throw new Error("external_session_requires_build_surface");
|
|
239
|
+
if (!sessionVersion || !idempotencyKey || !inspectedSourceHash || !expectedBundleSha256)
|
|
240
|
+
throw new Error("build_session_deployment_arguments_required");
|
|
241
|
+
const session = asRecord(await callHostedBuildTool(opts, "platform_read_build_session", { sessionId }));
|
|
242
|
+
// The server resolves a repeated operation key before checking current
|
|
243
|
+
// version/source. Do not reject a lost-response retry using the newer read.
|
|
244
|
+
// Fresh mutations still require the caller's exact inspected revision.
|
|
245
|
+
sessionArtifactId = stringValue(session.artifactId, "artifactId");
|
|
246
|
+
if (input.artifactId !== undefined && input.artifactId !== sessionArtifactId)
|
|
247
|
+
throw new Error("build_session_artifact_mismatch");
|
|
248
|
+
}
|
|
249
|
+
else if (sessionVersion || idempotencyKey || inspectedSourceHash || suppliedPlatformAppDir)
|
|
250
|
+
throw new Error("build_session_id_required");
|
|
216
251
|
await onProgress?.(0.4, "Creating private artifact draft");
|
|
217
252
|
const created = await fetchJson(opts, artifactAppsPath(opts), {
|
|
218
253
|
method: "POST",
|
|
219
254
|
headers: await jsonHeaders(opts),
|
|
220
255
|
body: JSON.stringify({
|
|
221
|
-
artifactId: optionalString(input, "artifactId"),
|
|
256
|
+
artifactId: sessionArtifactId ?? optionalString(input, "artifactId"),
|
|
222
257
|
title: optionalString(input, "title"),
|
|
223
258
|
description: optionalString(input, "description"),
|
|
224
259
|
projectId: optionalString(input, "projectId"),
|
|
225
260
|
clientRequestId,
|
|
226
261
|
bundleSha256: bundle.sha256,
|
|
227
|
-
manifest
|
|
262
|
+
manifest,
|
|
263
|
+
...(sessionId ? { sessionId, artifactVersionId: sessionVersion, idempotencyKey, inspectedSourceHash } : {})
|
|
228
264
|
})
|
|
229
265
|
});
|
|
230
266
|
const createdRecord = asRecord(created);
|
|
@@ -241,19 +277,22 @@ export async function deployArtifactApp(opts, input, onProgress) {
|
|
|
241
277
|
"content-type": "application/gzip",
|
|
242
278
|
"MCP-Protocol-Version": opts.protocolVersion,
|
|
243
279
|
"x-platform-artifact-version-id": artifactVersionId,
|
|
244
|
-
"x-platform-app-dir": platformAppDir
|
|
280
|
+
"x-platform-app-dir": platformAppDir,
|
|
281
|
+
...(sessionId ? { "x-platform-build-session-id": sessionId, "x-platform-idempotency-key": idempotencyKey, "x-platform-inspected-source-sha256": inspectedSourceHash, "x-platform-expected-bundle-sha256": bundle.sha256 } : {})
|
|
245
282
|
},
|
|
246
283
|
body: bundle.body
|
|
247
284
|
});
|
|
248
285
|
await onProgress?.(1, "Completed trusted draft deployment");
|
|
249
286
|
const deployedRecord = asRecord(deployed);
|
|
250
|
-
const operationId = typeof deployedRecord.taskId === "string" ? deployedRecord.taskId
|
|
287
|
+
const operationId = typeof deployedRecord.taskId === "string" ? deployedRecord.taskId
|
|
288
|
+
: typeof deployedRecord.operationId === "string" ? deployedRecord.operationId : null;
|
|
251
289
|
return {
|
|
252
290
|
ok: true,
|
|
253
291
|
artifactId,
|
|
254
292
|
artifactVersionId,
|
|
255
293
|
manifestId: typeof manifest.id === "string" ? manifest.id : null,
|
|
256
294
|
clientRequestId,
|
|
295
|
+
...(sessionId ? { sessionId } : {}),
|
|
257
296
|
operationId,
|
|
258
297
|
bundle: {
|
|
259
298
|
fileCount: bundle.fileCount,
|
|
@@ -265,13 +304,135 @@ export async function deployArtifactApp(opts, input, onProgress) {
|
|
|
265
304
|
deploy: deployed
|
|
266
305
|
};
|
|
267
306
|
}
|
|
307
|
+
async function fetchBuildTransfer(opts, advertisedPath, expectedPath, sha256, maxBytes, expectedSize, revision) {
|
|
308
|
+
if (advertisedPath !== expectedPath || typeof sha256 !== "string" || !/^[a-f0-9]{64}$/i.test(sha256))
|
|
309
|
+
throw new Error("build_transfer_identity_invalid");
|
|
310
|
+
const base = new URL(platformBaseUrl(opts));
|
|
311
|
+
const url = new URL(expectedPath, base);
|
|
312
|
+
if (url.origin !== base.origin || url.pathname !== expectedPath || url.search || url.hash)
|
|
313
|
+
throw new Error("build_transfer_identity_invalid");
|
|
314
|
+
const controller = new AbortController();
|
|
315
|
+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
|
|
316
|
+
try {
|
|
317
|
+
const response = await (opts.fetchImpl ?? fetch)(url.href, { method: "GET", headers: await jsonHeaders(opts), redirect: "error", signal: controller.signal });
|
|
318
|
+
if (!response.ok || (response.url && new URL(response.url).origin !== base.origin))
|
|
319
|
+
throw new Error("build_transfer_unavailable");
|
|
320
|
+
if (response.headers.get("x-topline-content-sha256")?.toLowerCase() !== sha256.toLowerCase())
|
|
321
|
+
throw new Error("build_transfer_hash_mismatch");
|
|
322
|
+
if (revision && (response.headers.get("x-topline-artifact-version") !== revision.artifactVersionId || response.headers.get("x-topline-source-commit") !== revision.commitSha))
|
|
323
|
+
throw new Error("build_transfer_revision_mismatch");
|
|
324
|
+
const body = await boundedTransfer(response, maxBytes, expectedSize);
|
|
325
|
+
if (createHash("sha256").update(body).digest("hex") !== sha256.toLowerCase())
|
|
326
|
+
throw new Error("build_transfer_hash_mismatch");
|
|
327
|
+
return body;
|
|
328
|
+
}
|
|
329
|
+
catch (error) {
|
|
330
|
+
if (controller.signal.aborted)
|
|
331
|
+
throw new Error("platform_harness_timeout");
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
334
|
+
finally {
|
|
335
|
+
clearTimeout(timer);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
async function newBuildInputTarget(opts, input) {
|
|
339
|
+
if (mcpSurface(opts) !== "build")
|
|
340
|
+
throw new Error("external_session_requires_build_surface");
|
|
341
|
+
const target = await resolveWorkspacePathSafely(opts.workspaceRoot, stringArg(input, "targetDir"));
|
|
342
|
+
if (existsSync(target))
|
|
343
|
+
throw new Error("build_input_target_exists");
|
|
344
|
+
await assertAuthFileOutsideAppDir(opts.authFile, target);
|
|
345
|
+
await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
346
|
+
const checked = await resolveWorkspacePathSafely(opts.workspaceRoot, stringArg(input, "targetDir"));
|
|
347
|
+
if (checked !== target)
|
|
348
|
+
throw new Error("build_input_target_changed");
|
|
349
|
+
return target;
|
|
350
|
+
}
|
|
351
|
+
async function installBuildInputs(opts, target, stage) {
|
|
352
|
+
// Reserve the new target exclusively; never rename over a directory another
|
|
353
|
+
// process created. cp refuses existing files rather than overwriting them.
|
|
354
|
+
await resolveWorkspacePathSafely(opts.workspaceRoot, path.relative(opts.workspaceRoot, target));
|
|
355
|
+
await mkdir(target, { mode: 0o700 });
|
|
356
|
+
try {
|
|
357
|
+
for (const entry of await readdir(stage))
|
|
358
|
+
await cp(path.join(stage, entry), path.join(target, entry), { recursive: true, force: false, errorOnExist: true });
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
await rm(target, { recursive: true, force: true });
|
|
362
|
+
throw error;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
export async function checkoutArtifactApp(opts, input) {
|
|
366
|
+
const target = await newBuildInputTarget(opts, input);
|
|
367
|
+
const sessionId = stringArg(input, "sessionId");
|
|
368
|
+
const metadata = asRecord(await callHostedBuildTool(opts, "platform_checkout_artifact_app", { sessionId }));
|
|
369
|
+
const artifactVersionId = stringValue(metadata.artifactVersionId, "artifactVersionId");
|
|
370
|
+
const commitSha = stringValue(metadata.commitSha, "commitSha");
|
|
371
|
+
if (!/^[a-f0-9]{40}$/.test(commitSha) || !/^[0-9a-f-]{36}$/i.test(artifactVersionId))
|
|
372
|
+
throw new Error("build_transfer_revision_invalid");
|
|
373
|
+
const body = await fetchBuildTransfer(opts, metadata.downloadPath, `/api/harness/build-sessions/${sessionId}/source`, metadata.sha256, MAX_SOURCE_ARCHIVE_BYTES, undefined, { artifactVersionId, commitSha });
|
|
374
|
+
const staging = await mkdtemp(path.join(path.dirname(target), ".topline-app-checkout-"));
|
|
375
|
+
try {
|
|
376
|
+
const source = path.join(staging, "source");
|
|
377
|
+
await mkdir(source, { mode: 0o700 });
|
|
378
|
+
const extracted = await extractArtifactSourceArchive(body, source);
|
|
379
|
+
if (extracted.fileCount !== metadata.fileCount || extracted.byteCount !== metadata.byteCount)
|
|
380
|
+
throw new Error("artifact_source_shape_mismatch");
|
|
381
|
+
await readManifest(source);
|
|
382
|
+
await installBuildInputs(opts, target, source);
|
|
383
|
+
return { ok: true, sessionId, artifactId: metadata.artifactId, artifactVersionId, commitSha, sourceHash: extracted.sha256,
|
|
384
|
+
appDir: path.relative(opts.workspaceRoot, target).replaceAll("\\", "/"), platformAppDir: metadata.appDir,
|
|
385
|
+
fileCount: extracted.fileCount, byteCount: extracted.byteCount };
|
|
386
|
+
}
|
|
387
|
+
finally {
|
|
388
|
+
await rm(staging, { recursive: true, force: true });
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
export async function materializeBuildAssets(opts, input) {
|
|
392
|
+
const target = await newBuildInputTarget(opts, input);
|
|
393
|
+
const sessionId = stringArg(input, "sessionId");
|
|
394
|
+
const assetIds = input.assetIds;
|
|
395
|
+
if (!Array.isArray(assetIds) || !assetIds.length || assetIds.length > 50 || assetIds.some(id => typeof id !== "string" || !/^[0-9a-f-]{36}$/i.test(id)))
|
|
396
|
+
throw new Error("asset_selection_required");
|
|
397
|
+
const selected = new Set(assetIds);
|
|
398
|
+
const metadata = asRecord(await callHostedBuildTool(opts, "platform_materialize_assets", { sessionId, assetIds }));
|
|
399
|
+
if (!Array.isArray(metadata.assets) || metadata.assets.length !== selected.size)
|
|
400
|
+
throw new Error("asset_transfer_identity_invalid");
|
|
401
|
+
const assets = metadata.assets.map(asRecord);
|
|
402
|
+
if (assets.some(asset => typeof asset.id !== "string" || !selected.delete(asset.id)) || selected.size)
|
|
403
|
+
throw new Error("asset_transfer_identity_invalid");
|
|
404
|
+
const staging = await mkdtemp(path.join(path.dirname(target), ".topline-assets-"));
|
|
405
|
+
let total = 0;
|
|
406
|
+
try {
|
|
407
|
+
const files = [];
|
|
408
|
+
for (const asset of assets) {
|
|
409
|
+
const size = asset.size;
|
|
410
|
+
if (typeof size !== "number" || !Number.isSafeInteger(size) || size < 0 || size > 16 * 1024 * 1024 || (total += size) > 64 * 1024 * 1024)
|
|
411
|
+
throw new Error("asset_transfer_too_large");
|
|
412
|
+
const name = stringValue(asset.name, "name");
|
|
413
|
+
if (Buffer.byteLength(name, "utf8") > 200 || name.includes("/") || safeSourcePath(name) !== name)
|
|
414
|
+
throw new Error("asset_transfer_name_invalid");
|
|
415
|
+
const id = stringValue(asset.id, "id");
|
|
416
|
+
const body = await fetchBuildTransfer(opts, asset.downloadPath, `/api/harness/build-sessions/${sessionId}/assets/${id}`, asset.sha256, 16 * 1024 * 1024, size);
|
|
417
|
+
const localName = `${id}-${name}`;
|
|
418
|
+
await writeFile(path.join(staging, localName), body, { mode: 0o600, flag: "wx" });
|
|
419
|
+
files.push({ id, file: localName, size, sha256: asset.sha256, mime: asset.mime });
|
|
420
|
+
}
|
|
421
|
+
await installBuildInputs(opts, target, staging);
|
|
422
|
+
return { ok: true, sessionId, directory: path.relative(opts.workspaceRoot, target).replaceAll("\\", "/"), byteCount: total, files };
|
|
423
|
+
}
|
|
424
|
+
finally {
|
|
425
|
+
await rm(staging, { recursive: true, force: true });
|
|
426
|
+
}
|
|
427
|
+
}
|
|
268
428
|
export async function inspectArtifactApp(opts, input) {
|
|
269
429
|
const appDir = stringArg(input, "appDir");
|
|
270
430
|
const absoluteAppDir = await resolveWorkspacePathSafely(opts.workspaceRoot, appDir);
|
|
271
431
|
await assertAuthFileOutsideAppDir(opts.authFile, absoluteAppDir);
|
|
272
432
|
const workspaceAppDir = workspaceRelativeAppDir(opts.workspaceRoot, absoluteAppDir);
|
|
273
433
|
const manifest = await readManifest(absoluteAppDir);
|
|
274
|
-
const
|
|
434
|
+
const suppliedPlatformAppDir = optionalString(input, "platformAppDir");
|
|
435
|
+
const platformAppDir = suppliedPlatformAppDir ? (suppliedPlatformAppDir === "." ? "." : validateArchivePrefix(suppliedPlatformAppDir)) : logicalArtifactAppDir(workspaceAppDir, manifest);
|
|
275
436
|
const bundle = await packArtifactAppDirectory(absoluteAppDir, platformAppDir === "." ? "" : platformAppDir);
|
|
276
437
|
return {
|
|
277
438
|
ok: true,
|
|
@@ -787,6 +948,8 @@ function shouldExclude(relative, isDirectory) {
|
|
|
787
948
|
return true;
|
|
788
949
|
if (parts.some((part) => part === ".git" || part === "node_modules"))
|
|
789
950
|
return true;
|
|
951
|
+
if (parts.some(part => /(?:\.(?:pem|key|p12|pfx)|^\.?(?:credentials|netrc|npmrc|aws|ssh)$)/i.test(part)))
|
|
952
|
+
return true;
|
|
790
953
|
if (isDirectory && FORBIDDEN_DIRS.has(name))
|
|
791
954
|
return true;
|
|
792
955
|
return false;
|
|
@@ -927,7 +1090,12 @@ const BUILD_MCP_TOOLS = [
|
|
|
927
1090
|
description: { type: "string" },
|
|
928
1091
|
projectId: { type: "string", format: "uuid" },
|
|
929
1092
|
expectedBundleSha256: { type: "string", pattern: "^[0-9a-fA-F]{64}$", description: "Optional SHA-256 from platform_inspect_artifact_app; deployment fails locally if source changed." },
|
|
930
|
-
requestId: { type: "string", minLength: 1, maxLength: 128, description: "Optional stable retry key; generated deterministically when omitted." }
|
|
1093
|
+
requestId: { type: "string", minLength: 1, maxLength: 128, description: "Optional stable retry key; generated deterministically when omitted." },
|
|
1094
|
+
sessionId: { type: "string", format: "uuid" },
|
|
1095
|
+
artifactVersionId: { type: "string", format: "uuid", description: "Exact current version from the session or checkout; required with sessionId." },
|
|
1096
|
+
idempotencyKey: { type: "string", minLength: 1, maxLength: 128, pattern: "^[A-Za-z0-9._:-]+$" },
|
|
1097
|
+
inspectedSourceHash: { type: "string", pattern: "^[0-9a-fA-F]{64}$", description: "Echo the session/source checkout hash; required with sessionId. expectedBundleSha256 separately pins the newly inspected local upload." },
|
|
1098
|
+
platformAppDir: { type: "string", minLength: 1, description: "Saved remote app root from an existing-app checkout. Session mode only; preserve this when the local checkout directory has a different name." }
|
|
931
1099
|
},
|
|
932
1100
|
required: ["appDir"],
|
|
933
1101
|
additionalProperties: false
|
|
@@ -953,7 +1121,7 @@ const BUILD_MCP_TOOLS = [
|
|
|
953
1121
|
annotations: mcpToolAnnotations("platform_inspect_artifact_app", { readOnly: true }),
|
|
954
1122
|
inputSchema: {
|
|
955
1123
|
type: "object",
|
|
956
|
-
properties: { appDir: { type: "string", minLength: 1 } },
|
|
1124
|
+
properties: { appDir: { type: "string", minLength: 1 }, platformAppDir: { type: "string", minLength: 1, description: "Preserve the saved remote source root returned by checkout when inspecting an existing app." } },
|
|
957
1125
|
required: ["appDir"],
|
|
958
1126
|
additionalProperties: false
|
|
959
1127
|
},
|
|
@@ -1207,7 +1375,8 @@ const BUILD_MCP_TOOLS = [
|
|
|
1207
1375
|
properties: { ok: { type: "boolean" }, deleted: { type: "boolean" }, artifactId: { type: "string" } },
|
|
1208
1376
|
required: ["ok", "deleted", "artifactId"]
|
|
1209
1377
|
}
|
|
1210
|
-
}
|
|
1378
|
+
},
|
|
1379
|
+
...externalBuildHelperTools(mcpToolAnnotations)
|
|
1211
1380
|
].map(withRecoveryOutputSchema);
|
|
1212
1381
|
export function listTools() {
|
|
1213
1382
|
// Preserve the original tools/list isolation boundary: callers must not be
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare const MAX_SOURCE_ARCHIVE_BYTES: number;
|
|
2
|
+
/** All path rules apply on every host, including Windows aliases and devices. */
|
|
3
|
+
export declare function safeSourcePath(raw: string): string;
|
|
4
|
+
/** Extract only regular files into a task-created empty staging directory. */
|
|
5
|
+
export declare function extractArtifactSourceArchive(body: Buffer, stagingRoot: string): Promise<{
|
|
6
|
+
fileCount: number;
|
|
7
|
+
byteCount: number;
|
|
8
|
+
sha256: string;
|
|
9
|
+
}>;
|
|
10
|
+
export declare function boundedTransfer(response: Response, maxBytes: number, expectedSize?: number): Promise<Buffer>;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { gunzipSync } from "node:zlib";
|
|
5
|
+
export const MAX_SOURCE_ARCHIVE_BYTES = 64 * 1024 * 1024;
|
|
6
|
+
const MAX_CONTENT_BYTES = 512 * 1024 * 1024;
|
|
7
|
+
const MAX_FILES = 25_000;
|
|
8
|
+
const BLOCK = 512;
|
|
9
|
+
const forbiddenDirs = new Set([".git", "node_modules", ".next", ".vite", ".turbo", ".cache", "dist", "build", "coverage"]);
|
|
10
|
+
/** All path rules apply on every host, including Windows aliases and devices. */
|
|
11
|
+
export function safeSourcePath(raw) {
|
|
12
|
+
if (!raw || raw.startsWith("/") || raw.includes("\\"))
|
|
13
|
+
throw new Error("artifact_source_path_invalid");
|
|
14
|
+
const segments = raw.replace(/^\.\//, "").split("/");
|
|
15
|
+
if (segments.at(-1) === "")
|
|
16
|
+
segments.pop();
|
|
17
|
+
if (!segments.length || segments.length > 64 || segments.some(part => !part || part === "." || part === ".." || /[\u0000-\u001f\u007f:]/u.test(part) || /[. ]$/.test(part) || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part)))
|
|
18
|
+
throw new Error("artifact_source_path_invalid");
|
|
19
|
+
if (segments.some(part => forbiddenDirs.has(part.toLowerCase()) || part.toLowerCase().startsWith(".env") || /(?:\.(?:pem|key|p12|pfx)|^\.?(?:credentials|netrc|npmrc|aws|ssh)$)/i.test(part)))
|
|
20
|
+
throw new Error("artifact_source_credential_or_cache_forbidden");
|
|
21
|
+
return segments.join("/");
|
|
22
|
+
}
|
|
23
|
+
function text(header, offset, length) {
|
|
24
|
+
const field = header.subarray(offset, offset + length);
|
|
25
|
+
const nul = field.indexOf(0);
|
|
26
|
+
return field.subarray(0, nul < 0 ? field.length : nul).toString("utf8");
|
|
27
|
+
}
|
|
28
|
+
function octal(header, offset, length) {
|
|
29
|
+
const value = text(header, offset, length).trim();
|
|
30
|
+
if (!/^[0-7]+$/.test(value))
|
|
31
|
+
throw new Error("artifact_source_tar_invalid");
|
|
32
|
+
const number = Number.parseInt(value, 8);
|
|
33
|
+
if (!Number.isSafeInteger(number) || number < 0)
|
|
34
|
+
throw new Error("artifact_source_tar_invalid");
|
|
35
|
+
return number;
|
|
36
|
+
}
|
|
37
|
+
/** Extract only regular files into a task-created empty staging directory. */
|
|
38
|
+
export async function extractArtifactSourceArchive(body, stagingRoot) {
|
|
39
|
+
if (!body.length || body.length > MAX_SOURCE_ARCHIVE_BYTES)
|
|
40
|
+
throw new Error("artifact_source_transfer_too_large");
|
|
41
|
+
let tar;
|
|
42
|
+
try {
|
|
43
|
+
tar = gunzipSync(body, { maxOutputLength: MAX_CONTENT_BYTES + MAX_FILES * 1024 });
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
throw new Error("artifact_source_archive_invalid");
|
|
47
|
+
}
|
|
48
|
+
let position = 0;
|
|
49
|
+
let fileCount = 0;
|
|
50
|
+
let byteCount = 0;
|
|
51
|
+
let ended = false;
|
|
52
|
+
let entryCount = 0;
|
|
53
|
+
const files = new Set();
|
|
54
|
+
const dirs = new Set();
|
|
55
|
+
const paths = new Map();
|
|
56
|
+
while (position + BLOCK <= tar.length) {
|
|
57
|
+
const header = tar.subarray(position, position + BLOCK);
|
|
58
|
+
position += BLOCK;
|
|
59
|
+
if (header.every(byte => byte === 0)) {
|
|
60
|
+
if (position + BLOCK > tar.length || !tar.subarray(position).every(byte => byte === 0))
|
|
61
|
+
throw new Error("artifact_source_tar_invalid");
|
|
62
|
+
ended = true;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
if (++entryCount > MAX_FILES * 2)
|
|
66
|
+
throw new Error("artifact_source_unpacked_too_large");
|
|
67
|
+
const expectedChecksum = octal(header, 148, 8);
|
|
68
|
+
const checksum = header.reduce((sum, byte, index) => sum + (index >= 148 && index < 156 ? 32 : byte), 0);
|
|
69
|
+
if (checksum !== expectedChecksum || text(header, 257, 6) !== "ustar")
|
|
70
|
+
throw new Error("artifact_source_tar_invalid");
|
|
71
|
+
const prefix = text(header, 345, 155);
|
|
72
|
+
const rawName = `${prefix ? prefix + "/" : ""}${text(header, 0, 100)}`;
|
|
73
|
+
const size = octal(header, 124, 12);
|
|
74
|
+
const padded = Math.ceil(size / BLOCK) * BLOCK;
|
|
75
|
+
if (position + padded > tar.length)
|
|
76
|
+
throw new Error("artifact_source_tar_invalid");
|
|
77
|
+
const kind = String.fromCharCode(header[156]);
|
|
78
|
+
if ((rawName === "." || rawName === "./") && kind === "5" && size === 0)
|
|
79
|
+
continue;
|
|
80
|
+
const relative = safeSourcePath(rawName);
|
|
81
|
+
const key = relative.toLowerCase();
|
|
82
|
+
const segments = relative.split("/");
|
|
83
|
+
for (let length = 1; length <= segments.length; length++) {
|
|
84
|
+
const actual = segments.slice(0, length).join("/");
|
|
85
|
+
const canonical = actual.toLowerCase();
|
|
86
|
+
if (paths.has(canonical) && paths.get(canonical) !== actual)
|
|
87
|
+
throw new Error("artifact_source_duplicate_path");
|
|
88
|
+
paths.set(canonical, actual);
|
|
89
|
+
}
|
|
90
|
+
if (paths.size > MAX_FILES * 2)
|
|
91
|
+
throw new Error("artifact_source_unpacked_too_large");
|
|
92
|
+
if (kind !== "0" && kind !== "\0" && kind !== "5")
|
|
93
|
+
throw new Error("artifact_source_entry_type_forbidden");
|
|
94
|
+
if (files.has(key) || dirs.has(key))
|
|
95
|
+
throw new Error("artifact_source_duplicate_path");
|
|
96
|
+
const ancestors = key.split("/").slice(0, -1);
|
|
97
|
+
if (ancestors.some((_, index) => files.has(ancestors.slice(0, index + 1).join("/"))))
|
|
98
|
+
throw new Error("artifact_source_duplicate_path");
|
|
99
|
+
const output = path.resolve(stagingRoot, relative);
|
|
100
|
+
const diff = path.relative(stagingRoot, output);
|
|
101
|
+
if (diff === ".." || diff.startsWith(`..${path.sep}`) || path.isAbsolute(diff))
|
|
102
|
+
throw new Error("artifact_source_path_invalid");
|
|
103
|
+
if (kind === "5") {
|
|
104
|
+
if (size !== 0)
|
|
105
|
+
throw new Error("artifact_source_tar_invalid");
|
|
106
|
+
dirs.add(key);
|
|
107
|
+
await mkdir(output, { recursive: true, mode: 0o700 });
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
if (++fileCount > MAX_FILES || (byteCount += size) > MAX_CONTENT_BYTES)
|
|
111
|
+
throw new Error("artifact_source_unpacked_too_large");
|
|
112
|
+
files.add(key);
|
|
113
|
+
await mkdir(path.dirname(output), { recursive: true, mode: 0o700 });
|
|
114
|
+
await writeFile(output, tar.subarray(position, position + size), { flag: "wx", mode: 0o600 });
|
|
115
|
+
}
|
|
116
|
+
position += padded;
|
|
117
|
+
}
|
|
118
|
+
if (!ended || !fileCount)
|
|
119
|
+
throw new Error("artifact_source_archive_invalid");
|
|
120
|
+
return { fileCount, byteCount, sha256: createHash("sha256").update(body).digest("hex") };
|
|
121
|
+
}
|
|
122
|
+
export async function boundedTransfer(response, maxBytes, expectedSize) {
|
|
123
|
+
const rawLength = response.headers.get("content-length");
|
|
124
|
+
const declared = rawLength === null ? null : Number(rawLength);
|
|
125
|
+
if (declared !== null && (!Number.isSafeInteger(declared) || declared < 0 || declared > maxBytes || (expectedSize !== undefined && declared !== expectedSize)))
|
|
126
|
+
throw new Error("build_transfer_size_invalid");
|
|
127
|
+
if (!response.body)
|
|
128
|
+
throw new Error("build_transfer_size_invalid");
|
|
129
|
+
const chunks = [];
|
|
130
|
+
let total = 0;
|
|
131
|
+
const reader = response.body.getReader();
|
|
132
|
+
for (;;) {
|
|
133
|
+
const part = await reader.read();
|
|
134
|
+
if (part.done)
|
|
135
|
+
break;
|
|
136
|
+
total += part.value.byteLength;
|
|
137
|
+
if (total > maxBytes) {
|
|
138
|
+
await reader.cancel().catch(() => undefined);
|
|
139
|
+
throw new Error("build_transfer_size_invalid");
|
|
140
|
+
}
|
|
141
|
+
chunks.push(Buffer.from(part.value));
|
|
142
|
+
}
|
|
143
|
+
if ((!total && expectedSize !== 0) || (declared !== null && total !== declared) || (expectedSize !== undefined && total !== expectedSize))
|
|
144
|
+
throw new Error("build_transfer_size_invalid");
|
|
145
|
+
return Buffer.concat(chunks);
|
|
146
|
+
}
|