@sema-agent/core 7.17.7 → 7.18.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 +140 -13
- package/dist/agents/send-message-tool.js +1 -0
- package/dist/agents/subagent.d.ts +1 -0
- package/dist/agents/subagent.js +5 -0
- package/dist/agents/teacher.js +1 -0
- package/dist/agents/verify.js +3 -0
- package/dist/brain/route-adjudicator.d.ts +18 -0
- package/dist/brain/route-adjudicator.js +16 -0
- package/dist/core/agent-definition.d.ts +10 -0
- package/dist/core/artifact-host.d.ts +41 -4
- package/dist/core/artifact-host.js +18 -2
- package/dist/core/auto-mode.d.ts +141 -45
- package/dist/core/auto-mode.js +109 -3
- package/dist/core/checkpoint-store.d.ts +16 -2
- package/dist/core/engine-notice.d.ts +47 -1
- package/dist/core/engine-notice.js +7 -0
- package/dist/core/gate-lanes.js +2 -1
- package/dist/core/gate-outcome.d.ts +17 -2
- package/dist/core/gate-outcome.js +16 -0
- package/dist/core/governance-codes.d.ts +1 -1
- package/dist/core/governance-codes.js +8 -0
- package/dist/core/hook-seats.d.ts +66 -0
- package/dist/core/hook-seats.js +37 -0
- package/dist/core/runner/advertised-writable-dirs.d.ts +8 -3
- package/dist/core/runner/assemble-result.d.ts +2 -1
- package/dist/core/runner/assemble-result.js +1 -1
- package/dist/core/runner/classifier-ladder.d.ts +89 -0
- package/dist/core/runner/classifier-ladder.js +65 -0
- package/dist/core/runner/contracts.d.ts +69 -13
- package/dist/core/runner/decide-continuation.d.ts +1 -3
- package/dist/core/runner/decide-continuation.js +1 -7
- package/dist/core/runner/deferral-census.d.ts +52 -0
- package/dist/core/runner/deferral-census.js +44 -0
- package/dist/core/runner/derived-route-fallback.d.ts +8 -4
- package/dist/core/runner/derived-route-fallback.js +8 -3
- package/dist/core/runner/gate-exit.d.ts +12 -0
- package/dist/core/runner/gate-exit.js +6 -3
- package/dist/core/runner/prepare-artifact.d.ts +10 -2
- package/dist/core/runner/prepare-artifact.js +5 -4
- package/dist/core/runner/prepare-caps-and-workflow.d.ts +4 -2
- package/dist/core/runner/prepare-caps-and-workflow.js +30 -31
- package/dist/core/runner/prepare-config-doors.d.ts +1 -1
- package/dist/core/runner/prepare-config-doors.js +5 -3
- package/dist/core/runner/prepare-defer-classify.d.ts +13 -19
- package/dist/core/runner/prepare-defer-classify.js +8 -3
- package/dist/core/runner/prepare-gate-stations.d.ts +26 -0
- package/dist/core/runner/prepare-gate-stations.js +1 -1
- package/dist/core/runner/prepare-hands-readface.d.ts +24 -7
- package/dist/core/runner/prepare-hands-readface.js +60 -24
- package/dist/core/runner/prepare-inherited-gate.d.ts +17 -4
- package/dist/core/runner/prepare-inherited-gate.js +10 -2
- package/dist/core/runner/prepare-lsp.d.ts +2 -2
- package/dist/core/runner/prepare-lsp.js +1 -1
- package/dist/core/runner/prepare-policy-chain.js +2 -0
- package/dist/core/runner/prepare-project-context.js +4 -2
- package/dist/core/runner/prepare-prompt-inputs.d.ts +4 -2
- package/dist/core/runner/prepare-prompt-inputs.js +4 -1
- package/dist/core/runner/prepare-run-refs.d.ts +9 -1
- package/dist/core/runner/prepare-run-refs.js +3 -2
- package/dist/core/runner/prepare-suspend-saga.d.ts +5 -0
- package/dist/core/runner/prepare-suspend-saga.js +5 -2
- package/dist/core/runner/prepare-task.js +18 -13
- package/dist/core/runner/prepare-tool-disclosure-mount.d.ts +5 -0
- package/dist/core/runner/prepare-tool-disclosure-mount.js +10 -2
- package/dist/core/runner/prepare-turn-wiring.d.ts +8 -4
- package/dist/core/runner/prepare-turn-wiring.js +3 -2
- package/dist/core/runner/prepare-wiring-manifest.d.ts +5 -5
- package/dist/core/runner/prepare-wiring-manifest.js +5 -2
- package/dist/core/runner/resume-checkpoint-screen.js +7 -0
- package/dist/core/runner/run-harness-handlers.js +4 -1
- package/dist/core/runner/run-identity-wiring.js +6 -2
- package/dist/core/runner/run-leg.d.ts +2 -0
- package/dist/core/runner/run-leg.js +35 -41
- package/dist/core/runner/run-notification-lane.js +55 -27
- package/dist/core/runner/run-settle-and-teardown.js +1 -0
- package/dist/core/runner/run-terminal-adoption.js +16 -13
- package/dist/core/runner/run-turn-boundary.js +3 -1
- package/dist/core/runner/runtask.d.ts +6 -5
- package/dist/core/runner/runtask.js +26 -14
- package/dist/core/runner/skill-base-dir.d.ts +54 -0
- package/dist/core/runner/skill-base-dir.js +42 -0
- package/dist/core/runner/synthetic-tools.d.ts +16 -19
- package/dist/core/runner/synthetic-tools.js +18 -10
- package/dist/core/runner/tool-defer-gate.d.ts +22 -9
- package/dist/core/runner/tool-defer-gate.js +15 -5
- package/dist/core/runner/tool-disclosure.d.ts +5 -0
- package/dist/core/runner/tool-disclosure.js +2 -0
- package/dist/core/runner/tool-progress-lane.d.ts +42 -0
- package/dist/core/runner/tool-progress-lane.js +61 -0
- package/dist/core/runner-deps.d.ts +48 -7
- package/dist/core/swappable-deps.d.ts +9 -7
- package/dist/core/swappable-deps.js +7 -3
- package/dist/core/task-event.d.ts +96 -29
- package/dist/core/task-notification.d.ts +142 -16
- package/dist/core/task-notification.js +106 -0
- package/dist/core/task-result.d.ts +9 -1
- package/dist/core/task-spec.d.ts +20 -3
- package/dist/core/tighten-task-spec.js +7 -5
- package/dist/core/tool-model-gate.d.ts +1 -1
- package/dist/core/tool-search-setting.d.ts +78 -0
- package/dist/core/tool-search-setting.js +90 -0
- package/dist/core/tool-spec.d.ts +8 -0
- package/dist/core/types.d.ts +1 -1
- package/dist/core/types.js +1 -1
- package/dist/core/wiring-manifest.d.ts +46 -0
- package/dist/core/wiring-manifest.js +9 -1
- package/dist/engine/execution-env/node-execution-env.js +10 -4
- package/dist/engine/harness/agent-harness.d.ts +42 -0
- package/dist/engine/harness/agent-harness.js +61 -29
- package/dist/engine/loop/agent-loop.d.ts +68 -3
- package/dist/engine/loop/agent-loop.js +109 -16
- package/dist/index.d.ts +10 -5
- package/dist/index.js +9 -5
- package/dist/internal/harness.d.ts +1 -1
- package/dist/orchestration/run-workflow-tool.d.ts +3 -0
- package/dist/orchestration/run-workflow-tool.js +6 -1
- package/dist/orchestration/workflow-governance.d.ts +7 -1
- package/dist/orchestration/workflow-governance.js +9 -5
- package/dist/orchestration/workflow-primitives.d.ts +3 -0
- package/dist/orchestration/workflow-primitives.js +6 -1
- package/dist/orchestration/workflow-types.d.ts +48 -13
- package/dist/orchestration/workflow-types.js +1 -1
- package/dist/orchestration/workflow.js +45 -17
- package/dist/prompt-assembly/epoch.js +2 -0
- package/dist/prompt-assembly/packs/sema-default.js +2 -2
- package/dist/prompt-assembly/section-usage.d.ts +26 -0
- package/dist/prompt-assembly/section-usage.js +10 -0
- package/dist/prompt-assembly/types.d.ts +8 -6
- package/dist/prompts/default.d.ts +13 -0
- package/dist/prompts/default.js +16 -8
- package/dist/tools/artifact/artifact-text.d.ts +10 -1
- package/dist/tools/artifact/artifact-text.js +6 -3
- package/dist/tools/artifact/artifact-tool.d.ts +12 -2
- package/dist/tools/artifact/artifact-tool.js +203 -70
- package/dist/tools/artifact/conformance.d.ts +56 -0
- package/dist/tools/artifact/conformance.js +349 -0
- package/dist/tools/artifact/local-stub.js +1 -1
- package/dist/tools/artifact/reject-breaker.d.ts +28 -0
- package/dist/tools/artifact/reject-breaker.js +42 -0
- package/dist/tools/fs/bash-lexer.d.ts +132 -0
- package/dist/tools/fs/bash-lexer.js +563 -0
- package/dist/tools/fs/bash-program-position.d.ts +142 -0
- package/dist/tools/fs/bash-program-position.js +125 -0
- package/dist/tools/fs/bash-readonly-classifier.d.ts +54 -16
- package/dist/tools/fs/bash-readonly-classifier.js +121 -552
- package/dist/tools/fs/containment-face.d.ts +89 -0
- package/dist/tools/fs/containment-face.js +81 -0
- package/dist/tools/fs/fs-bash.d.ts +46 -1
- package/dist/tools/fs/fs-bash.js +158 -22
- package/dist/tools/fs/fs-read.d.ts +1 -1
- package/dist/tools/fs/fs-search-tools.d.ts +3 -3
- package/dist/tools/fs/fs-write.d.ts +4 -3
- package/dist/tools/fs/fs-write.js +9 -9
- package/dist/tools/fs/index.d.ts +24 -5
- package/dist/tools/fs/index.js +18 -6
- package/dist/tools/fs/repo-map.d.ts +2 -2
- package/dist/tools/fs/safety.d.ts +3 -3
- package/dist/tools/fs/safety.js +4 -4
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +85 -9
- package/wire-changes/core@7.17.7.json +49 -0
- package/dist/tools/fs/read-face.d.ts +0 -68
- package/dist/tools/fs/read-face.js +0 -47
package/dist/prompts/default.js
CHANGED
|
@@ -109,10 +109,14 @@ You have no persistent memory store: what is said in this conversation is not au
|
|
|
109
109
|
export const CYBER_RISK = `IMPORTANT: Assist with authorized security testing, defensive security, CTF challenges, and educational contexts. Refuse requests for destructive techniques, DoS attacks, mass targeting, supply chain compromise, or detection evasion for malicious purposes. Dual-use security tools (C2 frameworks, credential testing, exploit development) require clear authorization context: pentesting engagements, CTF competitions, security research, or defensive use cases.`;
|
|
110
110
|
export const URL_SAFETY = `IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.`;
|
|
111
111
|
export const SUMMARIZE_TOOL_RESULTS = `When working with tool results, write down any important information you might need later in your own response, as the original tool result may be cleared or summarized from the context later.`;
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
112
|
+
const READ_WRITE_BULLET = {
|
|
113
|
+
"roots/roots": "- You can read and write within the project working directory. Writes outside it, or to system paths, may be denied by the environment or the permission policy.",
|
|
114
|
+
"open/roots": "- You can read files anywhere this environment exposes (a small sensitive-path deny list applies), and write within the project working directory. Writes outside it, or to system paths, may be denied by the environment or the permission policy.",
|
|
115
|
+
"roots/open": "- You can read within the project working directory, and write files anywhere this environment exposes (a small protected-path list still applies). Writes to system paths may still be denied by the environment or the permission policy.",
|
|
116
|
+
"open/open": "- You can read and write files anywhere this environment exposes (small sensitive-path and protected-path lists still apply). Writes to system paths may still be denied by the environment or the permission policy.",
|
|
117
|
+
};
|
|
118
|
+
export function executionEnvironmentBlockFor(faces = {}) {
|
|
119
|
+
const readWriteLine = READ_WRITE_BULLET[`${faces.readOpen === true ? "open" : "roots"}/${faces.writeOpen === true ? "open" : "roots"}`];
|
|
116
120
|
return `# Execution environment
|
|
117
121
|
Commands run inside an isolated execution environment (a managed container or remote host), not on the operator's machine. Within it:
|
|
118
122
|
${readWriteLine}
|
|
@@ -125,8 +129,8 @@ When a command fails, identify the cause before retrying:
|
|
|
125
129
|
|
|
126
130
|
If a restriction genuinely blocks a necessary action, do NOT attempt to circumvent it (no privilege escalation, no disabling of guards, no destructive workarounds). Adjust your approach, or surface the limitation to the user with the specific evidence you saw.`;
|
|
127
131
|
}
|
|
128
|
-
export const EXECUTION_ENVIRONMENT =
|
|
129
|
-
export const EXECUTION_ENVIRONMENT_OPEN_READS =
|
|
132
|
+
export const EXECUTION_ENVIRONMENT = executionEnvironmentBlockFor();
|
|
133
|
+
export const EXECUTION_ENVIRONMENT_OPEN_READS = executionEnvironmentBlockFor({ readOpen: true });
|
|
130
134
|
export const WORKTREE_NOTICE = `# Isolated worktree
|
|
131
135
|
This task runs in its own isolated git worktree — a separate working copy whose root is the working directory shown in # Environment, NOT the repository's main checkout. Any absolute path you were given that points at the main checkout (or another worktree) refers to a DIFFERENT copy; translate it to the same relative path under this worktree's root before reading or writing, and operate only within this worktree. A file's content here may differ from the main checkout, so re-read a file in this worktree before editing it rather than assuming an earlier or external view is current.`;
|
|
132
136
|
export const WORKTREE_STASH_WARNING = "The git stash stack is shared with the main checkout and all other worktrees, and other agent sessions may push or pop it concurrently. " +
|
|
@@ -162,8 +166,9 @@ export function harnessHeadLines(ctx) {
|
|
|
162
166
|
}
|
|
163
167
|
export function harnessContext(ctx) {
|
|
164
168
|
const blocks = [harnessHeadLines(ctx), CYBER_RISK, URL_SAFETY, SUMMARIZE_TOOL_RESULTS];
|
|
165
|
-
if (ctx.policyEnabled || ctx.isolationEnabled)
|
|
166
|
-
blocks.push(ctx.readFaceOpen === true
|
|
169
|
+
if (ctx.policyEnabled || ctx.isolationEnabled) {
|
|
170
|
+
blocks.push(executionEnvironmentBlockFor({ readOpen: ctx.readFaceOpen === true, writeOpen: ctx.writeFaceOpen === true }));
|
|
171
|
+
}
|
|
167
172
|
if (ctx.promptProfile !== "classic") {
|
|
168
173
|
blocks.push(ctx.fableMitigations === true ? SIMPLE_COMMUNICATING_FABLE : SIMPLE_COMMUNICATING_LEAN);
|
|
169
174
|
blocks.push(SIMPLE_PRONOUNS);
|
|
@@ -259,6 +264,9 @@ export function buildEnvironmentContext(facts) {
|
|
|
259
264
|
if (facts.readFace === "open") {
|
|
260
265
|
lines.push("File reads are not confined to the workspace root. A small sensitive-path deny list applies (see refusals for the exact pattern).");
|
|
261
266
|
}
|
|
267
|
+
if (facts.writeFace === "open") {
|
|
268
|
+
lines.push("File writes are not confined to the workspace root. A small protected-path list still applies, and every write still passes the deployment's approval policy (see refusals for the exact reason).");
|
|
269
|
+
}
|
|
262
270
|
if (facts.isGitRepo !== undefined)
|
|
263
271
|
lines.push(`Is a git repository: ${facts.isGitRepo ? "yes" : "no"}`);
|
|
264
272
|
if (facts.gitWorktreeRoot)
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* neutral phrase. The replacement list is registered in the divergence register beside this package.
|
|
7
7
|
*/
|
|
8
8
|
import type { ArtifactAction, ArtifactPlane } from "../../core/artifact-host.js";
|
|
9
|
+
import type { AssertAllKeysHandled } from "../../core/ask-origin.js";
|
|
9
10
|
/** Opening sentence of the hosted face (`U$t`, line 51145; "hosted on claude.ai" replaced). */
|
|
10
11
|
export declare const HOSTED_OPENING = "Render an HTML file to an Artifact \u2014 a default-private web page hosted by this deployment's artifact host. Use this when communicating visually would be clearer than terminal text. Publishing proactively is fine for your own work-product \u2014 artifacts start private. The exception is content that could mislead or cause harm if shared onward: anything imitating a real organization, person, or record, or content the user framed as sensitive. Build those as files, and let the user decide whether they get a URL.";
|
|
11
12
|
/** The title rule (`G$t`, line 51145). */
|
|
@@ -54,7 +55,15 @@ export declare const PARAM_TEXT: {
|
|
|
54
55
|
readonly scope: "list only: 'mine' (default) lists artifacts the user owns — the only ones the update flow can target; 'shared' lists artifacts other people shared with the user (read-only); 'all' lists both. Rows are labeled (mine)/(shared) whenever scope is not 'mine'.";
|
|
55
56
|
readonly path: "read_file only: the published path of the file to return (as listed by 'list_files').";
|
|
56
57
|
};
|
|
57
|
-
/** The
|
|
58
|
+
/** The opening paragraphs of each declared plane: the hosted opening with its title rule, or the two stub sentences
|
|
59
|
+
* (the stub names no gallery, so no title rule). Every paragraph after the opening is keyed on a declared action. */
|
|
60
|
+
export declare const ARTIFACT_PLANE_OPENING: {
|
|
61
|
+
readonly hosted: readonly ["Render an HTML file to an Artifact — a default-private web page hosted by this deployment's artifact host. Use this when communicating visually would be clearer than terminal text. Publishing proactively is fine for your own work-product — artifacts start private. The exception is content that could mislead or cause harm if shared onward: anything imitating a real organization, person, or record, or content the user framed as sensitive. Build those as files, and let the user decide whether they get a URL.", "**Title**: Set a `<title>` at the top of the HTML — only the first 8KB of the file is scanned for it. It names the artifact in the browser tab and gallery, so make it a name, not a summary: a short noun phrase, typically two to four words, distinctive to this page's subject so the reader can pick it out of a gallery of many — the way an app or a document gets named, never a generic category label, and never a name plus an appended explainer after a dash or colon. When a natural title pairs the name with a generic word, the name is the half that survives the trim — keeping the generic half and dropping the identity makes the title worse, not shorter. And trim only actual explainers: a multi-word title that already reads as one specific name is finished as it is. The explanation belongs in the `description` parameter instead: pass a one-sentence `description` — it becomes the gallery card's subtitle. For HTML publishes, a `title` parameter fills in when the file has no tag (Markdown pages always keep their filename identity). Keep the title stable across redeploys."];
|
|
62
|
+
readonly "local-stub": readonly ["This eval run stubs artifact publishing locally and has no artifact control plane: only publishing (the default action) is available here — list, delete, comments, replies, watches, database writes, asset uploads, and live docs are not.", "This eval run stubs artifact publishing locally: `url` may only name an artifact this run published (an eval-stub:// link), never an artifact hosted anywhere else. Omit `url` to publish fresh."];
|
|
63
|
+
};
|
|
64
|
+
export type OpeningTableCoversEveryPlane = AssertAllKeysHandled<Exclude<ArtifactPlane, keyof typeof ARTIFACT_PLANE_OPENING>>;
|
|
65
|
+
/** The tool description for a face: the plane's opening, the update rule, one paragraph per declared action, and the
|
|
66
|
+
* comments disclosure — no host declares a comments action in this version, so its absence is a fact of every face. */
|
|
58
67
|
export declare function artifactDescription(plane: ArtifactPlane, actions: ReadonlyArray<ArtifactAction>): string;
|
|
59
68
|
/** The `url` parameter's description for a face: the list hint and the read clause ride only with their actions. */
|
|
60
69
|
export declare function urlDescription(actions: ReadonlyArray<ArtifactAction>): string;
|
|
@@ -33,11 +33,13 @@ export const PARAM_TEXT = {
|
|
|
33
33
|
scope: "list only: 'mine' (default) lists artifacts the user owns — the only ones the update flow can target; 'shared' lists artifacts other people shared with the user (read-only); 'all' lists both. Rows are labeled (mine)/(shared) whenever scope is not 'mine'.",
|
|
34
34
|
path: "read_file only: the published path of the file to return (as listed by 'list_files').",
|
|
35
35
|
};
|
|
36
|
+
export const ARTIFACT_PLANE_OPENING = {
|
|
37
|
+
hosted: [HOSTED_OPENING, TITLE_RULE],
|
|
38
|
+
"local-stub": [STUB_PUBLISH_ONLY, STUB_OWN_MINT_ONLY],
|
|
39
|
+
};
|
|
36
40
|
export function artifactDescription(plane, actions) {
|
|
37
|
-
if (plane === "local-stub")
|
|
38
|
-
return [STUB_PUBLISH_ONLY, STUB_OWN_MINT_ONLY, UPDATE_RULE, COMMENTS_NOT_ENABLED].join("\n\n");
|
|
39
41
|
const has = (a) => actions.includes(a);
|
|
40
|
-
const parts = [
|
|
42
|
+
const parts = [...ARTIFACT_PLANE_OPENING[plane], UPDATE_RULE];
|
|
41
43
|
if (has("list") && has("read"))
|
|
42
44
|
parts.push(UPDATE_FROM_EARLIER_RULE);
|
|
43
45
|
if (has("read"))
|
|
@@ -48,6 +50,7 @@ export function artifactDescription(plane, actions) {
|
|
|
48
50
|
parts.push(LIST_FILES_RULE);
|
|
49
51
|
if (has("read_file"))
|
|
50
52
|
parts.push(READ_FILE_RULE);
|
|
53
|
+
parts.push(COMMENTS_NOT_ENABLED);
|
|
51
54
|
return parts.join("\n\n");
|
|
52
55
|
}
|
|
53
56
|
export function urlDescription(actions) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ArtifactHost, type ArtifactHostCapabilities } from "../../core/artifact-host.js";
|
|
2
|
+
import { type OnAsk } from "../../core/tool-policy.js";
|
|
2
3
|
import type { ToolSpec } from "../../core/types.js";
|
|
3
4
|
/** What the engine's local-file reader hands back: the canonical key (the path-binding key) and the bytes, or one of
|
|
4
5
|
* three refusals — the fence's own (`refused`, with its text and card), a file over the byte budget the caller
|
|
@@ -35,11 +36,20 @@ export interface ArtifactToolOptions {
|
|
|
35
36
|
/** The engine's fs-gated single read of a model-named local file (one read per call; the bytes read are the bytes
|
|
36
37
|
* published); `maxBytes` is the budget left for this file, judged on metadata before the read. */
|
|
37
38
|
readLocalFile: (path: string, maxBytes: number, signal?: AbortSignal) => Promise<ArtifactLocalRead>;
|
|
39
|
+
/** The run's effective approver — the same frozen seat the tool gate resolves asks at (`frozenOnAsk` =
|
|
40
|
+
* `spec.onAsk ?? deps.onAsk`), riding the MOUNT dep under the same rule as the workflow mount's `parentOnAsk`: a core
|
|
41
|
+
* mount's execute ctx is minimal, so the seat never rides `ctx`. The shared-read consent gate resolves at it. Absent
|
|
42
|
+
* = the run has no ask face, and a shared read is then REFUSED fail-closed (`artifact.refused`, content withheld) —
|
|
43
|
+
* never silently delivered. */
|
|
44
|
+
onAsk?: OnAsk;
|
|
38
45
|
}
|
|
46
|
+
/** Supporting files per publish (upstream cap); enforced in the schema AND in the body, for callers that bypass the schema. */
|
|
47
|
+
export declare const MAX_FILES_PER_PUBLISH = 255;
|
|
39
48
|
export declare function mediaTypeOf(path: string): string;
|
|
40
49
|
export declare function isTextMedia(mediaType: string | undefined): boolean;
|
|
41
|
-
/** One opaque key per
|
|
42
|
-
*
|
|
50
|
+
/** One opaque key per HOST CALL (`occurrence` counts prior host calls made under that tool-call id in this run — so a
|
|
51
|
+
* gateway that reuses a call id across turns never replays, and neither do two host calls of ONE execution), stable
|
|
52
|
+
* for any retry the host itself makes of that call: the host's idempotency key. */
|
|
43
53
|
export declare function artifactAttemptId(runId: string, toolCallId: string, occurrence?: number): string;
|
|
44
54
|
/** A published path: relative, forward-slash, no dot segments, no NUL, no backslash; returns the NFC form or the refusal. */
|
|
45
55
|
export declare function normalizePublishedPath(raw: string): {
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
-
import { ARTIFACT_FAILURE_RESULT_CODE, ARTIFACT_HOST_ERROR_CODE, ARTIFACT_LIST_SCOPES, ARTIFACT_PLANE_CONTENT_ORIGIN, ARTIFACT_PUBLISH_CONTEXTS, } from "../../core/artifact-host.js";
|
|
3
|
+
import { ARTIFACT_ACTION_CONCURRENCY_SAFE, ARTIFACT_COMMIT_THROW_OUTCOME, ARTIFACT_FAILURE_RESULT_CODE, ARTIFACT_HOST_ERROR_CODE, ARTIFACT_LIST_SCOPES, ARTIFACT_PLANE_CONTENT_ORIGIN, ARTIFACT_PUBLISH_CONTEXTS, } from "../../core/artifact-host.js";
|
|
4
4
|
import { ARTIFACT_TOOL_NAME, toolFace } from "../../core/tool-catalog-entries.js";
|
|
5
|
+
import { resolveAsk } from "../../core/tool-policy.js";
|
|
5
6
|
import { errorResult } from "../../core/tools.js";
|
|
6
7
|
import { inlineUntrusted } from "../../core/untrusted-text.js";
|
|
7
8
|
import { actionDescription, artifactDescription, PARAM_TEXT, urlDescription } from "./artifact-text.js";
|
|
8
|
-
|
|
9
|
+
import { ARTIFACT_REJECT_BREAKER_THRESHOLD, artifactRejectBannerFor, artifactRejectKeyOf, createArtifactRejectBreaker } from "./reject-breaker.js";
|
|
10
|
+
export const MAX_FILES_PER_PUBLISH = 255;
|
|
9
11
|
const LIST_LIMIT_DEFAULT = 25;
|
|
10
12
|
const LIST_LIMIT_MAX = 100;
|
|
11
13
|
const BINARY_NOTE_MEDIA = new Set(["application/json", "application/javascript", "text/javascript", "image/svg+xml", "application/xml"]);
|
|
@@ -125,36 +127,42 @@ function buildSchema(caps) {
|
|
|
125
127
|
const invalid = (text) => errorResult(`Error (Artifact): ${text}`, { type: "artifact", invalidArguments: true });
|
|
126
128
|
const typed = (code, text, extra = {}) => errorResult(`Error (Artifact): ${text}`, { type: "artifact", code: ARTIFACT_FAILURE_RESULT_CODE[code], ...extra });
|
|
127
129
|
export function createArtifactTool(opts) {
|
|
128
|
-
const { host, capabilities: caps, runId, taskId, sessionId, agentName, readLocalFile } = opts;
|
|
130
|
+
const { host, capabilities: caps, runId, taskId, sessionId, agentName, readLocalFile, onAsk } = opts;
|
|
129
131
|
const versions = new Map();
|
|
130
132
|
const bindings = new Map();
|
|
131
133
|
const occurrences = new Map();
|
|
134
|
+
const breaker = createArtifactRejectBreaker();
|
|
135
|
+
const ownUrls = new Set();
|
|
136
|
+
const sharedUrls = new Set();
|
|
137
|
+
const sharedReadApproved = new Set();
|
|
138
|
+
const consentInFlight = new Map();
|
|
139
|
+
const activeTargets = new Map();
|
|
132
140
|
const hasAction = (a) => caps.actions.includes(a);
|
|
133
141
|
const rememberRef = (ref) => {
|
|
134
142
|
versions.set(ref.url, ref.version);
|
|
135
143
|
};
|
|
136
|
-
const
|
|
144
|
+
const rememberOwner = (url, owner) => {
|
|
145
|
+
(owner === "mine" ? ownUrls : sharedUrls).add(url);
|
|
146
|
+
};
|
|
147
|
+
const nextContext = (ctx) => {
|
|
137
148
|
const n = occurrences.get(ctx.toolCallId) ?? 0;
|
|
138
149
|
occurrences.set(ctx.toolCallId, n + 1);
|
|
139
|
-
return
|
|
150
|
+
return {
|
|
151
|
+
taskId,
|
|
152
|
+
...(sessionId !== undefined ? { sessionId } : {}),
|
|
153
|
+
...(agentName !== undefined ? { agentName } : {}),
|
|
154
|
+
runId,
|
|
155
|
+
attemptId: artifactAttemptId(runId, ctx.toolCallId, n),
|
|
156
|
+
};
|
|
140
157
|
};
|
|
141
|
-
const contextFor = (attemptId) => ({
|
|
142
|
-
taskId,
|
|
143
|
-
...(sessionId !== undefined ? { sessionId } : {}),
|
|
144
|
-
...(agentName !== undefined ? { agentName } : {}),
|
|
145
|
-
runId,
|
|
146
|
-
attemptId,
|
|
147
|
-
});
|
|
148
158
|
const hostError = (verb, fault, url) => {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
});
|
|
159
|
+
const outcome = fault.kind === "threw" ? ARTIFACT_COMMIT_THROW_OUTCOME[caps.commit] : "unknown";
|
|
160
|
+
const details = { type: "artifact", code: ARTIFACT_HOST_ERROR_CODE, outcome, plane: caps.plane, commit: caps.commit };
|
|
161
|
+
if (outcome === "not_executed") {
|
|
162
|
+
return errorResult(`Error (Artifact): the artifact host failed before committing (${inlineUntrusted(fault.message, 200)}). Nothing was published; the call can be repeated.`, details);
|
|
153
163
|
}
|
|
154
164
|
const verifiable = hasAction("read") && url !== "";
|
|
155
|
-
return errorResult(`Error (Artifact): the artifact host did not answer the ${verb.slice(0, 20)} (${inlineUntrusted(fault.message, 200)}). Whether it was applied is not known.${verifiable ? ` Verify with action: "read" on ${url.slice(0, 200)}
|
|
156
|
-
type: "artifact", code: ARTIFACT_HOST_ERROR_CODE, outcome: "unknown", plane: caps.plane,
|
|
157
|
-
});
|
|
165
|
+
return errorResult(`Error (Artifact): the artifact host did not answer the ${verb.slice(0, 20)} (${inlineUntrusted(fault.message, 200)}). Whether it was applied is not known: it may already have been committed, so do not simply repeat the call.${verifiable ? ` Verify with action: "read" on ${url.slice(0, 200)} first.` : ""}`, details);
|
|
158
166
|
};
|
|
159
167
|
const notFoundOrForbidden = (code, url, message) => code === "not_found"
|
|
160
168
|
? typed(code, `no artifact at ${inlineUntrusted(url, 200)} (${inlineUntrusted(message, 200)}).`, { url })
|
|
@@ -173,42 +181,60 @@ export function createArtifactTool(opts) {
|
|
|
173
181
|
}
|
|
174
182
|
}
|
|
175
183
|
};
|
|
184
|
+
const manifestOf = (a) => {
|
|
185
|
+
const mainNorm = normalizePublishedPath(basenameOf(a.file_path));
|
|
186
|
+
if (!mainNorm.ok)
|
|
187
|
+
return { ok: false, result: invalid(`the published name of ${inlineUntrusted(a.file_path, 200)} ${mainNorm.reason.slice(0, 80)}.`) };
|
|
188
|
+
const entries = [];
|
|
189
|
+
if (a.files === undefined || (Array.isArray(a.files) && a.files.length === 0))
|
|
190
|
+
return { ok: true, main: mainNorm.path, entries };
|
|
191
|
+
if (!caps.multiFile)
|
|
192
|
+
return { ok: false, result: invalid("this artifact host publishes single files only; `files` is not accepted.") };
|
|
193
|
+
if (!Array.isArray(a.files))
|
|
194
|
+
return { ok: false, result: invalid("`files` must be an array of { path, file_path } entries.") };
|
|
195
|
+
if (a.files.length > MAX_FILES_PER_PUBLISH)
|
|
196
|
+
return { ok: false, result: invalid(`\`files\` carries ${String(a.files.length).slice(0, 20)} entries; at most ${MAX_FILES_PER_PUBLISH} supporting files are accepted per publish.`) };
|
|
197
|
+
const seen = new Set([mainNorm.path.toLowerCase()]);
|
|
198
|
+
for (const entry of a.files) {
|
|
199
|
+
if (!isRecord(entry) || typeof entry["path"] !== "string" || typeof entry["file_path"] !== "string")
|
|
200
|
+
return { ok: false, result: invalid("every `files` entry must carry a string `path` and a string `file_path`.") };
|
|
201
|
+
const norm = normalizePublishedPath(entry["path"]);
|
|
202
|
+
if (!norm.ok)
|
|
203
|
+
return { ok: false, result: invalid(`files entry ${inlineUntrusted(entry["path"], 120)} ${norm.reason.slice(0, 80)}.`) };
|
|
204
|
+
const fold = norm.path.toLowerCase();
|
|
205
|
+
if (seen.has(fold))
|
|
206
|
+
return { ok: false, result: invalid(`files entry ${inlineUntrusted(norm.path, 120)} collides with another published path.`) };
|
|
207
|
+
seen.add(fold);
|
|
208
|
+
entries.push({ path: norm.path, file_path: entry["file_path"] });
|
|
209
|
+
}
|
|
210
|
+
return { ok: true, main: mainNorm.path, entries };
|
|
211
|
+
};
|
|
176
212
|
async function publish(a, ctx) {
|
|
177
|
-
|
|
178
|
-
if (a.file_path === undefined)
|
|
213
|
+
if (typeof a.file_path !== "string")
|
|
179
214
|
return invalid("`file_path` is required to publish.");
|
|
215
|
+
const manifest = manifestOf(a);
|
|
216
|
+
if (!manifest.ok)
|
|
217
|
+
return manifest.result;
|
|
180
218
|
const files = [];
|
|
181
219
|
const budget = Math.min(caps.maxFileBytes, caps.maxTotalBytes);
|
|
182
220
|
const first = await readLocalFile(a.file_path, budget, ctx.signal);
|
|
183
221
|
if (!first.ok)
|
|
184
222
|
return readRefusal(a.file_path, first);
|
|
185
|
-
files.push({ path:
|
|
223
|
+
files.push({ path: manifest.main, content: first.bytes, mediaType: mediaTypeOf(manifest.main) });
|
|
186
224
|
let total = first.bytes.byteLength;
|
|
187
225
|
const url = a.url ?? bindings.get(first.key);
|
|
188
226
|
if (url === undefined && (a.force === true || a.baseVersion !== undefined)) {
|
|
189
227
|
return invalid("`force` and `baseVersion` only apply to an update — pass `url`, or republish the same file path this session already published.");
|
|
190
228
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if (seen.has(fold))
|
|
201
|
-
return invalid(`files entry ${inlineUntrusted(norm.path, 120)} collides with another published path.`);
|
|
202
|
-
seen.add(fold);
|
|
203
|
-
const left = caps.maxTotalBytes - total;
|
|
204
|
-
if (left <= 0)
|
|
205
|
-
return typed("payload_too_large", `the files already total ${String(total).slice(0, 20)} bytes; this host accepts at most ${String(caps.maxTotalBytes).slice(0, 20)} bytes per version.`, { limit: caps.maxTotalBytes, measured: total });
|
|
206
|
-
const r = await readLocalFile(entry.file_path, Math.min(caps.maxFileBytes, left), ctx.signal);
|
|
207
|
-
if (!r.ok)
|
|
208
|
-
return readRefusal(entry.file_path, r);
|
|
209
|
-
total += r.bytes.byteLength;
|
|
210
|
-
files.push({ path: norm.path, content: r.bytes, mediaType: mediaTypeOf(norm.path) });
|
|
211
|
-
}
|
|
229
|
+
for (const entry of manifest.entries) {
|
|
230
|
+
const left = caps.maxTotalBytes - total;
|
|
231
|
+
if (left <= 0)
|
|
232
|
+
return typed("payload_too_large", `the files already total ${String(total).slice(0, 20)} bytes; this host accepts at most ${String(caps.maxTotalBytes).slice(0, 20)} bytes per version.`, { limit: caps.maxTotalBytes, measured: total });
|
|
233
|
+
const r = await readLocalFile(entry.file_path, Math.min(caps.maxFileBytes, left), ctx.signal);
|
|
234
|
+
if (!r.ok)
|
|
235
|
+
return readRefusal(entry.file_path, r);
|
|
236
|
+
total += r.bytes.byteLength;
|
|
237
|
+
files.push({ path: entry.path, content: r.bytes, mediaType: mediaTypeOf(entry.path) });
|
|
212
238
|
}
|
|
213
239
|
const baseVersion = a.baseVersion ?? (url !== undefined ? versions.get(url) : undefined);
|
|
214
240
|
const req = {
|
|
@@ -222,12 +248,13 @@ export function createArtifactTool(opts) {
|
|
|
222
248
|
...(a.force === true ? { force: true } : {}),
|
|
223
249
|
publishContext: a.publishContext ?? "plain",
|
|
224
250
|
};
|
|
225
|
-
const call = await callHost("publish", () => host.publish(req,
|
|
251
|
+
const call = await callHost("publish", () => host.publish(req, nextContext(ctx)), ctx.signal);
|
|
226
252
|
if (!call.ok)
|
|
227
253
|
return hostError("publish", call, inlineUntrusted(url ?? "", 200));
|
|
228
254
|
const out = call.value;
|
|
229
255
|
if (out.ok) {
|
|
230
256
|
rememberRef(out.ref);
|
|
257
|
+
rememberOwner(out.ref.url, "mine");
|
|
231
258
|
bindings.set(first.key, out.ref.url);
|
|
232
259
|
const title = out.ref.title !== undefined ? ` — ${inlineUntrusted(out.ref.title, 120)}` : "";
|
|
233
260
|
return {
|
|
@@ -236,7 +263,12 @@ export function createArtifactTool(opts) {
|
|
|
236
263
|
};
|
|
237
264
|
}
|
|
238
265
|
if (out.code === "conflict") {
|
|
239
|
-
|
|
266
|
+
let snapshot = out.current;
|
|
267
|
+
if (snapshot !== "reread") {
|
|
268
|
+
const withheld = url === undefined || (await filesOwnership(url, ctx)) !== undefined;
|
|
269
|
+
if (withheld || (url !== undefined && sharedUrls.has(url) && !sharedReadApproved.has(url)))
|
|
270
|
+
snapshot = "reread";
|
|
271
|
+
}
|
|
240
272
|
if (snapshot !== "reread" && url !== undefined)
|
|
241
273
|
versions.set(url, out.currentVersion);
|
|
242
274
|
const handedBack = snapshot === "reread"
|
|
@@ -266,56 +298,125 @@ export function createArtifactTool(opts) {
|
|
|
266
298
|
}
|
|
267
299
|
}
|
|
268
300
|
async function list(a, ctx) {
|
|
269
|
-
const attemptId = attemptIdOf(ctx);
|
|
270
301
|
const scope = a.scope ?? "mine";
|
|
271
302
|
const limit = a.limit ?? LIST_LIMIT_DEFAULT;
|
|
272
|
-
const call = await callHost("list", () => host.list({ scope, limit },
|
|
303
|
+
const call = await callHost("list", () => host.list({ scope, limit }, nextContext(ctx)), ctx.signal);
|
|
273
304
|
if (!call.ok)
|
|
274
305
|
return hostError("list", call, "");
|
|
275
306
|
const out = call.value;
|
|
276
307
|
if (!out.ok)
|
|
277
308
|
return typed("refused", `the host refused the listing (${inlineUntrusted(out.message, 200)}).`);
|
|
309
|
+
for (const r of out.rows)
|
|
310
|
+
rememberOwner(r.url, r.owner);
|
|
278
311
|
const lines = out.rows.map((r) => `${inlineUntrusted(r.url, 300)} v${inlineUntrusted(r.version, 40)} ${inlineUntrusted(r.updatedAt, 40)} (${r.owner})${r.title !== undefined ? ` ${inlineUntrusted(r.title, 120)}` : ""}`);
|
|
279
312
|
const body = lines.length === 0 ? "nothing listed" : lines.join("\n");
|
|
280
313
|
return { content: `${body}${out.truncated ? "\n(more artifacts exist; raise `limit` to see them)" : ""}`, details: { type: "artifact", action: "list", plane: caps.plane, scope, rows: out.rows, truncated: out.truncated } };
|
|
281
314
|
}
|
|
315
|
+
const sharedReadConsent = (url, ref, ctx) => {
|
|
316
|
+
if (sharedReadApproved.has(url))
|
|
317
|
+
return Promise.resolve({ ok: true });
|
|
318
|
+
const pending = consentInFlight.get(url);
|
|
319
|
+
if (pending !== undefined)
|
|
320
|
+
return pending;
|
|
321
|
+
const ask = (async () => {
|
|
322
|
+
const title = ref?.title !== undefined ? ` (${inlineUntrusted(ref.title, 120)})` : "";
|
|
323
|
+
const version = ref !== undefined ? `, version ${inlineUntrusted(ref.version, 60)}` : "";
|
|
324
|
+
const resolved = await resolveAsk({
|
|
325
|
+
toolName: ARTIFACT_TOOL_NAME,
|
|
326
|
+
toolCallId: ctx.toolCallId,
|
|
327
|
+
args: { action: "read", url },
|
|
328
|
+
message: `Read a shared artifact: ${inlineUntrusted(url, 300)}${title}${version} — an artifact another person shared with this user; approving covers re-reads of it for the rest of this run.`,
|
|
329
|
+
requiresRealApproval: true,
|
|
330
|
+
}, onAsk, ctx.signal);
|
|
331
|
+
if (resolved.action !== "allow")
|
|
332
|
+
return { ok: false, reason: resolved.message ?? "not approved" };
|
|
333
|
+
if (resolved.updatedInput !== undefined)
|
|
334
|
+
return { ok: false, reason: "the approval edited the request; an edited approval does not authorize the original target" };
|
|
335
|
+
sharedReadApproved.add(url);
|
|
336
|
+
return { ok: true };
|
|
337
|
+
})().finally(() => {
|
|
338
|
+
if (!activeTargets.has(url))
|
|
339
|
+
consentInFlight.delete(url);
|
|
340
|
+
});
|
|
341
|
+
consentInFlight.set(url, ask);
|
|
342
|
+
return ask;
|
|
343
|
+
};
|
|
344
|
+
const filesOwnership = async (url, ctx) => {
|
|
345
|
+
if (!ownUrls.has(url) && !sharedUrls.has(url)) {
|
|
346
|
+
if (!hasAction("read"))
|
|
347
|
+
return typed("refused", `${inlineUntrusted(url, 200)} is not an artifact this run published or listed as its own, and this host declares no read to establish who owns it; its files were not delivered.`, { url, sharedRead: "ownership_unknown" });
|
|
348
|
+
const probe = await callHost("read", () => host.read({ url }, nextContext(ctx)), ctx.signal);
|
|
349
|
+
if (!probe.ok)
|
|
350
|
+
return hostError("read", probe, inlineUntrusted(url, 200));
|
|
351
|
+
if (!probe.value.ok)
|
|
352
|
+
return notFoundOrForbidden(probe.value.code, url, probe.value.message);
|
|
353
|
+
if (probe.value.ref.url !== url)
|
|
354
|
+
return hostError("read", { kind: "malformed", message: "read.ref.url does not match the requested target" }, inlineUntrusted(url, 200));
|
|
355
|
+
rememberOwner(url, probe.value.isolatedSummary ? "shared" : "mine");
|
|
356
|
+
}
|
|
357
|
+
if (!sharedUrls.has(url))
|
|
358
|
+
return undefined;
|
|
359
|
+
const consent = await sharedReadConsent(url, undefined, ctx);
|
|
360
|
+
return consent.ok ? undefined : typed("refused", `reading the shared artifact ${inlineUntrusted(url, 200)} was not approved (${inlineUntrusted(consent.reason, 200)}); its files were not delivered.`, { url, sharedRead: "not_approved" });
|
|
361
|
+
};
|
|
282
362
|
async function read(a, ctx) {
|
|
283
|
-
const attemptId = attemptIdOf(ctx);
|
|
284
363
|
if (a.url === undefined)
|
|
285
364
|
return invalid("`url` is required to read an artifact.");
|
|
286
|
-
const call = await callHost("read", () => host.read({ url: a.url },
|
|
365
|
+
const call = await callHost("read", () => host.read({ url: a.url }, nextContext(ctx)), ctx.signal);
|
|
287
366
|
if (!call.ok)
|
|
288
367
|
return hostError("read", call, inlineUntrusted(a.url, 200));
|
|
289
368
|
const out = call.value;
|
|
290
369
|
if (!out.ok)
|
|
291
370
|
return notFoundOrForbidden(out.code, a.url, out.message);
|
|
371
|
+
if (out.ref.url !== a.url)
|
|
372
|
+
return hostError("read", { kind: "malformed", message: "read.ref.url does not match the requested target" }, inlineUntrusted(a.url, 200));
|
|
373
|
+
rememberOwner(a.url, out.isolatedSummary ? "shared" : "mine");
|
|
374
|
+
if (sharedUrls.has(a.url)) {
|
|
375
|
+
const consent = await sharedReadConsent(a.url, out.ref, ctx);
|
|
376
|
+
if (!consent.ok)
|
|
377
|
+
return typed("refused", `reading the shared artifact ${inlineUntrusted(a.url, 200)} was not approved (${inlineUntrusted(consent.reason, 200)}); its content was not delivered.`, { url: a.url, sharedRead: "not_approved" });
|
|
378
|
+
}
|
|
292
379
|
rememberRef(out.ref);
|
|
293
380
|
const head = out.isolatedSummary ? `[isolated summary of a shared artifact — ${inlineUntrusted(out.ref.url, 300)}, version ${inlineUntrusted(out.ref.version, 60)}]\n` : "";
|
|
294
381
|
return { content: `${head}${out.content}`, details: { type: "artifact", action: "read", plane: caps.plane, ref: out.ref, isolatedSummary: out.isolatedSummary } };
|
|
295
382
|
}
|
|
296
383
|
async function listFiles(a, ctx) {
|
|
297
|
-
const attemptId = attemptIdOf(ctx);
|
|
298
384
|
if (a.url === undefined)
|
|
299
385
|
return invalid("`url` is required to list an artifact's files.");
|
|
300
|
-
const
|
|
386
|
+
const gated = await filesOwnership(a.url, ctx);
|
|
387
|
+
if (gated !== undefined)
|
|
388
|
+
return gated;
|
|
389
|
+
const call = await callHost("list_files", () => host.listFiles({ url: a.url }, nextContext(ctx)), ctx.signal);
|
|
301
390
|
if (!call.ok)
|
|
302
391
|
return hostError("list_files", call, inlineUntrusted(a.url, 200));
|
|
303
392
|
const out = call.value;
|
|
304
393
|
if (!out.ok)
|
|
305
394
|
return notFoundOrForbidden(out.code, a.url, out.message);
|
|
395
|
+
if (sharedUrls.has(a.url)) {
|
|
396
|
+
const denied = await filesOwnership(a.url, ctx);
|
|
397
|
+
if (denied !== undefined)
|
|
398
|
+
return denied;
|
|
399
|
+
}
|
|
306
400
|
const lines = out.entries.map((e) => `${inlineUntrusted(e.path, 200)} ${String(e.bytes).slice(0, 20)} bytes${e.mediaType !== undefined ? ` ${inlineUntrusted(e.mediaType, 80)}` : ""}`);
|
|
307
401
|
return { content: lines.length === 0 ? "no files" : lines.join("\n"), details: { type: "artifact", action: "list_files", plane: caps.plane, url: a.url, entries: out.entries } };
|
|
308
402
|
}
|
|
309
403
|
async function readFile(a, ctx) {
|
|
310
|
-
const attemptId = attemptIdOf(ctx);
|
|
311
404
|
if (a.url === undefined || a.path === undefined)
|
|
312
405
|
return invalid("`url` and `path` are required to read a published file.");
|
|
313
|
-
const
|
|
406
|
+
const gated = await filesOwnership(a.url, ctx);
|
|
407
|
+
if (gated !== undefined)
|
|
408
|
+
return gated;
|
|
409
|
+
const call = await callHost("read_file", () => host.readFile({ url: a.url, path: a.path }, nextContext(ctx)), ctx.signal);
|
|
314
410
|
if (!call.ok)
|
|
315
411
|
return hostError("read_file", call, inlineUntrusted(a.url, 200));
|
|
316
412
|
const out = call.value;
|
|
317
413
|
if (!out.ok)
|
|
318
414
|
return notFoundOrForbidden(out.code, a.url, out.message);
|
|
415
|
+
if (sharedUrls.has(a.url)) {
|
|
416
|
+
const denied = await filesOwnership(a.url, ctx);
|
|
417
|
+
if (denied !== undefined)
|
|
418
|
+
return denied;
|
|
419
|
+
}
|
|
319
420
|
return { content: renderFile(out.file), details: { type: "artifact", action: "read_file", plane: caps.plane, url: a.url, path: out.file.path, bytes: out.file.content.byteLength, mediaType: out.file.mediaType } };
|
|
320
421
|
}
|
|
321
422
|
return {
|
|
@@ -325,27 +426,59 @@ export function createArtifactTool(opts) {
|
|
|
325
426
|
description: artifactDescription(caps.plane, caps.actions),
|
|
326
427
|
parameters: buildSchema(caps),
|
|
327
428
|
prepareArguments: (args) => (isRecord(args) && args["action"] === undefined ? { ...args, action: "publish" } : args),
|
|
328
|
-
|
|
429
|
+
isConcurrencySafe: (args) => isRecord(args) && typeof args["action"] === "string" && ARTIFACT_ACTION_CONCURRENCY_SAFE[args["action"]] === true,
|
|
430
|
+
execute: async (args, ctx) => {
|
|
329
431
|
const a = args;
|
|
330
432
|
const action = a.action ?? "publish";
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
433
|
+
const target = typeof a.url === "string" ? a.url : undefined;
|
|
434
|
+
if (target !== undefined)
|
|
435
|
+
activeTargets.set(target, (activeTargets.get(target) ?? 0) + 1);
|
|
436
|
+
let result;
|
|
437
|
+
try {
|
|
438
|
+
result = await dispatch(action, a, ctx);
|
|
439
|
+
}
|
|
440
|
+
finally {
|
|
441
|
+
if (target !== undefined) {
|
|
442
|
+
const left = activeTargets.get(target) - 1;
|
|
443
|
+
if (left > 0)
|
|
444
|
+
activeTargets.set(target, left);
|
|
445
|
+
else {
|
|
446
|
+
activeTargets.delete(target);
|
|
447
|
+
consentInFlight.delete(target);
|
|
448
|
+
}
|
|
347
449
|
}
|
|
348
450
|
}
|
|
451
|
+
if (typeof result === "string" || result.isError !== true) {
|
|
452
|
+
breaker.clear();
|
|
453
|
+
return result;
|
|
454
|
+
}
|
|
455
|
+
const details = isRecord(result.details) ? result.details : {};
|
|
456
|
+
const reason = typeof details["code"] === "string" ? details["code"] : details["invalidArguments"] === true ? "invalid_arguments" : "error";
|
|
457
|
+
const text = typeof result.content === "string" ? result.content : "";
|
|
458
|
+
const count = breaker.hit(artifactRejectKeyOf(a), reason, text);
|
|
459
|
+
if (count < ARTIFACT_REJECT_BREAKER_THRESHOLD || typeof result.content !== "string")
|
|
460
|
+
return result;
|
|
461
|
+
return { ...result, content: `${artifactRejectBannerFor(reason)}\n\n${result.content}`, details: { ...details, rejectStreak: count } };
|
|
349
462
|
},
|
|
350
463
|
};
|
|
464
|
+
function dispatch(action, a, ctx) {
|
|
465
|
+
if (!hasAction(action))
|
|
466
|
+
return invalid(`action ${inlineUntrusted(String(action), 40)} is not available on this artifact host.`);
|
|
467
|
+
switch (action) {
|
|
468
|
+
case "publish":
|
|
469
|
+
return publish(a, ctx);
|
|
470
|
+
case "list":
|
|
471
|
+
return list(a, ctx);
|
|
472
|
+
case "read":
|
|
473
|
+
return read(a, ctx);
|
|
474
|
+
case "list_files":
|
|
475
|
+
return listFiles(a, ctx);
|
|
476
|
+
case "read_file":
|
|
477
|
+
return readFile(a, ctx);
|
|
478
|
+
default: {
|
|
479
|
+
const _exhaustive = action;
|
|
480
|
+
return _exhaustive;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
351
484
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type ArtifactAction, type ArtifactHost, type ArtifactHostCapabilities, type ArtifactRef } from "../../core/artifact-host.js";
|
|
2
|
+
export interface ArtifactConformanceHooks {
|
|
3
|
+
/** Make the host's NEXT write fail BEFORE its commit point (`arm`), then restore it (`disarm`). The atomicity vector
|
|
4
|
+
* runs only with this hook: the kit cannot reach inside an arbitrary host. */
|
|
5
|
+
precommitFault?: {
|
|
6
|
+
arm: () => void | Promise<void>;
|
|
7
|
+
disarm: () => void | Promise<void>;
|
|
8
|
+
};
|
|
9
|
+
/** Save a new version of `ref`'s page from INSIDE the page — the version a plain `force` must not discard
|
|
10
|
+
* (`force_refused`). The force-refusal vector runs only with this hook. */
|
|
11
|
+
pageSavedVersion?: (ref: ArtifactRef) => void | Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
export interface ArtifactConformanceOptions {
|
|
14
|
+
hooks?: ArtifactConformanceHooks;
|
|
15
|
+
/** Run the at-limit POSITIVE arms of the byte-limit vector: they allocate and publish payloads of the declared
|
|
16
|
+
* sizes. Default `false` — reported `skipped`, named. The over-limit refusal arms always run. */
|
|
17
|
+
largePayloads?: boolean;
|
|
18
|
+
/** The run id the kit mounts (`onMount`) and calls under; default a fresh uuid. */
|
|
19
|
+
runId?: string;
|
|
20
|
+
}
|
|
21
|
+
export type ArtifactConformanceStatus = "pass" | "fail" | "not_applicable" | "skipped";
|
|
22
|
+
export interface ArtifactConformanceVector {
|
|
23
|
+
/** Stable id: a host's own conformance test keys on it. */
|
|
24
|
+
id: string;
|
|
25
|
+
describe: string;
|
|
26
|
+
/** Every listed action must be declared for the vector to apply. */
|
|
27
|
+
requiresActions: readonly ArtifactAction[];
|
|
28
|
+
requiresMultiFile?: true;
|
|
29
|
+
requiresHook?: keyof ArtifactConformanceHooks;
|
|
30
|
+
requiresLargePayloads?: true;
|
|
31
|
+
}
|
|
32
|
+
export interface ArtifactConformanceResult {
|
|
33
|
+
id: string;
|
|
34
|
+
status: ArtifactConformanceStatus;
|
|
35
|
+
/** What passed, what failed (the first violated expectation), or why the vector did not run. */
|
|
36
|
+
detail: string;
|
|
37
|
+
}
|
|
38
|
+
export interface ArtifactConformanceReport {
|
|
39
|
+
capabilities: ArtifactHostCapabilities;
|
|
40
|
+
runId: string;
|
|
41
|
+
results: readonly ArtifactConformanceResult[];
|
|
42
|
+
passed: number;
|
|
43
|
+
failed: number;
|
|
44
|
+
notApplicable: number;
|
|
45
|
+
skipped: number;
|
|
46
|
+
}
|
|
47
|
+
/** The full vector matrix (data; ids are stable). */
|
|
48
|
+
export declare const ARTIFACT_CONFORMANCE_VECTORS: readonly ArtifactConformanceVector[];
|
|
49
|
+
/** The vectors a host's DECLARATION covers (hook- and payload-gated vectors included: their gating is reported at run time). */
|
|
50
|
+
export declare function artifactConformanceVectors(host: ArtifactHost): ArtifactConformanceVector[];
|
|
51
|
+
/**
|
|
52
|
+
* Drive `host` through every vector its declaration covers, sequentially, under one fresh run id (the host's
|
|
53
|
+
* `onMount` is called first). Throws only when the declaration itself is invalid — every vector outcome, a thrown
|
|
54
|
+
* host method included, is a row of the report.
|
|
55
|
+
*/
|
|
56
|
+
export declare function runArtifactConformance(host: ArtifactHost, opts?: ArtifactConformanceOptions): Promise<ArtifactConformanceReport>;
|