@manifest-network/manifest-agent-core 0.14.0 → 0.16.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/dist/close-lease.d.ts.map +1 -1
- package/dist/close-lease.js +15 -3
- package/dist/close-lease.js.map +1 -1
- package/dist/deploy-app.d.ts +2 -2
- package/dist/deploy-app.d.ts.map +1 -1
- package/dist/deploy-app.js +84 -37
- package/dist/deploy-app.js.map +1 -1
- package/dist/deploy-app.test-d.d.ts +1 -0
- package/dist/deploy-app.test-d.js +11 -0
- package/dist/deploy-app.test-d.js.map +1 -0
- package/dist/guarded-fetch.d.ts +2 -0
- package/dist/guarded-fetch.js +2 -0
- package/dist/index.d.ts +2 -3
- package/dist/index.js +1 -2
- package/dist/internals/cancellation.d.ts +57 -0
- package/dist/internals/cancellation.d.ts.map +1 -0
- package/dist/internals/cancellation.js +79 -0
- package/dist/internals/cancellation.js.map +1 -0
- package/dist/internals/inspect-image.js +1 -1
- package/dist/internals/inspect-image.js.map +1 -1
- package/dist/internals/render-intent-recap.d.ts +13 -11
- package/dist/internals/render-intent-recap.d.ts.map +1 -1
- package/dist/internals/render-intent-recap.js +5 -4
- package/dist/internals/render-intent-recap.js.map +1 -1
- package/dist/internals/spec-normalize.d.ts +34 -28
- package/dist/internals/spec-normalize.d.ts.map +1 -1
- package/dist/internals/spec-normalize.js +28 -22
- package/dist/internals/spec-normalize.js.map +1 -1
- package/dist/manage-domain.d.ts.map +1 -1
- package/dist/manage-domain.js +34 -8
- package/dist/manage-domain.js.map +1 -1
- package/dist/node_modules/@vitest/pretty-format/dist/index.js +888 -0
- package/dist/node_modules/@vitest/pretty-format/dist/index.js.map +1 -0
- package/dist/node_modules/@vitest/runner/dist/chunk-artifact.js +1500 -0
- package/dist/node_modules/@vitest/runner/dist/chunk-artifact.js.map +1 -0
- package/dist/node_modules/@vitest/runner/dist/index.js +1 -0
- package/dist/node_modules/@vitest/runner/dist/utils.js +1 -0
- package/dist/node_modules/@vitest/utils/dist/chunk-pathe.M-eThtNZ.js +82 -0
- package/dist/node_modules/@vitest/utils/dist/chunk-pathe.M-eThtNZ.js.map +1 -0
- package/dist/node_modules/@vitest/utils/dist/display.js +558 -0
- package/dist/node_modules/@vitest/utils/dist/display.js.map +1 -0
- package/dist/node_modules/@vitest/utils/dist/helpers.js +68 -0
- package/dist/node_modules/@vitest/utils/dist/helpers.js.map +1 -0
- package/dist/node_modules/@vitest/utils/dist/source-map.js +95 -0
- package/dist/node_modules/@vitest/utils/dist/source-map.js.map +1 -0
- package/dist/node_modules/@vitest/utils/dist/timers.js +20 -0
- package/dist/node_modules/@vitest/utils/dist/timers.js.map +1 -0
- package/dist/node_modules/tinyrainbow/dist/index.js +86 -0
- package/dist/node_modules/tinyrainbow/dist/index.js.map +1 -0
- package/dist/node_modules/vite/dist/node/module-runner.js +22 -0
- package/dist/node_modules/vite/dist/node/module-runner.js.map +1 -0
- package/dist/node_modules/vitest/dist/index.js +6 -0
- package/dist/troubleshoot.d.ts.map +1 -1
- package/dist/troubleshoot.js +11 -1
- package/dist/troubleshoot.js.map +1 -1
- package/dist/types.d.ts +30 -51
- package/dist/types.d.ts.map +1 -1
- package/package.json +15 -7
- package/dist/internals/build-fred-input.d.ts +0 -46
- package/dist/internals/build-fred-input.d.ts.map +0 -1
- package/dist/internals/build-fred-input.js +0 -160
- package/dist/internals/build-fred-input.js.map +0 -1
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { ManifestMCPError, ManifestMCPErrorCode, resolveCallSignal } from "@manifest-network/manifest-mcp-core";
|
|
2
|
+
//#region src/internals/cancellation.ts
|
|
3
|
+
/**
|
|
4
|
+
* Build the structured cancellation error for an aborted/timed-out PRE-broadcast
|
|
5
|
+
* await (or a stopped-awaiting read). `OPERATION_CANCELLED` is non-retryable and
|
|
6
|
+
* keeps the abort path consistent with the SDK error model; the original
|
|
7
|
+
* `AbortError`/`TimeoutError` reason is preserved in the message + details.
|
|
8
|
+
*
|
|
9
|
+
* - `broadcasts: true` → mutating flows (deploy / domain-set / lease-close):
|
|
10
|
+
* the abort happened before any tx was sent.
|
|
11
|
+
* - `broadcasts: false` → read-only flows (troubleshoot / domain-lookup): there
|
|
12
|
+
* is no broadcast to reference; we merely stopped awaiting the query.
|
|
13
|
+
*/
|
|
14
|
+
function cancelledError(reason, opLabel, broadcasts) {
|
|
15
|
+
const detail = reason instanceof Error ? reason.message : String(reason);
|
|
16
|
+
const message = broadcasts ? `${opLabel} was cancelled before broadcast (${detail}); no transaction was sent.` : `${opLabel} was cancelled (${detail}).`;
|
|
17
|
+
return new ManifestMCPError(ManifestMCPErrorCode.OPERATION_CANCELLED, message, { reason });
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Race a pending promise against an `AbortSignal`. Copies the executor + swallow
|
|
21
|
+
* shape from core's `internals/tx-confirmation.ts:withTxConfirmation`: the losing
|
|
22
|
+
* branch's eventual rejection is swallowed (no unhandled rejection) and the abort
|
|
23
|
+
* listener is added `{ once: true }` and removed in `.finally`. The rejection
|
|
24
|
+
* error is produced by the injected `makeError`, so the primitive stays
|
|
25
|
+
* operation-agnostic. Manifestjs queries take no `AbortSignal`, so for read-only
|
|
26
|
+
* callers this does NOT cancel the RPC — it only stops AWAITING it.
|
|
27
|
+
*/
|
|
28
|
+
function raceAbort(promise, signal, makeError) {
|
|
29
|
+
if (signal.aborted) {
|
|
30
|
+
promise.catch(() => {});
|
|
31
|
+
return Promise.reject(makeError(signal.reason));
|
|
32
|
+
}
|
|
33
|
+
promise.catch(() => {});
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const onAbort = () => reject(makeError(signal.reason));
|
|
36
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
37
|
+
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Build the per-call cancellation seam: captures the resolved signal, a once-guard
|
|
42
|
+
* for the terminal `cancelled` progress event, and the operation label /
|
|
43
|
+
* broadcast-ness for the error message. PURE at construction — call
|
|
44
|
+
* `throwIfCancelled()` explicitly for the already-aborted short-circuit.
|
|
45
|
+
*/
|
|
46
|
+
function makeCancellationScope(args) {
|
|
47
|
+
const { opts, onProgress, opLabel, broadcasts } = args;
|
|
48
|
+
const signal = resolveCallSignal(opts);
|
|
49
|
+
const makeError = (reason) => cancelledError(reason, opLabel, broadcasts);
|
|
50
|
+
let cancelledEmitted = false;
|
|
51
|
+
const cancelOnAbort = (reason) => {
|
|
52
|
+
if (!cancelledEmitted) {
|
|
53
|
+
cancelledEmitted = true;
|
|
54
|
+
onProgress?.({ kind: "cancelled" });
|
|
55
|
+
}
|
|
56
|
+
throw makeError(reason);
|
|
57
|
+
};
|
|
58
|
+
const throwIfCancelled = () => {
|
|
59
|
+
if (signal?.aborted) cancelOnAbort(signal.reason);
|
|
60
|
+
};
|
|
61
|
+
const race = async (p) => {
|
|
62
|
+
if (signal === void 0) return p;
|
|
63
|
+
try {
|
|
64
|
+
return await raceAbort(p, signal, makeError);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (err instanceof ManifestMCPError && err.code === ManifestMCPErrorCode.OPERATION_CANCELLED && signal.aborted) cancelOnAbort(signal.reason);
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
return {
|
|
71
|
+
signal,
|
|
72
|
+
throwIfCancelled,
|
|
73
|
+
race
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
//#endregion
|
|
77
|
+
export { cancelledError, makeCancellationScope, raceAbort };
|
|
78
|
+
|
|
79
|
+
//# sourceMappingURL=cancellation.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cancellation.js","names":[],"sources":["../../src/internals/cancellation.ts"],"sourcesContent":["import {\n ManifestMCPError,\n ManifestMCPErrorCode,\n resolveCallSignal,\n} from '@manifest-network/manifest-mcp-core';\nimport type { ProgressEvent } from '../types.js';\n\n/**\n * Build the structured cancellation error for an aborted/timed-out PRE-broadcast\n * await (or a stopped-awaiting read). `OPERATION_CANCELLED` is non-retryable and\n * keeps the abort path consistent with the SDK error model; the original\n * `AbortError`/`TimeoutError` reason is preserved in the message + details.\n *\n * - `broadcasts: true` → mutating flows (deploy / domain-set / lease-close):\n * the abort happened before any tx was sent.\n * - `broadcasts: false` → read-only flows (troubleshoot / domain-lookup): there\n * is no broadcast to reference; we merely stopped awaiting the query.\n */\nexport function cancelledError(\n reason: unknown,\n opLabel: string,\n broadcasts: boolean,\n): ManifestMCPError {\n const detail = reason instanceof Error ? reason.message : String(reason);\n const message = broadcasts\n ? `${opLabel} was cancelled before broadcast (${detail}); no transaction was sent.`\n : `${opLabel} was cancelled (${detail}).`;\n return new ManifestMCPError(\n ManifestMCPErrorCode.OPERATION_CANCELLED,\n message,\n {\n reason,\n },\n );\n}\n\n/**\n * Race a pending promise against an `AbortSignal`. Copies the executor + swallow\n * shape from core's `internals/tx-confirmation.ts:withTxConfirmation`: the losing\n * branch's eventual rejection is swallowed (no unhandled rejection) and the abort\n * listener is added `{ once: true }` and removed in `.finally`. The rejection\n * error is produced by the injected `makeError`, so the primitive stays\n * operation-agnostic. Manifestjs queries take no `AbortSignal`, so for read-only\n * callers this does NOT cancel the RPC — it only stops AWAITING it.\n */\nexport function raceAbort<T>(\n promise: Promise<T>,\n signal: AbortSignal,\n makeError: (reason: unknown) => ManifestMCPError,\n): Promise<T> {\n if (signal.aborted) {\n promise.catch(() => {}); // swallow the loser even on the already-aborted path\n return Promise.reject(makeError(signal.reason));\n }\n promise.catch(() => {}); // swallow the losing branch's eventual rejection\n return new Promise<T>((resolve, reject) => {\n const onAbort = (): void => reject(makeError(signal.reason));\n signal.addEventListener('abort', onAbort, { once: true });\n promise\n .then(resolve, reject)\n .finally(() => signal.removeEventListener('abort', onAbort));\n });\n}\n\n/** Per-call cancellation seam shared by all four agent-core orchestrators. */\nexport interface CancellationScope {\n /** Effective signal (caller `signal` composed with `timeout`), or `undefined`. */\n signal: AbortSignal | undefined;\n /** Throw `OPERATION_CANCELLED` (emitting `cancelled` once) if already aborted. */\n throwIfCancelled: () => void;\n /**\n * Race a pre-broadcast callback or a read query against the signal. A no-op\n * passthrough when no signal is present. On abort it emits `cancelled` once\n * and throws `OPERATION_CANCELLED`.\n */\n race: <T>(p: Promise<T>) => Promise<T>;\n}\n\n/**\n * Build the per-call cancellation seam: captures the resolved signal, a once-guard\n * for the terminal `cancelled` progress event, and the operation label /\n * broadcast-ness for the error message. PURE at construction — call\n * `throwIfCancelled()` explicitly for the already-aborted short-circuit.\n */\nexport function makeCancellationScope(args: {\n opts: { signal?: AbortSignal; timeout?: number };\n onProgress: ((event: ProgressEvent) => void) | undefined;\n opLabel: string;\n broadcasts: boolean;\n}): CancellationScope {\n const { opts, onProgress, opLabel, broadcasts } = args;\n const signal = resolveCallSignal(opts);\n const makeError = (reason: unknown): ManifestMCPError =>\n cancelledError(reason, opLabel, broadcasts);\n let cancelledEmitted = false;\n const cancelOnAbort = (reason: unknown): never => {\n if (!cancelledEmitted) {\n cancelledEmitted = true;\n onProgress?.({ kind: 'cancelled' });\n }\n throw makeError(reason);\n };\n const throwIfCancelled = (): void => {\n if (signal?.aborted) cancelOnAbort(signal.reason);\n };\n const race = async <T>(p: Promise<T>): Promise<T> => {\n if (signal === undefined) return p;\n try {\n return await raceAbort(p, signal, makeError);\n } catch (err) {\n if (\n err instanceof ManifestMCPError &&\n err.code === ManifestMCPErrorCode.OPERATION_CANCELLED &&\n signal.aborted\n ) {\n cancelOnAbort(signal.reason);\n }\n throw err;\n }\n };\n return { signal, throwIfCancelled, race };\n}\n"],"mappings":";;;;;;;;;;;;;AAkBA,SAAgB,eACd,QACA,SACA,YACkB;CAClB,MAAM,SAAS,kBAAkB,QAAQ,OAAO,UAAU,OAAO,MAAM;CACvE,MAAM,UAAU,aACZ,GAAG,QAAQ,mCAAmC,OAAO,+BACrD,GAAG,QAAQ,kBAAkB,OAAO;CACxC,OAAO,IAAI,iBACT,qBAAqB,qBACrB,SACA,EACE,OACF,CACF;AACF;;;;;;;;;;AAWA,SAAgB,UACd,SACA,QACA,WACY;CACZ,IAAI,OAAO,SAAS;EAClB,QAAQ,YAAY,CAAC,CAAC;EACtB,OAAO,QAAQ,OAAO,UAAU,OAAO,MAAM,CAAC;CAChD;CACA,QAAQ,YAAY,CAAC,CAAC;CACtB,OAAO,IAAI,SAAY,SAAS,WAAW;EACzC,MAAM,gBAAsB,OAAO,UAAU,OAAO,MAAM,CAAC;EAC3D,OAAO,iBAAiB,SAAS,SAAS,EAAE,MAAM,KAAK,CAAC;EACxD,QACG,KAAK,SAAS,MAAM,EACpB,cAAc,OAAO,oBAAoB,SAAS,OAAO,CAAC;CAC/D,CAAC;AACH;;;;;;;AAsBA,SAAgB,sBAAsB,MAKhB;CACpB,MAAM,EAAE,MAAM,YAAY,SAAS,eAAe;CAClD,MAAM,SAAS,kBAAkB,IAAI;CACrC,MAAM,aAAa,WACjB,eAAe,QAAQ,SAAS,UAAU;CAC5C,IAAI,mBAAmB;CACvB,MAAM,iBAAiB,WAA2B;EAChD,IAAI,CAAC,kBAAkB;GACrB,mBAAmB;GACnB,aAAa,EAAE,MAAM,YAAY,CAAC;EACpC;EACA,MAAM,UAAU,MAAM;CACxB;CACA,MAAM,yBAA+B;EACnC,IAAI,QAAQ,SAAS,cAAc,OAAO,MAAM;CAClD;CACA,MAAM,OAAO,OAAU,MAA8B;EACnD,IAAI,WAAW,KAAA,GAAW,OAAO;EACjC,IAAI;GACF,OAAO,MAAM,UAAU,GAAG,QAAQ,SAAS;EAC7C,SAAS,KAAK;GACZ,IACE,eAAe,oBACf,IAAI,SAAS,qBAAqB,uBAClC,OAAO,SAEP,cAAc,OAAO,MAAM;GAE7B,MAAM;EACR;CACF;CACA,OAAO;EAAE;EAAQ;EAAkB;CAAK;AAC1C"}
|
|
@@ -23,7 +23,7 @@ import { createGuardedFetch } from "./guarded-fetch.js";
|
|
|
23
23
|
*
|
|
24
24
|
* ## Security — SSRF (production callers MUST read)
|
|
25
25
|
*
|
|
26
|
-
* `imageRef` is user-controlled (it comes from `
|
|
26
|
+
* `imageRef` is user-controlled (it comes from `AppDeploySpec.image`).
|
|
27
27
|
* Without an SSRF guard, an image ref like `169.254.169.254:80/foo:bar`
|
|
28
28
|
* (cloud-metadata) or `127.0.0.1:6379/foo:bar` (local Redis) would
|
|
29
29
|
* cause this function to probe internal services on the host. The CJS
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"inspect-image.js","names":[],"sources":["../../src/internals/inspect-image.ts"],"sourcesContent":["import { createGuardedFetch } from './guarded-fetch.js';\n\n/**\n * Inspect a public container image via the OCI Distribution API. Returns\n * the manifest digest, exposed ports, image defaults (env / cmd /\n * entrypoint / user / workingDir), healthcheck, labels, volumes, and a\n * heuristic `suggestedTmpfs` list for known-good Fred image families.\n *\n * HTTPS requests go through `opts.fetch`, defaulting to `createGuardedFetch()`\n * (DIY undici Dispatcher + RFC-cited block ranges + IPv4-mapped IPv6\n * normalization — see `guarded-fetch.ts` for the design).\n *\n * **Fail-soft contract:** returns `null` on every non-fatal failure mode:\n * - 401 / 403 (private registry / auth required)\n * - 429 (Docker Hub rate-limit)\n * - OCI grammar violation in the `imageRef`\n * - Manifest body exceeding the 10 MiB cap\n * - Request timeout (10s)\n * - Unparseable manifest / blob JSON\n * - SSRF block (default fetch refuses RFC 1918 / loopback / etc.)\n * Callers treat `null` as \"no info, ask the user\".\n * Diagnostics flow through `opts.logger` instead of stderr.\n *\n * ## Security — SSRF (production callers MUST read)\n *\n * `imageRef` is user-controlled (it comes from `DeploySpec.image`).\n * Without an SSRF guard, an image ref like `169.254.169.254:80/foo:bar`\n * (cloud-metadata) or `127.0.0.1:6379/foo:bar` (local Redis) would\n * cause this function to probe internal services on the host. The CJS\n * blocks this via its SSRF-aware HTTPS agent; the TS port delegates to\n * the caller's `opts.fetch`, defaulting to `createGuardedFetch()` which\n * blocks at connect time.\n *\n * Opt-out-of-safety semantics (parent's PR-2 directive): the default\n * `opts.fetch = createGuardedFetch()` is safe by construction. Callers\n * pass their own `opts.fetch` ONLY for tests (canned responses) or\n * unusual production cases (e.g. a trusted private registry on an RFC\n * 1918 IP, after explicit allow-listing). See `createGuardedFetch`'s\n * JSDoc for the production-guard contract.\n */\n\nconst ACCEPT_MANIFEST = [\n 'application/vnd.oci.image.index.v1+json',\n 'application/vnd.oci.image.manifest.v1+json',\n 'application/vnd.docker.distribution.manifest.list.v2+json',\n 'application/vnd.docker.distribution.manifest.v2+json',\n].join(', ');\n\n// OCI Distribution Spec v1.1 grammar for URL-interpolated fields. Validate\n// BEFORE the URL is constructed; the `imageRef` flag is user-controlled,\n// so a malformed input like `foo/bar:..%2F..%2Fconfig` must be rejected\n// here rather than forwarded to the registry.\nconst OCI_NAME_COMPONENT = /^[a-z0-9]+(?:(?:\\.|_|__|-+)[a-z0-9]+)*$/;\nconst OCI_TAG = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/;\nconst OCI_DIGEST = /^sha256:[0-9a-f]{64}$/;\n\n// Body-size cap (10 MiB). Real-world configs are <100 KB; even JVM-rich\n// images rarely exceed a few MB. Anything over 10 MiB indicates a hostile\n// or buggy registry; abort rather than risk OOM.\nconst MAX_BODY_BYTES = 10 * 1024 * 1024;\n\n// Request timeout — 10s. Registry queries should be fast; longer waits\n// indicate a hung registry.\nconst REQUEST_TIMEOUT_MS = 10_000;\n\n// Heuristic table: image base name or resolved Cmd/Entrypoint contains\n// one of these tokens → suggest the corresponding tmpfs paths. Order:\n// longer/more-specific tokens first when there's ambiguity.\nconst TMPFS_HINTS: ReadonlyArray<{\n readonly match: string;\n readonly paths: readonly string[];\n}> = [\n { match: 'wordpress', paths: ['/run/lock', '/var/run/apache2'] },\n { match: 'mariadb', paths: ['/run/mysqld'] },\n { match: 'postgres', paths: ['/var/run/postgresql'] },\n { match: 'mysql', paths: ['/var/run/mysqld'] },\n { match: 'nginx', paths: ['/var/cache/nginx', '/var/run'] },\n];\n\nexport interface ImageInfo {\n image: string;\n digest: string | null;\n ports: string[];\n env: Record<string, string>;\n cmd: string[] | null;\n entrypoint: string[] | null;\n user: string;\n workingDir: string;\n healthcheck: Record<string, unknown> | null;\n labels: Record<string, string> | null;\n volumes: Record<string, unknown> | null;\n suggestedTmpfs: string[];\n}\n\nexport interface InspectImageOptions {\n /**\n * HTTP client. **Production callers SHOULD use the default** (which is\n * `createGuardedFetch()`, blocking RFC 1918 / loopback / link-local /\n * metadata at connect time). Tests pass canned implementations.\n * Browser/Deno consumers pass their own SSRF-guarded fetch since\n * `createGuardedFetch()` throws on non-Node runtimes.\n */\n fetch?: typeof fetch;\n /** Sink for fail-soft diagnostics. Defaults to `console.warn`. */\n logger?: (reason: string) => void;\n}\n\nconst defaultLogger: (reason: string) => void = (reason) => {\n console.warn(reason);\n};\n\ninterface ParsedRef {\n registry: string;\n name: string;\n tag: string | null;\n digest: string | null;\n}\n\nexport async function inspectImage(\n imageRef: string,\n opts: InspectImageOptions = {},\n): Promise<ImageInfo | null> {\n const logger = opts.logger ?? defaultLogger;\n const fetchImpl: typeof fetch = opts.fetch ?? createDefaultGuardedFetch();\n\n let parsed: ParsedRef;\n try {\n parsed = parseRef(imageRef);\n } catch (err) {\n logger(\n `inspect-image: ${err instanceof Error ? err.message : String(err)}`,\n );\n return null;\n }\n\n const ref = parsed.digest ?? parsed.tag ?? 'latest';\n try {\n let authHeader: string | null = null;\n if (parsed.registry === 'docker.io') {\n const token = await getDockerHubToken(parsed.name, fetchImpl);\n authHeader = `Bearer ${token}`;\n }\n\n // Step 1: fetch manifest (may be an index → pick platform → refetch).\n let manifestRes = await fetchManifest(\n parsed.registry,\n parsed.name,\n ref,\n authHeader,\n fetchImpl,\n );\n if (\n manifestRes.contentType.includes('manifest.list') ||\n manifestRes.contentType.includes('image.index') ||\n isManifestIndex(manifestRes.manifest)\n ) {\n const child = pickPlatformManifest(manifestRes.manifest);\n if (!child || typeof child.digest !== 'string') {\n throw new Error('multi-arch index has no usable child manifest');\n }\n manifestRes = await fetchManifest(\n parsed.registry,\n parsed.name,\n child.digest,\n authHeader,\n fetchImpl,\n );\n }\n\n // Step 2: fetch the config blob — the actual image config lives there.\n const config = manifestRes.manifest.config as\n | { digest?: unknown }\n | undefined;\n if (!config || typeof config.digest !== 'string') {\n throw new Error('manifest has no config descriptor');\n }\n const configBlob = await fetchBlobJson(\n parsed.registry,\n parsed.name,\n config.digest,\n authHeader,\n fetchImpl,\n );\n const c = (configBlob.config ?? {}) as Record<string, unknown>;\n\n const out: ImageInfo = {\n image: `${parsed.registry}/${parsed.name}${parsed.digest ? '@' + parsed.digest : ':' + (parsed.tag ?? 'latest')}`,\n digest: manifestRes.digest ?? parsed.digest ?? null,\n ports: pickPorts(c.ExposedPorts),\n env: parseEnv(c.Env),\n cmd: Array.isArray(c.Cmd) ? (c.Cmd as string[]) : null,\n entrypoint: Array.isArray(c.Entrypoint)\n ? (c.Entrypoint as string[])\n : null,\n user: typeof c.User === 'string' ? c.User : '',\n workingDir: typeof c.WorkingDir === 'string' ? c.WorkingDir : '',\n healthcheck:\n c.Healthcheck !== null &&\n typeof c.Healthcheck === 'object' &&\n !Array.isArray(c.Healthcheck)\n ? (c.Healthcheck as Record<string, unknown>)\n : null,\n labels:\n c.Labels !== null &&\n typeof c.Labels === 'object' &&\n !Array.isArray(c.Labels)\n ? (c.Labels as Record<string, string>)\n : null,\n volumes:\n c.Volumes !== null &&\n typeof c.Volumes === 'object' &&\n !Array.isArray(c.Volumes)\n ? (c.Volumes as Record<string, unknown>)\n : null,\n suggestedTmpfs: [],\n };\n out.suggestedTmpfs = suggestedTmpfsFor(parsed.name, [\n ...(out.cmd ?? []),\n ...(out.entrypoint ?? []),\n ]);\n\n return out;\n } catch (err) {\n logger(`inspect-image: ${formatErrorChain(err)}`);\n return null;\n }\n}\n\n/**\n * Walk an Error's `cause` chain and join all message strings. undici wraps\n * connection errors (including SSRF blocks from our custom Dispatcher) in\n * a fetch-side TypeError with the underlying cause nested via `.cause`.\n * Surfacing the chain in the logger gives the user the real reason (e.g.,\n * \"SSRF blocked: 127.0.0.1 ... loopback\") instead of an opaque\n * \"fetch failed\".\n */\nfunction formatErrorChain(err: unknown): string {\n const parts: string[] = [];\n let current: unknown = err;\n let depth = 0;\n // Defensive bound — sane Error chains are <5 levels; cap at 10 to avoid\n // pathological cycles.\n while (current !== null && current !== undefined && depth < 10) {\n if (current instanceof Error) {\n parts.push(current.message);\n current = (current as Error & { cause?: unknown }).cause;\n } else {\n parts.push(String(current));\n current = undefined;\n }\n depth += 1;\n }\n return parts.join(' | ');\n}\n\nlet cachedDefaultFetch: typeof fetch | undefined;\nfunction createDefaultGuardedFetch(): typeof fetch {\n if (!cachedDefaultFetch) {\n cachedDefaultFetch = createGuardedFetch();\n }\n return cachedDefaultFetch;\n}\n\nfunction parseRef(ref: string): ParsedRef {\n // \"<reg>/<name>@sha256:<digest>\" | \"<reg>/<name>:<tag>\" | \"<name>\" | \"<name>:<tag>\"\n let registry = 'docker.io';\n let name: string;\n let tag: string | null = null;\n let digest: string | null = null;\n\n let rest = ref;\n const atIdx = rest.indexOf('@');\n if (atIdx >= 0) {\n digest = rest.slice(atIdx + 1);\n rest = rest.slice(0, atIdx);\n }\n\n // Detect registry segment: head before first `/` is a registry only if\n // it has a `.` or `:` (port) or is `localhost`.\n const firstSlash = rest.indexOf('/');\n if (firstSlash > 0) {\n const head = rest.slice(0, firstSlash);\n if (head === 'localhost' || head.includes('.') || head.includes(':')) {\n registry = head;\n rest = rest.slice(firstSlash + 1);\n }\n }\n\n if (!digest) {\n const colonIdx = rest.lastIndexOf(':');\n if (colonIdx >= 0) {\n tag = rest.slice(colonIdx + 1);\n name = rest.slice(0, colonIdx);\n } else {\n name = rest;\n tag = 'latest';\n }\n } else {\n name = rest;\n }\n\n // Docker Hub library prefix for single-segment names (\"nginx\" → \"library/nginx\").\n if (registry === 'docker.io' && !name.includes('/')) {\n name = `library/${name}`;\n }\n\n // Validate URL-interpolated fields against OCI Distribution Spec grammar\n // BEFORE the URL is constructed. The ref strings reach the user via\n // `DeploySpec.image`, so malformed input must be rejected here.\n for (const component of name.split('/')) {\n if (!OCI_NAME_COMPONENT.test(component)) {\n throw new Error(`invalid name component \"${component}\" in image ref`);\n }\n }\n if (tag !== null && !OCI_TAG.test(tag)) {\n throw new Error(`invalid tag \"${tag}\" in image ref`);\n }\n if (digest !== null && !OCI_DIGEST.test(digest)) {\n throw new Error(\n `invalid digest \"${digest}\" in image ref (expected sha256:<64-hex>)`,\n );\n }\n\n return { registry, name, tag, digest };\n}\n\nfunction registryHost(registry: string): string {\n // Docker Hub's image API lives at registry-1.docker.io even though the\n // canonical \"registry\" name is docker.io.\n return registry === 'docker.io' ? 'registry-1.docker.io' : registry;\n}\n\nasync function getDockerHubToken(\n name: string,\n fetchImpl: typeof fetch,\n): Promise<string> {\n // Docker Hub requires anonymous access still go through a token grant.\n // Surface 429 specifically — anonymous pulls are rate-limited per-IP\n // and a 60-min wait fixes it. Without this special case the user sees\n // the same fail-soft `null` outcome as a hard 401 with no signal that\n // the situation is temporary.\n const res = await capturingFetch(\n `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${name}:pull`,\n {},\n fetchImpl,\n );\n if (res.status === 429) {\n throw new Error(\n 'Docker Hub token: HTTP 429 (anonymous pulls rate-limited per-IP; retry after ~60 min, or authenticate)',\n );\n }\n if (res.status !== 200) {\n throw new Error(`Docker Hub token: HTTP ${res.status}`);\n }\n let parsed: unknown;\n try {\n parsed = JSON.parse(res.body);\n } catch {\n throw new Error('Docker Hub token: invalid JSON');\n }\n if (\n parsed === null ||\n typeof parsed !== 'object' ||\n typeof (parsed as { token?: unknown }).token !== 'string'\n ) {\n throw new Error('Docker Hub token: missing `token` in response');\n }\n return (parsed as { token: string }).token;\n}\n\nasync function fetchManifest(\n registry: string,\n name: string,\n ref: string,\n authHeader: string | null,\n fetchImpl: typeof fetch,\n): Promise<{\n manifest: Record<string, unknown>;\n contentType: string;\n digest: string | null;\n}> {\n const host = registryHost(registry);\n const url = `https://${host}/v2/${name}/manifests/${ref}`;\n const headers: Record<string, string> = { Accept: ACCEPT_MANIFEST };\n if (authHeader) headers.Authorization = authHeader;\n const res = await capturingFetch(url, { headers }, fetchImpl);\n if (res.status === 401 || res.status === 403) {\n throw new Error(\n `registry returned ${res.status} on manifest fetch (auth required? private registry?)`,\n );\n }\n if (res.status === 404) {\n // Digest-pinned refs use `@sha256:...`; tag refs use `:tag`. Pick the\n // right separator so the error message doesn't show\n // `registry/name:sha256:...` mistakenly.\n const sep = ref.startsWith('sha256:') ? '@' : ':';\n throw new Error(`image not found: ${registry}/${name}${sep}${ref}`);\n }\n if (res.status !== 200) {\n throw new Error(`registry returned ${res.status} on manifest fetch`);\n }\n let parsed: unknown;\n try {\n parsed = JSON.parse(res.body);\n } catch {\n throw new Error('manifest is not valid JSON');\n }\n if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {\n throw new Error('manifest is not a JSON object');\n }\n return {\n manifest: parsed as Record<string, unknown>,\n contentType: res.headers.get('content-type') ?? '',\n digest: res.headers.get('docker-content-digest'),\n };\n}\n\nasync function fetchBlobJson(\n registry: string,\n name: string,\n digest: string,\n authHeader: string | null,\n fetchImpl: typeof fetch,\n): Promise<{ config?: unknown }> {\n const host = registryHost(registry);\n const url = `https://${host}/v2/${name}/blobs/${digest}`;\n const headers: Record<string, string> = {};\n if (authHeader) headers.Authorization = authHeader;\n // undici fetch follows redirects by default; registries 307 → CDN.\n const res = await capturingFetch(url, { headers }, fetchImpl);\n if (res.status !== 200) {\n throw new Error(`registry returned ${res.status} on blob fetch`);\n }\n try {\n return JSON.parse(res.body) as { config?: unknown };\n } catch {\n throw new Error('blob is not valid JSON');\n }\n}\n\ninterface CapturedResponse {\n status: number;\n headers: Headers;\n body: string;\n}\n\n/**\n * Wrap fetch with `AbortSignal.timeout(REQUEST_TIMEOUT_MS)` and a streamed\n * body-size cap. Throws on overflow, timeout, or read error so the outer\n * try/catch produces the fail-soft `null` return.\n */\nasync function capturingFetch(\n url: string,\n init: RequestInit,\n fetchImpl: typeof fetch,\n): Promise<CapturedResponse> {\n const signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS);\n let response: Response;\n try {\n response = await fetchImpl(url, { ...init, signal });\n } catch (err) {\n if (err instanceof Error && err.name === 'TimeoutError') {\n throw new Error(`request timeout on ${url}`);\n }\n throw err;\n }\n // Stream the body with a manual chunk-accumulation cap. Avoids the\n // unbounded `await response.text()` path that would let a hostile\n // registry exhaust memory.\n const reader = response.body?.getReader();\n if (!reader) {\n return { status: response.status, headers: response.headers, body: '' };\n }\n const chunks: Uint8Array[] = [];\n let totalBytes = 0;\n const decoder = new TextDecoder();\n let body = '';\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n if (value) {\n totalBytes += value.length;\n if (totalBytes > MAX_BODY_BYTES) {\n await reader.cancel();\n throw new Error(\n `response body exceeded ${MAX_BODY_BYTES} bytes (cap) on ${url}`,\n );\n }\n chunks.push(value);\n }\n }\n body = decoder.decode(concatUint8Arrays(chunks));\n } finally {\n reader.releaseLock();\n }\n return { status: response.status, headers: response.headers, body };\n}\n\nfunction concatUint8Arrays(chunks: Uint8Array[]): Uint8Array {\n if (chunks.length === 0) return new Uint8Array(0);\n if (chunks.length === 1) {\n const only = chunks[0];\n if (only !== undefined) return only;\n }\n let total = 0;\n for (const c of chunks) total += c.length;\n const out = new Uint8Array(total);\n let offset = 0;\n for (const c of chunks) {\n out.set(c, offset);\n offset += c.length;\n }\n return out;\n}\n\nfunction pickPorts(exposedPorts: unknown): string[] {\n if (\n exposedPorts === null ||\n typeof exposedPorts !== 'object' ||\n Array.isArray(exposedPorts)\n ) {\n return [];\n }\n return Object.keys(exposedPorts as Record<string, unknown>).sort();\n}\n\nfunction parseEnv(env: unknown): Record<string, string> {\n if (!Array.isArray(env)) return {};\n const out: Record<string, string> = {};\n for (const kv of env) {\n if (typeof kv !== 'string') continue;\n const i = kv.indexOf('=');\n if (i > 0) {\n const key = kv.slice(0, i);\n const value = kv.slice(i + 1);\n out[key] = value;\n } else {\n out[kv] = '';\n }\n }\n return out;\n}\n\nfunction isManifestIndex(m: Record<string, unknown>): boolean {\n return Array.isArray(m.manifests);\n}\n\nfunction pickPlatformManifest(\n index: Record<string, unknown>,\n): Record<string, unknown> | null {\n const list = index.manifests;\n if (!Array.isArray(list)) return null;\n const linuxAmd64 = list.find(\n (m): m is Record<string, unknown> =>\n m !== null &&\n typeof m === 'object' &&\n (m as { platform?: unknown }).platform !== null &&\n typeof (m as { platform?: unknown }).platform === 'object' &&\n (m as { platform: { os?: unknown } }).platform.os === 'linux' &&\n (m as { platform: { architecture?: unknown } }).platform.architecture ===\n 'amd64',\n );\n if (linuxAmd64) return linuxAmd64;\n // Fall back to first entry.\n const first = list[0];\n if (first !== null && typeof first === 'object' && !Array.isArray(first)) {\n return first as Record<string, unknown>;\n }\n return null;\n}\n\nfunction suggestedTmpfsFor(\n name: string,\n cmdAndEntrypoint: ReadonlyArray<string>,\n): string[] {\n const haystack = [name, ...cmdAndEntrypoint].join(' ').toLowerCase();\n for (const hint of TMPFS_HINTS) {\n if (haystack.includes(hint.match)) return [...hint.paths];\n }\n return [];\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"inspect-image.js","names":[],"sources":["../../src/internals/inspect-image.ts"],"sourcesContent":["import { createGuardedFetch } from './guarded-fetch.js';\n\n/**\n * Inspect a public container image via the OCI Distribution API. Returns\n * the manifest digest, exposed ports, image defaults (env / cmd /\n * entrypoint / user / workingDir), healthcheck, labels, volumes, and a\n * heuristic `suggestedTmpfs` list for known-good Fred image families.\n *\n * HTTPS requests go through `opts.fetch`, defaulting to `createGuardedFetch()`\n * (DIY undici Dispatcher + RFC-cited block ranges + IPv4-mapped IPv6\n * normalization — see `guarded-fetch.ts` for the design).\n *\n * **Fail-soft contract:** returns `null` on every non-fatal failure mode:\n * - 401 / 403 (private registry / auth required)\n * - 429 (Docker Hub rate-limit)\n * - OCI grammar violation in the `imageRef`\n * - Manifest body exceeding the 10 MiB cap\n * - Request timeout (10s)\n * - Unparseable manifest / blob JSON\n * - SSRF block (default fetch refuses RFC 1918 / loopback / etc.)\n * Callers treat `null` as \"no info, ask the user\".\n * Diagnostics flow through `opts.logger` instead of stderr.\n *\n * ## Security — SSRF (production callers MUST read)\n *\n * `imageRef` is user-controlled (it comes from `AppDeploySpec.image`).\n * Without an SSRF guard, an image ref like `169.254.169.254:80/foo:bar`\n * (cloud-metadata) or `127.0.0.1:6379/foo:bar` (local Redis) would\n * cause this function to probe internal services on the host. The CJS\n * blocks this via its SSRF-aware HTTPS agent; the TS port delegates to\n * the caller's `opts.fetch`, defaulting to `createGuardedFetch()` which\n * blocks at connect time.\n *\n * Opt-out-of-safety semantics (parent's PR-2 directive): the default\n * `opts.fetch = createGuardedFetch()` is safe by construction. Callers\n * pass their own `opts.fetch` ONLY for tests (canned responses) or\n * unusual production cases (e.g. a trusted private registry on an RFC\n * 1918 IP, after explicit allow-listing). See `createGuardedFetch`'s\n * JSDoc for the production-guard contract.\n */\n\nconst ACCEPT_MANIFEST = [\n 'application/vnd.oci.image.index.v1+json',\n 'application/vnd.oci.image.manifest.v1+json',\n 'application/vnd.docker.distribution.manifest.list.v2+json',\n 'application/vnd.docker.distribution.manifest.v2+json',\n].join(', ');\n\n// OCI Distribution Spec v1.1 grammar for URL-interpolated fields. Validate\n// BEFORE the URL is constructed; the `imageRef` flag is user-controlled,\n// so a malformed input like `foo/bar:..%2F..%2Fconfig` must be rejected\n// here rather than forwarded to the registry.\nconst OCI_NAME_COMPONENT = /^[a-z0-9]+(?:(?:\\.|_|__|-+)[a-z0-9]+)*$/;\nconst OCI_TAG = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/;\nconst OCI_DIGEST = /^sha256:[0-9a-f]{64}$/;\n\n// Body-size cap (10 MiB). Real-world configs are <100 KB; even JVM-rich\n// images rarely exceed a few MB. Anything over 10 MiB indicates a hostile\n// or buggy registry; abort rather than risk OOM.\nconst MAX_BODY_BYTES = 10 * 1024 * 1024;\n\n// Request timeout — 10s. Registry queries should be fast; longer waits\n// indicate a hung registry.\nconst REQUEST_TIMEOUT_MS = 10_000;\n\n// Heuristic table: image base name or resolved Cmd/Entrypoint contains\n// one of these tokens → suggest the corresponding tmpfs paths. Order:\n// longer/more-specific tokens first when there's ambiguity.\nconst TMPFS_HINTS: ReadonlyArray<{\n readonly match: string;\n readonly paths: readonly string[];\n}> = [\n { match: 'wordpress', paths: ['/run/lock', '/var/run/apache2'] },\n { match: 'mariadb', paths: ['/run/mysqld'] },\n { match: 'postgres', paths: ['/var/run/postgresql'] },\n { match: 'mysql', paths: ['/var/run/mysqld'] },\n { match: 'nginx', paths: ['/var/cache/nginx', '/var/run'] },\n];\n\nexport interface ImageInfo {\n image: string;\n digest: string | null;\n ports: string[];\n env: Record<string, string>;\n cmd: string[] | null;\n entrypoint: string[] | null;\n user: string;\n workingDir: string;\n healthcheck: Record<string, unknown> | null;\n labels: Record<string, string> | null;\n volumes: Record<string, unknown> | null;\n suggestedTmpfs: string[];\n}\n\nexport interface InspectImageOptions {\n /**\n * HTTP client. **Production callers SHOULD use the default** (which is\n * `createGuardedFetch()`, blocking RFC 1918 / loopback / link-local /\n * metadata at connect time). Tests pass canned implementations.\n * Browser/Deno consumers pass their own SSRF-guarded fetch since\n * `createGuardedFetch()` throws on non-Node runtimes.\n */\n fetch?: typeof fetch;\n /** Sink for fail-soft diagnostics. Defaults to `console.warn`. */\n logger?: (reason: string) => void;\n}\n\nconst defaultLogger: (reason: string) => void = (reason) => {\n console.warn(reason);\n};\n\ninterface ParsedRef {\n registry: string;\n name: string;\n tag: string | null;\n digest: string | null;\n}\n\nexport async function inspectImage(\n imageRef: string,\n opts: InspectImageOptions = {},\n): Promise<ImageInfo | null> {\n const logger = opts.logger ?? defaultLogger;\n const fetchImpl: typeof fetch = opts.fetch ?? createDefaultGuardedFetch();\n\n let parsed: ParsedRef;\n try {\n parsed = parseRef(imageRef);\n } catch (err) {\n logger(\n `inspect-image: ${err instanceof Error ? err.message : String(err)}`,\n );\n return null;\n }\n\n const ref = parsed.digest ?? parsed.tag ?? 'latest';\n try {\n let authHeader: string | null = null;\n if (parsed.registry === 'docker.io') {\n const token = await getDockerHubToken(parsed.name, fetchImpl);\n authHeader = `Bearer ${token}`;\n }\n\n // Step 1: fetch manifest (may be an index → pick platform → refetch).\n let manifestRes = await fetchManifest(\n parsed.registry,\n parsed.name,\n ref,\n authHeader,\n fetchImpl,\n );\n if (\n manifestRes.contentType.includes('manifest.list') ||\n manifestRes.contentType.includes('image.index') ||\n isManifestIndex(manifestRes.manifest)\n ) {\n const child = pickPlatformManifest(manifestRes.manifest);\n if (!child || typeof child.digest !== 'string') {\n throw new Error('multi-arch index has no usable child manifest');\n }\n manifestRes = await fetchManifest(\n parsed.registry,\n parsed.name,\n child.digest,\n authHeader,\n fetchImpl,\n );\n }\n\n // Step 2: fetch the config blob — the actual image config lives there.\n const config = manifestRes.manifest.config as\n | { digest?: unknown }\n | undefined;\n if (!config || typeof config.digest !== 'string') {\n throw new Error('manifest has no config descriptor');\n }\n const configBlob = await fetchBlobJson(\n parsed.registry,\n parsed.name,\n config.digest,\n authHeader,\n fetchImpl,\n );\n const c = (configBlob.config ?? {}) as Record<string, unknown>;\n\n const out: ImageInfo = {\n image: `${parsed.registry}/${parsed.name}${parsed.digest ? '@' + parsed.digest : ':' + (parsed.tag ?? 'latest')}`,\n digest: manifestRes.digest ?? parsed.digest ?? null,\n ports: pickPorts(c.ExposedPorts),\n env: parseEnv(c.Env),\n cmd: Array.isArray(c.Cmd) ? (c.Cmd as string[]) : null,\n entrypoint: Array.isArray(c.Entrypoint)\n ? (c.Entrypoint as string[])\n : null,\n user: typeof c.User === 'string' ? c.User : '',\n workingDir: typeof c.WorkingDir === 'string' ? c.WorkingDir : '',\n healthcheck:\n c.Healthcheck !== null &&\n typeof c.Healthcheck === 'object' &&\n !Array.isArray(c.Healthcheck)\n ? (c.Healthcheck as Record<string, unknown>)\n : null,\n labels:\n c.Labels !== null &&\n typeof c.Labels === 'object' &&\n !Array.isArray(c.Labels)\n ? (c.Labels as Record<string, string>)\n : null,\n volumes:\n c.Volumes !== null &&\n typeof c.Volumes === 'object' &&\n !Array.isArray(c.Volumes)\n ? (c.Volumes as Record<string, unknown>)\n : null,\n suggestedTmpfs: [],\n };\n out.suggestedTmpfs = suggestedTmpfsFor(parsed.name, [\n ...(out.cmd ?? []),\n ...(out.entrypoint ?? []),\n ]);\n\n return out;\n } catch (err) {\n logger(`inspect-image: ${formatErrorChain(err)}`);\n return null;\n }\n}\n\n/**\n * Walk an Error's `cause` chain and join all message strings. undici wraps\n * connection errors (including SSRF blocks from our custom Dispatcher) in\n * a fetch-side TypeError with the underlying cause nested via `.cause`.\n * Surfacing the chain in the logger gives the user the real reason (e.g.,\n * \"SSRF blocked: 127.0.0.1 ... loopback\") instead of an opaque\n * \"fetch failed\".\n */\nfunction formatErrorChain(err: unknown): string {\n const parts: string[] = [];\n let current: unknown = err;\n let depth = 0;\n // Defensive bound — sane Error chains are <5 levels; cap at 10 to avoid\n // pathological cycles.\n while (current !== null && current !== undefined && depth < 10) {\n if (current instanceof Error) {\n parts.push(current.message);\n current = (current as Error & { cause?: unknown }).cause;\n } else {\n parts.push(String(current));\n current = undefined;\n }\n depth += 1;\n }\n return parts.join(' | ');\n}\n\nlet cachedDefaultFetch: typeof fetch | undefined;\nfunction createDefaultGuardedFetch(): typeof fetch {\n if (!cachedDefaultFetch) {\n cachedDefaultFetch = createGuardedFetch();\n }\n return cachedDefaultFetch;\n}\n\nfunction parseRef(ref: string): ParsedRef {\n // \"<reg>/<name>@sha256:<digest>\" | \"<reg>/<name>:<tag>\" | \"<name>\" | \"<name>:<tag>\"\n let registry = 'docker.io';\n let name: string;\n let tag: string | null = null;\n let digest: string | null = null;\n\n let rest = ref;\n const atIdx = rest.indexOf('@');\n if (atIdx >= 0) {\n digest = rest.slice(atIdx + 1);\n rest = rest.slice(0, atIdx);\n }\n\n // Detect registry segment: head before first `/` is a registry only if\n // it has a `.` or `:` (port) or is `localhost`.\n const firstSlash = rest.indexOf('/');\n if (firstSlash > 0) {\n const head = rest.slice(0, firstSlash);\n if (head === 'localhost' || head.includes('.') || head.includes(':')) {\n registry = head;\n rest = rest.slice(firstSlash + 1);\n }\n }\n\n if (!digest) {\n const colonIdx = rest.lastIndexOf(':');\n if (colonIdx >= 0) {\n tag = rest.slice(colonIdx + 1);\n name = rest.slice(0, colonIdx);\n } else {\n name = rest;\n tag = 'latest';\n }\n } else {\n name = rest;\n }\n\n // Docker Hub library prefix for single-segment names (\"nginx\" → \"library/nginx\").\n if (registry === 'docker.io' && !name.includes('/')) {\n name = `library/${name}`;\n }\n\n // Validate URL-interpolated fields against OCI Distribution Spec grammar\n // BEFORE the URL is constructed. The ref strings reach the user via\n // `AppDeploySpec.image`, so malformed input must be rejected here.\n for (const component of name.split('/')) {\n if (!OCI_NAME_COMPONENT.test(component)) {\n throw new Error(`invalid name component \"${component}\" in image ref`);\n }\n }\n if (tag !== null && !OCI_TAG.test(tag)) {\n throw new Error(`invalid tag \"${tag}\" in image ref`);\n }\n if (digest !== null && !OCI_DIGEST.test(digest)) {\n throw new Error(\n `invalid digest \"${digest}\" in image ref (expected sha256:<64-hex>)`,\n );\n }\n\n return { registry, name, tag, digest };\n}\n\nfunction registryHost(registry: string): string {\n // Docker Hub's image API lives at registry-1.docker.io even though the\n // canonical \"registry\" name is docker.io.\n return registry === 'docker.io' ? 'registry-1.docker.io' : registry;\n}\n\nasync function getDockerHubToken(\n name: string,\n fetchImpl: typeof fetch,\n): Promise<string> {\n // Docker Hub requires anonymous access still go through a token grant.\n // Surface 429 specifically — anonymous pulls are rate-limited per-IP\n // and a 60-min wait fixes it. Without this special case the user sees\n // the same fail-soft `null` outcome as a hard 401 with no signal that\n // the situation is temporary.\n const res = await capturingFetch(\n `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${name}:pull`,\n {},\n fetchImpl,\n );\n if (res.status === 429) {\n throw new Error(\n 'Docker Hub token: HTTP 429 (anonymous pulls rate-limited per-IP; retry after ~60 min, or authenticate)',\n );\n }\n if (res.status !== 200) {\n throw new Error(`Docker Hub token: HTTP ${res.status}`);\n }\n let parsed: unknown;\n try {\n parsed = JSON.parse(res.body);\n } catch {\n throw new Error('Docker Hub token: invalid JSON');\n }\n if (\n parsed === null ||\n typeof parsed !== 'object' ||\n typeof (parsed as { token?: unknown }).token !== 'string'\n ) {\n throw new Error('Docker Hub token: missing `token` in response');\n }\n return (parsed as { token: string }).token;\n}\n\nasync function fetchManifest(\n registry: string,\n name: string,\n ref: string,\n authHeader: string | null,\n fetchImpl: typeof fetch,\n): Promise<{\n manifest: Record<string, unknown>;\n contentType: string;\n digest: string | null;\n}> {\n const host = registryHost(registry);\n const url = `https://${host}/v2/${name}/manifests/${ref}`;\n const headers: Record<string, string> = { Accept: ACCEPT_MANIFEST };\n if (authHeader) headers.Authorization = authHeader;\n const res = await capturingFetch(url, { headers }, fetchImpl);\n if (res.status === 401 || res.status === 403) {\n throw new Error(\n `registry returned ${res.status} on manifest fetch (auth required? private registry?)`,\n );\n }\n if (res.status === 404) {\n // Digest-pinned refs use `@sha256:...`; tag refs use `:tag`. Pick the\n // right separator so the error message doesn't show\n // `registry/name:sha256:...` mistakenly.\n const sep = ref.startsWith('sha256:') ? '@' : ':';\n throw new Error(`image not found: ${registry}/${name}${sep}${ref}`);\n }\n if (res.status !== 200) {\n throw new Error(`registry returned ${res.status} on manifest fetch`);\n }\n let parsed: unknown;\n try {\n parsed = JSON.parse(res.body);\n } catch {\n throw new Error('manifest is not valid JSON');\n }\n if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {\n throw new Error('manifest is not a JSON object');\n }\n return {\n manifest: parsed as Record<string, unknown>,\n contentType: res.headers.get('content-type') ?? '',\n digest: res.headers.get('docker-content-digest'),\n };\n}\n\nasync function fetchBlobJson(\n registry: string,\n name: string,\n digest: string,\n authHeader: string | null,\n fetchImpl: typeof fetch,\n): Promise<{ config?: unknown }> {\n const host = registryHost(registry);\n const url = `https://${host}/v2/${name}/blobs/${digest}`;\n const headers: Record<string, string> = {};\n if (authHeader) headers.Authorization = authHeader;\n // undici fetch follows redirects by default; registries 307 → CDN.\n const res = await capturingFetch(url, { headers }, fetchImpl);\n if (res.status !== 200) {\n throw new Error(`registry returned ${res.status} on blob fetch`);\n }\n try {\n return JSON.parse(res.body) as { config?: unknown };\n } catch {\n throw new Error('blob is not valid JSON');\n }\n}\n\ninterface CapturedResponse {\n status: number;\n headers: Headers;\n body: string;\n}\n\n/**\n * Wrap fetch with `AbortSignal.timeout(REQUEST_TIMEOUT_MS)` and a streamed\n * body-size cap. Throws on overflow, timeout, or read error so the outer\n * try/catch produces the fail-soft `null` return.\n */\nasync function capturingFetch(\n url: string,\n init: RequestInit,\n fetchImpl: typeof fetch,\n): Promise<CapturedResponse> {\n const signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS);\n let response: Response;\n try {\n response = await fetchImpl(url, { ...init, signal });\n } catch (err) {\n if (err instanceof Error && err.name === 'TimeoutError') {\n throw new Error(`request timeout on ${url}`);\n }\n throw err;\n }\n // Stream the body with a manual chunk-accumulation cap. Avoids the\n // unbounded `await response.text()` path that would let a hostile\n // registry exhaust memory.\n const reader = response.body?.getReader();\n if (!reader) {\n return { status: response.status, headers: response.headers, body: '' };\n }\n const chunks: Uint8Array[] = [];\n let totalBytes = 0;\n const decoder = new TextDecoder();\n let body = '';\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n if (value) {\n totalBytes += value.length;\n if (totalBytes > MAX_BODY_BYTES) {\n await reader.cancel();\n throw new Error(\n `response body exceeded ${MAX_BODY_BYTES} bytes (cap) on ${url}`,\n );\n }\n chunks.push(value);\n }\n }\n body = decoder.decode(concatUint8Arrays(chunks));\n } finally {\n reader.releaseLock();\n }\n return { status: response.status, headers: response.headers, body };\n}\n\nfunction concatUint8Arrays(chunks: Uint8Array[]): Uint8Array {\n if (chunks.length === 0) return new Uint8Array(0);\n if (chunks.length === 1) {\n const only = chunks[0];\n if (only !== undefined) return only;\n }\n let total = 0;\n for (const c of chunks) total += c.length;\n const out = new Uint8Array(total);\n let offset = 0;\n for (const c of chunks) {\n out.set(c, offset);\n offset += c.length;\n }\n return out;\n}\n\nfunction pickPorts(exposedPorts: unknown): string[] {\n if (\n exposedPorts === null ||\n typeof exposedPorts !== 'object' ||\n Array.isArray(exposedPorts)\n ) {\n return [];\n }\n return Object.keys(exposedPorts as Record<string, unknown>).sort();\n}\n\nfunction parseEnv(env: unknown): Record<string, string> {\n if (!Array.isArray(env)) return {};\n const out: Record<string, string> = {};\n for (const kv of env) {\n if (typeof kv !== 'string') continue;\n const i = kv.indexOf('=');\n if (i > 0) {\n const key = kv.slice(0, i);\n const value = kv.slice(i + 1);\n out[key] = value;\n } else {\n out[kv] = '';\n }\n }\n return out;\n}\n\nfunction isManifestIndex(m: Record<string, unknown>): boolean {\n return Array.isArray(m.manifests);\n}\n\nfunction pickPlatformManifest(\n index: Record<string, unknown>,\n): Record<string, unknown> | null {\n const list = index.manifests;\n if (!Array.isArray(list)) return null;\n const linuxAmd64 = list.find(\n (m): m is Record<string, unknown> =>\n m !== null &&\n typeof m === 'object' &&\n (m as { platform?: unknown }).platform !== null &&\n typeof (m as { platform?: unknown }).platform === 'object' &&\n (m as { platform: { os?: unknown } }).platform.os === 'linux' &&\n (m as { platform: { architecture?: unknown } }).platform.architecture ===\n 'amd64',\n );\n if (linuxAmd64) return linuxAmd64;\n // Fall back to first entry.\n const first = list[0];\n if (first !== null && typeof first === 'object' && !Array.isArray(first)) {\n return first as Record<string, unknown>;\n }\n return null;\n}\n\nfunction suggestedTmpfsFor(\n name: string,\n cmdAndEntrypoint: ReadonlyArray<string>,\n): string[] {\n const haystack = [name, ...cmdAndEntrypoint].join(' ').toLowerCase();\n for (const hint of TMPFS_HINTS) {\n if (haystack.includes(hint.match)) return [...hint.paths];\n }\n return [];\n}\n"],"mappings":"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"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AppDeploySpec } from "../types.js";
|
|
2
2
|
|
|
3
3
|
//#region src/internals/render-intent-recap.d.ts
|
|
4
4
|
/**
|
|
@@ -20,20 +20,22 @@ import { DeploySpec } from "../types.js";
|
|
|
20
20
|
* surfaced; only keys appear. Mirrors `summarizeSpec`'s contract. FQDNs are
|
|
21
21
|
* not secrets so `customDomain` is surfaced verbatim.
|
|
22
22
|
*
|
|
23
|
-
* **Port-shape handling:**
|
|
24
|
-
* -
|
|
25
|
-
* -
|
|
26
|
-
* entry per port-key
|
|
23
|
+
* **Port-shape handling:** two runtime shapes for ports:
|
|
24
|
+
* - Single-service: `port: number` → renders one ingress=true entry.
|
|
25
|
+
* - Stack service: `ServiceConfig.ports: Record<portKey, {}>` (canonical
|
|
26
|
+
* map, e.g. `{ '80/tcp': {} }`) → one entry per port-key (ingress
|
|
27
|
+
* default false). The recap renders the map KEY string (`'80/tcp'`),
|
|
28
|
+
* not a bare number.
|
|
27
29
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
30
|
+
* `extractPorts` also handles the historical `number[]` Record-or-array
|
|
31
|
+
* shapes at runtime (matching `summarizeSpec`'s defensive widening) so
|
|
32
|
+
* callers passing unknown-typed input from JSON.parse don't silently drop
|
|
33
|
+
* ports.
|
|
32
34
|
*/
|
|
33
35
|
/** Render output is a multi-paragraph plain-text block, ready to print verbatim. */
|
|
34
36
|
interface RenderIntentRecapInput {
|
|
35
|
-
/** The structured deploy spec (
|
|
36
|
-
spec:
|
|
37
|
+
/** The structured deploy spec (canonical `AppDeploySpec` shape). */
|
|
38
|
+
spec: AppDeploySpec;
|
|
37
39
|
/** Active chain — drives the mainnet permanence warning. */
|
|
38
40
|
activeChain: 'testnet' | 'mainnet';
|
|
39
41
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"render-intent-recap.d.ts","names":[],"sources":["../../src/internals/render-intent-recap.ts"],"mappings":";;;;;
|
|
1
|
+
{"version":3,"file":"render-intent-recap.d.ts","names":[],"sources":["../../src/internals/render-intent-recap.ts"],"mappings":";;;;;AAoCA;;;;;;;;AAIa;AAgBb;;;;AAA+D;;;;;;;;;;;;;;;;;UApB9C,sBAAA;;EAEf,IAAA,EAAM,aAAa;;EAEnB,WAAA;AAAA;AAAA,iBAgBc,iBAAA,CAAkB,KAA6B,EAAtB,sBAAsB"}
|
|
@@ -25,10 +25,11 @@ function projectServices(spec) {
|
|
|
25
25
|
});
|
|
26
26
|
}
|
|
27
27
|
/**
|
|
28
|
-
* Services-map shape: `{ "80": { ingress?: boolean }, "9090": { ... } }
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* by treating each entry as
|
|
28
|
+
* Services-map shape: `{ "80/tcp": { ingress?: boolean }, "9090": { ... } }`
|
|
29
|
+
* (`ServiceConfig.ports` is a `Record<string, …>` port map). Ingress flag may
|
|
30
|
+
* be absent — default `false` matches Fred's cluster-private default. Also
|
|
31
|
+
* tolerates a legacy `number[]` shape defensively by treating each entry as
|
|
32
|
+
* ingress=false (services-map default).
|
|
32
33
|
*/
|
|
33
34
|
function extractPorts(ports) {
|
|
34
35
|
if (Array.isArray(ports)) return ports.filter((p) => typeof p === "number").map((p) => ({
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"render-intent-recap.js","names":[],"sources":["../../src/internals/render-intent-recap.ts"],"sourcesContent":["import type {\n DeploySpec,\n ServiceDef,\n SingleServiceSpec,\n StackSpec,\n} from '../types.js';\nimport { isStackSpec, normalizeServices } from './spec-normalize.js';\n\n/**\n * Render the structural portion of the intent-recap block shown to the user\n * before any chain round-trips in the deploy-app orchestrator.\n *\n * The 4 deterministic items the recap covers:\n *\n * 1. Deployment surface (service count + per-service `name — image`)\n * 2. Connectivity (per-port ingress posture)\n * 3. Redacted sensitive-key inventory (env / label keys only; never values)\n * 4. Custom-domain + dual-tx clarifier + mainnet warning (when applicable)\n *\n * The 2 LLM-judgment items (\"what you provided vs auto-detected\", \"heads-up:\n * obvious gaps\") stay in prose — the orchestrator appends them between the\n * deterministic block and the `AskUserQuestion` prompt.\n *\n * **Sensitive-value posture:** env values and label values are NEVER\n * surfaced; only keys appear. Mirrors `summarizeSpec`'s contract. FQDNs are\n * not secrets so `customDomain` is surfaced verbatim.\n *\n * **Port-shape handling:** the CJS supports two runtime shapes for ports:\n * - Legacy single-service: `port: number` → renders one ingress=true entry.\n * - Services-map: `ports: Record<portKey, { ingress?: boolean }>` → one\n * entry per port-key with the declared ingress flag (default false).\n *\n * The frozen TS contract narrows `ServiceDef.ports` to `number[]` for the\n * common case; this port also handles the historical Record shape at runtime\n * (matching `summarizeSpec`'s defensive widening) so callers passing\n * unknown-typed input from JSON.parse don't silently drop ports.\n */\n\n/** Render output is a multi-paragraph plain-text block, ready to print verbatim. */\nexport interface RenderIntentRecapInput {\n /** The structured deploy spec (frozen `DeploySpec` shape). */\n spec: DeploySpec;\n /** Active chain — drives the mainnet permanence warning. */\n activeChain: 'testnet' | 'mainnet';\n}\n\ninterface NormalizedService {\n /** `null` for legacy single-service; the services-map key for stack leases. */\n name: string | null;\n /** Image string. Falls back to `(unknown image)` when missing. */\n image: string;\n /** Per-port ingress posture, in declaration order. */\n ports: { port: string; ingress: boolean }[];\n /** Sorted env keys (values redacted). */\n envKeys: string[];\n /** Sorted label keys (values redacted). */\n labelKeys: string[];\n}\n\nexport function renderIntentRecap(input: RenderIntentRecapInput): string {\n if (input.activeChain !== 'testnet' && input.activeChain !== 'mainnet') {\n throw new TypeError(\n `renderIntentRecap: activeChain must be \"testnet\" or \"mainnet\"; got \"${String(input.activeChain)}\"`,\n );\n }\n\n const services = projectServices(input.spec);\n\n const blocks: string[] = [\n renderServiceList(services, input.activeChain),\n renderConnectivity(services),\n renderRedactedInventory(services),\n ];\n const domainBlock = renderCustomDomain(input.spec, input.activeChain);\n if (domainBlock !== null) {\n blocks.push(domainBlock);\n }\n\n return blocks.join('\\n\\n');\n}\n\nfunction projectServices(spec: DeploySpec): NormalizedService[] {\n return normalizeServices(spec).map(({ name, raw }): NormalizedService => {\n const rawRecord = raw as unknown as Record<string, unknown>;\n const image =\n typeof rawRecord.image === 'string' && rawRecord.image.length > 0\n ? rawRecord.image\n : '(unknown image)';\n const ports =\n name === null\n ? extractPortsLegacy((raw as SingleServiceSpec).port)\n : extractPorts((raw as ServiceDef).ports);\n return {\n name,\n image,\n ports,\n envKeys: extractKeys(rawRecord.env),\n labelKeys: extractKeys(rawRecord.labels),\n };\n });\n}\n\n/**\n * Services-map shape: `{ \"80\": { ingress?: boolean }, \"9090\": { ... } }`.\n * Ingress flag may be absent — default `false` matches Fred's cluster-private\n * default. Also handles the typed `number[]` shape (frozen `ServiceDef.ports`)\n * by treating each entry as ingress=false (services-map default).\n */\nfunction extractPorts(ports: unknown): { port: string; ingress: boolean }[] {\n if (Array.isArray(ports)) {\n return ports\n .filter((p): p is number => typeof p === 'number')\n .map((p) => ({ port: String(p), ingress: false }));\n }\n if (ports !== null && typeof ports === 'object') {\n return Object.entries(ports as Record<string, unknown>).map(\n ([port, cfg]) => ({\n port,\n ingress: !!(\n cfg !== null &&\n typeof cfg === 'object' &&\n (cfg as { ingress?: unknown }).ingress\n ),\n }),\n );\n }\n return [];\n}\n\n/**\n * Legacy single-service shape: bare `port: number`. Fred treats this as\n * ingress=true by default — that's the whole point of the simplified\n * shape.\n *\n * Also handles the `number[]` form (the frozen-contract array form):\n * returns one `{ port, ingress: true }` entry per array element, each\n * with `ingress: true` matching the single-service convention. Returns\n * `[]` for any other value (undefined, non-number scalar, non-array\n * object).\n *\n * M2 fix: prior JSDoc incorrectly stated \"Returns `[]` for any other\n * value (including `number[]`...)\" — empirically wrong per the\n * `Array.isArray(port)` branch below.\n */\nfunction extractPortsLegacy(\n port: number | number[] | undefined,\n): { port: string; ingress: boolean }[] {\n if (typeof port === 'number') {\n return [{ port: String(port), ingress: true }];\n }\n if (Array.isArray(port)) {\n return port\n .filter((p): p is number => typeof p === 'number')\n .map((p) => ({ port: String(p), ingress: true }));\n }\n return [];\n}\n\nfunction extractKeys(obj: unknown): string[] {\n if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return [];\n return Object.keys(obj as Record<string, unknown>).sort();\n}\n\nfunction renderServiceList(\n services: NormalizedService[],\n activeChain: 'testnet' | 'mainnet',\n): string {\n const count = services.length;\n const noun = count === 1 ? 'service' : 'services';\n const lines = [`Deploying ${count} ${noun} on ${activeChain}:`];\n for (const svc of services) {\n const prefix = svc.name === null ? '' : `${svc.name} — `;\n lines.push(` - ${prefix}${svc.image}`);\n }\n return lines.join('\\n');\n}\n\nfunction renderConnectivity(services: NormalizedService[]): string {\n const lines = ['Connectivity:'];\n let total = 0;\n for (const svc of services) {\n if (svc.ports.length === 0) continue;\n for (const p of svc.ports) {\n total += 1;\n const prefix =\n svc.name === null ? `port ${p.port}` : `${svc.name} port ${p.port}`;\n const reach = p.ingress\n ? \"publicly reachable via the provider's HTTPS subdomain\"\n : 'internal only (cluster-private)';\n lines.push(` - ${prefix}: ${reach}`);\n }\n }\n if (total === 0) {\n lines.push(\n ' (no ports declared — the deployment will not expose any network surface)',\n );\n }\n return lines.join('\\n');\n}\n\nfunction renderRedactedInventory(services: NormalizedService[]): string {\n // Always render the section header even if everything is empty — the user\n // should know we'd have shown values if there were any. This is also\n // documentation of the redaction discipline.\n const lines = [\n 'Sensitive values are redacted in this recap (keys only, never values):',\n ];\n let anything = false;\n for (const svc of services) {\n const prefix = svc.name === null ? 'this service' : svc.name;\n const parts: string[] = [];\n if (svc.envKeys.length > 0) {\n anything = true;\n parts.push(`env keys [${svc.envKeys.join(', ')}]`);\n }\n if (svc.labelKeys.length > 0) {\n anything = true;\n parts.push(`label keys [${svc.labelKeys.join(', ')}]`);\n }\n if (parts.length === 0) {\n lines.push(` - ${prefix}: no env or labels supplied`);\n } else {\n lines.push(` - ${prefix}: ${parts.join('; ')}`);\n }\n }\n if (!anything) {\n lines.push(\n ' - (no env or labels supplied across any service — nothing to redact)',\n );\n }\n return lines.join('\\n');\n}\n\nfunction renderCustomDomain(\n spec: DeploySpec,\n activeChain: 'testnet' | 'mainnet',\n): string | null {\n const customDomain = (spec as { customDomain?: unknown }).customDomain;\n if (typeof customDomain !== 'string' || customDomain.length === 0) {\n return null;\n }\n // `serviceName` is only legal on StackSpec; for SingleServiceSpec the\n // single service implicitly receives the domain.\n const serviceName = isStackSpec(spec)\n ? (spec as StackSpec).serviceName\n : undefined;\n const target =\n typeof serviceName === 'string' && serviceName.length > 0\n ? `service ${serviceName}`\n : 'single-service lease';\n const lines = [`Custom domain: ${customDomain} → ${target}`];\n lines.push('');\n lines.push(\n 'Note: when a custom domain is set, deploy_app broadcasts TWO billing\\n' +\n 'transactions atomically: create-lease AND set-item-custom-domain. The\\n' +\n 'single permission prompt that fires later covers BOTH; this textual\\n' +\n 'recap is your per-tx review.',\n );\n if (activeChain === 'mainnet') {\n lines.push('');\n lines.push(\n `Mainnet warning: this transaction permanently associates ${customDomain}\\n` +\n 'with this lease on-chain until you --clear it via\\n' +\n '/manifest-agent:manage-domain or close the lease. FQDN squatting is\\n' +\n 'irreversible.',\n );\n }\n return lines.join('\\n');\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"render-intent-recap.js","names":[],"sources":["../../src/internals/render-intent-recap.ts"],"sourcesContent":["import type { AppDeploySpec, ServiceConfig } from '../types.js';\nimport { isStackSpec, normalizeServices } from './spec-normalize.js';\n\n/**\n * Render the structural portion of the intent-recap block shown to the user\n * before any chain round-trips in the deploy-app orchestrator.\n *\n * The 4 deterministic items the recap covers:\n *\n * 1. Deployment surface (service count + per-service `name — image`)\n * 2. Connectivity (per-port ingress posture)\n * 3. Redacted sensitive-key inventory (env / label keys only; never values)\n * 4. Custom-domain + dual-tx clarifier + mainnet warning (when applicable)\n *\n * The 2 LLM-judgment items (\"what you provided vs auto-detected\", \"heads-up:\n * obvious gaps\") stay in prose — the orchestrator appends them between the\n * deterministic block and the `AskUserQuestion` prompt.\n *\n * **Sensitive-value posture:** env values and label values are NEVER\n * surfaced; only keys appear. Mirrors `summarizeSpec`'s contract. FQDNs are\n * not secrets so `customDomain` is surfaced verbatim.\n *\n * **Port-shape handling:** two runtime shapes for ports:\n * - Single-service: `port: number` → renders one ingress=true entry.\n * - Stack service: `ServiceConfig.ports: Record<portKey, {}>` (canonical\n * map, e.g. `{ '80/tcp': {} }`) → one entry per port-key (ingress\n * default false). The recap renders the map KEY string (`'80/tcp'`),\n * not a bare number.\n *\n * `extractPorts` also handles the historical `number[]` Record-or-array\n * shapes at runtime (matching `summarizeSpec`'s defensive widening) so\n * callers passing unknown-typed input from JSON.parse don't silently drop\n * ports.\n */\n\n/** Render output is a multi-paragraph plain-text block, ready to print verbatim. */\nexport interface RenderIntentRecapInput {\n /** The structured deploy spec (canonical `AppDeploySpec` shape). */\n spec: AppDeploySpec;\n /** Active chain — drives the mainnet permanence warning. */\n activeChain: 'testnet' | 'mainnet';\n}\n\ninterface NormalizedService {\n /** `null` for legacy single-service; the services-map key for stack leases. */\n name: string | null;\n /** Image string. Falls back to `(unknown image)` when missing. */\n image: string;\n /** Per-port ingress posture, in declaration order. */\n ports: { port: string; ingress: boolean }[];\n /** Sorted env keys (values redacted). */\n envKeys: string[];\n /** Sorted label keys (values redacted). */\n labelKeys: string[];\n}\n\nexport function renderIntentRecap(input: RenderIntentRecapInput): string {\n if (input.activeChain !== 'testnet' && input.activeChain !== 'mainnet') {\n throw new TypeError(\n `renderIntentRecap: activeChain must be \"testnet\" or \"mainnet\"; got \"${String(input.activeChain)}\"`,\n );\n }\n\n const services = projectServices(input.spec);\n\n const blocks: string[] = [\n renderServiceList(services, input.activeChain),\n renderConnectivity(services),\n renderRedactedInventory(services),\n ];\n const domainBlock = renderCustomDomain(input.spec, input.activeChain);\n if (domainBlock !== null) {\n blocks.push(domainBlock);\n }\n\n return blocks.join('\\n\\n');\n}\n\nfunction projectServices(spec: AppDeploySpec): NormalizedService[] {\n return normalizeServices(spec).map(({ name, raw }): NormalizedService => {\n const rawRecord = raw as unknown as Record<string, unknown>;\n const image =\n typeof rawRecord.image === 'string' && rawRecord.image.length > 0\n ? rawRecord.image\n : '(unknown image)';\n const ports =\n name === null\n ? extractPortsLegacy((raw as AppDeploySpec).port)\n : extractPorts((raw as ServiceConfig).ports);\n return {\n name,\n image,\n ports,\n envKeys: extractKeys(rawRecord.env),\n labelKeys: extractKeys(rawRecord.labels),\n };\n });\n}\n\n/**\n * Services-map shape: `{ \"80/tcp\": { ingress?: boolean }, \"9090\": { ... } }`\n * (`ServiceConfig.ports` is a `Record<string, …>` port map). Ingress flag may\n * be absent — default `false` matches Fred's cluster-private default. Also\n * tolerates a legacy `number[]` shape defensively by treating each entry as\n * ingress=false (services-map default).\n */\nfunction extractPorts(ports: unknown): { port: string; ingress: boolean }[] {\n if (Array.isArray(ports)) {\n return ports\n .filter((p): p is number => typeof p === 'number')\n .map((p) => ({ port: String(p), ingress: false }));\n }\n if (ports !== null && typeof ports === 'object') {\n return Object.entries(ports as Record<string, unknown>).map(\n ([port, cfg]) => ({\n port,\n ingress: !!(\n cfg !== null &&\n typeof cfg === 'object' &&\n (cfg as { ingress?: unknown }).ingress\n ),\n }),\n );\n }\n return [];\n}\n\n/**\n * Legacy single-service shape: bare `port: number`. Fred treats this as\n * ingress=true by default — that's the whole point of the simplified\n * shape.\n *\n * Also handles the `number[]` form (the frozen-contract array form):\n * returns one `{ port, ingress: true }` entry per array element, each\n * with `ingress: true` matching the single-service convention. Returns\n * `[]` for any other value (undefined, non-number scalar, non-array\n * object).\n *\n * M2 fix: prior JSDoc incorrectly stated \"Returns `[]` for any other\n * value (including `number[]`...)\" — empirically wrong per the\n * `Array.isArray(port)` branch below.\n */\nfunction extractPortsLegacy(\n port: number | number[] | undefined,\n): { port: string; ingress: boolean }[] {\n if (typeof port === 'number') {\n return [{ port: String(port), ingress: true }];\n }\n if (Array.isArray(port)) {\n return port\n .filter((p): p is number => typeof p === 'number')\n .map((p) => ({ port: String(p), ingress: true }));\n }\n return [];\n}\n\nfunction extractKeys(obj: unknown): string[] {\n if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return [];\n return Object.keys(obj as Record<string, unknown>).sort();\n}\n\nfunction renderServiceList(\n services: NormalizedService[],\n activeChain: 'testnet' | 'mainnet',\n): string {\n const count = services.length;\n const noun = count === 1 ? 'service' : 'services';\n const lines = [`Deploying ${count} ${noun} on ${activeChain}:`];\n for (const svc of services) {\n const prefix = svc.name === null ? '' : `${svc.name} — `;\n lines.push(` - ${prefix}${svc.image}`);\n }\n return lines.join('\\n');\n}\n\nfunction renderConnectivity(services: NormalizedService[]): string {\n const lines = ['Connectivity:'];\n let total = 0;\n for (const svc of services) {\n if (svc.ports.length === 0) continue;\n for (const p of svc.ports) {\n total += 1;\n const prefix =\n svc.name === null ? `port ${p.port}` : `${svc.name} port ${p.port}`;\n const reach = p.ingress\n ? \"publicly reachable via the provider's HTTPS subdomain\"\n : 'internal only (cluster-private)';\n lines.push(` - ${prefix}: ${reach}`);\n }\n }\n if (total === 0) {\n lines.push(\n ' (no ports declared — the deployment will not expose any network surface)',\n );\n }\n return lines.join('\\n');\n}\n\nfunction renderRedactedInventory(services: NormalizedService[]): string {\n // Always render the section header even if everything is empty — the user\n // should know we'd have shown values if there were any. This is also\n // documentation of the redaction discipline.\n const lines = [\n 'Sensitive values are redacted in this recap (keys only, never values):',\n ];\n let anything = false;\n for (const svc of services) {\n const prefix = svc.name === null ? 'this service' : svc.name;\n const parts: string[] = [];\n if (svc.envKeys.length > 0) {\n anything = true;\n parts.push(`env keys [${svc.envKeys.join(', ')}]`);\n }\n if (svc.labelKeys.length > 0) {\n anything = true;\n parts.push(`label keys [${svc.labelKeys.join(', ')}]`);\n }\n if (parts.length === 0) {\n lines.push(` - ${prefix}: no env or labels supplied`);\n } else {\n lines.push(` - ${prefix}: ${parts.join('; ')}`);\n }\n }\n if (!anything) {\n lines.push(\n ' - (no env or labels supplied across any service — nothing to redact)',\n );\n }\n return lines.join('\\n');\n}\n\nfunction renderCustomDomain(\n spec: AppDeploySpec,\n activeChain: 'testnet' | 'mainnet',\n): string | null {\n const customDomain = spec.customDomain;\n if (typeof customDomain !== 'string' || customDomain.length === 0) {\n return null;\n }\n // `serviceName` only disambiguates a stack service; a single-service\n // spec's single service implicitly receives the domain.\n const serviceName = isStackSpec(spec) ? spec.serviceName : undefined;\n const target =\n typeof serviceName === 'string' && serviceName.length > 0\n ? `service ${serviceName}`\n : 'single-service lease';\n const lines = [`Custom domain: ${customDomain} → ${target}`];\n lines.push('');\n lines.push(\n 'Note: when a custom domain is set, deploy_app broadcasts TWO billing\\n' +\n 'transactions atomically: create-lease AND set-item-custom-domain. The\\n' +\n 'single permission prompt that fires later covers BOTH; this textual\\n' +\n 'recap is your per-tx review.',\n );\n if (activeChain === 'mainnet') {\n lines.push('');\n lines.push(\n `Mainnet warning: this transaction permanently associates ${customDomain}\\n` +\n 'with this lease on-chain until you --clear it via\\n' +\n '/manifest-agent:manage-domain or close the lease. FQDN squatting is\\n' +\n 'irreversible.',\n );\n }\n return lines.join('\\n');\n}\n"],"mappings":"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"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AppDeploySpec, ServiceConfig, SpecSummary } from "../types.js";
|
|
2
2
|
|
|
3
3
|
//#region src/internals/spec-normalize.d.ts
|
|
4
4
|
/**
|
|
@@ -6,37 +6,42 @@ import { DeploySpec, ServiceDef, SingleServiceSpec, SpecSummary, StackSpec } fro
|
|
|
6
6
|
* `firstImage`, `normalizeServices`, `summarizeSpec`, and `validateSpec`
|
|
7
7
|
* (the latter surfaces pre-broadcast shape violations).
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* - **
|
|
12
|
-
* - **
|
|
9
|
+
* The single canonical spec type (`AppDeploySpec`, ENG-310) covers two
|
|
10
|
+
* shapes, discriminated by the presence of `services`:
|
|
11
|
+
* - **stack** — `{ services: { <name>: ServiceConfig }, customDomain?, serviceName?, size, ... }`
|
|
12
|
+
* - **single-service** — `{ image, port?, env?, customDomain?, size, ... }`
|
|
13
13
|
*
|
|
14
14
|
* `normalizeServices` collapses the two shapes into a single iterable form
|
|
15
15
|
* so callers (Plan summary, manifest builder, etc.) walk one structure
|
|
16
16
|
* regardless of which form the user passed.
|
|
17
17
|
*
|
|
18
|
-
* Validation: `validateSpec`
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
18
|
+
* Validation: `validateSpec` stays a `void` `TypeError`-thrower on shape
|
|
19
|
+
* violations. The high-level `deployApp` entry wrappers
|
|
20
|
+
* (`deploy-app.ts`) map that `TypeError` to
|
|
21
|
+
* `ManifestMCPError(INVALID_CONFIG)` at the public-API boundary, so this
|
|
22
|
+
* helper does not depend on core's error type directly.
|
|
23
23
|
*/
|
|
24
24
|
/**
|
|
25
|
-
* True when `spec` uses the services-map
|
|
26
|
-
*
|
|
25
|
+
* True when `spec` uses the services-map (stack) shape. Discriminates on
|
|
26
|
+
* the canonical `services` key being present and defined (ENG-310). The
|
|
27
|
+
* non-null-object / non-array guard is retained as defense-in-depth so an
|
|
28
|
+
* `unknown`-cast caller smuggling `services: null` / `[]` / `'str'` is NOT
|
|
29
|
+
* mistaken for a stack (which would crash `Object.entries` downstream).
|
|
27
30
|
*/
|
|
28
|
-
declare function isStackSpec(spec:
|
|
31
|
+
declare function isStackSpec(spec: unknown): spec is AppDeploySpec & {
|
|
32
|
+
services: Record<string, ServiceConfig>;
|
|
33
|
+
};
|
|
29
34
|
/**
|
|
30
35
|
* Return the canonical first image string for a spec. For legacy single-
|
|
31
36
|
* service: `spec.image`. For stack: the first non-empty `image` in
|
|
32
37
|
* `Object.values(spec.services)`. Returns `null` when neither shape
|
|
33
38
|
* carries an image (or `spec` is malformed).
|
|
34
39
|
*/
|
|
35
|
-
declare function firstImage(spec:
|
|
40
|
+
declare function firstImage(spec: unknown): string | null;
|
|
36
41
|
/**
|
|
37
42
|
* Walk a spec as `[{name, raw}]` where:
|
|
38
43
|
* - `name === null` for legacy single-service (only one entry, raw is the spec itself).
|
|
39
|
-
* - `name === <key>` for each services-map entry; `raw` is the per-service
|
|
44
|
+
* - `name === <key>` for each services-map entry; `raw` is the per-service ServiceConfig.
|
|
40
45
|
*
|
|
41
46
|
* Stable iteration order matches `Object.entries` (insertion order in v8/modern engines).
|
|
42
47
|
*/
|
|
@@ -44,30 +49,31 @@ interface NormalizedService {
|
|
|
44
49
|
/** `null` for legacy single-service; the services-map key for stack leases. */
|
|
45
50
|
name: string | null;
|
|
46
51
|
/** The per-service object exactly as the spec stores it. No field projection. */
|
|
47
|
-
raw:
|
|
52
|
+
raw: ServiceConfig | AppDeploySpec;
|
|
48
53
|
}
|
|
49
|
-
declare function normalizeServices(spec:
|
|
54
|
+
declare function normalizeServices(spec: unknown): NormalizedService[];
|
|
50
55
|
/**
|
|
51
56
|
* Produce the frozen `SpecSummary` shape for inclusion in the `Plan`
|
|
52
57
|
* (camelCase fields: `serviceCount`, etc.).
|
|
53
58
|
*
|
|
54
59
|
* Port count rules:
|
|
55
|
-
* -
|
|
56
|
-
* -
|
|
57
|
-
*
|
|
58
|
-
* -
|
|
60
|
+
* - single-service `port: number` → +1 port.
|
|
61
|
+
* - `ServiceConfig.ports` map (canonical `{ '<port>/<proto>': {} }`) →
|
|
62
|
+
* +key count.
|
|
63
|
+
* - A bare `port`/`ports` array smuggled in by an `unknown`-cast caller
|
|
64
|
+
* is still counted defensively (+1 / +length) below.
|
|
59
65
|
*
|
|
60
66
|
* Env key uniqueness is computed across services (one `env_keys` set
|
|
61
67
|
* spans the whole spec); `envCount` is the size of that set; `envKeys`
|
|
62
68
|
* is sorted ascending.
|
|
63
69
|
*/
|
|
64
|
-
declare function summarizeSpec(spec:
|
|
70
|
+
declare function summarizeSpec(spec: unknown): SpecSummary;
|
|
65
71
|
/**
|
|
66
|
-
* Validate
|
|
67
|
-
* first violation. The
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
72
|
+
* Validate an `AppDeploySpec` shape pre-broadcast. Throws `TypeError` on
|
|
73
|
+
* the first violation. The canonical type already enforces most
|
|
74
|
+
* structural rules at compile time; this runtime check defends against
|
|
75
|
+
* `unknown`-cast callers and `JSON.parse`-decoded inputs (and the
|
|
76
|
+
* image-XOR-services invariant the type cannot express).
|
|
71
77
|
*
|
|
72
78
|
* Rules (mirror fred's `deployApp.ts` input validation):
|
|
73
79
|
* - `spec` must be a non-null object.
|
|
@@ -79,7 +85,7 @@ declare function summarizeSpec(spec: DeploySpec): SpecSummary;
|
|
|
79
85
|
* The high-level `deployApp` in PR 3 layers domain checks on top
|
|
80
86
|
* (`customDomain` shape, `serviceName` membership, etc.).
|
|
81
87
|
*/
|
|
82
|
-
declare function validateSpec(spec:
|
|
88
|
+
declare function validateSpec(spec: unknown): void;
|
|
83
89
|
//#endregion
|
|
84
90
|
export { NormalizedService, firstImage, isStackSpec, normalizeServices, summarizeSpec, validateSpec };
|
|
85
91
|
//# sourceMappingURL=spec-normalize.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"spec-normalize.d.ts","names":[],"sources":["../../src/internals/spec-normalize.ts"],"mappings":";;;;;
|
|
1
|
+
{"version":3,"file":"spec-normalize.d.ts","names":[],"sources":["../../src/internals/spec-normalize.ts"],"mappings":";;;;;AA8BA;;;;;;;;;;;;;;;AAEmE;AAmBnE;;;;AAAwC;AAyBxC;;;;iBA9CgB,WAAA,CACd,IAAA,YACC,IAAA,IAAQ,aAAA;EAAkB,QAAA,EAAU,MAAA,SAAe,aAAA;AAAA;;;AAgDlB;AAGpC;;;iBAhCgB,UAAA,CAAW,IAAa;AAgC2B;AA8BnE;;;;AAAyD;AA2DzD;AAzFmE,UAPlD,iBAAA;;EAEf,IAAA;EA8FwC;EA5FxC,GAAA,EAAK,aAAA,GAAgB,aAAa;AAAA;AAAA,iBAGpB,iBAAA,CAAkB,IAAA,YAAgB,iBAAiB;;;;;;;;;;;;;;;;iBA8BnD,aAAA,CAAc,IAAA,YAAgB,WAAW;;;;;;;;;;;;;;;;;;iBA2DzC,YAAA,CAAa,IAAa"}
|
|
@@ -4,28 +4,33 @@
|
|
|
4
4
|
* `firstImage`, `normalizeServices`, `summarizeSpec`, and `validateSpec`
|
|
5
5
|
* (the latter surfaces pre-broadcast shape violations).
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* - **
|
|
10
|
-
* - **
|
|
7
|
+
* The single canonical spec type (`AppDeploySpec`, ENG-310) covers two
|
|
8
|
+
* shapes, discriminated by the presence of `services`:
|
|
9
|
+
* - **stack** — `{ services: { <name>: ServiceConfig }, customDomain?, serviceName?, size, ... }`
|
|
10
|
+
* - **single-service** — `{ image, port?, env?, customDomain?, size, ... }`
|
|
11
11
|
*
|
|
12
12
|
* `normalizeServices` collapses the two shapes into a single iterable form
|
|
13
13
|
* so callers (Plan summary, manifest builder, etc.) walk one structure
|
|
14
14
|
* regardless of which form the user passed.
|
|
15
15
|
*
|
|
16
|
-
* Validation: `validateSpec`
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
16
|
+
* Validation: `validateSpec` stays a `void` `TypeError`-thrower on shape
|
|
17
|
+
* violations. The high-level `deployApp` entry wrappers
|
|
18
|
+
* (`deploy-app.ts`) map that `TypeError` to
|
|
19
|
+
* `ManifestMCPError(INVALID_CONFIG)` at the public-API boundary, so this
|
|
20
|
+
* helper does not depend on core's error type directly.
|
|
21
21
|
*/
|
|
22
22
|
/**
|
|
23
|
-
* True when `spec` uses the services-map
|
|
24
|
-
*
|
|
23
|
+
* True when `spec` uses the services-map (stack) shape. Discriminates on
|
|
24
|
+
* the canonical `services` key being present and defined (ENG-310). The
|
|
25
|
+
* non-null-object / non-array guard is retained as defense-in-depth so an
|
|
26
|
+
* `unknown`-cast caller smuggling `services: null` / `[]` / `'str'` is NOT
|
|
27
|
+
* mistaken for a stack (which would crash `Object.entries` downstream).
|
|
25
28
|
*/
|
|
26
29
|
function isStackSpec(spec) {
|
|
27
30
|
if (spec === null || spec === void 0 || typeof spec !== "object") return false;
|
|
28
|
-
const
|
|
31
|
+
const record = spec;
|
|
32
|
+
if (!("services" in record) || record.services === void 0) return false;
|
|
33
|
+
const services = record.services;
|
|
29
34
|
return services !== null && typeof services === "object" && !Array.isArray(services);
|
|
30
35
|
}
|
|
31
36
|
/**
|
|
@@ -61,10 +66,11 @@ function normalizeServices(spec) {
|
|
|
61
66
|
* (camelCase fields: `serviceCount`, etc.).
|
|
62
67
|
*
|
|
63
68
|
* Port count rules:
|
|
64
|
-
* -
|
|
65
|
-
* -
|
|
66
|
-
*
|
|
67
|
-
* -
|
|
69
|
+
* - single-service `port: number` → +1 port.
|
|
70
|
+
* - `ServiceConfig.ports` map (canonical `{ '<port>/<proto>': {} }`) →
|
|
71
|
+
* +key count.
|
|
72
|
+
* - A bare `port`/`ports` array smuggled in by an `unknown`-cast caller
|
|
73
|
+
* is still counted defensively (+1 / +length) below.
|
|
68
74
|
*
|
|
69
75
|
* Env key uniqueness is computed across services (one `env_keys` set
|
|
70
76
|
* spans the whole spec); `envCount` is the size of that set; `envKeys`
|
|
@@ -99,11 +105,11 @@ function summarizeSpec(spec) {
|
|
|
99
105
|
};
|
|
100
106
|
}
|
|
101
107
|
/**
|
|
102
|
-
* Validate
|
|
103
|
-
* first violation. The
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
108
|
+
* Validate an `AppDeploySpec` shape pre-broadcast. Throws `TypeError` on
|
|
109
|
+
* the first violation. The canonical type already enforces most
|
|
110
|
+
* structural rules at compile time; this runtime check defends against
|
|
111
|
+
* `unknown`-cast callers and `JSON.parse`-decoded inputs (and the
|
|
112
|
+
* image-XOR-services invariant the type cannot express).
|
|
107
113
|
*
|
|
108
114
|
* Rules (mirror fred's `deployApp.ts` input validation):
|
|
109
115
|
* - `spec` must be a non-null object.
|
|
@@ -123,7 +129,7 @@ function validateSpec(spec) {
|
|
|
123
129
|
if (hasImageKey && hasServicesKey) throw new TypeError("validateSpec: spec has both `image` and `services` keys; these are mutually exclusive (regardless of value validity)");
|
|
124
130
|
const hasImage = typeof record.image === "string" && record.image.length > 0;
|
|
125
131
|
const hasServices = isStackSpec(spec);
|
|
126
|
-
if (!hasImage && !hasServices) throw new TypeError("validateSpec: spec must declare either `image` (
|
|
132
|
+
if (!hasImage && !hasServices) throw new TypeError("validateSpec: spec must declare either `image` (single-service) or `services` (stack)");
|
|
127
133
|
if ("customDomain" in record) {
|
|
128
134
|
const cd = record.customDomain;
|
|
129
135
|
if (cd !== void 0) {
|