@openwop/openwop-conformance 1.10.0 → 1.11.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +2 -2
  3. package/api/asyncapi.yaml +70 -0
  4. package/api/openapi.yaml +268 -1
  5. package/coverage.md +30 -2
  6. package/fixtures/oauth-providers/synthetic.json +38 -0
  7. package/fixtures.md +10 -0
  8. package/package.json +1 -1
  9. package/schemas/README.md +12 -0
  10. package/schemas/agent-deployment-transition.schema.json +49 -0
  11. package/schemas/agent-deployment.schema.json +54 -0
  12. package/schemas/agent-eval-suite.schema.json +140 -0
  13. package/schemas/agent-inventory-response.schema.json +25 -0
  14. package/schemas/agent-manifest.schema.json +5 -0
  15. package/schemas/agent-org-chart.schema.json +82 -0
  16. package/schemas/agent-ref.schema.json +12 -2
  17. package/schemas/agent-roster-entry.schema.json +81 -0
  18. package/schemas/agent-roster-response.schema.json +21 -0
  19. package/schemas/budget-policy.schema.json +18 -0
  20. package/schemas/capabilities.schema.json +277 -0
  21. package/schemas/credential-provenance.schema.json +18 -0
  22. package/schemas/eval-summary.schema.json +92 -0
  23. package/schemas/node-pack-manifest.schema.json +17 -0
  24. package/schemas/org-chart-responsibility-view.schema.json +26 -0
  25. package/schemas/run-event-payloads.schema.json +286 -3
  26. package/schemas/run-event.schema.json +19 -0
  27. package/schemas/tool-descriptor.schema.json +63 -0
  28. package/schemas/trigger-subscription.schema.json +26 -0
  29. package/src/lib/agentRoster.ts +76 -0
  30. package/src/lib/liveRuntime.ts +59 -0
  31. package/src/lib/profiles.ts +157 -0
  32. package/src/lib/runtimeRequires.ts +38 -0
  33. package/src/lib/safeFetch.ts +87 -0
  34. package/src/scenarios/agent-deployment-shape.test.ts +139 -0
  35. package/src/scenarios/agent-eval-suite-shape.test.ts +167 -0
  36. package/src/scenarios/agent-live-allowlist-enforced.test.ts +53 -0
  37. package/src/scenarios/agent-live-invocation-bracket.test.ts +98 -0
  38. package/src/scenarios/agent-live-runtime-shape.test.ts +98 -0
  39. package/src/scenarios/agent-live-structured-output.test.ts +58 -0
  40. package/src/scenarios/agent-org-chart-shape.test.ts +127 -0
  41. package/src/scenarios/agent-platform-profile.test.ts +158 -0
  42. package/src/scenarios/agent-roster-attribution.test.ts +179 -0
  43. package/src/scenarios/agent-roster-shape.test.ts +146 -0
  44. package/src/scenarios/budget-policy-shape.test.ts +136 -0
  45. package/src/scenarios/egress-provenance-shape.test.ts +137 -0
  46. package/src/scenarios/memory-capability-model-shape.test.ts +186 -0
  47. package/src/scenarios/oauth-authorization-code-roundtrip.test.ts +145 -0
  48. package/src/scenarios/runtime-requires-install-gate.test.ts +92 -0
  49. package/src/scenarios/runtime-requires-shape.test.ts +134 -0
  50. package/src/scenarios/safefetch-behavior.test.ts +99 -0
  51. package/src/scenarios/safefetch-live-audit.test.ts +175 -0
  52. package/src/scenarios/spec-corpus-validity.test.ts +19 -3
  53. package/src/scenarios/tool-descriptor-shape.test.ts +133 -0
  54. package/src/scenarios/trigger-bridge-shape.test.ts +135 -0
  55. package/src/scenarios/x-openwop-form-pack-manifest.test.ts +155 -0
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://openwop.dev/spec/v1/trigger-subscription.schema.json",
4
+ "title": "TriggerSubscription",
5
+ "description": "RFC 0083 §B. A durable inbound-trigger subscription record (a webhook registration, a schedule, a queue consumer) with a standardized four-state machine layered over the existing per-source registration. Composes RFC 0052/0053/0017 + webhooks.md + RFC 0040 causation; the channel wire format stays a vendor extension (§E). Content-free of inbound payloads/credentials (SR-1).",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["subscriptionId", "source", "state"],
9
+ "properties": {
10
+ "subscriptionId": { "type": "string", "minLength": 1, "description": "Stable host-unique id for the subscription. Correlates the §C delivery events + the management surface." },
11
+ "source": { "type": "string", "enum": ["webhook", "schedule", "queue", "email", "form"], "description": "Which trigger source backs the subscription. Channels beyond these (Slack/Discord/SMS) bridge as a vendor extension by registering a subscription of the closest source kind (§E)." },
12
+ "state": { "type": "string", "enum": ["active", "paused", "failed", "dead-lettered"], "description": "The §B state. `active`: accepting + delivering; `paused`: retained, not delivering (operator-held); `failed`: delivery failing past policy (the webhooks.md circuit-breaker generalized); `dead-lettered`: terminal, deliveries routed to the RFC 0053 sink." },
13
+ "dedupEnabled": { "type": "boolean", "description": "When true, the host de-duplicates inbound events by `dedupKey` within the retention window (§C-1; the idempotency.md Layer-1 model applied to inbound triggers)." },
14
+ "retryPolicy": {
15
+ "type": "object",
16
+ "additionalProperties": false,
17
+ "description": "Delivery retry policy (§C-2). On exhaustion the subscription/delivery transitions to `dead-lettered` (RFC 0053).",
18
+ "properties": {
19
+ "maxAttempts": { "type": "integer", "minimum": 1, "description": "Maximum delivery attempts before dead-lettering." },
20
+ "backoff": { "type": "string", "enum": ["none", "fixed", "exponential"], "description": "Backoff strategy between attempts." }
21
+ }
22
+ },
23
+ "webhookId": { "type": "string", "minLength": 1, "description": "MAY — for `source: \"webhook\"`, the existing webhooks.md register key (unchanged; the state machine layers over it)." },
24
+ "secretFingerprint": { "type": "string", "minLength": 1, "maxLength": 32, "description": "MAY — for `source: \"webhook\"`, an identifier for the signing secret (the `(webhookId, secretFingerprint)` register key). It MUST be a **salted or host-keyed, TRUNCATED** one-way digest (e.g. the first 8–16 hex of `HMAC(hostKey, secret)`) — NOT the raw secret (SR-1) and NOT a full unsalted `SHA256(secret)` (a full unsalted hash of a low-entropy secret is an offline brute-force / confirmation oracle). The `maxLength: 32` ceiling structurally rejects a full 64-hex digest." }
25
+ }
26
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Shared helpers for the RFC 0086 `agents.roster` conformance scenarios.
3
+ * Lives in lib/ (not a `*.test.ts`) so scenarios import it via
4
+ * `../lib/agentRoster.js`.
5
+ *
6
+ * Two surfaces:
7
+ * - the NORMATIVE read (`GET /v1/agents/roster[/{rosterId}]`, RFC 0086 §B),
8
+ * exercised black-box against any conformant host; and
9
+ * - the host-sample fire seam (`POST /v1/host/sample/roster/fire`), used to
10
+ * drive a portfolio trigger so the `roster.run.initiated` attribution +
11
+ * ordering can be asserted against the test event-log seam. The fire seam
12
+ * is OPTIONAL — scenarios soft-skip on 404/405 (the reference roster store
13
+ * is deferred per RFC 0086 §Conformance).
14
+ *
15
+ * @see RFCS/0086-standing-agent-roster-and-workflow-portfolio.md
16
+ * @see spec/v1/agent-roster.md
17
+ */
18
+ import { driver } from './driver.js';
19
+ import { readCapabilityFamily } from './discovery-capabilities.js';
20
+
21
+ /** Reads `agents.roster` from discovery (root-first per RFC 0073); null when
22
+ * unadvertised. */
23
+ export async function readRosterCap(): Promise<Record<string, unknown> | null> {
24
+ const agents = await readCapabilityFamily<{ roster?: unknown }>('agents');
25
+ const r = agents?.roster;
26
+ return r && typeof r === 'object' ? (r as Record<string, unknown>) : null;
27
+ }
28
+
29
+ export interface RosterEntry {
30
+ rosterId?: string;
31
+ persona?: string;
32
+ agentRef?: { agentId?: string; version?: string; channel?: string };
33
+ workflows?: string[];
34
+ owner?: { tenantId?: string; workspaceId?: string };
35
+ [k: string]: unknown;
36
+ }
37
+
38
+ export interface RosterResponse {
39
+ roster?: RosterEntry[];
40
+ total?: number;
41
+ }
42
+
43
+ /** GET the NORMATIVE standing roster (RFC 0086 §B `GET /v1/agents/roster`);
44
+ * null when the host doesn't serve it (404/405/501). */
45
+ export async function listRoster(): Promise<RosterResponse | null> {
46
+ const res = await driver.get('/v1/agents/roster');
47
+ if (res.status === 404 || res.status === 405 || res.status === 501) return null;
48
+ return (res.json as RosterResponse | undefined) ?? {};
49
+ }
50
+
51
+ /** GET a single roster entry by id. Returns `{ status, entry }` so a caller can
52
+ * distinguish a 404 (cross-tenant / unknown) from a served entry. */
53
+ export async function getRosterEntry(
54
+ rosterId: string,
55
+ ): Promise<{ status: number; entry: RosterEntry | undefined }> {
56
+ const res = await driver.get(`/v1/agents/roster/${encodeURIComponent(rosterId)}`);
57
+ return { status: res.status, entry: res.json as RosterEntry | undefined };
58
+ }
59
+
60
+ export interface RosterFireResult {
61
+ runId?: string;
62
+ rosterId?: string;
63
+ triggerSubscriptionId?: string;
64
+ }
65
+
66
+ /** Drive a portfolio trigger for a roster member via the host-sample fire seam.
67
+ * `asWorkItem:true` requests the RFC 0083 durable-work-item path (carries a
68
+ * `triggerSubscriptionId` + run `causationId`). Returns null when the seam is
69
+ * unwired (404/405). */
70
+ export async function fireRosterPortfolio(
71
+ body: { rosterId?: string; triggerSource?: string; asWorkItem?: boolean } = {},
72
+ ): Promise<RosterFireResult | null> {
73
+ const res = await driver.post('/v1/host/sample/roster/fire', body);
74
+ if (res.status === 404 || res.status === 405) return null;
75
+ return (res.json as RosterFireResult | undefined) ?? {};
76
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Shared helpers for the RFC 0077 `agents.liveRuntime` conformance scenarios.
3
+ * Lives in lib/ (not a `*.test.ts`) so scenarios import it via
4
+ * `../lib/liveRuntime.js`.
5
+ *
6
+ * RFC 0077 adds NO new endpoint — a live manifest invocation rides the existing
7
+ * run surface (agent as root of `POST /v1/runs`, a `WorkflowNode.agent` step, or
8
+ * a chat `@mention`) and brackets the existing `agent.*` family with
9
+ * `agent.invocation.started` / `agent.invocation.completed`. To drive one
10
+ * deterministically in conformance, the host exposes the OPTIONAL sample seam
11
+ * `POST /v1/host/sample/agents/live-invoke` returning `{ runId, invocationId }`;
12
+ * the bracketed events are read back via the test event-log seam. The seam is
13
+ * deferred per RFC 0077 §Conformance, so scenarios soft-skip on 404/405.
14
+ *
15
+ * @see RFCS/0077-agent-run-lifecycle-and-live-manifest-dispatch.md
16
+ * @see spec/v1/multi-agent-execution.md §"Live manifest dispatch"
17
+ */
18
+ import { driver } from './driver.js';
19
+ import { readCapabilityFamily } from './discovery-capabilities.js';
20
+
21
+ /** Reads `agents.liveRuntime` from discovery (root-first per RFC 0073); null
22
+ * when unadvertised. */
23
+ export async function readLiveRuntimeCap(): Promise<Record<string, unknown> | null> {
24
+ const agents = await readCapabilityFamily<{ liveRuntime?: unknown }>('agents');
25
+ const lr = agents?.liveRuntime;
26
+ return lr && typeof lr === 'object' ? (lr as Record<string, unknown>) : null;
27
+ }
28
+
29
+ export interface LiveInvokeResult {
30
+ runId?: string;
31
+ invocationId?: string;
32
+ outcome?: string;
33
+ }
34
+
35
+ /**
36
+ * Drive one live manifest invocation via the host-sample seam. Body fields:
37
+ * - `agentId` (optional): the manifest agent to invoke; host picks a default
38
+ * when omitted.
39
+ * - `source` (optional): `workflow-node` | `run-api` | `chat-mention`.
40
+ * - `returnSchemaRef` (optional) + `forceInvalidResult` (optional): exercise
41
+ * the §B step-6 structured-output enforcement — force a result that violates
42
+ * the handoff schema so a `structuredOutput` host fails the run.
43
+ * - `attemptTool` (optional): the id of a tool OUTSIDE the agent's
44
+ * `toolAllowlist` the invocation should attempt (the §F-1 allowlist floor).
45
+ * Returns null when the seam is unwired (404/405).
46
+ */
47
+ export async function invokeLive(
48
+ body: {
49
+ agentId?: string;
50
+ source?: string;
51
+ returnSchemaRef?: string;
52
+ forceInvalidResult?: boolean;
53
+ attemptTool?: string;
54
+ } = {},
55
+ ): Promise<LiveInvokeResult | null> {
56
+ const res = await driver.post('/v1/host/sample/agents/live-invoke', body);
57
+ if (res.status === 404 || res.status === 405) return null;
58
+ return (res.json as LiveInvokeResult | undefined) ?? {};
59
+ }
@@ -30,6 +30,8 @@ export const PROFILE_NAMES = [
30
30
  'openwop-node-packs',
31
31
  'openwop-replay-fork',
32
32
  'openwop-fixtures',
33
+ 'openwop-memory',
34
+ 'openwop-trigger-bridge',
33
35
  ] as const;
34
36
 
35
37
  export type ProfileName = (typeof PROFILE_NAMES)[number];
@@ -211,6 +213,155 @@ export function isFixtures(c: DiscoveryPayload): boolean {
211
213
  return c.fixtures.every((id) => typeof id === 'string' && id.length > 0);
212
214
  }
213
215
 
216
+ /**
217
+ * `openwop-memory` predicate (RFC 0080). Host implements the reconciled
218
+ * memory-capability model at the core tier: a read/write `MemoryAdapter`
219
+ * (`memory.supported: true` and `memory.writable !== false`) plus a cross-run
220
+ * durable store (`agents.memoryBackends` includes `'long-term'`). Capability
221
+ * families are document-root properties of the discovery payload (RFC 0073),
222
+ * so this reads `c.memory` / `c.agents`, matching `isReplayFork`.
223
+ *
224
+ * @see spec/v1/profiles.md §`openwop-memory`
225
+ * @see spec/v1/agent-memory.md §"Memory capability model"
226
+ */
227
+ export function isMemory(c: DiscoveryPayload): boolean {
228
+ if (!isCore(c)) return false;
229
+ const memory = c.memory as { supported?: unknown; writable?: unknown } | undefined;
230
+ if (memory == null || typeof memory !== 'object') return false;
231
+ if (memory.supported !== true) return false;
232
+ if (memory.writable === false) return false;
233
+ const agents = c.agents as { memoryBackends?: unknown } | undefined;
234
+ if (agents == null || !isStringArray(agents.memoryBackends)) return false;
235
+ return agents.memoryBackends.includes('long-term');
236
+ }
237
+
238
+ /**
239
+ * `openwop-trigger-bridge` predicate (RFC 0083). Host composes the durable
240
+ * inbound-work contract: advertises the `triggerBridge`, has a `deadLetter`
241
+ * sink for exhausted deliveries, and has at least one durable inbound source
242
+ * (queue bus, durable webhooks, or scheduling). Capability families are
243
+ * document-root properties (RFC 0073), so this reads `c.triggerBridge` /
244
+ * `c.deadLetter` / `c.queueBus` / `c.webhooks` / `c.scheduling`.
245
+ *
246
+ * @see spec/v1/profiles.md §`openwop-trigger-bridge`
247
+ * @see spec/v1/trigger-bridge.md
248
+ */
249
+ export function isTriggerBridge(c: DiscoveryPayload): boolean {
250
+ if (!isCore(c)) return false;
251
+ const supported = (v: unknown): boolean =>
252
+ v != null && typeof v === 'object' && (v as { supported?: unknown }).supported === true;
253
+ if (!supported(c.triggerBridge)) return false;
254
+ if (!supported(c.deadLetter)) return false;
255
+ const webhooks = c.webhooks as { durable?: unknown } | undefined;
256
+ const durableSource =
257
+ supported(c.queueBus) ||
258
+ supported(c.scheduling) ||
259
+ (webhooks != null && typeof webhooks === 'object' && webhooks.durable === true);
260
+ return durableSource;
261
+ }
262
+
263
+ // ─────────────────────────────────────────────────────────────────────────────
264
+ // Operational annex: openwop-agent-platform (RFC 0085).
265
+ //
266
+ // NOT part of the closed `profiles.md` predicate catalog (PROFILE_NAMES /
267
+ // deriveProfiles above) — it is an operational ANNEX (the production-profile.md /
268
+ // auth-profiles.md pattern) combining a discovery predicate with required runtime
269
+ // conformance evidence + documentation + a badge. These helpers compute only the
270
+ // discovery-PREDICATE part; the live aggregate-evidence assertion (does every
271
+ // constituent scenario actually pass?) lives in agent-platform-profile.test.ts.
272
+ //
273
+ // @see spec/v1/agent-platform-profile.md
274
+ // ─────────────────────────────────────────────────────────────────────────────
275
+
276
+ /** Narrow helper: a capability sub-block with `supported === true`. */
277
+ function blockSupported(v: unknown): boolean {
278
+ return v != null && typeof v === 'object' && (v as { supported?: unknown }).supported === true;
279
+ }
280
+
281
+ /** The `openwop-agent-platform` FLOOR (`partial`) discovery predicate — RFC 0085 §B. */
282
+ export function isAgentPlatformPartial(c: DiscoveryPayload): boolean {
283
+ if (!isCore(c)) return false;
284
+ const agents = c.agents as { manifestRuntime?: unknown; liveRuntime?: unknown } | undefined;
285
+ const httpClient = c.httpClient as { safeFetch?: unknown; egressPolicy?: unknown } | undefined;
286
+ const replay = c.replay as { supported?: unknown } | undefined;
287
+ const nondet = c.nondeterminismPolicy as { declared?: unknown } | undefined;
288
+ return (
289
+ blockSupported(agents?.manifestRuntime) &&
290
+ blockSupported(agents?.liveRuntime) &&
291
+ blockSupported(c.toolCatalog) &&
292
+ blockSupported(c.toolHooks) &&
293
+ blockSupported(httpClient?.safeFetch) &&
294
+ blockSupported(c.providerUsage) &&
295
+ blockSupported(c.prompts) &&
296
+ blockSupported(c.memory) &&
297
+ blockSupported(c.feedback) &&
298
+ (replay?.supported === true || nondet?.declared === true)
299
+ );
300
+ }
301
+
302
+ /** The `openwop-agent-platform` `full` discovery predicate (floor + governance tier) — RFC 0085 §B. */
303
+ export function isAgentPlatformFull(c: DiscoveryPayload): boolean {
304
+ if (!isAgentPlatformPartial(c)) return false;
305
+ const agents = c.agents as { manifestRuntime?: { installScope?: unknown } } | undefined;
306
+ const memory = c.memory as { attribution?: unknown } | undefined;
307
+ // Debug bundle is advertised at `capabilities.debugBundle.supported` (debug-bundle.md /
308
+ // RFC 0009), NOT under `production.*` — the production block only adds stricter truncation MUSTs.
309
+ const httpClient = c.httpClient as { egressPolicy?: unknown } | undefined;
310
+ return (
311
+ blockSupported(c.authorization) &&
312
+ agents?.manifestRuntime?.installScope === 'tenant' &&
313
+ blockSupported(memory?.attribution) &&
314
+ blockSupported(c.debugBundle) &&
315
+ blockSupported(c.triggerBridge) &&
316
+ blockSupported(httpClient?.egressPolicy)
317
+ );
318
+ }
319
+
320
+ /** The host-reported annex status: `full` ⊃ `partial` ⊃ `none` (discovery-predicate only). */
321
+ export function agentPlatformStatus(c: DiscoveryPayload): 'none' | 'partial' | 'full' {
322
+ if (isAgentPlatformFull(c)) return 'full';
323
+ if (isAgentPlatformPartial(c)) return 'partial';
324
+ return 'none';
325
+ }
326
+
327
+ /**
328
+ * The per-term satisfaction breakdown (RFC 0085 §D) — the richer interop signal
329
+ * alongside the flat `none`/`partial`/`full` ladder. Adoption is NON-CONTIGUOUS:
330
+ * a real host built feature-by-feature can satisfy `full`-tier terms (RBAC,
331
+ * memory-attribution, tenant-scoping) while still failing `floor` terms, so the
332
+ * flat status would understate it (reads identical to a do-nothing host). This
333
+ * returns exactly the term ids a host satisfies, so a `none` host honoring 6/16
334
+ * terms is distinguishable from one honoring 0/16.
335
+ */
336
+ export function agentPlatformSatisfiedTerms(c: DiscoveryPayload): readonly string[] {
337
+ const agents = c.agents as { manifestRuntime?: { installScope?: unknown }; liveRuntime?: unknown } | undefined;
338
+ const httpClient = c.httpClient as { safeFetch?: unknown; egressPolicy?: unknown } | undefined;
339
+ const memory = c.memory as { attribution?: unknown } | undefined;
340
+ const replay = c.replay as { supported?: unknown } | undefined;
341
+ const nondet = c.nondeterminismPolicy as { declared?: unknown } | undefined;
342
+ const checks: ReadonlyArray<readonly [string, boolean]> = [
343
+ // floor
344
+ ['floor:agents.manifestRuntime', blockSupported(agents?.manifestRuntime)],
345
+ ['floor:agents.liveRuntime', blockSupported(agents?.liveRuntime)],
346
+ ['floor:toolCatalog', blockSupported(c.toolCatalog)],
347
+ ['floor:toolHooks', blockSupported(c.toolHooks)],
348
+ ['floor:httpClient.safeFetch', blockSupported(httpClient?.safeFetch)],
349
+ ['floor:providerUsage', blockSupported(c.providerUsage)],
350
+ ['floor:prompts', blockSupported(c.prompts)],
351
+ ['floor:memory', blockSupported(c.memory)],
352
+ ['floor:feedback', blockSupported(c.feedback)],
353
+ ['floor:replay-or-nondeterminism', replay?.supported === true || nondet?.declared === true],
354
+ // full (governance)
355
+ ['full:authorization', blockSupported(c.authorization)],
356
+ ['full:tenant-installScope', agents?.manifestRuntime?.installScope === 'tenant'],
357
+ ['full:memory.attribution', blockSupported(memory?.attribution)],
358
+ ['full:debugBundle', blockSupported(c.debugBundle)],
359
+ ['full:triggerBridge', blockSupported(c.triggerBridge)],
360
+ ['full:egressPolicy', blockSupported(httpClient?.egressPolicy)],
361
+ ];
362
+ return checks.filter(([, ok]) => ok).map(([id]) => id);
363
+ }
364
+
214
365
  /**
215
366
  * Derive the full profile set from a discovery payload.
216
367
  *
@@ -228,6 +379,8 @@ export function deriveProfiles(c: DiscoveryPayload): readonly ProfileName[] {
228
379
  if (isNodePacksDiscovery(c)) result.push('openwop-node-packs');
229
380
  if (isReplayFork(c)) result.push('openwop-replay-fork');
230
381
  if (isFixtures(c)) result.push('openwop-fixtures');
382
+ if (isMemory(c)) result.push('openwop-memory');
383
+ if (isTriggerBridge(c)) result.push('openwop-trigger-bridge');
231
384
  return result;
232
385
  }
233
386
 
@@ -254,5 +407,9 @@ export function hasProfile(c: DiscoveryPayload, profile: ProfileName): boolean {
254
407
  return isReplayFork(c);
255
408
  case 'openwop-fixtures':
256
409
  return isFixtures(c);
410
+ case 'openwop-memory':
411
+ return isMemory(c);
412
+ case 'openwop-trigger-bridge':
413
+ return isTriggerBridge(c);
257
414
  }
258
415
  }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Shared helper for the RFC 0076 §A `runtime.requires[]` install-gate
3
+ * conformance scenarios. Lives in lib/ (not a *.test.ts) so scenarios import it
4
+ * via `../lib/runtimeRequires.js`.
5
+ *
6
+ * Drives the conformance-only host seam specified in host-sample-test-seams.md
7
+ * §"Open seams": `POST /v1/host/sample/packs/install-gate`. The seam evaluates a
8
+ * manifest's `runtime.requires[]` against a simulated host grant-set and returns
9
+ * the install-time outcome the host would produce — letting a single seam
10
+ * exercise the grant / refuse / non-sandbox-projection behaviors deterministically.
11
+ */
12
+ import { driver } from './driver.js';
13
+
14
+ export interface InstallGateRequest {
15
+ /** The candidate pack manifest (carrying runtime.requires[]). */
16
+ manifest: Record<string, unknown>;
17
+ /** Primitives the simulated sandbox grants. Ignored when `gating === false`. */
18
+ grantSet?: string[];
19
+ /** Whether the simulated host gates platform access. Default true (sandbox host). */
20
+ gating?: boolean;
21
+ }
22
+
23
+ export interface InstallGateResponse {
24
+ /** HTTP status the seam returned (200 install, 400 refuse). */
25
+ status: number;
26
+ /** Parsed response body. */
27
+ body: Record<string, unknown>;
28
+ }
29
+
30
+ /**
31
+ * Drives one install-gate evaluation via the host-sample seam, or null
32
+ * (soft-skip) when the host doesn't expose it.
33
+ */
34
+ export async function installGate(req: InstallGateRequest): Promise<InstallGateResponse | null> {
35
+ const res = await driver.post('/v1/host/sample/packs/install-gate', req as unknown as Record<string, unknown>);
36
+ if (res.status === 404 || res.status === 405) return null; // seam absent — soft-skip
37
+ return { status: res.status, body: (res.json as Record<string, unknown> | undefined) ?? {} };
38
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Shared helper for the RFC 0076 §B `ctx.http.safeFetch` conformance scenarios.
3
+ * Lives in lib/ (not a *.test.ts) so scenarios import it via `../lib/safeFetch.js`.
4
+ *
5
+ * Reads `capabilities.httpClient.safeFetch` (root-first, wrapper-fallback) and
6
+ * drives the conformance-only host seam `POST /v1/host/sample/http/safe-fetch`
7
+ * (host-sample-test-seams.md §"Open seams").
8
+ */
9
+ import { driver } from './driver.js';
10
+ import { capabilityFamily } from './discovery-capabilities.js';
11
+
12
+ interface HttpClientCap {
13
+ supported?: boolean;
14
+ safeFetch?: { supported?: boolean };
15
+ }
16
+
17
+ /** True when the host advertises `capabilities.httpClient.safeFetch.supported`. */
18
+ export async function isSafeFetchSupported(): Promise<boolean> {
19
+ const disco = await driver.get('/.well-known/openwop');
20
+ return capabilityFamily<HttpClientCap>(disco.json, 'httpClient')?.safeFetch?.supported === true;
21
+ }
22
+
23
+ /** True when the host also advertises `capabilities.toolHooks.prePostEvents`. */
24
+ export async function isToolHookAuditOn(): Promise<boolean> {
25
+ const disco = await driver.get('/.well-known/openwop');
26
+ return capabilityFamily<{ prePostEvents?: boolean }>(disco.json, 'toolHooks')?.prePostEvents === true;
27
+ }
28
+
29
+ export interface SafeFetchResult {
30
+ outcome?: 'fetched' | 'blocked';
31
+ status?: number;
32
+ blocked?: 'ssrf' | 'upgrade' | string;
33
+ toolCalled?: Record<string, unknown>;
34
+ toolReturned?: Record<string, unknown>;
35
+ }
36
+
37
+ /**
38
+ * Drives one safeFetch evaluation via the host-sample seam, or null (soft-skip)
39
+ * when the host doesn't expose it.
40
+ */
41
+ export async function safeFetch(body: Record<string, unknown>): Promise<SafeFetchResult | null> {
42
+ const res = await driver.post('/v1/host/sample/http/safe-fetch', body);
43
+ if (res.status === 404 || res.status === 405) return null; // seam absent — soft-skip
44
+ return (res.json as SafeFetchResult | undefined) ?? {};
45
+ }
46
+
47
+ /**
48
+ * True when the host advertises BOTH `httpClient.safeFetch.supported` AND
49
+ * `toolHooks.prePostEvents` — the co-advertisement that, per
50
+ * `host-capabilities.md` §host.http + RFC 0076 §B, makes live audit-pair
51
+ * emission a MUST. One discovery fetch (the two single-flag helpers above each
52
+ * fetch; this avoids the double round-trip for the live-audit gate).
53
+ */
54
+ export async function isSafeFetchLiveAuditAdvertised(): Promise<boolean> {
55
+ const disco = await driver.get('/.well-known/openwop');
56
+ const safeFetchOn =
57
+ capabilityFamily<HttpClientCap>(disco.json, 'httpClient')?.safeFetch?.supported === true;
58
+ const auditOn =
59
+ capabilityFamily<{ prePostEvents?: boolean }>(disco.json, 'toolHooks')?.prePostEvents === true;
60
+ return safeFetchOn && auditOn;
61
+ }
62
+
63
+ /** Result of the live-run safe-fetch seam: the host executed one
64
+ * `ctx.http.safeFetch` call inside a real run via the production injection
65
+ * path, and returns the run's id so the caller can read the durable event
66
+ * log. `null` ⇒ the run seam is unwired (soft-skip, host-pending). */
67
+ export interface SafeFetchRunResult {
68
+ runId?: string;
69
+ outcome?: 'fetched' | 'blocked';
70
+ }
71
+
72
+ /**
73
+ * Drives one `ctx.http.safeFetch` call **inside a real run** via the open seam
74
+ * `POST /v1/host/sample/http/safe-fetch-run`, returning `{ runId, outcome }`,
75
+ * or null (soft-skip) when the run seam isn't wired. Distinct from `safeFetch`
76
+ * (which returns the audit pair INLINE from the seam): this exercises the
77
+ * production per-ctx `ctx.http.safeFetch` path so the caller can assert the
78
+ * `agent.toolCalled`/`agent.toolReturned` pair landed in the DURABLE run event
79
+ * log — closing the seam-vs-production gap in `safefetch-behavior.test.ts`.
80
+ */
81
+ export async function safeFetchViaRun(
82
+ body: Record<string, unknown>,
83
+ ): Promise<SafeFetchRunResult | null> {
84
+ const res = await driver.post('/v1/host/sample/http/safe-fetch-run', body);
85
+ if (res.status === 404 || res.status === 405) return null; // run seam unwired — soft-skip
86
+ return (res.json as SafeFetchRunResult | undefined) ?? {};
87
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Agent deployment lifecycle — record + binding + event shapes (RFC 0082).
3
+ *
4
+ * Always-on, server-free schema-shape probe. Verifies that:
5
+ * - `capabilities.agents.deployment` is declared with its `supported` /
6
+ * `channels` / `canary` / `rollback` / `states` sub-flags.
7
+ * - `agent-deployment.schema.json` compiles and round-trips a conforming
8
+ * deployment record, and rejects malformed ones (an out-of-enum `state`;
9
+ * `canaryPercent` out of 0..100).
10
+ * - the `AgentRef` `channel` XOR `version` rule holds: each alone (and
11
+ * neither) validates; both together is rejected (the `not` clause).
12
+ * - the four `deployment.*` payload $defs validate conforming content-free
13
+ * payloads and reject malformed ones.
14
+ * - the four `deployment.*` payloads are CONTENT-FREE: a `deployment.promoted`
15
+ * carrying a `manifestBody`, and a `deployment.state.changed` carrying a
16
+ * `prompt`, are rejected (`additionalProperties:false`). This is the public
17
+ * test for the protocol-tier SECURITY invariant `deployment-event-no-content-leak`.
18
+ * - `agent.invocation.started` carries the additive recorded-fact
19
+ * `resolvedAgentVersion` / `resolvedChannel` fields (RFC 0082 §B).
20
+ * - all four event names appear in the RunEventType enum.
21
+ *
22
+ * Behavioral assertions (the authz → approvalGate → eval-verify → promotion path,
23
+ * the fail-closed denial, the §B replay re-read of `resolvedAgentVersion`) are
24
+ * gated on `capabilities.agents.deployment.supported` and land in
25
+ * `agent-deployment-lifecycle.test.ts` (deferred per RFC 0082 §Conformance —
26
+ * reference host deferred). This scenario asserts the wire contract, not host
27
+ * behavior.
28
+ *
29
+ * Spec references:
30
+ * - https://github.com/openwop/openwop/blob/main/spec/v1/agent-deployment.md
31
+ * - https://github.com/openwop/openwop/blob/main/RFCS/0082-agent-deployment-lifecycle.md
32
+ * - https://github.com/openwop/openwop/blob/main/SECURITY/invariants.yaml (deployment-event-no-content-leak)
33
+ */
34
+
35
+ import { describe, it, expect } from 'vitest';
36
+ import { readFileSync } from 'node:fs';
37
+ import { join } from 'node:path';
38
+ import Ajv2020 from 'ajv/dist/2020.js';
39
+ import addFormats from 'ajv-formats';
40
+ import { SCHEMAS_DIR } from '../lib/paths.js';
41
+
42
+ /** Server-free assertion-message helper. */
43
+ const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
44
+
45
+ function loadSchema(name: string): Record<string, unknown> {
46
+ return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
47
+ }
48
+
49
+ describe('agent-deployment-shape: capability advertisement (RFC 0082, server-free)', () => {
50
+ it('the capabilities schema declares agents.deployment with its sub-flags', () => {
51
+ const caps = loadSchema('capabilities.schema.json');
52
+ const agents = (caps.properties as Record<string, { properties?: Record<string, { properties?: Record<string, unknown> }> }>).agents;
53
+ const deployment = agents?.properties?.deployment;
54
+ expect(deployment, why('capabilities.md §agents', 'agents.deployment MUST be declared')).toBeDefined();
55
+ for (const flag of ['supported', 'channels', 'canary', 'rollback', 'states']) {
56
+ expect(
57
+ deployment?.properties?.[flag],
58
+ why('agent-deployment.md §F', `agents.deployment.${flag} MUST be declared`),
59
+ ).toBeDefined();
60
+ }
61
+ });
62
+ });
63
+
64
+ describe('agent-deployment-shape: deployment record + AgentRef binding (RFC 0082, server-free)', () => {
65
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
66
+ addFormats(ajv);
67
+ const record = ajv.compile(loadSchema('agent-deployment.schema.json'));
68
+ const agentRef = ajv.compile(loadSchema('agent-ref.schema.json'));
69
+
70
+ it('AgentDeployment validates a conforming record and rejects a bad state / out-of-range canary', () => {
71
+ const good = { agentId: 'core.openwop.agents.support-resolver', version: '2.4.0', state: 'active', canaryPercent: 10, channels: ['stable'] };
72
+ expect(record(good), why('RFC 0082 §C', 'a conforming deployment record MUST validate')).toBe(true);
73
+ expect(record({ ...good, state: 'live' }), why('RFC 0082 §C', 'an out-of-enum state MUST be rejected')).toBe(false);
74
+ expect(record({ ...good, canaryPercent: 150 }), why('RFC 0082 §C', 'canaryPercent > 100 MUST be rejected')).toBe(false);
75
+ });
76
+
77
+ it('AgentRef channel XOR version: each alone and neither validate; both is rejected (RFC 0082 §A)', () => {
78
+ expect(agentRef({ agentId: 'core.x.y.z', version: '1.0.0' }), why('RFC 0082 §A', 'version-only AgentRef MUST validate')).toBe(true);
79
+ expect(agentRef({ agentId: 'core.x.y.z', channel: 'stable' }), why('RFC 0082 §A', 'channel-only AgentRef MUST validate')).toBe(true);
80
+ expect(agentRef({ agentId: 'core.x.y.z' }), why('RFC 0082 §A', 'a ref with neither version nor channel MUST validate (host default)')).toBe(true);
81
+ expect(agentRef({ agentId: 'core.x.y.z', version: '1.0.0', channel: 'stable' }), why('RFC 0082 §A', 'a ref with BOTH version and channel MUST be rejected')).toBe(false);
82
+ });
83
+ });
84
+
85
+ describe('agent-deployment-shape: deployment.* event payloads (RFC 0082, server-free)', () => {
86
+ const payloads = loadSchema('run-event-payloads.schema.json');
87
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
88
+ addFormats(ajv);
89
+ ajv.addSchema(payloads, 'payloads');
90
+
91
+ const promoted = ajv.getSchema('payloads#/$defs/deploymentPromoted');
92
+ const rolledBack = ajv.getSchema('payloads#/$defs/deploymentRolledBack');
93
+ const canary = ajv.getSchema('payloads#/$defs/deploymentCanaryAdjusted');
94
+ const stateChanged = ajv.getSchema('payloads#/$defs/deploymentStateChanged');
95
+
96
+ it('deployment.promoted validates a content-free promotion record and requires toVersion + toState', () => {
97
+ expect(promoted, 'the deploymentPromoted $def MUST exist').toBeTruthy();
98
+ expect(
99
+ promoted!({ agentId: 'core.openwop.agents.support-resolver', toVersion: '2.4.0', toState: 'active', channel: 'stable', canaryPercent: 10, evalRunId: 'run_abc' }),
100
+ why('RFC 0082 §D', 'a conforming deployment.promoted payload MUST validate'),
101
+ ).toBe(true);
102
+ expect(promoted!({ agentId: 'a' }), why('RFC 0082 §D', 'deployment.promoted without toVersion/toState MUST be rejected')).toBe(false);
103
+ });
104
+
105
+ it('deployment.rolled-back / canary.adjusted / state.changed validate conforming records', () => {
106
+ expect(rolledBack!({ agentId: 'a', fromVersion: '2.4.0', toVersion: '2.3.1', rollbackPointer: '2.3.1' }), why('RFC 0082 §D', 'a conforming deployment.rolled-back MUST validate')).toBe(true);
107
+ expect(canary!({ agentId: 'a', version: '2.4.0', fromPercent: 10, toPercent: 50 }), why('RFC 0082 §D', 'a conforming deployment.canary.adjusted MUST validate')).toBe(true);
108
+ expect(stateChanged!({ agentId: 'a', version: '2.4.0', fromState: 'active', toState: 'paused' }), why('RFC 0082 §D', 'a conforming deployment.state.changed MUST validate')).toBe(true);
109
+ expect(stateChanged!({ agentId: 'a', version: '2.4.0', fromState: 'active', toState: 'live' }), why('RFC 0082 §D', 'an out-of-enum toState MUST be rejected')).toBe(false);
110
+ });
111
+
112
+ it('deployment.* events are content-free — a manifest body and a prompt are rejected (deployment-event-no-content-leak)', () => {
113
+ expect(
114
+ promoted!({ agentId: 'a', toVersion: '2.4.0', toState: 'active', manifestBody: '{...}' }),
115
+ why('SECURITY invariant deployment-event-no-content-leak', 'a deployment.promoted MUST NOT carry a manifest body'),
116
+ ).toBe(false);
117
+ expect(
118
+ stateChanged!({ agentId: 'a', version: '2.4.0', fromState: 'active', toState: 'paused', prompt: 'system: …' }),
119
+ why('SECURITY invariant deployment-event-no-content-leak', 'a deployment.state.changed MUST NOT carry prompt content'),
120
+ ).toBe(false);
121
+ });
122
+ });
123
+
124
+ describe('agent-deployment-shape: §B recorded-fact pin + enum (RFC 0082, server-free)', () => {
125
+ it('agent.invocation.started carries the additive recorded-fact resolvedAgentVersion / resolvedChannel', () => {
126
+ const payloads = loadSchema('run-event-payloads.schema.json');
127
+ const started = ((payloads.$defs as Record<string, { properties?: Record<string, unknown> }>).agentInvocationStarted)?.properties ?? {};
128
+ expect(started.resolvedAgentVersion, why('RFC 0082 §B', 'agent.invocation.started.resolvedAgentVersion MUST be declared (the channel pin)')).toBeDefined();
129
+ expect(started.resolvedChannel, why('RFC 0082 §B', 'agent.invocation.started.resolvedChannel MUST be declared')).toBeDefined();
130
+ });
131
+
132
+ it('all four deployment event names appear in the RunEventType enum', () => {
133
+ const runEvent = loadSchema('run-event.schema.json');
134
+ const enumVals = (runEvent.$defs as Record<string, { enum?: string[] }>).RunEventType?.enum ?? [];
135
+ for (const e of ['deployment.promoted', 'deployment.rolled-back', 'deployment.canary.adjusted', 'deployment.state.changed']) {
136
+ expect(enumVals).toContain(e);
137
+ }
138
+ });
139
+ });