@openwop/openwop-conformance 1.6.1 → 1.10.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 +10 -0
- package/README.md +2 -2
- package/api/asyncapi.yaml +57 -0
- package/api/openapi.yaml +250 -0
- package/coverage.md +14 -0
- package/fixtures/conformance-run-duration-breach.json +33 -0
- package/fixtures.md +19 -0
- package/package.json +1 -1
- package/schemas/README.md +10 -0
- package/schemas/agent-inventory-response.schema.json +90 -0
- package/schemas/ai-envelope.schema.json +28 -0
- package/schemas/artifact-type-pack-manifest.schema.json +160 -0
- package/schemas/capabilities.schema.json +171 -4
- package/schemas/chat-card-pack-manifest.schema.json +158 -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/heartbeat-evaluated.schema.json +14 -0
- package/schemas/heartbeat-state-changed.schema.json +14 -0
- package/schemas/node-pack-manifest.schema.json +16 -1
- package/schemas/run-event-payloads.schema.json +96 -5
- package/schemas/run-event.schema.json +4 -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/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/memoryAttribution.ts +48 -0
- package/src/lib/subRunAttestation.ts +35 -0
- package/src/lib/toolHooks.ts +33 -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/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/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/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-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-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/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 +1 -1
- 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-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/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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workspace-cross-tenant-isolation — RFC 0059 §E WCT-1.
|
|
3
|
+
*
|
|
4
|
+
* Status: ACTIVE (advertisement + behavioral). Public test for the
|
|
5
|
+
* `workspace-cross-tenant-isolation` SECURITY invariant: a workspace file
|
|
6
|
+
* owned by `{tenant, workspace}` MUST NOT be readable (get or list) under a
|
|
7
|
+
* different `{tenant′, workspace′}`, regardless of the caller's permissions
|
|
8
|
+
* elsewhere. Mirrors `kv-cross-tenant-isolation` / `agent-memory-cti-1`.
|
|
9
|
+
*
|
|
10
|
+
* The two owners are driven through the documented test seam
|
|
11
|
+
* `POST /v1/host/sample/workspace/op` (host-sample-test-seams.md §9), which
|
|
12
|
+
* lets a single-credential host exercise distinct owners. Hosts without the
|
|
13
|
+
* seam soft-skip the behavioral probe; the advertisement-shape assertion still
|
|
14
|
+
* runs whenever `capabilities.workspace.supported` is advertised.
|
|
15
|
+
*
|
|
16
|
+
* @see RFCS/0059-agent-workspace.md §E WCT-1
|
|
17
|
+
* @see spec/v1/agent-workspace.md §"§E — Invariants"
|
|
18
|
+
* @see SECURITY/invariants.yaml workspace-cross-tenant-isolation
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, it, expect } from 'vitest';
|
|
22
|
+
import { driver } from '../lib/driver.js';
|
|
23
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
24
|
+
|
|
25
|
+
interface DiscoveryDoc {
|
|
26
|
+
capabilities?: { workspace?: { supported?: boolean } };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function workspaceSupported(): Promise<boolean> {
|
|
30
|
+
const res = await driver.get('/.well-known/openwop');
|
|
31
|
+
const body = res.json as DiscoveryDoc | undefined;
|
|
32
|
+
return capabilityFamily(body, 'workspace')?.supported === true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function seam(args: Record<string, unknown>) {
|
|
36
|
+
return driver.post('/v1/host/sample/workspace/op', args);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const SEAM_PATH = 'WCT1-SECRET.md';
|
|
40
|
+
|
|
41
|
+
describe('workspace-cross-tenant-isolation: a workspace file MUST NOT leak across owners (RFC 0059 §E WCT-1)', () => {
|
|
42
|
+
it('a file written under {tenant A, workspace A} is not readable under a different owner', async () => {
|
|
43
|
+
if (!(await workspaceSupported())) return; // capability not advertised — skip
|
|
44
|
+
|
|
45
|
+
// Owner A writes a file.
|
|
46
|
+
const put = await seam({ tenant: 'wct1-tenant-a', workspace: 'ws-a', op: 'put', path: SEAM_PATH, content: 'A-only secret body' });
|
|
47
|
+
if (put.status === 404) return; // seam unwired — soft-skip the behavioral probe
|
|
48
|
+
expect(put.status, driver.describe('agent-workspace.md §C PUT', 'seam put MUST succeed for the owning workspace')).toBe(200);
|
|
49
|
+
|
|
50
|
+
// A DIFFERENT workspace (same tenant) MUST NOT read it.
|
|
51
|
+
const crossWs = await seam({ tenant: 'wct1-tenant-a', workspace: 'ws-b', op: 'get', path: SEAM_PATH });
|
|
52
|
+
expect(
|
|
53
|
+
crossWs.status === 404 || crossWs.status === 403,
|
|
54
|
+
driver.describe('agent-workspace.md §E WCT-1', `a cross-workspace get MUST fail closed (404/403, no existence leak), got ${crossWs.status}`),
|
|
55
|
+
).toBe(true);
|
|
56
|
+
const crossWsBody = JSON.stringify(crossWs.json ?? '');
|
|
57
|
+
expect(
|
|
58
|
+
!crossWsBody.includes('A-only secret body'),
|
|
59
|
+
driver.describe('agent-workspace.md §E WCT-1', 'a cross-workspace read MUST NOT surface the other owner\'s content'),
|
|
60
|
+
).toBe(true);
|
|
61
|
+
|
|
62
|
+
// A DIFFERENT tenant MUST NOT read it either.
|
|
63
|
+
const crossTenant = await seam({ tenant: 'wct1-tenant-b', workspace: 'ws-a', op: 'get', path: SEAM_PATH });
|
|
64
|
+
expect(
|
|
65
|
+
crossTenant.status === 404 || crossTenant.status === 403,
|
|
66
|
+
driver.describe('agent-workspace.md §E WCT-1', `a cross-tenant get MUST fail closed, got ${crossTenant.status}`),
|
|
67
|
+
).toBe(true);
|
|
68
|
+
|
|
69
|
+
// And list MUST NOT enumerate the other owner's path.
|
|
70
|
+
const crossList = await seam({ tenant: 'wct1-tenant-a', workspace: 'ws-b', op: 'list' });
|
|
71
|
+
const listed = JSON.stringify((crossList.json as { files?: unknown })?.files ?? []);
|
|
72
|
+
expect(
|
|
73
|
+
!listed.includes(SEAM_PATH),
|
|
74
|
+
driver.describe('agent-workspace.md §E WCT-1', 'a cross-workspace list MUST NOT enumerate another owner\'s file'),
|
|
75
|
+
).toBe(true);
|
|
76
|
+
|
|
77
|
+
// Sanity: the owner itself still reads its file (isolation, not loss).
|
|
78
|
+
const ownerRead = await seam({ tenant: 'wct1-tenant-a', workspace: 'ws-a', op: 'get', path: SEAM_PATH });
|
|
79
|
+
expect(
|
|
80
|
+
(ownerRead.json as { content?: string } | undefined)?.content,
|
|
81
|
+
driver.describe('agent-workspace.md §C GET', 'the owning workspace MUST still read its own file'),
|
|
82
|
+
).toBe('A-only secret body');
|
|
83
|
+
});
|
|
84
|
+
});
|