@arnilo/prism 0.1.7 → 0.2.1
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 +10 -0
- package/dist/agent-approval.d.ts +10 -1
- package/dist/agent-approval.js +81 -0
- package/dist/agent-run-lifecycle.js +6 -8
- package/dist/cache-telemetry.js +4 -1
- package/dist/content.d.ts +2 -2
- package/dist/content.js +30 -50
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/oauth-device-code.d.ts +58 -0
- package/dist/oauth-device-code.js +119 -0
- package/dist/pinned-fetch.d.ts +25 -0
- package/dist/pinned-fetch.js +246 -0
- package/dist/providers/openai-compatible.d.ts +2 -2
- package/dist/providers/openai-compatible.js +2 -2
- package/dist/providers/transport.d.ts +14 -1
- package/dist/providers/transport.js +43 -0
- package/docs/agent-session-runtime.md +2 -0
- package/docs/coding-security.md +27 -6
- package/docs/credential-storage.md +1 -0
- package/docs/credentials-and-redaction.md +1 -0
- package/docs/host-security.md +1 -1
- package/docs/index.md +13 -13
- package/docs/mcp-tools.md +1 -1
- package/docs/migration.md +56 -0
- package/docs/multimodal-content.md +1 -0
- package/docs/policy-and-audit.md +1 -0
- package/docs/provider-caching.md +4 -1
- package/docs/provider-primitives.md +17 -0
- package/docs/providers/azure.md +1 -1
- package/docs/providers/bedrock.md +1 -0
- package/docs/providers/openai-compatible.md +4 -3
- package/docs/providers/vertex.md +1 -1
- package/docs/public-contracts.md +1 -1
- package/docs/release-and-install.md +38 -0
- package/docs/work-tools.md +24 -1
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.1] - 2026-08-13
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
- **Release 0.2.1 (plan 021)** is the provider-completion and outbound-trust-boundaries cut on the 0.2.x review-remediation line. API surface **additive-only** vs 0.2.0 (plain reviewed compat gate at 0.2.1: deltas are the version literal plus `@arnilo/prism-mcp` transport helpers re-exported from the lifted core primitives — same names/signatures, no removal; no `--allow-break`), five documented security-motivated behavior tightenings in `docs/migration.md` `0.2.0 → 0.2.1`: (1) **strict stream completion is the shared default** (`strict-completion`, core `createOpenAICompatibleProvider`) — `strictCompletion` defaults to `true` (explicit `false` stays the documented opt-out): a stream ending without `[DONE]` plus a choice-level `finish_reason` now fails with `ProviderTransportError` `incomplete_delta` instead of a successful `providerDone`, and done events carry usage only with completion evidence; applies to Azure, Bedrock, Vertex, OpenRouter, ZAI, NeuralWatt (Alibaba/Kimi/Ollama/OpenCode-go had already opted in). (2) **bounded success bodies** (`bounded-bodies`, core `@arnilo/prism/providers/transport`) — additive `readBoundedResponseJson` (65,536-byte UTF-8 ceiling, max JSON depth 32, max properties 4096, caller shape gate, abort, redacted errors, new `ProviderTransportError` code `response_body_shape`) replaces unbounded `response.json()` at all ten model-discovery sites plus NeuralWatt quota, Alibaba embeddings, OpenAI uploads, and both OAuth success paths. (3) **DNS-pinned OIDC/OPA/content fetch, redirects rejected** (`dns-pinning`, core `src/pinned-fetch.ts` + `@arnilo/prism-credentials-node`, `@arnilo/prism-policy`, `@arnilo/prism-mcp`) — `pinnedFetch`/`resolvePinnedAddress`/`requestPinned` lift the MCP pinning algorithm (one resolve, 1–32 bound, family check, loopback confinement, per-candidate `assertSsrfAllowedUrl`, pinned-lookup socket) into core; default JWKS, OPA decision, and content/media fetches route through it and **reject 3xx redirects outright** (`MediaContentError` code `redirect`); private/metadata/loopback answers fail closed `ssrf_denied`; MCP re-exports the lifted helpers with byte-identical behavior; egress `dns-pin.ts` deliberately NOT converged (0.3.x candidate). (4) **shared bounded OAuth device/token polling** (`oauth-consolidation`, core `src/oauth-device-code.ts`) — `pollDeviceCodeToken` serves both `@arnilo/prism-provider-openai` and `@arnilo/prism-credentials-node` device flows (RFC 8628 poll, `slow_down` +5 s, expiry deadline, cancellation, bounded reads, fail-closed token shape, `[REDACTED]` redaction); adapter fields stay plain options; behavior equivalent. (5) **edge fixes** (`edge-fixes`) — Azure/Vertex resolve rotating credentials **once per request** (inner provider re-created with the resolved token; never consumed twice); Bedrock SigV4 merges duplicate-case headers last-wins and sorts query params by encoded key then value (malformed duplicate-case signatures eliminated, single-case byte-identical); OpenAI upload cleanup retains file ids until their `DELETE` succeeds (no remote-file leak on failed cleanup); cache-telemetry `__overflow__` never carries cost (requests + token totals only). New regression surface: `scripts/phase21-security.test.mjs` (10 conformance tests over built public entrypoints covering all five items, wired into `security:threat-suites`) plus a packed plain-JS `security21.mjs` consumer in install-smoke; phase-21 freeze manifest `scripts/phase21-freeze-manifest.json` machine-checks each task's diff (preserved surface: egress dns-pin primitives, OAuth connector consumers, strictCompletion opt-in adapters, native streaming adapters). Release graph stays **50** publishable manifests (root + 49 workspace packages) at exact **0.2.1**; zero new runtime dependency names (core remains dependency-free). Store compatibility with 0.2.0: **compatible, no migration** (no persisted-shape change). Exit gate green (core + script gates incl. phase21-freeze done-phase, `sdk:ready`, audit 0 moderate, pack dry-run 50/50 twice byte-identical, plain reviewed compat gate at 0.2.1, live OIDC JWKS protected evidence + live OPA fail-closed evidence, evidence in `scripts/phase21-baseline.json`). **Publication remains the operator handoff** (`docs/release-and-install.md` `0.2.1 publish handoff` — signed `v0.2.1` tag + npm OIDC).
|
|
7
|
+
|
|
8
|
+
## [0.2.0] - 2026-08-13
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- **Release 0.2.0 (plan 020)** is the first cut of the 0.2.x review-remediation line — fail-closed runtime and sandbox security, closing the three blockers from the 2026-08-12 comprehensive review. API surface **additive-only** vs 0.1.7 (plain compat gate at 0.2.0: 0 breaking deltas; no `--allow-break`), three documented security-motivated behavior tightenings in `docs/migration.md` `0.1.7 → 0.2.0`: (1) **durable-resume input validation** (`resume-validation`, core) — `assertValidAgentRunResume` runs once at the top of `prepareAgentRunResume` before any state claim/checkpoint write/tool execution, covering all four public resume entrypoints; unknown legacy decisions (`"sideways"`), malformed batches, oversized reasons/elicitation, and duplicate approval ids fail closed with stable `ERR_PRISM_DECISION_*` codes, version untouched, zero tool calls; the server parser stays defense in depth. (2) **work-tool environment isolation** (`work-tools-env`, `@arnilo/prism-work-tools`) — `createCliRunner` children no longer inherit ambient host env: fixed base allow-list (PATH/LANG/LC_ALL/TZ + Windows system keys), explicit validated `env`, forced HOME/telemetry controls, late-bound per-identity tokens, 64-name/64-KiB caps (`ERR_PRISM_WORK_ENV`), absolute `binary`/`configDir` required, linear output capture (single final `Buffer.concat`). (3) **explicit sandbox capabilities** (`sandbox-capabilities`, `@arnilo/prism-coding-security`) — `SandboxAdapter.capabilities` (`workspaceCoherent`/`filesystemIsolated`/`networkIsolated`/`processIsolated`/`privilegeIsolated`/`egressRestricted`); omission/malformed metadata resolves all isolation `false`; `SandboxCodingComposition.capabilities` resolved from real wiring + validated adapter metadata; `containmentClaim` retained as `@deprecated` conservative projection (`workspaceCoherent && filesystemIsolated && networkIsolated && processIsolated`); Docker reports only verified controls, native reports filesystem/process/privilege `false`; authorization reads individual capabilities (docs/coding-security.md capability table + docs/host-security.md). New regression surface: `scripts/phase20-security.test.mjs` (public built entrypoints, all three blockers + gate accounting, wired into `security:threat-suites`), packed plain-JS consumer regressions in install-smoke, and the sandbox-browser workflow now records Docker/native capability evidence with a fail-loud 0.2.0 blocker gate (never a passing skip). Release graph stays **50** publishable manifests (root + 49 workspace packages) at exact **0.2.0**; zero new runtime dependency names (dependency fingerprint unchanged). Store compatibility with 0.1.7: **compatible, no migration** (no persisted-shape change). Exit gate green (core + script gates incl. phase20-freeze done-phase, `sdk:ready`, audit 0 moderate, pack dry-run 50/50 twice byte-identical, plain reviewed compat gate at 0.2.0, Docker daemon + native netns protected evidence, evidence in `scripts/phase20-baseline.json`). **Publication remains the operator handoff** (`docs/release-and-install.md` `0.2.0 publish handoff` — signed `v0.2.0` tag + npm OIDC).
|
|
12
|
+
|
|
3
13
|
## [0.1.7] - 2026-08-12
|
|
4
14
|
|
|
5
15
|
### Changed
|
package/dist/agent-approval.d.ts
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import type { StoredAgentRunState } from "./agent-run-state.js";
|
|
2
|
-
import { Agent, AgentRunState, AgentRunStateOptions, DecisionScope, NestedRunOutcome, PendingDecision, RunDecision, StickyDecision, ToolResult } from "./contracts.js";
|
|
2
|
+
import { Agent, AgentRunResume, AgentRunState, AgentRunStateOptions, DecisionScope, NestedRunOutcome, PendingDecision, RunDecision, StickyDecision, ToolResult } from "./contracts.js";
|
|
3
3
|
import type { AgentIdentity } from "./identity.js";
|
|
4
4
|
/** Pending decisions of a suspended state, synthesizing the legacy single-approval shape. */
|
|
5
5
|
export declare function pendingDecisionsOf(state: StoredAgentRunState): readonly PendingDecision[] | undefined;
|
|
6
|
+
/**
|
|
7
|
+
* Transport-neutral shape validation for the complete `AgentRunResume` input (plan 020 Task 2).
|
|
8
|
+
* Runs before any checkpoint read/write, agent resolution, subscription, or tool execution so
|
|
9
|
+
* untyped callers (plain JavaScript, `as any`) cannot make resume fall through to approval or
|
|
10
|
+
* crash with raw TypeErrors. State-dependent checks (foreign/stale/duplicate ids, scope,
|
|
11
|
+
* schema, policy) stay in {@link resolveRunDecisions}. Errors never include tool arguments,
|
|
12
|
+
* elicitation payloads, credentials, or foreign approval details.
|
|
13
|
+
*/
|
|
14
|
+
export declare function assertValidAgentRunResume(resume: AgentRunResume): void;
|
|
6
15
|
interface ResolvedRunDecisions {
|
|
7
16
|
readonly decisionsById: ReadonlyMap<string, RunDecision>;
|
|
8
17
|
readonly stickyDecisions: readonly StickyDecision[];
|
package/dist/agent-approval.js
CHANGED
|
@@ -21,6 +21,87 @@ export function pendingDecisionsOf(state) {
|
|
|
21
21
|
}
|
|
22
22
|
return undefined;
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Transport-neutral shape validation for the complete `AgentRunResume` input (plan 020 Task 2).
|
|
26
|
+
* Runs before any checkpoint read/write, agent resolution, subscription, or tool execution so
|
|
27
|
+
* untyped callers (plain JavaScript, `as any`) cannot make resume fall through to approval or
|
|
28
|
+
* crash with raw TypeErrors. State-dependent checks (foreign/stale/duplicate ids, scope,
|
|
29
|
+
* schema, policy) stay in {@link resolveRunDecisions}. Errors never include tool arguments,
|
|
30
|
+
* elicitation payloads, credentials, or foreign approval details.
|
|
31
|
+
*/
|
|
32
|
+
export function assertValidAgentRunResume(resume) {
|
|
33
|
+
const invalid = (message) => new AgentDecisionError("ERR_PRISM_DECISION_INVALID", message);
|
|
34
|
+
if (resume === null || typeof resume !== "object" || Array.isArray(resume)) {
|
|
35
|
+
throw invalid("Resume must be a non-null object");
|
|
36
|
+
}
|
|
37
|
+
if (!Number.isSafeInteger(resume.expectedVersion) || resume.expectedVersion <= 0) {
|
|
38
|
+
throw invalid("Resume expectedVersion must be a positive safe integer");
|
|
39
|
+
}
|
|
40
|
+
const hasDecision = resume.decision !== undefined;
|
|
41
|
+
const hasDecisions = resume.decisions !== undefined;
|
|
42
|
+
if (hasDecision && hasDecisions)
|
|
43
|
+
throw invalid("Resume accepts exactly one of decision or decisions");
|
|
44
|
+
if (!hasDecision && !hasDecisions)
|
|
45
|
+
throw invalid("Resume requires a decision or decisions");
|
|
46
|
+
if (hasDecision) {
|
|
47
|
+
if (resume.decision !== "approve" && resume.decision !== "deny") {
|
|
48
|
+
throw invalid("Unknown legacy decision");
|
|
49
|
+
}
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const decisions = resume.decisions;
|
|
53
|
+
if (!Array.isArray(decisions))
|
|
54
|
+
throw invalid("Decision batch must be an array");
|
|
55
|
+
if (decisions.length === 0)
|
|
56
|
+
throw invalid("Decision batch must not be empty");
|
|
57
|
+
if (decisions.length > HARD_MAX_PENDING_DECISIONS) {
|
|
58
|
+
throw new AgentDecisionError("ERR_PRISM_DECISION_LIMIT", `Decision batch exceeds ${HARD_MAX_PENDING_DECISIONS} entries`);
|
|
59
|
+
}
|
|
60
|
+
const seen = new Set();
|
|
61
|
+
for (const decision of decisions) {
|
|
62
|
+
if (decision === null || typeof decision !== "object" || Array.isArray(decision)) {
|
|
63
|
+
throw invalid("Decision batch entries must be objects");
|
|
64
|
+
}
|
|
65
|
+
if (typeof decision.approvalId !== "string" || decision.approvalId.length === 0 || decision.approvalId.length > 128) {
|
|
66
|
+
throw invalid("Decision approvalId must be a bounded non-empty string");
|
|
67
|
+
}
|
|
68
|
+
if (seen.has(decision.approvalId)) {
|
|
69
|
+
throw new AgentDecisionError("ERR_PRISM_DECISION_DUPLICATE", "Duplicate approval decision in batch");
|
|
70
|
+
}
|
|
71
|
+
seen.add(decision.approvalId);
|
|
72
|
+
if (decision.outcome !== "allow_once" &&
|
|
73
|
+
decision.outcome !== "allow_for_run" &&
|
|
74
|
+
decision.outcome !== "reject_once" &&
|
|
75
|
+
decision.outcome !== "reject_for_run") {
|
|
76
|
+
throw invalid("Unknown approval outcome");
|
|
77
|
+
}
|
|
78
|
+
if (decision.reason !== undefined) {
|
|
79
|
+
if (typeof decision.reason !== "string")
|
|
80
|
+
throw invalid("Decision reason must be a string");
|
|
81
|
+
if (Buffer.byteLength(decision.reason, "utf8") > MAX_DECISION_REASON_BYTES) {
|
|
82
|
+
throw new AgentDecisionError("ERR_PRISM_DECISION_LIMIT", `Decision reason exceeds ${MAX_DECISION_REASON_BYTES} bytes`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
for (const key of ["modifiedArguments", "elicitation"]) {
|
|
86
|
+
const payload = decision[key];
|
|
87
|
+
if (payload === undefined)
|
|
88
|
+
continue;
|
|
89
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
|
|
90
|
+
throw invalid(`Decision ${key} must be a JSON object`);
|
|
91
|
+
}
|
|
92
|
+
let text;
|
|
93
|
+
try {
|
|
94
|
+
text = JSON.stringify(payload);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
throw invalid(`Decision ${key} must be a JSON object`);
|
|
98
|
+
}
|
|
99
|
+
if (text === undefined || Buffer.byteLength(text, "utf8") > MAX_ELICITATION_BYTES) {
|
|
100
|
+
throw new AgentDecisionError("ERR_PRISM_DECISION_LIMIT", `Decision ${key} exceeds ${MAX_ELICITATION_BYTES} bytes`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
24
105
|
/**
|
|
25
106
|
* Validate one decision batch against the suspended state. Fail-closed and atomic: any
|
|
26
107
|
* invalid entry rejects the whole batch before any CAS, leaving state and version untouched.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { agentFingerprint, loadAgentRunState, publicState, saveAgentRunState } from "./agent-run-state.js";
|
|
2
|
-
import {
|
|
3
|
-
import { pendingDecisionsOf, resolveRunDecisions } from "./agent-approval.js";
|
|
2
|
+
import { AgentRunStateError } from "./contracts.js";
|
|
3
|
+
import { assertValidAgentRunResume, pendingDecisionsOf, resolveRunDecisions } from "./agent-approval.js";
|
|
4
4
|
import { RuntimeAgentSession, throwIfAbortedSignal } from "./agent-session.js";
|
|
5
5
|
function assertAgentId(actual, expected) {
|
|
6
6
|
if (expected !== undefined && actual !== expected)
|
|
@@ -82,6 +82,10 @@ export async function* resumeAgentRunStream(agent, ref, resume, options) {
|
|
|
82
82
|
}
|
|
83
83
|
async function prepareAgentRunResume(agent, ref, resume, options, signal) {
|
|
84
84
|
throwIfAbortedSignal(signal);
|
|
85
|
+
// Plan 020 Task 2: one shared shape assertion before any checkpoint read/write, agent
|
|
86
|
+
// resolution, subscription, or tool execution. Unknown legacy decisions (e.g. "sideways")
|
|
87
|
+
// and malformed untyped batches fail closed here instead of falling through to approval.
|
|
88
|
+
assertValidAgentRunResume(resume);
|
|
85
89
|
const { record, state } = await loadAgentRunState(options.checkpoints, ref, options.ownership);
|
|
86
90
|
if (state.definitionRevision !== options.definitionRevision ||
|
|
87
91
|
state.agentId !== (agent.config.id ?? agent.config.name) ||
|
|
@@ -102,9 +106,6 @@ async function prepareAgentRunResume(agent, ref, resume, options, signal) {
|
|
|
102
106
|
if (options.persistSessionState && options.includeSkillBodies && state.sessionState?.loadedSkillBodies) {
|
|
103
107
|
session.restoreLoadedSkillBodies(state.sessionState.loadedSkillBodies);
|
|
104
108
|
}
|
|
105
|
-
if (resume.decision !== undefined && resume.decisions !== undefined) {
|
|
106
|
-
throw new AgentDecisionError("ERR_PRISM_DECISION_INVALID", "Resume accepts exactly one of decision or decisions");
|
|
107
|
-
}
|
|
108
109
|
const pendingDecisions = pendingDecisionsOf(state);
|
|
109
110
|
// Legacy approve maps to allow-once on every pending decision; legacy deny keeps its
|
|
110
111
|
// terminal-denied behavior. Batch decisions are validated and applied atomically below.
|
|
@@ -118,9 +119,6 @@ async function prepareAgentRunResume(agent, ref, resume, options, signal) {
|
|
|
118
119
|
signal,
|
|
119
120
|
})
|
|
120
121
|
: undefined;
|
|
121
|
-
if (resume.decision === undefined && resume.decisions === undefined) {
|
|
122
|
-
throw new AgentDecisionError("ERR_PRISM_DECISION_INVALID", "Resume requires a decision or decisions");
|
|
123
|
-
}
|
|
124
122
|
if (resolved && resolved.remaining.length > 0) {
|
|
125
123
|
throwIfAbortedSignal(signal);
|
|
126
124
|
const single = resolved.remaining.length === 1 ? resolved.remaining[0] : undefined;
|
package/dist/cache-telemetry.js
CHANGED
|
@@ -72,7 +72,10 @@ export function createCacheTelemetry(options = {}) {
|
|
|
72
72
|
cacheReadTokens: sample.cacheReadTokens,
|
|
73
73
|
inputTokens: sample.inputTokens,
|
|
74
74
|
});
|
|
75
|
-
|
|
75
|
+
// The __overflow__ bucket aggregates mixed provider/model tokens, so it
|
|
76
|
+
// never carries cost: one model's cost metadata must not be applied to
|
|
77
|
+
// other models' tokens. It reports requests and token totals only.
|
|
78
|
+
if (model?.cost && sample !== overflowSample) {
|
|
76
79
|
// cacheSavings depends only on read tokens + cost metadata, so the
|
|
77
80
|
// aggregate equals the sum of per-call savings; feed it the totals
|
|
78
81
|
// to reuse the exact cache-helpers math rather than reimplementing it.
|
package/dist/content.d.ts
CHANGED
|
@@ -104,8 +104,8 @@ export declare class UnsupportedModalityError extends Error {
|
|
|
104
104
|
constructor(modality: ModelInputCapability, model: ModelConfig);
|
|
105
105
|
}
|
|
106
106
|
export declare class MediaContentError extends Error {
|
|
107
|
-
readonly code: "ambiguous_source" | "missing_source" | "item_too_large" | "request_too_large" | "too_many_items" | "audio_too_long" | "invalid_base64" | "ssrf_denied" | "fetch_failed" | "fetch_timeout" | "resource_required" | "mime_mismatch" | "unsupported_url_scheme";
|
|
108
|
-
constructor(code: MediaContentError["code"], message: string);
|
|
107
|
+
readonly code: "ambiguous_source" | "missing_source" | "item_too_large" | "request_too_large" | "too_many_items" | "audio_too_long" | "invalid_base64" | "ssrf_denied" | "redirect" | "fetch_failed" | "fetch_timeout" | "resource_required" | "mime_mismatch" | "unsupported_url_scheme";
|
|
108
|
+
constructor(code: MediaContentError["code"], message: string, options?: ErrorOptions);
|
|
109
109
|
}
|
|
110
110
|
export declare function contentBlockInputModality(block: ContentBlock): ModelInputCapability | undefined;
|
|
111
111
|
export declare function collectMessageContentBlocks(messages: readonly Message[]): ContentBlock[];
|
package/dist/content.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { lookup as dnsLookup } from "node:dns/promises";
|
|
2
|
-
import { request as httpRequest } from "node:http";
|
|
3
|
-
import { request as httpsRequest } from "node:https";
|
|
4
2
|
import { isIP } from "node:net";
|
|
3
|
+
import { pinnedFetch } from "./pinned-fetch.js";
|
|
5
4
|
import { assertPermission } from "./security.js";
|
|
6
5
|
/** Known model input capability tags for `ModelCapabilities.input`. */
|
|
7
6
|
export const MODEL_INPUT_CAPABILITIES = ["text", "image", "audio", "file", "document"];
|
|
@@ -29,8 +28,8 @@ export class UnsupportedModalityError extends Error {
|
|
|
29
28
|
}
|
|
30
29
|
export class MediaContentError extends Error {
|
|
31
30
|
code;
|
|
32
|
-
constructor(code, message) {
|
|
33
|
-
super(message);
|
|
31
|
+
constructor(code, message, options) {
|
|
32
|
+
super(message, options);
|
|
34
33
|
this.name = "MediaContentError";
|
|
35
34
|
this.code = code;
|
|
36
35
|
}
|
|
@@ -310,22 +309,34 @@ async function fetchBoundedMediaUrl(url, options) {
|
|
|
310
309
|
try {
|
|
311
310
|
if (options.fetch)
|
|
312
311
|
return await readFetchResponse(await options.fetch(url, { signal, redirect: "error" }), options.maxBytes);
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
312
|
+
if (options.requestUrl) {
|
|
313
|
+
// Test/custom seam: explicit resolve + caller-provided requester. The default
|
|
314
|
+
// path below routes through the shared pinnedFetch primitive instead.
|
|
315
|
+
const parsed = new URL(url);
|
|
316
|
+
const hostname = normalizeHostname(parsed.hostname);
|
|
317
|
+
const family = isIP(hostname);
|
|
318
|
+
const address = family
|
|
319
|
+
? { address: hostname, family: family }
|
|
320
|
+
: await resolvePublicAddress(hostname, options.resolveHostname ?? defaultMediaHostnameResolver, signal, options.ssrf);
|
|
321
|
+
const bytes = await options.requestUrl({
|
|
322
|
+
url: parsed,
|
|
323
|
+
address,
|
|
324
|
+
maxBytes: options.maxBytes,
|
|
325
|
+
signal,
|
|
326
|
+
});
|
|
327
|
+
if (bytes.byteLength > options.maxBytes) {
|
|
328
|
+
throw new MediaContentError("item_too_large", `Fetched media exceeded ${options.maxBytes} bytes`);
|
|
329
|
+
}
|
|
330
|
+
return bytes;
|
|
327
331
|
}
|
|
328
|
-
|
|
332
|
+
// Default: one DNS-pinned, redirect-free, byte-bounded fetch (task 4).
|
|
333
|
+
const response = await pinnedFetch(new URL(url), { signal, redirect: "manual" }, {
|
|
334
|
+
errorPrefix: "Media",
|
|
335
|
+
hostnameErrorPrefix: "Media",
|
|
336
|
+
resolver: options.resolveHostname ?? defaultMediaHostnameResolver,
|
|
337
|
+
ssrf: options.ssrf,
|
|
338
|
+
});
|
|
339
|
+
return await readFetchResponse(response, options.maxBytes);
|
|
329
340
|
}
|
|
330
341
|
catch (error) {
|
|
331
342
|
if (error instanceof MediaContentError)
|
|
@@ -411,37 +422,6 @@ async function readFetchResponse(response, maxBytes) {
|
|
|
411
422
|
}
|
|
412
423
|
return joinChunks(chunks, total);
|
|
413
424
|
}
|
|
414
|
-
function requestPinnedMediaUrl({ url, address, maxBytes, signal }) {
|
|
415
|
-
return new Promise((resolve, reject) => {
|
|
416
|
-
const request = (url.protocol === "https:" ? httpsRequest : httpRequest)(url, {
|
|
417
|
-
agent: false,
|
|
418
|
-
family: address.family,
|
|
419
|
-
signal,
|
|
420
|
-
lookup: (_hostname, _options, callback) => callback(null, address.address, address.family),
|
|
421
|
-
}, (response) => readIncomingMessage(response, maxBytes).then(resolve, reject));
|
|
422
|
-
request.on("error", reject);
|
|
423
|
-
request.end();
|
|
424
|
-
});
|
|
425
|
-
}
|
|
426
|
-
async function readIncomingMessage(response, maxBytes) {
|
|
427
|
-
const status = response.statusCode ?? 0;
|
|
428
|
-
if (status < 200 || status >= 300) {
|
|
429
|
-
response.resume();
|
|
430
|
-
throw new MediaContentError("fetch_failed", `Media fetch failed with status ${status}`);
|
|
431
|
-
}
|
|
432
|
-
const chunks = [];
|
|
433
|
-
let total = 0;
|
|
434
|
-
for await (const chunk of response) {
|
|
435
|
-
const bytes = typeof chunk === "string" ? new TextEncoder().encode(chunk) : new Uint8Array(chunk);
|
|
436
|
-
total += bytes.byteLength;
|
|
437
|
-
if (total > maxBytes) {
|
|
438
|
-
response.destroy();
|
|
439
|
-
throw new MediaContentError("item_too_large", `Fetched media exceeded ${maxBytes} bytes`);
|
|
440
|
-
}
|
|
441
|
-
chunks.push(bytes);
|
|
442
|
-
}
|
|
443
|
-
return joinChunks(chunks, total);
|
|
444
|
-
}
|
|
445
425
|
function joinChunks(chunks, total) {
|
|
446
426
|
const bytes = new Uint8Array(total);
|
|
447
427
|
let offset = 0;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { resolveAgentDefinition } from "./agent-definitions.js";
|
|
2
|
+
export { abortableSleep, pollDeviceCodeToken, redactOAuthError, throwIfAborted, type OAuthTokenSuccessPayload, type PollDeviceCodeTokenOptions, } from "./oauth-device-code.js";
|
|
3
|
+
export { boundResponse, defaultResolver, isLoopbackAddress, isLoopbackHostname, normalizeHostname, pinnedFetch, raceAbort, requestPinned, resolvePinnedAddress, type PinnedFetchOptions, } from "./pinned-fetch.js";
|
|
2
4
|
export type { AgentEventSourceErrorCode } from "./agent-event-source.js";
|
|
3
5
|
export { AgentEventSourceError, createMemoryAgentEventSource } from "./agent-event-source.js";
|
|
4
6
|
export { dispatchToolCallsInOrder, generateValidateReviseLoop, isAgentLoopOptions, resolveLoop, resolveToolConcurrency, singleShotLoop, } from "./agent-loops.js";
|
|
@@ -107,5 +109,5 @@ export { createToolParameterValidator, createToolRegistry, dispatchToolCall, fil
|
|
|
107
109
|
export type { ResolvedUseCaseModel, ResolveUseCaseModelInput, UseCaseModelBinding, } from "./use-case-model.js";
|
|
108
110
|
export { resolveUseCaseModel, resolveUseCaseModelBinding, useCaseCredentialProviderId, } from "./use-case-model.js";
|
|
109
111
|
export declare const name = "prism";
|
|
110
|
-
export declare const version = "0.1
|
|
112
|
+
export declare const version = "0.2.1";
|
|
111
113
|
export declare const description = "Agent harness for AI providers, agents, sessions, and tools.";
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { resolveAgentDefinition } from "./agent-definitions.js";
|
|
2
|
+
export { abortableSleep, pollDeviceCodeToken, redactOAuthError, throwIfAborted, } from "./oauth-device-code.js";
|
|
3
|
+
export { boundResponse, defaultResolver, isLoopbackAddress, isLoopbackHostname, normalizeHostname, pinnedFetch, raceAbort, requestPinned, resolvePinnedAddress, } from "./pinned-fetch.js";
|
|
2
4
|
export { AgentEventSourceError, createMemoryAgentEventSource } from "./agent-event-source.js";
|
|
3
5
|
export { dispatchToolCallsInOrder, generateValidateReviseLoop, isAgentLoopOptions, resolveLoop, resolveToolConcurrency, singleShotLoop, } from "./agent-loops.js";
|
|
4
6
|
export { createAgentRunLifecycle } from "./agent-run-lifecycle.js";
|
|
@@ -58,6 +60,6 @@ export { DEFAULT_TOOL_RESULT_FOLD_MAX_SUMMARY_BYTES, DEFAULT_TOOL_RESULT_FOLD_MI
|
|
|
58
60
|
export { createToolParameterValidator, createToolRegistry, dispatchToolCall, filterTools } from "./tools.js";
|
|
59
61
|
export { resolveUseCaseModel, resolveUseCaseModelBinding, useCaseCredentialProviderId, } from "./use-case-model.js";
|
|
60
62
|
export const name = "prism";
|
|
61
|
-
export const version = "0.1
|
|
63
|
+
export const version = "0.2.1";
|
|
62
64
|
export const description = "Agent harness for AI providers, agents, sessions, and tools.";
|
|
63
65
|
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { OAuthCredentials, OAuthLoginCallbacks } from "./contracts-core.js";
|
|
2
|
+
/**
|
|
3
|
+
* Shared RFC 8628 device-code flow used by both the OpenAI Codex OAuth provider
|
|
4
|
+
* (provider-openai) and the generic OAuth 2.0 provider (credentials-node).
|
|
5
|
+
* Owns the device-code request, the poll loop (authorization_pending continue,
|
|
6
|
+
* slow_down backoff, expiry deadline), cancellation, bounded success/error
|
|
7
|
+
* body reads, secret redaction, and token-shape parsing. Adapter-specific
|
|
8
|
+
* fields are parameters/callbacks, not subclasses.
|
|
9
|
+
*/
|
|
10
|
+
export declare const REDACTED = "[REDACTED]";
|
|
11
|
+
export declare const DEFAULT_DEVICE_POLL_INTERVAL_MS = 5000;
|
|
12
|
+
export declare const SLOW_DOWN_INCREMENT_MS = 5000;
|
|
13
|
+
export interface OAuthDeviceCodePayload {
|
|
14
|
+
readonly device_code: string;
|
|
15
|
+
readonly user_code: string;
|
|
16
|
+
readonly verification_uri: string;
|
|
17
|
+
readonly expires_in?: number;
|
|
18
|
+
readonly interval?: number;
|
|
19
|
+
}
|
|
20
|
+
export interface OAuthTokenSuccessPayload {
|
|
21
|
+
readonly access_token?: string;
|
|
22
|
+
readonly refresh_token?: string;
|
|
23
|
+
readonly expires_in?: number;
|
|
24
|
+
readonly account_id?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface OAuthTokenErrorPayload {
|
|
27
|
+
readonly error?: string;
|
|
28
|
+
readonly error_description?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface PollDeviceCodeTokenOptions {
|
|
31
|
+
readonly fetchImpl: typeof fetch;
|
|
32
|
+
readonly deviceCodeUrl: string;
|
|
33
|
+
readonly tokenUrl: string;
|
|
34
|
+
readonly clientId: string;
|
|
35
|
+
readonly scope?: string;
|
|
36
|
+
/** Extra token-request params merged into every poll body (e.g. client_secret, audience). Never logged. */
|
|
37
|
+
readonly extraTokenParams?: Readonly<Record<string, string>>;
|
|
38
|
+
readonly callbacks?: Pick<OAuthLoginCallbacks, "onDeviceCode" | "signal">;
|
|
39
|
+
/** Message prefix, e.g. "OpenAI" or the adapter id. */
|
|
40
|
+
readonly errorPrefix: string;
|
|
41
|
+
/** Test seam: override wall clock for device-code expiry. */
|
|
42
|
+
readonly now?: () => number;
|
|
43
|
+
/** Test seam: override poll delay between device-code token requests. */
|
|
44
|
+
readonly sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
45
|
+
/** Token-shape parsing; adapter-specific fields (e.g. accountId fallback) applied here. */
|
|
46
|
+
readonly parseTokenCredentials: (json: OAuthTokenSuccessPayload) => OAuthCredentials;
|
|
47
|
+
}
|
|
48
|
+
export declare function throwIfAborted(signal?: AbortSignal): void;
|
|
49
|
+
export declare function abortableSleep(ms: number, signal?: AbortSignal): Promise<void>;
|
|
50
|
+
export declare function redactOAuthError(error: Error, secrets: readonly (string | undefined)[]): Error;
|
|
51
|
+
/**
|
|
52
|
+
* Request a device code, surface it through `onDeviceCode`, then poll the token
|
|
53
|
+
* endpoint until success, expiry, a terminal OAuth error, or abort. All response
|
|
54
|
+
* bodies are read under the shared byte ceiling; success bodies must be bounded
|
|
55
|
+
* JSON with an `access_token` string (fail closed otherwise); error bodies are
|
|
56
|
+
* bounded text parsed as JSON with a redacted-text fallback.
|
|
57
|
+
*/
|
|
58
|
+
export declare function pollDeviceCodeToken(options: PollDeviceCodeTokenOptions): Promise<OAuthCredentials>;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
2
|
+
import { readBoundedResponseJson, readBoundedResponseText } from "./providers/transport.js";
|
|
3
|
+
/**
|
|
4
|
+
* Shared RFC 8628 device-code flow used by both the OpenAI Codex OAuth provider
|
|
5
|
+
* (provider-openai) and the generic OAuth 2.0 provider (credentials-node).
|
|
6
|
+
* Owns the device-code request, the poll loop (authorization_pending continue,
|
|
7
|
+
* slow_down backoff, expiry deadline), cancellation, bounded success/error
|
|
8
|
+
* body reads, secret redaction, and token-shape parsing. Adapter-specific
|
|
9
|
+
* fields are parameters/callbacks, not subclasses.
|
|
10
|
+
*/
|
|
11
|
+
export const REDACTED = "[REDACTED]";
|
|
12
|
+
export const DEFAULT_DEVICE_POLL_INTERVAL_MS = 5_000;
|
|
13
|
+
export const SLOW_DOWN_INCREMENT_MS = 5_000;
|
|
14
|
+
export function throwIfAborted(signal) {
|
|
15
|
+
if (signal?.aborted)
|
|
16
|
+
throw signal.reason ?? new Error("OAuth login aborted");
|
|
17
|
+
}
|
|
18
|
+
export async function abortableSleep(ms, signal) {
|
|
19
|
+
if (ms <= 0)
|
|
20
|
+
return;
|
|
21
|
+
throwIfAborted(signal);
|
|
22
|
+
await delay(ms, undefined, { signal });
|
|
23
|
+
}
|
|
24
|
+
export function redactOAuthError(error, secrets) {
|
|
25
|
+
let message = error.message;
|
|
26
|
+
for (const secret of secrets) {
|
|
27
|
+
if (secret)
|
|
28
|
+
message = message.split(secret).join(REDACTED);
|
|
29
|
+
}
|
|
30
|
+
return new Error(message);
|
|
31
|
+
}
|
|
32
|
+
const isDeviceCodePayload = (value) => {
|
|
33
|
+
if (typeof value !== "object" || value === null)
|
|
34
|
+
return false;
|
|
35
|
+
const candidate = value;
|
|
36
|
+
return (typeof candidate.device_code === "string" &&
|
|
37
|
+
candidate.device_code.length > 0 &&
|
|
38
|
+
typeof candidate.user_code === "string" &&
|
|
39
|
+
candidate.user_code.length > 0 &&
|
|
40
|
+
typeof candidate.verification_uri === "string" &&
|
|
41
|
+
candidate.verification_uri.length > 0);
|
|
42
|
+
};
|
|
43
|
+
const isTokenSuccessPayload = (value) => {
|
|
44
|
+
if (typeof value !== "object" || value === null)
|
|
45
|
+
return false;
|
|
46
|
+
return typeof value.access_token === "string";
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Request a device code, surface it through `onDeviceCode`, then poll the token
|
|
50
|
+
* endpoint until success, expiry, a terminal OAuth error, or abort. All response
|
|
51
|
+
* bodies are read under the shared byte ceiling; success bodies must be bounded
|
|
52
|
+
* JSON with an `access_token` string (fail closed otherwise); error bodies are
|
|
53
|
+
* bounded text parsed as JSON with a redacted-text fallback.
|
|
54
|
+
*/
|
|
55
|
+
export async function pollDeviceCodeToken(options) {
|
|
56
|
+
const { fetchImpl, deviceCodeUrl, tokenUrl, clientId, scope, extraTokenParams, callbacks, errorPrefix, parseTokenCredentials } = options;
|
|
57
|
+
const now = options.now ?? Date.now;
|
|
58
|
+
const sleep = options.sleep ?? abortableSleep;
|
|
59
|
+
const requestBody = { client_id: clientId };
|
|
60
|
+
if (scope)
|
|
61
|
+
requestBody.scope = scope;
|
|
62
|
+
const response = await fetchImpl(deviceCodeUrl, {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: { "content-type": "application/json" },
|
|
65
|
+
body: JSON.stringify(requestBody),
|
|
66
|
+
signal: callbacks?.signal,
|
|
67
|
+
});
|
|
68
|
+
if (!response.ok) {
|
|
69
|
+
const detail = await readBoundedResponseText(response);
|
|
70
|
+
throw redactOAuthError(new Error(`${errorPrefix} device code failed: ${response.status}${detail ? ` ${detail}` : ""}`), []);
|
|
71
|
+
}
|
|
72
|
+
const json = await readBoundedResponseJson(response, { shape: isDeviceCodePayload });
|
|
73
|
+
const secrets = [json.device_code, json.user_code];
|
|
74
|
+
const expiresAtMs = now() + (json.expires_in ?? 0) * 1_000;
|
|
75
|
+
await callbacks?.onDeviceCode?.({
|
|
76
|
+
userCode: json.user_code,
|
|
77
|
+
verificationUri: json.verification_uri,
|
|
78
|
+
expiresAt: json.expires_in ? new Date(expiresAtMs).toISOString() : undefined,
|
|
79
|
+
});
|
|
80
|
+
let intervalMs = Math.max(1, (json.interval ?? DEFAULT_DEVICE_POLL_INTERVAL_MS / 1_000) * 1_000);
|
|
81
|
+
while (now() < expiresAtMs) {
|
|
82
|
+
throwIfAborted(callbacks?.signal);
|
|
83
|
+
await sleep(intervalMs, callbacks?.signal);
|
|
84
|
+
throwIfAborted(callbacks?.signal);
|
|
85
|
+
const tokenResponse = await fetchImpl(tokenUrl, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers: { "content-type": "application/json" },
|
|
88
|
+
body: JSON.stringify({
|
|
89
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
90
|
+
client_id: clientId,
|
|
91
|
+
device_code: json.device_code,
|
|
92
|
+
...extraTokenParams,
|
|
93
|
+
}),
|
|
94
|
+
signal: callbacks?.signal,
|
|
95
|
+
});
|
|
96
|
+
if (tokenResponse.ok) {
|
|
97
|
+
const payload = await readBoundedResponseJson(tokenResponse, { shape: isTokenSuccessPayload });
|
|
98
|
+
return parseTokenCredentials(payload);
|
|
99
|
+
}
|
|
100
|
+
const errorText = await readBoundedResponseText(tokenResponse, { secrets });
|
|
101
|
+
let errorPayload;
|
|
102
|
+
try {
|
|
103
|
+
errorPayload = JSON.parse(errorText);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
errorPayload = { error: "invalid_token_response", error_description: errorText };
|
|
107
|
+
}
|
|
108
|
+
const code = errorPayload.error ?? "unknown_error";
|
|
109
|
+
if (code === "authorization_pending")
|
|
110
|
+
continue;
|
|
111
|
+
if (code === "slow_down") {
|
|
112
|
+
intervalMs += SLOW_DOWN_INCREMENT_MS;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
throw redactOAuthError(new Error(`${errorPrefix} device code login failed: ${code}${errorPayload.error_description ? ` ${errorPayload.error_description}` : ""}`), secrets);
|
|
116
|
+
}
|
|
117
|
+
throw redactOAuthError(new Error(`${errorPrefix} device code login expired before authorization completed`), secrets);
|
|
118
|
+
}
|
|
119
|
+
//# sourceMappingURL=oauth-device-code.js.map
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type MediaHostAddress, type MediaHostnameResolver, type SsrfPolicy } from "./content.js";
|
|
2
|
+
export interface PinnedFetchOptions {
|
|
3
|
+
/** Prefix for request-level error messages ("redirects are not allowed", "response exceeds", ...). Default "Request". */
|
|
4
|
+
readonly errorPrefix?: string;
|
|
5
|
+
/** Prefix for hostname-resolution error messages. Default: `errorPrefix`. */
|
|
6
|
+
readonly hostnameErrorPrefix?: string;
|
|
7
|
+
/** Custom resolver; defaults to `node:dns/promises` lookup (all answers, verbatim). */
|
|
8
|
+
readonly resolver?: MediaHostnameResolver;
|
|
9
|
+
/** Allow loopback destinations only when the requested hostname is itself loopback AND this is true. */
|
|
10
|
+
readonly allowLoopback?: boolean;
|
|
11
|
+
/** SSRF policy applied to the URL precheck and to every resolved candidate. */
|
|
12
|
+
readonly ssrf?: SsrfPolicy;
|
|
13
|
+
/** Byte ceiling for the response stream (content-length precheck + streaming bound). */
|
|
14
|
+
readonly maxResponseBytes?: number;
|
|
15
|
+
}
|
|
16
|
+
/** One DNS-pinned, redirect-free, byte-bounded fetch. See module comment. */
|
|
17
|
+
export declare function pinnedFetch(url: URL, init: RequestInit | undefined, options?: PinnedFetchOptions): Promise<Response>;
|
|
18
|
+
export declare function resolvePinnedAddress(url: URL, resolver: MediaHostnameResolver, signal: AbortSignal | null | undefined, allowLoopback: boolean, ssrf: SsrfPolicy | undefined, hostnameErrorPrefix?: string): Promise<MediaHostAddress>;
|
|
19
|
+
export declare function defaultResolver(hostname: string): Promise<readonly MediaHostAddress[]>;
|
|
20
|
+
export declare function requestPinned(url: URL, address: MediaHostAddress, init: RequestInit | undefined, errorPrefix?: string): Promise<Response>;
|
|
21
|
+
export declare function boundResponse(response: Response, maxBytes: number, errorPrefix?: string): Response;
|
|
22
|
+
export declare function raceAbort<T>(promise: Promise<T>, signal: AbortSignal | null | undefined): Promise<T>;
|
|
23
|
+
export declare function normalizeHostname(value: string): string;
|
|
24
|
+
export declare function isLoopbackHostname(value: string): boolean;
|
|
25
|
+
export declare function isLoopbackAddress(value: string): boolean;
|