@openwop/openwop-conformance 1.6.1 → 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.
- package/CHANGELOG.md +44 -0
- package/README.md +2 -2
- package/api/asyncapi.yaml +127 -0
- package/api/openapi.yaml +518 -1
- package/coverage.md +44 -2
- package/fixtures/conformance-run-duration-breach.json +33 -0
- package/fixtures/oauth-providers/synthetic.json +38 -0
- package/fixtures.md +29 -0
- package/package.json +1 -1
- package/schemas/README.md +22 -0
- package/schemas/agent-deployment-transition.schema.json +49 -0
- package/schemas/agent-deployment.schema.json +54 -0
- package/schemas/agent-eval-suite.schema.json +140 -0
- package/schemas/agent-inventory-response.schema.json +115 -0
- package/schemas/agent-manifest.schema.json +5 -0
- package/schemas/agent-org-chart.schema.json +82 -0
- package/schemas/agent-ref.schema.json +12 -2
- package/schemas/agent-roster-entry.schema.json +81 -0
- package/schemas/agent-roster-response.schema.json +21 -0
- package/schemas/ai-envelope.schema.json +28 -0
- package/schemas/artifact-type-pack-manifest.schema.json +160 -0
- package/schemas/budget-policy.schema.json +18 -0
- package/schemas/capabilities.schema.json +448 -4
- package/schemas/chat-card-pack-manifest.schema.json +158 -0
- package/schemas/credential-provenance.schema.json +18 -0
- package/schemas/envelopes/media.audio.schema.json +38 -0
- package/schemas/envelopes/media.file.schema.json +37 -0
- package/schemas/envelopes/media.image.schema.json +33 -0
- package/schemas/eval-summary.schema.json +92 -0
- package/schemas/heartbeat-evaluated.schema.json +14 -0
- package/schemas/heartbeat-state-changed.schema.json +14 -0
- package/schemas/node-pack-manifest.schema.json +33 -1
- package/schemas/org-chart-responsibility-view.schema.json +26 -0
- package/schemas/run-event-payloads.schema.json +380 -6
- package/schemas/run-event.schema.json +23 -0
- package/schemas/tool-descriptor.schema.json +63 -0
- package/schemas/trigger-subscription.schema.json +26 -0
- package/schemas/workflow-definition.schema.json +5 -0
- package/schemas/workspace-file-create.schema.json +20 -0
- package/schemas/workspace-file.schema.json +39 -0
- package/src/lib/agentLoop.ts +44 -0
- package/src/lib/agentRoster.ts +76 -0
- package/src/lib/agentRuntime.ts +45 -0
- package/src/lib/artifactTypes.ts +96 -0
- package/src/lib/cardPacks.ts +52 -0
- package/src/lib/discovery-capabilities.ts +50 -0
- package/src/lib/distillation.ts +38 -0
- package/src/lib/feedback.ts +3 -3
- package/src/lib/heartbeat.ts +31 -0
- package/src/lib/liveRuntime.ts +59 -0
- package/src/lib/memoryAttribution.ts +48 -0
- package/src/lib/profiles.ts +157 -0
- package/src/lib/runtimeRequires.ts +38 -0
- package/src/lib/safeFetch.ts +87 -0
- package/src/lib/subRunAttestation.ts +35 -0
- package/src/lib/toolHooks.ts +33 -0
- package/src/scenarios/agent-deployment-shape.test.ts +139 -0
- package/src/scenarios/agent-eval-suite-shape.test.ts +167 -0
- package/src/scenarios/agent-live-allowlist-enforced.test.ts +53 -0
- package/src/scenarios/agent-live-invocation-bracket.test.ts +98 -0
- package/src/scenarios/agent-live-runtime-shape.test.ts +98 -0
- package/src/scenarios/agent-live-structured-output.test.ts +58 -0
- package/src/scenarios/agent-loop-iteration-monotonic.test.ts +33 -0
- package/src/scenarios/agent-loop-stateful-resume.test.ts +28 -0
- package/src/scenarios/agent-loop-version5-shape.test.ts +41 -0
- package/src/scenarios/agent-loop-workspace-snapshot.test.ts +33 -0
- package/src/scenarios/agent-manifest-runtime.test.ts +85 -0
- package/src/scenarios/agent-org-chart-shape.test.ts +127 -0
- package/src/scenarios/agent-platform-profile.test.ts +158 -0
- package/src/scenarios/agent-roster-attribution.test.ts +179 -0
- package/src/scenarios/agent-roster-shape.test.ts +146 -0
- package/src/scenarios/ai-envelope-shape.test.ts +14 -18
- package/src/scenarios/aiEnvelope.capBreached.test.ts +2 -1
- package/src/scenarios/aiEnvelope.schemaDrift.test.ts +2 -1
- package/src/scenarios/aiEnvelope.universalKinds.test.ts +2 -1
- package/src/scenarios/approval-gate-flow.test.ts +4 -6
- package/src/scenarios/artifact-schema-compile-bounded.test.ts +126 -0
- package/src/scenarios/artifact-type-pack-install.test.ts +78 -0
- package/src/scenarios/artifact-type-pack-manifest-validation.test.ts +140 -0
- package/src/scenarios/artifact-type-store-without-render.test.ts +54 -0
- package/src/scenarios/audit-log-integrity.test.ts +3 -2
- package/src/scenarios/auth-api-key-rotation.test.ts +2 -1
- package/src/scenarios/auth-mtls.test.ts +2 -1
- package/src/scenarios/auth-oauth2-client-credentials.test.ts +2 -1
- package/src/scenarios/auth-oidc-user-bearer.test.ts +2 -1
- package/src/scenarios/auth-saml-profile.test.ts +2 -1
- package/src/scenarios/auth-scim-profile.test.ts +2 -1
- package/src/scenarios/authorization-fail-closed.test.ts +2 -1
- package/src/scenarios/authorization-roles-shape.test.ts +2 -1
- package/src/scenarios/budget-policy-shape.test.ts +136 -0
- package/src/scenarios/byok-auth-modes.test.ts +141 -0
- package/src/scenarios/chat-card-pack-execution.test.ts +56 -0
- package/src/scenarios/chat-card-pack-manifest-validation.test.ts +128 -0
- package/src/scenarios/commitment-fired.test.ts +83 -0
- package/src/scenarios/credential-payload-redaction.test.ts +2 -1
- package/src/scenarios/credentials-capability-shape.test.ts +2 -1
- package/src/scenarios/cross-engine-append-ordering.test.ts +2 -1
- package/src/scenarios/cross-host-ancestry-endpoint.test.ts +3 -2
- package/src/scenarios/cross-host-causation-shape.test.ts +3 -2
- package/src/scenarios/deadletter-capability-shape.test.ts +2 -1
- package/src/scenarios/deadletter-retry-exhaustion.test.ts +2 -1
- package/src/scenarios/distillation-index-roundtrip.test.ts +35 -0
- package/src/scenarios/distillation-secret-carryforward.test.ts +35 -0
- package/src/scenarios/distillation-shape.test.ts +41 -0
- package/src/scenarios/distillation-stable-archive.test.ts +37 -0
- package/src/scenarios/distillation-token-budget.test.ts +45 -0
- package/src/scenarios/egress-provenance-shape.test.ts +137 -0
- package/src/scenarios/envelope-completion-distinguishes-truncation.test.ts +4 -3
- package/src/scenarios/envelope-reasoning-secret-redaction.test.ts +5 -4
- package/src/scenarios/envelope-reasoning-shape.test.ts +3 -2
- package/src/scenarios/envelope-refusal-shape.test.ts +3 -2
- package/src/scenarios/envelope-rendering-hint.test.ts +95 -0
- package/src/scenarios/envelope-retry-attempted.test.ts +2 -1
- package/src/scenarios/envelope-tier-one-subset-static.test.ts +3 -2
- package/src/scenarios/exec-not-protocol-tier.test.ts +137 -0
- package/src/scenarios/experimental-tier-shape.test.ts +5 -4
- package/src/scenarios/fs-path-traversal.test.ts +2 -1
- package/src/scenarios/heartbeat-capability-shape.test.ts +35 -0
- package/src/scenarios/heartbeat-fires-once-per-tick.test.ts +28 -0
- package/src/scenarios/heartbeat-idempotent-no-spam.test.ts +43 -0
- package/src/scenarios/heartbeat-runtime-bound.test.ts +30 -0
- package/src/scenarios/http-client-ssrf.test.ts +10 -13
- package/src/scenarios/mcp-toolcall-redaction.test.ts +3 -2
- package/src/scenarios/media-url-inline-cap.test.ts +167 -0
- package/src/scenarios/memory-attribution-emits-on-write.test.ts +54 -0
- package/src/scenarios/memory-attribution-no-content.test.ts +45 -0
- package/src/scenarios/memory-attribution-replay-stable.test.ts +60 -0
- package/src/scenarios/memory-attribution-shape.test.ts +28 -0
- package/src/scenarios/memory-attribution-tenant-scoped.test.ts +44 -0
- package/src/scenarios/memory-capability-model-shape.test.ts +186 -0
- package/src/scenarios/memory-compaction-event-emitted.test.ts +2 -1
- package/src/scenarios/memory-compaction-provenance-tag.test.ts +2 -1
- package/src/scenarios/memory-compaction-sr1-carry-forward.test.ts +2 -1
- package/src/scenarios/memory-consolidation-idempotent.test.ts +77 -0
- package/src/scenarios/memory-consolidation-shape.test.ts +90 -0
- package/src/scenarios/model-capability-substituted.test.ts +2 -1
- package/src/scenarios/multi-agent-confidence-escalation.test.ts +5 -4
- package/src/scenarios/multi-agent-handoff-state-machine.test.ts +6 -5
- package/src/scenarios/multi-agent-memory-lifecycle.test.ts +4 -3
- package/src/scenarios/multi-region-idempotency.test.ts +10 -10
- package/src/scenarios/oauth-authorization-code-roundtrip.test.ts +145 -0
- package/src/scenarios/oauth-capability-shape.test.ts +2 -1
- package/src/scenarios/oauth-connector-redaction.test.ts +2 -1
- package/src/scenarios/pause-resume.test.ts +3 -3
- package/src/scenarios/production-backpressure.test.ts +2 -2
- package/src/scenarios/production-retention-expiry.test.ts +2 -2
- package/src/scenarios/prompt-all-four-kinds-events.test.ts +2 -1
- package/src/scenarios/prompt-composed-secret-redaction.test.ts +2 -1
- package/src/scenarios/prompt-composed-trust-marker.test.ts +2 -1
- package/src/scenarios/prompt-end-to-end-events.test.ts +2 -1
- package/src/scenarios/prompt-list-and-fetch.test.ts +2 -1
- package/src/scenarios/prompt-mutable-lifecycle.test.ts +2 -1
- package/src/scenarios/prompt-mutation-workspace-membership-enforced.test.ts +2 -1
- package/src/scenarios/prompt-pack-install.test.ts +2 -1
- package/src/scenarios/prompt-read-workspace-membership-enforced.test.ts +2 -1
- package/src/scenarios/prompt-render-deterministic.test.ts +2 -1
- package/src/scenarios/prompt-resolution-chain-agent-intrinsic.test.ts +2 -1
- package/src/scenarios/prompt-resolution-chain-fallback-cascade.test.ts +2 -1
- package/src/scenarios/prompt-resolution-chain-node-wins.test.ts +2 -1
- package/src/scenarios/prompt-template-shape.test.ts +2 -1
- package/src/scenarios/provider-usage.test.ts +2 -1
- package/src/scenarios/replay-divergence-at-refusal.test.ts +4 -3
- package/src/scenarios/replay-fork-arbitrary.test.ts +3 -1
- package/src/scenarios/replay-llm-cache-key-portable.test.ts +2 -1
- package/src/scenarios/replayDeterminism.test.ts +3 -1
- package/src/scenarios/run-execution-bounds-shape.test.ts +133 -0
- package/src/scenarios/runtime-requires-install-gate.test.ts +92 -0
- package/src/scenarios/runtime-requires-shape.test.ts +134 -0
- package/src/scenarios/safefetch-behavior.test.ts +99 -0
- package/src/scenarios/safefetch-live-audit.test.ts +175 -0
- package/src/scenarios/sandbox-memory-cap.test.ts +2 -1
- package/src/scenarios/sandbox-mvp-behavior.test.ts +2 -1
- package/src/scenarios/sandbox-no-host-fs-escape.test.ts +2 -1
- package/src/scenarios/sandbox-timeout-cap.test.ts +2 -1
- package/src/scenarios/scheduling-capability-shape.test.ts +2 -1
- package/src/scenarios/scheduling-cron-fires-once.test.ts +2 -1
- package/src/scenarios/secret-leakage-otel-attribute.test.ts +7 -6
- package/src/scenarios/spec-corpus-validity.test.ts +20 -4
- package/src/scenarios/subrun-approval-fail-closed.test.ts +33 -0
- package/src/scenarios/subrun-approval-gate.test.ts +35 -0
- package/src/scenarios/subrun-attestation-shape.test.ts +30 -0
- package/src/scenarios/subrun-checksum-stable.test.ts +43 -0
- package/src/scenarios/tool-descriptor-shape.test.ts +133 -0
- package/src/scenarios/tool-hooks-authorization-fail-closed.test.ts +39 -0
- package/src/scenarios/tool-hooks-content-free.test.ts +40 -0
- package/src/scenarios/tool-hooks-rate-limit.test.ts +32 -0
- package/src/scenarios/tool-hooks-secret-redaction.test.ts +34 -0
- package/src/scenarios/tool-hooks-shape.test.ts +34 -0
- package/src/scenarios/trigger-bridge-shape.test.ts +135 -0
- package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +3 -10
- package/src/scenarios/wasm-pack-invoke-completed.test.ts +2 -2
- package/src/scenarios/wasm-pack-invoke-suspended.test.ts +2 -2
- package/src/scenarios/wasm-pack-load.test.ts +2 -2
- package/src/scenarios/wasm-pack-memory-cap.test.ts +3 -6
- package/src/scenarios/wasm-pack-replay-determinism.test.ts +2 -2
- package/src/scenarios/workflow-primary-output-annotation.test.ts +142 -0
- package/src/scenarios/workspace-behavior.test.ts +134 -0
- package/src/scenarios/workspace-capability-shape.test.ts +73 -0
- package/src/scenarios/workspace-cross-tenant-isolation.test.ts +84 -0
- package/src/scenarios/x-openwop-form-pack-manifest.test.ts +155 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable trigger + channel bridge — subscription + events + profile shapes (RFC 0083).
|
|
3
|
+
*
|
|
4
|
+
* Always-on, server-free schema-shape probe. Verifies that:
|
|
5
|
+
* - `trigger-subscription.schema.json` round-trips a conforming
|
|
6
|
+
* `TriggerSubscription` and rejects the malformed (missing REQUIRED `state`;
|
|
7
|
+
* an out-of-enum `source`; an unknown property under
|
|
8
|
+
* `additionalProperties:false`).
|
|
9
|
+
* - the four-state vocabulary (`active`/`paused`/`failed`/`dead-lettered`) is
|
|
10
|
+
* stable on the subscription `state` + the event `fromState`/`toState`.
|
|
11
|
+
* - the `trigger.subscription.state.changed` + `trigger.delivery.attempted`
|
|
12
|
+
* payload $defs validate conforming content-free records and reject malformed
|
|
13
|
+
* ones (a missing `outcome`; an out-of-enum `outcome`), and both event names
|
|
14
|
+
* appear in the RunEventType enum.
|
|
15
|
+
* - `capabilities.triggerBridge` (+ `webhooks.durable`) is declared.
|
|
16
|
+
* - `deriveProfiles` surfaces `openwop-trigger-bridge` for a host advertising
|
|
17
|
+
* the bridge + a dead-letter sink + a durable source, and withholds it when
|
|
18
|
+
* the dead-letter sink is absent (the §D predicate's OR + sink requirement).
|
|
19
|
+
*
|
|
20
|
+
* Behavioral assertions (the dedup → retry → dead-letter → causation delivery
|
|
21
|
+
* loop) are gated on the `openwop-trigger-bridge` profile and land in
|
|
22
|
+
* `trigger-bridge-delivery.test.ts` (deferred per RFC 0083 §Conformance —
|
|
23
|
+
* reference host deferred). This scenario asserts the wire contract, not host
|
|
24
|
+
* behavior.
|
|
25
|
+
*
|
|
26
|
+
* Spec references:
|
|
27
|
+
* - https://github.com/openwop/openwop/blob/main/spec/v1/trigger-bridge.md
|
|
28
|
+
* - https://github.com/openwop/openwop/blob/main/spec/v1/profiles.md (§`openwop-trigger-bridge`)
|
|
29
|
+
* - https://github.com/openwop/openwop/blob/main/RFCS/0083-durable-trigger-and-channel-bridge-profile.md
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { describe, it, expect } from 'vitest';
|
|
33
|
+
import { readFileSync } from 'node:fs';
|
|
34
|
+
import { join } from 'node:path';
|
|
35
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
36
|
+
import addFormats from 'ajv-formats';
|
|
37
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
38
|
+
import { deriveProfiles } from '../lib/profiles.js';
|
|
39
|
+
|
|
40
|
+
const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
|
|
41
|
+
|
|
42
|
+
function loadSchema(name: string): Record<string, unknown> {
|
|
43
|
+
return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const STATES = ['active', 'paused', 'failed', 'dead-lettered'] as const;
|
|
47
|
+
|
|
48
|
+
describe('trigger-bridge-shape: TriggerSubscription (RFC 0083 §B, server-free)', () => {
|
|
49
|
+
const ajv = addFormats(new Ajv2020({ strict: false }));
|
|
50
|
+
const sub = loadSchema('trigger-subscription.schema.json');
|
|
51
|
+
const validate = ajv.compile(sub);
|
|
52
|
+
|
|
53
|
+
it('a conforming subscription validates', () => {
|
|
54
|
+
expect(
|
|
55
|
+
validate({ subscriptionId: 'sub-1', source: 'webhook', state: 'active', dedupEnabled: true, retryPolicy: { maxAttempts: 8, backoff: 'exponential' }, webhookId: 'wh-1', secretFingerprint: 'fp-abc' }),
|
|
56
|
+
why('trigger-bridge.md §B', 'a conforming TriggerSubscription MUST validate'),
|
|
57
|
+
).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('rejects a missing REQUIRED state, an out-of-enum source, and an unknown property', () => {
|
|
61
|
+
expect(validate({ subscriptionId: 's', source: 'webhook' }), why('trigger-bridge.md §B', 'state is REQUIRED')).toBe(false);
|
|
62
|
+
expect(validate({ subscriptionId: 's', source: 'carrier-pigeon', state: 'active' }), why('trigger-bridge.md §B', 'source is a closed enum')).toBe(false);
|
|
63
|
+
expect(validate({ subscriptionId: 's', source: 'webhook', state: 'active', body: 'inbound' }), why('trigger-bridge.md §B', 'TriggerSubscription MUST be additionalProperties:false')).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('secretFingerprint MUST be bounded — a full 64-hex (unsalted-hash-smelling) digest is rejected', () => {
|
|
67
|
+
const truncated = 'a1b2c3d4e5f6a7b8'; // 16 hex — a truncated host-keyed fingerprint
|
|
68
|
+
const fullDigest = 'a'.repeat(64); // 64 hex — smells like an unsalted SHA256(secret)
|
|
69
|
+
expect(validate({ subscriptionId: 's', source: 'webhook', state: 'active', secretFingerprint: truncated }), why('trigger-bridge.md §B', 'a truncated fingerprint MUST validate')).toBe(true);
|
|
70
|
+
expect(validate({ subscriptionId: 's', source: 'webhook', state: 'active', secretFingerprint: fullDigest }), why('SR-1', 'a full 64-hex digest MUST be rejected (brute-force oracle)')).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('the state enum is exactly the four §B states', () => {
|
|
74
|
+
const stateEnum = ((sub.properties as Record<string, { enum?: string[] }>).state?.enum) ?? [];
|
|
75
|
+
expect([...stateEnum].sort(), why('trigger-bridge.md §B', 'the four-state vocabulary MUST be stable')).toEqual([...STATES].sort());
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('trigger-bridge-shape: trigger.* events (RFC 0083 §C, server-free)', () => {
|
|
80
|
+
const payloads = loadSchema('run-event-payloads.schema.json');
|
|
81
|
+
const ajv = addFormats(new Ajv2020({ strict: false }));
|
|
82
|
+
const compile = (defName: string) => ajv.compile({
|
|
83
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
84
|
+
$defs: (payloads as { $defs: Record<string, unknown> }).$defs,
|
|
85
|
+
$ref: `#/$defs/${defName}`,
|
|
86
|
+
} as Record<string, unknown>);
|
|
87
|
+
|
|
88
|
+
it('trigger.subscription.state.changed validates + reason is a CLOSED enum (no URL-bearing free text)', () => {
|
|
89
|
+
const v = compile('triggerSubscriptionStateChanged');
|
|
90
|
+
expect(v({ subscriptionId: 's', source: 'webhook', fromState: 'active', toState: 'dead-lettered', reason: 'retry-exhausted' }), why('trigger-bridge.md §C', 'state-changed MUST validate')).toBe(true);
|
|
91
|
+
expect(v({ subscriptionId: 's', source: 'webhook', fromState: 'active' }), why('trigger-bridge.md §C', 'toState is REQUIRED')).toBe(false);
|
|
92
|
+
expect(v({ subscriptionId: 's', source: 'webhook', fromState: 'active', toState: 'failed', reason: 'https://attacker.example/leak?token=sk' }), why('SR-1', 'a free-form / URL-bearing reason MUST be rejected')).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('trigger.delivery.attempted validates + enforces the outcome enum', () => {
|
|
96
|
+
const v = compile('triggerDeliveryAttempted');
|
|
97
|
+
expect(v({ subscriptionId: 's', dedupKey: 'evt-9f3', attempt: 1, outcome: 'delivered', runId: 'run_x' }), why('trigger-bridge.md §C', 'delivery-attempted MUST validate')).toBe(true);
|
|
98
|
+
expect(v({ subscriptionId: 's', dedupKey: 'evt-9f3', attempt: 1 }), why('trigger-bridge.md §C', 'outcome is REQUIRED')).toBe(false);
|
|
99
|
+
expect(v({ subscriptionId: 's', dedupKey: 'evt-9f3', attempt: 1, outcome: 'exploded' }), why('trigger-bridge.md §C', 'outcome is a closed enum')).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('both trigger event names appear in the RunEventType enum', () => {
|
|
103
|
+
const runEvent = loadSchema('run-event.schema.json');
|
|
104
|
+
const enumVals = ((runEvent.$defs as Record<string, { enum?: string[] }>).RunEventType?.enum) ?? [];
|
|
105
|
+
for (const name of ['trigger.subscription.state.changed', 'trigger.delivery.attempted']) {
|
|
106
|
+
expect(enumVals.includes(name), why('run-event.schema.json', `${name} MUST be in the RunEventType enum`)).toBe(true);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('trigger-bridge-shape: capability + profile derivation (RFC 0083 §A/§D, server-free)', () => {
|
|
112
|
+
it('capabilities.triggerBridge + webhooks.durable are declared', () => {
|
|
113
|
+
const caps = loadSchema('capabilities.schema.json');
|
|
114
|
+
const props = caps.properties as Record<string, { properties?: Record<string, unknown> }>;
|
|
115
|
+
expect(props.triggerBridge?.properties?.supported, why('trigger-bridge.md §A', 'triggerBridge.supported MUST be declared')).toBeDefined();
|
|
116
|
+
expect(props.webhooks?.properties?.durable, why('trigger-bridge.md §A', 'webhooks.durable MUST be declared')).toBeDefined();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const coreBase = {
|
|
120
|
+
protocolVersion: '1.0',
|
|
121
|
+
supportedEnvelopes: ['clarification.request'],
|
|
122
|
+
schemaVersions: {},
|
|
123
|
+
limits: { clarificationRounds: 1, schemaRounds: 1, envelopesPerTurn: 1 },
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
it('deriveProfiles surfaces openwop-trigger-bridge for bridge + deadLetter + a durable source', () => {
|
|
127
|
+
const c = { ...coreBase, triggerBridge: { supported: true }, deadLetter: { supported: true }, queueBus: { supported: true } } as Record<string, unknown>;
|
|
128
|
+
expect(deriveProfiles(c).includes('openwop-trigger-bridge'), why('profiles.md §openwop-trigger-bridge', 'bridge + sink + durable source MUST derive the profile')).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('deriveProfiles withholds openwop-trigger-bridge when the dead-letter sink is absent', () => {
|
|
132
|
+
const c = { ...coreBase, triggerBridge: { supported: true }, webhooks: { durable: true } } as Record<string, unknown>;
|
|
133
|
+
expect(deriveProfiles(c).includes('openwop-trigger-bridge'), why('profiles.md §openwop-trigger-bridge', 'no deadLetter sink ⇒ MUST NOT derive the profile')).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
import { describe, it, expect } from 'vitest';
|
|
28
28
|
import { driver } from '../lib/driver.js';
|
|
29
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
29
30
|
|
|
30
31
|
const MISBEHAVING_PACK_NAME = 'vendor.openwop.misbehaving-abi';
|
|
31
32
|
const WELL_BEHAVED_PACK_NAME = 'vendor.openwop.rust-hello';
|
|
@@ -34,9 +35,7 @@ describe('wasm-pack-abi-version-rejection: host advertises supported ABI version
|
|
|
34
35
|
it('abiVersions[] contains positive integers; loader rejects unsupported versions', async () => {
|
|
35
36
|
const disco = await driver.get('/.well-known/openwop');
|
|
36
37
|
const wasm =
|
|
37
|
-
(disco.json
|
|
38
|
-
capabilities?: { nodePackRuntimes?: { wasm?: { supported?: boolean; abiVersions?: unknown } } };
|
|
39
|
-
}).capabilities?.nodePackRuntimes?.wasm;
|
|
38
|
+
capabilityFamily<{ wasm?: Record<string, unknown> }>(disco.json, 'nodePackRuntimes')?.wasm;
|
|
40
39
|
|
|
41
40
|
if (!wasm?.supported) return;
|
|
42
41
|
|
|
@@ -62,13 +61,7 @@ describe('wasm-pack-abi-version-rejection: positive path via misbehaving pack',
|
|
|
62
61
|
it('misbehaving-abi pack (declares ABI 999) MUST NOT appear in loadedPacks[]', async () => {
|
|
63
62
|
const disco = await driver.get('/.well-known/openwop');
|
|
64
63
|
const wasm =
|
|
65
|
-
(disco.json
|
|
66
|
-
capabilities?: {
|
|
67
|
-
nodePackRuntimes?: {
|
|
68
|
-
wasm?: { supported?: boolean; loadedPacks?: unknown };
|
|
69
|
-
};
|
|
70
|
-
};
|
|
71
|
-
}).capabilities?.nodePackRuntimes?.wasm;
|
|
64
|
+
capabilityFamily<{ wasm?: Record<string, unknown> }>(disco.json, 'nodePackRuntimes')?.wasm;
|
|
72
65
|
|
|
73
66
|
if (!wasm?.supported) return;
|
|
74
67
|
|
|
@@ -16,14 +16,14 @@ import { describe, it, expect } from 'vitest';
|
|
|
16
16
|
import { driver } from '../lib/driver.js';
|
|
17
17
|
import { pollUntilTerminal } from '../lib/polling.js';
|
|
18
18
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
19
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
19
20
|
|
|
20
21
|
const FIXTURE = 'conformance-wasm-pack-roundtrip';
|
|
21
22
|
|
|
22
23
|
async function isWasmSupported(): Promise<boolean> {
|
|
23
24
|
const disco = await driver.get('/.well-known/openwop');
|
|
24
25
|
return Boolean(
|
|
25
|
-
|
|
26
|
-
.capabilities?.nodePackRuntimes?.wasm?.supported,
|
|
26
|
+
capabilityFamily<{ wasm?: { supported?: boolean } }>(disco.json, 'nodePackRuntimes')?.wasm?.supported,
|
|
27
27
|
);
|
|
28
28
|
}
|
|
29
29
|
|
|
@@ -20,14 +20,14 @@ import { describe, it, expect } from 'vitest';
|
|
|
20
20
|
import { driver } from '../lib/driver.js';
|
|
21
21
|
import { pollUntilTerminal } from '../lib/polling.js';
|
|
22
22
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
23
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
23
24
|
|
|
24
25
|
const FIXTURE = 'conformance-wasm-pack-roundtrip';
|
|
25
26
|
|
|
26
27
|
async function isWasmSupported(): Promise<boolean> {
|
|
27
28
|
const disco = await driver.get('/.well-known/openwop');
|
|
28
29
|
return Boolean(
|
|
29
|
-
|
|
30
|
-
.capabilities?.nodePackRuntimes?.wasm?.supported,
|
|
30
|
+
capabilityFamily<{ wasm?: { supported?: boolean } }>(disco.json, 'nodePackRuntimes')?.wasm?.supported,
|
|
31
31
|
);
|
|
32
32
|
}
|
|
33
33
|
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { describe, it, expect } from 'vitest';
|
|
16
16
|
import { driver } from '../lib/driver.js';
|
|
17
17
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
18
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
18
19
|
|
|
19
20
|
const FIXTURE = 'conformance-wasm-pack-roundtrip';
|
|
20
21
|
|
|
@@ -28,8 +29,7 @@ interface WasmCaps {
|
|
|
28
29
|
async function getWasmCaps(): Promise<WasmCaps | null> {
|
|
29
30
|
const disco = await driver.get('/.well-known/openwop');
|
|
30
31
|
const caps =
|
|
31
|
-
|
|
32
|
-
.capabilities?.nodePackRuntimes?.wasm ?? null;
|
|
32
|
+
capabilityFamily<{ wasm?: WasmCaps }>(disco.json, 'nodePackRuntimes')?.wasm ?? null;
|
|
33
33
|
return caps;
|
|
34
34
|
}
|
|
35
35
|
|
|
@@ -26,6 +26,7 @@ import { describe, it, expect } from 'vitest';
|
|
|
26
26
|
import { driver } from '../lib/driver.js';
|
|
27
27
|
import { pollUntilTerminal } from '../lib/polling.js';
|
|
28
28
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
29
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
29
30
|
|
|
30
31
|
const CAP_BREACH_FIXTURE = 'conformance-wasm-pack-memory-cap-breach';
|
|
31
32
|
|
|
@@ -33,9 +34,7 @@ describe('wasm-pack-memory-cap: host advertises maxMemoryBytes', () => {
|
|
|
33
34
|
it('capabilities.nodePackRuntimes.wasm.maxMemoryBytes is a plausible number', async () => {
|
|
34
35
|
const disco = await driver.get('/.well-known/openwop');
|
|
35
36
|
const wasm =
|
|
36
|
-
(disco.json
|
|
37
|
-
capabilities?: { nodePackRuntimes?: { wasm?: { supported?: boolean; maxMemoryBytes?: unknown } } };
|
|
38
|
-
}).capabilities?.nodePackRuntimes?.wasm;
|
|
37
|
+
capabilityFamily<{ wasm?: Record<string, unknown> }>(disco.json, 'nodePackRuntimes')?.wasm;
|
|
39
38
|
|
|
40
39
|
if (!wasm?.supported) return;
|
|
41
40
|
|
|
@@ -64,9 +63,7 @@ describe('wasm-pack-memory-cap: positive path via misbehaving pack', () => {
|
|
|
64
63
|
}
|
|
65
64
|
const disco = await driver.get('/.well-known/openwop');
|
|
66
65
|
const wasm =
|
|
67
|
-
(disco.json
|
|
68
|
-
capabilities?: { nodePackRuntimes?: { wasm?: { supported?: boolean } } };
|
|
69
|
-
}).capabilities?.nodePackRuntimes?.wasm;
|
|
66
|
+
capabilityFamily<{ wasm?: Record<string, unknown> }>(disco.json, 'nodePackRuntimes')?.wasm;
|
|
70
67
|
if (!wasm?.supported) return;
|
|
71
68
|
|
|
72
69
|
const create = await driver.post('/v1/runs', {
|
|
@@ -14,14 +14,14 @@ import { describe, it, expect } from 'vitest';
|
|
|
14
14
|
import { driver } from '../lib/driver.js';
|
|
15
15
|
import { pollUntilTerminal } from '../lib/polling.js';
|
|
16
16
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
17
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
17
18
|
|
|
18
19
|
const FIXTURE = 'conformance-wasm-pack-roundtrip';
|
|
19
20
|
|
|
20
21
|
async function isWasmSupported(): Promise<boolean> {
|
|
21
22
|
const disco = await driver.get('/.well-known/openwop');
|
|
22
23
|
return Boolean(
|
|
23
|
-
|
|
24
|
-
.capabilities?.nodePackRuntimes?.wasm?.supported,
|
|
24
|
+
capabilityFamily<{ wasm?: { supported?: boolean } }>(disco.json, 'nodePackRuntimes')?.wasm?.supported,
|
|
25
25
|
);
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workflow-primary-output-annotation — RFC 0065 schema shape conformance.
|
|
3
|
+
*
|
|
4
|
+
* Server-free schema assertions that the optional `outputRole` field on
|
|
5
|
+
* `WorkflowNode` is exactly that — optional, additive, and a closed enum:
|
|
6
|
+
* 1. A WorkflowDefinition with one node declaring `outputRole: "primary"`
|
|
7
|
+
* and another declaring `outputRole: "secondary"` validates.
|
|
8
|
+
* 2. A WorkflowDefinition with the field absent (legacy shape) still
|
|
9
|
+
* validates — preserves the additive promise.
|
|
10
|
+
* 3. An unknown `outputRole` value is rejected by the closed enum.
|
|
11
|
+
* 4. The field set to a non-string is rejected.
|
|
12
|
+
*
|
|
13
|
+
* Always runs (pure on-disk Ajv2020 validation; no host involvement —
|
|
14
|
+
* the field has no engine-observable effect by design).
|
|
15
|
+
*
|
|
16
|
+
* @see RFCS/0065-workflow-node-primary-output-annotation.md
|
|
17
|
+
* @see schemas/workflow-definition.schema.json ($defs.WorkflowNode.outputRole)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, it, expect } from 'vitest';
|
|
21
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
22
|
+
import addFormats from 'ajv-formats';
|
|
23
|
+
import { readFileSync } from 'node:fs';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
26
|
+
|
|
27
|
+
function compileWorkflowDefinition(): ReturnType<Ajv2020['compile']> {
|
|
28
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
29
|
+
addFormats(ajv);
|
|
30
|
+
// Register cross-file `$ref` targets — same pattern as
|
|
31
|
+
// `fixtures-valid.test.ts`. Without these, Ajv throws
|
|
32
|
+
// `missingRef` when compiling `workflow-definition.schema.json`
|
|
33
|
+
// because it references agent-ref + prompt-ref by URL.
|
|
34
|
+
const agentRefSchema = JSON.parse(
|
|
35
|
+
readFileSync(join(SCHEMAS_DIR, 'agent-ref.schema.json'), 'utf8'),
|
|
36
|
+
) as Record<string, unknown>;
|
|
37
|
+
const promptRefSchema = JSON.parse(
|
|
38
|
+
readFileSync(join(SCHEMAS_DIR, 'prompt-ref.schema.json'), 'utf8'),
|
|
39
|
+
) as Record<string, unknown>;
|
|
40
|
+
const promptKindSchema = JSON.parse(
|
|
41
|
+
readFileSync(join(SCHEMAS_DIR, 'prompt-kind.schema.json'), 'utf8'),
|
|
42
|
+
) as Record<string, unknown>;
|
|
43
|
+
ajv.addSchema(agentRefSchema, 'agent-ref.schema.json');
|
|
44
|
+
ajv.addSchema(promptRefSchema, 'prompt-ref.schema.json');
|
|
45
|
+
ajv.addSchema(promptRefSchema, './prompt-ref.schema.json');
|
|
46
|
+
ajv.addSchema(promptKindSchema, 'prompt-kind.schema.json');
|
|
47
|
+
ajv.addSchema(promptKindSchema, './prompt-kind.schema.json');
|
|
48
|
+
const schema = JSON.parse(
|
|
49
|
+
readFileSync(join(SCHEMAS_DIR, 'workflow-definition.schema.json'), 'utf8'),
|
|
50
|
+
) as Record<string, unknown>;
|
|
51
|
+
return ajv.compile(schema);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Build the minimal-required shape of a WorkflowDefinition. Tests
|
|
55
|
+
* inject per-case node overrides via the `nodes` arg. */
|
|
56
|
+
function baseDefinition(nodes: Array<Record<string, unknown>>): Record<string, unknown> {
|
|
57
|
+
return {
|
|
58
|
+
id: 'wf-test',
|
|
59
|
+
name: 'Test',
|
|
60
|
+
version: '1.0.0',
|
|
61
|
+
nodes,
|
|
62
|
+
edges: [],
|
|
63
|
+
triggers: [],
|
|
64
|
+
variables: [],
|
|
65
|
+
metadata: { createdAt: '2026-05-25T00:00:00Z' },
|
|
66
|
+
settings: {},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function baseNode(id: string, extras: Record<string, unknown> = {}): Record<string, unknown> {
|
|
71
|
+
return {
|
|
72
|
+
id,
|
|
73
|
+
typeId: 'core.test.noop',
|
|
74
|
+
name: id,
|
|
75
|
+
position: { x: 0, y: 0 },
|
|
76
|
+
config: {},
|
|
77
|
+
inputs: {},
|
|
78
|
+
...extras,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe('workflow-primary-output-annotation: outputRole shape (RFC 0065)', () => {
|
|
83
|
+
const validate = compileWorkflowDefinition();
|
|
84
|
+
|
|
85
|
+
it('accepts a workflow with one node declaring outputRole="primary"', () => {
|
|
86
|
+
const def = baseDefinition([
|
|
87
|
+
baseNode('a', { outputRole: 'primary' }),
|
|
88
|
+
baseNode('b'),
|
|
89
|
+
]);
|
|
90
|
+
const ok = validate(def);
|
|
91
|
+
expect(ok, JSON.stringify(validate.errors, null, 2)).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('accepts primary AND secondary annotations on different nodes', () => {
|
|
95
|
+
const def = baseDefinition([
|
|
96
|
+
baseNode('a', { outputRole: 'primary' }),
|
|
97
|
+
baseNode('b', { outputRole: 'secondary' }),
|
|
98
|
+
baseNode('c'),
|
|
99
|
+
]);
|
|
100
|
+
const ok = validate(def);
|
|
101
|
+
expect(ok, JSON.stringify(validate.errors, null, 2)).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('accepts a workflow with the field absent (additive promise)', () => {
|
|
105
|
+
const def = baseDefinition([
|
|
106
|
+
baseNode('a'),
|
|
107
|
+
baseNode('b'),
|
|
108
|
+
]);
|
|
109
|
+
const ok = validate(def);
|
|
110
|
+
expect(ok, JSON.stringify(validate.errors, null, 2)).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('rejects an unknown outputRole enum value', () => {
|
|
114
|
+
const def = baseDefinition([
|
|
115
|
+
baseNode('a', { outputRole: 'tertiary' }),
|
|
116
|
+
]);
|
|
117
|
+
const ok = validate(def);
|
|
118
|
+
expect(ok).toBe(false);
|
|
119
|
+
expect(validate.errors).toBeTruthy();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('rejects outputRole set to a non-string', () => {
|
|
123
|
+
const def = baseDefinition([
|
|
124
|
+
baseNode('a', { outputRole: 1 }),
|
|
125
|
+
]);
|
|
126
|
+
const ok = validate(def);
|
|
127
|
+
expect(ok).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('permits multiple nodes declaring outputRole="primary" (tooling decides)', () => {
|
|
131
|
+
// The schema doesn't reject multiple primaries — tooling MAY pick
|
|
132
|
+
// any (lexicographic node id is the RFC's recommended tiebreaker).
|
|
133
|
+
// This test pins that the schema-layer doesn't enforce uniqueness,
|
|
134
|
+
// matching the RFC's "schema permits N primaries" promise.
|
|
135
|
+
const def = baseDefinition([
|
|
136
|
+
baseNode('a', { outputRole: 'primary' }),
|
|
137
|
+
baseNode('b', { outputRole: 'primary' }),
|
|
138
|
+
]);
|
|
139
|
+
const ok = validate(def);
|
|
140
|
+
expect(ok, JSON.stringify(validate.errors, null, 2)).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workspace-behavior — RFC 0059 §C/§D behavioral verification for a host
|
|
3
|
+
* advertising `capabilities.workspace.supported: true`.
|
|
4
|
+
*
|
|
5
|
+
* Status: ACTIVE. Capability-gated: every block soft-skips when the host does
|
|
6
|
+
* not advertise the workspace store.
|
|
7
|
+
*
|
|
8
|
+
* What this scenario asserts:
|
|
9
|
+
* 1. §C CRUD round-trip — PUT create (version 1, etag), GET, list (metadata
|
|
10
|
+
* only, no `content`), DELETE, then GET → 404.
|
|
11
|
+
* 2. §C optimistic concurrency — a PUT with a stale `If-Match` MUST return
|
|
12
|
+
* `409 workspace_conflict` with `details.currentVersion`; a matching
|
|
13
|
+
* `If-Match` MUST bump `version`.
|
|
14
|
+
* 3. §C size ceiling — `content` beyond `maxFileBytes` MUST return
|
|
15
|
+
* `workspace_too_large`.
|
|
16
|
+
* 4. §D run snapshot — a run started after a write exposes the workspace
|
|
17
|
+
* read snapshot on its run snapshot.
|
|
18
|
+
*
|
|
19
|
+
* @see RFCS/0059-agent-workspace.md §C §D
|
|
20
|
+
* @see spec/v1/agent-workspace.md §"§C — Endpoints" / §"§D — Run-time exposure"
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, it, expect } from 'vitest';
|
|
24
|
+
import { driver } from '../lib/driver.js';
|
|
25
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
26
|
+
|
|
27
|
+
interface DiscoveryWorkspace {
|
|
28
|
+
supported?: boolean;
|
|
29
|
+
maxFileBytes?: number;
|
|
30
|
+
}
|
|
31
|
+
interface DiscoveryDoc {
|
|
32
|
+
capabilities?: { workspace?: DiscoveryWorkspace };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function workspaceCap(): Promise<DiscoveryWorkspace | null> {
|
|
36
|
+
const res = await driver.get('/.well-known/openwop');
|
|
37
|
+
const ws = capabilityFamily((res.json as DiscoveryDoc | undefined), 'workspace');
|
|
38
|
+
return ws?.supported === true ? ws : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const FILES = '/v1/host/workspace/files';
|
|
42
|
+
|
|
43
|
+
interface WorkspaceFile {
|
|
44
|
+
path: string;
|
|
45
|
+
content?: string;
|
|
46
|
+
version: number;
|
|
47
|
+
etag?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('workspace-behavior: §C CRUD round-trip (RFC 0059)', () => {
|
|
51
|
+
it('PUT creates v1, GET returns it, list omits content, DELETE removes it', async () => {
|
|
52
|
+
if (!(await workspaceCap())) return;
|
|
53
|
+
const path = 'behavior/CRUD.md';
|
|
54
|
+
|
|
55
|
+
const created = await driver.put(`${FILES}/${encodeURIComponent(path)}`, { content: 'first body' });
|
|
56
|
+
expect(created.status, driver.describe('agent-workspace.md §C PUT', 'create MUST return 200')).toBe(200);
|
|
57
|
+
const file = created.json as WorkspaceFile;
|
|
58
|
+
expect(file.version, driver.describe('agent-workspace.md §File model', 'version MUST start at 1')).toBe(1);
|
|
59
|
+
expect(typeof file.etag, driver.describe('agent-workspace.md §File model', 'PUT MUST return an etag')).toBe('string');
|
|
60
|
+
|
|
61
|
+
const got = await driver.get(`${FILES}/${encodeURIComponent(path)}`);
|
|
62
|
+
expect((got.json as WorkspaceFile).content, driver.describe('agent-workspace.md §C GET', 'GET MUST return the content')).toBe('first body');
|
|
63
|
+
|
|
64
|
+
const list = await driver.get(FILES);
|
|
65
|
+
const rows = (list.json as { files?: WorkspaceFile[] }).files ?? [];
|
|
66
|
+
const row = rows.find((r) => r.path === path);
|
|
67
|
+
expect(row, driver.describe('agent-workspace.md §C list', 'list MUST include the created file')).toBeDefined();
|
|
68
|
+
expect('content' in (row as object), driver.describe('agent-workspace.md §C list', 'list MUST NOT include file bodies (metadata only)')).toBe(false);
|
|
69
|
+
|
|
70
|
+
const del = await driver.del(`${FILES}/${encodeURIComponent(path)}`);
|
|
71
|
+
expect(del.status, driver.describe('agent-workspace.md §C DELETE', 'DELETE MUST return 2xx')).toBeGreaterThanOrEqual(200);
|
|
72
|
+
expect(del.status, 'DELETE MUST be < 300').toBeLessThan(300);
|
|
73
|
+
|
|
74
|
+
const gone = await driver.get(`${FILES}/${encodeURIComponent(path)}`);
|
|
75
|
+
expect(gone.status, driver.describe('agent-workspace.md §C GET', 'GET after DELETE MUST be 404')).toBe(404);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('workspace-behavior: §C optimistic concurrency (RFC 0059)', () => {
|
|
80
|
+
it('stale If-Match → 409 workspace_conflict; matching If-Match bumps version', async () => {
|
|
81
|
+
if (!(await workspaceCap())) return;
|
|
82
|
+
const path = 'behavior/ETAG.md';
|
|
83
|
+
|
|
84
|
+
const v1 = await driver.put(`${FILES}/${encodeURIComponent(path)}`, { content: 'v1' });
|
|
85
|
+
const etag = (v1.json as WorkspaceFile).etag!;
|
|
86
|
+
|
|
87
|
+
const stale = await driver.put(`${FILES}/${encodeURIComponent(path)}`, { content: 'nope' }, { headers: { 'If-Match': '"definitely-stale"' } });
|
|
88
|
+
expect(stale.status, driver.describe('agent-workspace.md §C PUT', 'a stale If-Match MUST return 409 workspace_conflict')).toBe(409);
|
|
89
|
+
expect((stale.json as { error?: string }).error, driver.describe('rest-endpoints.md workspace_conflict', 'error code MUST be workspace_conflict')).toBe('workspace_conflict');
|
|
90
|
+
const details = (stale.json as { details?: { currentVersion?: number } }).details;
|
|
91
|
+
expect(typeof details?.currentVersion, driver.describe('agent-workspace.md §C PUT', '409 MUST carry details.currentVersion')).toBe('number');
|
|
92
|
+
|
|
93
|
+
const v2 = await driver.put(`${FILES}/${encodeURIComponent(path)}`, { content: 'v2' }, { headers: { 'If-Match': etag } });
|
|
94
|
+
expect(v2.status, 'a matching If-Match MUST succeed').toBe(200);
|
|
95
|
+
expect((v2.json as WorkspaceFile).version, driver.describe('agent-workspace.md §File model', 'a successful PUT MUST bump version')).toBe(2);
|
|
96
|
+
|
|
97
|
+
await driver.del(`${FILES}/${encodeURIComponent(path)}`);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('workspace-behavior: §C size ceiling (RFC 0059)', () => {
|
|
102
|
+
it('content beyond maxFileBytes returns workspace_too_large', async () => {
|
|
103
|
+
const cap = await workspaceCap();
|
|
104
|
+
if (cap === null) return;
|
|
105
|
+
const max = typeof cap.maxFileBytes === 'number' ? cap.maxFileBytes : 1_048_576;
|
|
106
|
+
const tooBig = 'x'.repeat(max + 1);
|
|
107
|
+
const res = await driver.put(`${FILES}/${encodeURIComponent('behavior/TOOBIG.md')}`, { content: tooBig });
|
|
108
|
+
expect(res.status, driver.describe('agent-workspace.md §C PUT', 'oversize content MUST be rejected (4xx)')).toBeGreaterThanOrEqual(400);
|
|
109
|
+
expect((res.json as { error?: string }).error, driver.describe('rest-endpoints.md workspace_too_large', 'error code MUST be workspace_too_large')).toBe('workspace_too_large');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('workspace-behavior: §D run-start snapshot (RFC 0059)', () => {
|
|
114
|
+
it('a run started after a write exposes the workspace snapshot', async () => {
|
|
115
|
+
if (!(await workspaceCap())) return;
|
|
116
|
+
const path = 'behavior/SNAPSHOT.md';
|
|
117
|
+
await driver.put(`${FILES}/${encodeURIComponent(path)}`, { content: 'snapshot body' });
|
|
118
|
+
|
|
119
|
+
const create = await driver.post('/v1/runs', { workflowId: 'conformance-noop' });
|
|
120
|
+
if (create.status !== 201 && create.status !== 200) return; // host lacks the noop fixture — skip
|
|
121
|
+
const runId = (create.json as { runId?: string }).runId;
|
|
122
|
+
if (runId === undefined) return;
|
|
123
|
+
|
|
124
|
+
const snap = await driver.get(`/v1/runs/${encodeURIComponent(runId)}`);
|
|
125
|
+
const ws = (snap.json as { workspace?: Array<{ path: string }> }).workspace;
|
|
126
|
+
if (ws === undefined) return; // host doesn't expose the snapshot field — skip
|
|
127
|
+
expect(
|
|
128
|
+
ws.some((f) => f.path === path),
|
|
129
|
+
driver.describe('agent-workspace.md §D', 'the run snapshot MUST reflect a workspace file present at run start'),
|
|
130
|
+
).toBe(true);
|
|
131
|
+
|
|
132
|
+
await driver.del(`${FILES}/${encodeURIComponent(path)}`);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workspace-capability-shape — RFC 0059 §A advertisement-shape verification.
|
|
3
|
+
*
|
|
4
|
+
* Status: ACTIVE (advertisement-shape; always runs). RFC 0059 (agent
|
|
5
|
+
* workspace) promoted Draft → Active 2026-05-25 — the `capabilities.workspace`
|
|
6
|
+
* block has landed in `schemas/capabilities.schema.json`.
|
|
7
|
+
*
|
|
8
|
+
* Always runs (shape-only): when the host advertises `capabilities.workspace`,
|
|
9
|
+
* its fields MUST be well-formed.
|
|
10
|
+
*
|
|
11
|
+
* What this scenario asserts:
|
|
12
|
+
* 1. `capabilities.workspace` is either absent or a well-formed object.
|
|
13
|
+
* 2. `supported` is a boolean when the block is present.
|
|
14
|
+
* 3. `maxFileBytes` / `maxFiles` / `maxVersions` are positive integers when present.
|
|
15
|
+
*
|
|
16
|
+
* Behavioral coverage (CRUD / ETag / cross-tenant isolation / run-snapshot)
|
|
17
|
+
* lands at the implementation milestone (RFC 0059 §E), capability-gated on
|
|
18
|
+
* `capabilities.workspace.supported`.
|
|
19
|
+
*
|
|
20
|
+
* @see RFCS/0059-agent-workspace.md §A
|
|
21
|
+
* @see spec/v1/agent-workspace.md §"Capability advertisement"
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { describe, it, expect } from 'vitest';
|
|
25
|
+
import { driver } from '../lib/driver.js';
|
|
26
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
27
|
+
|
|
28
|
+
interface DiscoveryWorkspace {
|
|
29
|
+
supported?: boolean;
|
|
30
|
+
versioned?: boolean;
|
|
31
|
+
maxFileBytes?: number;
|
|
32
|
+
maxFiles?: number;
|
|
33
|
+
maxVersions?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface DiscoveryDoc {
|
|
37
|
+
capabilities?: { workspace?: DiscoveryWorkspace };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function readWorkspace(): Promise<DiscoveryWorkspace | null> {
|
|
41
|
+
const res = await driver.get('/.well-known/openwop');
|
|
42
|
+
const body = res.json as DiscoveryDoc | undefined;
|
|
43
|
+
return capabilityFamily(body, 'workspace') ?? null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const POSITIVE_INT_FIELDS = ['maxFileBytes', 'maxFiles', 'maxVersions'] as const;
|
|
47
|
+
|
|
48
|
+
describe('workspace-capability-shape: advertisement shape (RFC 0059 §A)', () => {
|
|
49
|
+
it('capabilities.workspace is either absent or well-formed', async () => {
|
|
50
|
+
const ws = await readWorkspace();
|
|
51
|
+
if (ws === null) return; // host doesn't advertise workspace at all
|
|
52
|
+
expect(
|
|
53
|
+
typeof ws.supported,
|
|
54
|
+
driver.describe(
|
|
55
|
+
'capabilities.schema.json §workspace',
|
|
56
|
+
'capabilities.workspace.supported MUST be a boolean when workspace is advertised',
|
|
57
|
+
),
|
|
58
|
+
).toBe('boolean');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('maxFileBytes / maxFiles / maxVersions are positive integers when present', async () => {
|
|
62
|
+
const ws = await readWorkspace();
|
|
63
|
+
if (ws === null) return;
|
|
64
|
+
for (const field of POSITIVE_INT_FIELDS) {
|
|
65
|
+
const v = ws[field];
|
|
66
|
+
if (v === undefined) continue;
|
|
67
|
+
expect(
|
|
68
|
+
Number.isInteger(v) && v >= 1,
|
|
69
|
+
driver.describe('RFC 0059 §A', `capabilities.workspace.${field} MUST be an integer >= 1, got: ${v}`),
|
|
70
|
+
).toBe(true);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|