@openwop/openwop-conformance 1.1.0 → 1.2.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 +25 -0
- package/README.md +2 -2
- package/coverage.md +29 -17
- package/fixtures/conformance-agent-low-confidence.json +7 -4
- package/fixtures/conformance-agent-pack-handoff-schema-validation.json +30 -0
- package/fixtures/conformance-agent-reasoning.json +23 -4
- package/fixtures/conformance-dispatch-cross-worker-handoff-child-a.json +27 -0
- package/fixtures/conformance-dispatch-cross-worker-handoff-child-b.json +25 -0
- package/fixtures/conformance-dispatch-cross-worker-handoff.json +60 -0
- package/fixtures/conformance-dispatch-input-mapping-child.json +25 -0
- package/fixtures/conformance-dispatch-input-mapping.json +49 -0
- package/fixtures/conformance-dispatch-output-mapping-child.json +27 -0
- package/fixtures/conformance-dispatch-output-mapping.json +49 -0
- package/fixtures/conformance-subworkflow-input-mapping-child.json +27 -0
- package/fixtures/conformance-subworkflow-input-mapping.json +33 -0
- package/fixtures.md +12 -2
- package/package.json +1 -1
- package/schemas/README.md +7 -0
- package/schemas/agent-ref.schema.json +1 -1
- package/schemas/ai-envelope.schema.json +106 -0
- package/schemas/capabilities.schema.json +300 -3
- package/schemas/core-conformance-mock-agent-config.schema.json +147 -0
- package/schemas/dispatch-config.schema.json +26 -0
- package/schemas/envelopes/clarification.request.schema.json +43 -0
- package/schemas/envelopes/error.schema.json +26 -0
- package/schemas/envelopes/schema.request.schema.json +22 -0
- package/schemas/envelopes/schema.response.schema.json +22 -0
- package/schemas/node-pack-manifest.schema.json +5 -0
- package/schemas/pack-lockfile.schema.json +16 -0
- package/schemas/run-event-payloads.schema.json +18 -2
- package/schemas/run-event.schema.json +2 -1
- package/schemas/workflow-chain-pack-manifest.schema.json +226 -0
- package/src/lib/behavior-gate.ts +44 -5
- package/src/lib/env.ts +27 -0
- package/src/lib/webhook-receiver.ts +137 -0
- package/src/lib/workflow-chain-expansion.ts +213 -0
- package/src/scenarios/agentPackCatalog.test.ts +216 -0
- package/src/scenarios/agentPackHandoffSchemaValidation.test.ts +146 -0
- package/src/scenarios/agentReasoningEvents.test.ts +58 -7
- package/src/scenarios/agents-run-tool-allowlist.test.ts +182 -0
- package/src/scenarios/ai-envelope-shape.test.ts +362 -0
- package/src/scenarios/aiEnvelope.capBreached.test.ts +173 -0
- package/src/scenarios/aiEnvelope.contractRefusal.test.ts +150 -0
- package/src/scenarios/aiEnvelope.correlationReplay.test.ts +69 -0
- package/src/scenarios/aiEnvelope.redaction.test.ts +73 -0
- package/src/scenarios/aiEnvelope.schemaDrift.test.ts +87 -0
- package/src/scenarios/aiEnvelope.trustBoundaryPropagation.test.ts +143 -0
- package/src/scenarios/aiEnvelope.universalKinds.test.ts +176 -0
- package/src/scenarios/append-ordering.test.ts +44 -0
- package/src/scenarios/artifact-auth.test.ts +58 -0
- package/src/scenarios/blob-cross-tenant-isolation.test.ts +66 -0
- package/src/scenarios/blob-presign-expiry.test.ts +66 -0
- package/src/scenarios/blob-roundtrip.test.ts +48 -0
- package/src/scenarios/cache-cross-tenant-isolation.test.ts +61 -0
- package/src/scenarios/cache-ttl-expiry.test.ts +47 -0
- package/src/scenarios/dispatch-cross-worker-handoff.test.ts +98 -0
- package/src/scenarios/dispatch-input-mapping.test.ts +94 -0
- package/src/scenarios/dispatch-output-mapping.test.ts +65 -0
- package/src/scenarios/fs-path-traversal.test.ts +124 -0
- package/src/scenarios/idempotency-key-determinism.test.ts +230 -0
- package/src/scenarios/interrupt-token-matrix.test.ts +126 -0
- package/src/scenarios/kv-atomic-increment.test.ts +74 -0
- package/src/scenarios/kv-cas.test.ts +75 -0
- package/src/scenarios/kv-cross-tenant-isolation.test.ts +85 -0
- package/src/scenarios/kv-ttl-expiry.test.ts +47 -0
- package/src/scenarios/mcp-server-elicitation-bridge.test.ts +92 -0
- package/src/scenarios/mcp-server-prompt-roundtrip.test.ts +80 -0
- package/src/scenarios/mcp-server-resource-roundtrip.test.ts +82 -0
- package/src/scenarios/mcp-server-sampling-bridge.test.ts +84 -0
- package/src/scenarios/mcp-server-tool-roundtrip.test.ts +107 -0
- package/src/scenarios/mcp-server-untrusted-args.test.ts +105 -0
- package/src/scenarios/mcp-tool-roundtrip.test.ts +13 -6
- package/src/scenarios/memory-compaction-event-emitted.test.ts +121 -0
- package/src/scenarios/memory-compaction-provenance-tag.test.ts +116 -0
- package/src/scenarios/memory-compaction-sr1-carry-forward.test.ts +127 -0
- package/src/scenarios/multi-region-idempotency.test.ts +39 -4
- package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +139 -0
- package/src/scenarios/pause-resume.test.ts +43 -0
- package/src/scenarios/queue-ack-nack-dlq.test.ts +67 -0
- package/src/scenarios/queue-cross-tenant-isolation.test.ts +66 -0
- package/src/scenarios/queue-publish-consume-roundtrip.test.ts +48 -0
- package/src/scenarios/registry-public.test.ts +91 -0
- package/src/scenarios/search-bm25-roundtrip.test.ts +47 -0
- package/src/scenarios/spec-corpus-validity.test.ts +28 -7
- package/src/scenarios/sql-injection-rejection.test.ts +84 -0
- package/src/scenarios/sql-transaction-atomicity.test.ts +66 -0
- package/src/scenarios/stream-subscribe-from-beginning.test.ts +66 -0
- package/src/scenarios/subworkflow-input-mapping.test.ts +100 -0
- package/src/scenarios/table-cross-tenant-isolation.test.ts +65 -0
- package/src/scenarios/table-cursor-pagination.test.ts +47 -0
- package/src/scenarios/table-schema-enforcement.test.ts +47 -0
- package/src/scenarios/vector-knn-roundtrip.test.ts +48 -0
- package/src/scenarios/webhook-receiver-adversarial.test.ts +210 -0
- package/src/scenarios/workflow-chain-expansion.test.ts +366 -0
- package/src/scenarios/workflow-chain-pack-manifest-validation.test.ts +232 -0
- package/src/scenarios/workflow-chain-pack-signature-verification.test.ts +138 -0
- package/src/scenarios/workflow-chain-unresolvable-typeid.test.ts +170 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp-server-untrusted-args — RFC 0020 §D + SECURITY/invariants.yaml
|
|
3
|
+
* `mcp-server-untrusted-args`.
|
|
4
|
+
*
|
|
5
|
+
* Status: ACTIVE (advertisement + behavioral). Asserts that tools/call
|
|
6
|
+
* with arguments violating the registered inputSchema is rejected with
|
|
7
|
+
* JSON-RPC `-32602 invalid params` BEFORE any workflow side-effects.
|
|
8
|
+
*
|
|
9
|
+
* @see RFCS/0020-host-mcp-server-composition.md
|
|
10
|
+
* @see SECURITY/invariants.yaml — mcp-server-untrusted-args
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from 'vitest';
|
|
14
|
+
import { driver } from '../lib/driver.js';
|
|
15
|
+
|
|
16
|
+
interface DiscoveryDoc {
|
|
17
|
+
capabilities?: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function readCap(): Promise<Record<string, unknown> | null> {
|
|
21
|
+
const res = await driver.get('/.well-known/openwop');
|
|
22
|
+
const body = res.json as DiscoveryDoc | undefined;
|
|
23
|
+
const top = body?.capabilities as Record<string, unknown> | undefined;
|
|
24
|
+
const cur = (top && typeof top === 'object') ? (top as Record<string, unknown>)["mcp"] : undefined;
|
|
25
|
+
const final = (cur && typeof cur === 'object') ? (cur as Record<string, unknown>)["serverMount"] : undefined;
|
|
26
|
+
return (final && typeof final === 'object' ? (final as Record<string, unknown>) : null);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function rpc(method: string, params?: Record<string, unknown>) {
|
|
30
|
+
const id = Math.floor(Math.random() * 1e6);
|
|
31
|
+
const req: Record<string, unknown> = { jsonrpc: '2.0', id, method };
|
|
32
|
+
if (params !== undefined) req.params = params;
|
|
33
|
+
const res = await driver.post('/v1/host/sample/mcp', req);
|
|
34
|
+
return { status: res.status, body: res.json as { result?: unknown; error?: { code: number; message: string; data?: unknown } } };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const TEST_TOOL_NAME = `inj_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
|
38
|
+
|
|
39
|
+
async function registerStrictWorkflow(): Promise<boolean> {
|
|
40
|
+
const res = await driver.post('/v1/host/sample/workflows', {
|
|
41
|
+
workflowId: `mcp.untrusted.${Date.now()}`,
|
|
42
|
+
nodes: [
|
|
43
|
+
{
|
|
44
|
+
nodeId: 'expose',
|
|
45
|
+
typeId: 'core.openwop.mcp.expose-tool',
|
|
46
|
+
config: {
|
|
47
|
+
name: TEST_TOOL_NAME,
|
|
48
|
+
description: 'Strict-schema tool',
|
|
49
|
+
inputSchema: {
|
|
50
|
+
type: 'object',
|
|
51
|
+
properties: { text: { type: 'string' } },
|
|
52
|
+
required: ['text'],
|
|
53
|
+
additionalProperties: false,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
});
|
|
59
|
+
return res.status === 200 || res.status === 201;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('mcp-server-untrusted-args: advertisement shape (RFC 0020)', () => {
|
|
63
|
+
it('capabilities.mcp.serverMount is well-formed when present', async () => {
|
|
64
|
+
const cap = await readCap();
|
|
65
|
+
if (cap === null) return;
|
|
66
|
+
expect(typeof cap.supported).toBe('boolean');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('mcp-server-untrusted-args: behavioral (RFC 0020 §D)', () => {
|
|
71
|
+
it('tools/call with malformed arguments is rejected with JSON-RPC -32602 BEFORE workflow start', async () => {
|
|
72
|
+
const cap = await readCap();
|
|
73
|
+
if (!cap || cap.supported !== true) return;
|
|
74
|
+
if (!(await registerStrictWorkflow())) return;
|
|
75
|
+
|
|
76
|
+
const r = await rpc('tools/call', {
|
|
77
|
+
name: TEST_TOOL_NAME,
|
|
78
|
+
arguments: { wrongField: 'no' },
|
|
79
|
+
});
|
|
80
|
+
if (r.status === 404) return;
|
|
81
|
+
expect(r.status, 'JSON-RPC envelope MUST 200').toBe(200);
|
|
82
|
+
expect(
|
|
83
|
+
r.body.error?.code,
|
|
84
|
+
driver.describe(
|
|
85
|
+
'SECURITY/invariants.yaml mcp-server-untrusted-args',
|
|
86
|
+
'malformed arguments MUST be rejected with -32602 invalid params before workflow start',
|
|
87
|
+
),
|
|
88
|
+
).toBe(-32602);
|
|
89
|
+
expect(r.body.error?.data, 'error.data MUST carry validation violations').toBeDefined();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('tools/call with valid arguments is accepted', async () => {
|
|
93
|
+
const cap = await readCap();
|
|
94
|
+
if (!cap || cap.supported !== true) return;
|
|
95
|
+
const r = await rpc('tools/call', {
|
|
96
|
+
name: TEST_TOOL_NAME,
|
|
97
|
+
arguments: { text: 'hello' },
|
|
98
|
+
});
|
|
99
|
+
if (r.status === 404) return;
|
|
100
|
+
expect(r.status).toBe(200);
|
|
101
|
+
if (r.body.error) {
|
|
102
|
+
expect(r.body.error.code, 'valid args MUST NOT trigger -32602').not.toBe(-32602);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -32,15 +32,22 @@
|
|
|
32
32
|
* probe at a real MCP server. Auto-detects the transport from the
|
|
33
33
|
* server's `Content-Type` response header:
|
|
34
34
|
* - `application/json` → single-JSON response, parsed as one
|
|
35
|
-
* JSON-RPC frame.
|
|
35
|
+
* JSON-RPC frame. Verified end-to-end against
|
|
36
|
+
* `@modelcontextprotocol/sdk@1.29.0` `enableJsonResponse: true`
|
|
37
|
+
* streamable-http servers (2026-05-12).
|
|
36
38
|
* - `text/event-stream` → streamable-http+SSE; the probe reads
|
|
37
39
|
* SSE frames until it finds one whose `data:` payload matches
|
|
38
|
-
* the JSON-RPC `id` we sent, then returns that frame.
|
|
40
|
+
* the JSON-RPC `id` we sent, then returns that frame. Verified
|
|
41
|
+
* end-to-end against `@modelcontextprotocol/sdk@1.29.0`
|
|
42
|
+
* streamable-http servers WITHOUT `enableJsonResponse`
|
|
43
|
+
* (2026-05-13).
|
|
39
44
|
* The stdio transport (default for `modelcontextprotocol/servers`
|
|
40
|
-
* reference servers) is
|
|
41
|
-
* process speaking JSON-RPC over stdin/stdout, no HTTP
|
|
42
|
-
*
|
|
43
|
-
*
|
|
45
|
+
* reference servers) is HTTP-incompatible by design — those run
|
|
46
|
+
* as a child process speaking JSON-RPC over stdin/stdout, no HTTP
|
|
47
|
+
* endpoint. Operators collecting interop evidence against stdio
|
|
48
|
+
* servers run them under the documented HTTP-to-stdio bridge at
|
|
49
|
+
* `examples/mcp-stdio-bridge/` (verified end-to-end 2026-05-13;
|
|
50
|
+
* probe + bridge + `echo-stdio-server.mjs` round-trip passes 2/2).
|
|
44
51
|
* Assertions stay shape-only: tools/list returns ≥1 tool, a
|
|
45
52
|
* tools/call returns valid MCP content (a `result.content` array,
|
|
46
53
|
* possibly `isError: true` — both are spec-conformant).
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0012 §B — `memory.compacted` event emission shape.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that hosts advertising
|
|
5
|
+
* `capabilities.memory.compaction.supported: true` emit a canonical
|
|
6
|
+
* `memory.compacted` event payload per `run-event-payloads.schema.json`
|
|
7
|
+
* §`memoryCompacted` whenever a compaction run completes.
|
|
8
|
+
*
|
|
9
|
+
* Required fields per the schema:
|
|
10
|
+
* - `memoryRef` (string, non-empty)
|
|
11
|
+
* - `outputId` (string, non-empty)
|
|
12
|
+
* - `sourceCount` (integer ≥ 1)
|
|
13
|
+
* - `trigger` (closed enum: `host-managed | client-requested | both`)
|
|
14
|
+
* - `byteSize` (integer ≥ 0)
|
|
15
|
+
*
|
|
16
|
+
* Optional:
|
|
17
|
+
* - `sourceIds` (array of non-empty strings; exhaustive within the
|
|
18
|
+
* array — no "and N more" semantics — when present)
|
|
19
|
+
*
|
|
20
|
+
* Gating identical to `memory-compaction-sr1-carry-forward.test.ts`:
|
|
21
|
+
* capability advertisement + test seam reachable.
|
|
22
|
+
*
|
|
23
|
+
* @see RFCS/0012-memory-compaction-profile.md §B
|
|
24
|
+
* @see schemas/run-event-payloads.schema.json §memoryCompacted
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { describe, it, expect } from 'vitest';
|
|
28
|
+
import { driver } from '../lib/driver.js';
|
|
29
|
+
|
|
30
|
+
const MEMORY_REF = 'mem_tenant:default_agent:conformance-rfc0012-event_longTerm';
|
|
31
|
+
|
|
32
|
+
interface MemoryCaps {
|
|
33
|
+
compaction?: { supported?: boolean };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function isCompactionAdvertised(): Promise<boolean> {
|
|
37
|
+
const disco = await driver.get('/.well-known/openwop');
|
|
38
|
+
const memory = (disco.json as { capabilities?: { memory?: MemoryCaps } }).capabilities?.memory;
|
|
39
|
+
return memory?.compaction?.supported === true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function isTestSeamReachable(): Promise<boolean> {
|
|
43
|
+
const r = await driver.post('/v1/test/memory/compact', {});
|
|
44
|
+
return r.status !== 404;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('memory-compaction-event-emitted: canonical memory.compacted payload shape', () => {
|
|
48
|
+
it('compaction run returns a canonical memoryCompacted payload', async () => {
|
|
49
|
+
if (!(await isCompactionAdvertised())) {
|
|
50
|
+
// eslint-disable-next-line no-console
|
|
51
|
+
console.warn('[rfc0012-event] capabilities.memory.compaction.supported not advertised; skipping');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (!(await isTestSeamReachable())) {
|
|
55
|
+
// eslint-disable-next-line no-console
|
|
56
|
+
console.warn('[rfc0012-event] test seam unreachable; skipping');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const seed = await driver.post('/v1/test/memory/seed', {
|
|
61
|
+
memoryRef: MEMORY_REF,
|
|
62
|
+
entries: [
|
|
63
|
+
{ id: `event-src-${Date.now()}-a`, content: 'First memory entry.' },
|
|
64
|
+
{ id: `event-src-${Date.now()}-b`, content: 'Second memory entry.' },
|
|
65
|
+
{ id: `event-src-${Date.now()}-c`, content: 'Third memory entry.' },
|
|
66
|
+
],
|
|
67
|
+
});
|
|
68
|
+
expect(seed.status).toBe(201);
|
|
69
|
+
|
|
70
|
+
const compactRes = await driver.post('/v1/test/memory/compact', {
|
|
71
|
+
memoryRef: MEMORY_REF,
|
|
72
|
+
});
|
|
73
|
+
expect(compactRes.status).toBe(200);
|
|
74
|
+
|
|
75
|
+
const event = compactRes.json as {
|
|
76
|
+
type?: string;
|
|
77
|
+
payload?: Record<string, unknown>;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
expect(event.type, driver.describe(
|
|
81
|
+
'observability.md §"Canonical run lifecycle event names"',
|
|
82
|
+
'event MUST be type=memory.compacted',
|
|
83
|
+
)).toBe('memory.compacted');
|
|
84
|
+
|
|
85
|
+
const payload = event.payload ?? {};
|
|
86
|
+
|
|
87
|
+
expect(typeof payload.memoryRef, driver.describe(
|
|
88
|
+
'RFC 0012 §B / run-event-payloads.schema.json §memoryCompacted',
|
|
89
|
+
'memoryRef MUST be a non-empty string',
|
|
90
|
+
)).toBe('string');
|
|
91
|
+
expect((payload.memoryRef as string).length).toBeGreaterThan(0);
|
|
92
|
+
|
|
93
|
+
expect(typeof payload.outputId, driver.describe(
|
|
94
|
+
'RFC 0012 §B',
|
|
95
|
+
'outputId MUST be a string identifying the distilled entry',
|
|
96
|
+
)).toBe('string');
|
|
97
|
+
expect((payload.outputId as string).length).toBeGreaterThan(0);
|
|
98
|
+
|
|
99
|
+
expect(Number.isInteger(payload.sourceCount), driver.describe(
|
|
100
|
+
'RFC 0012 §B',
|
|
101
|
+
'sourceCount MUST be an integer',
|
|
102
|
+
)).toBe(true);
|
|
103
|
+
expect(payload.sourceCount as number).toBeGreaterThanOrEqual(1);
|
|
104
|
+
|
|
105
|
+
expect(['host-managed', 'client-requested', 'both']).toContain(payload.trigger);
|
|
106
|
+
|
|
107
|
+
expect(Number.isInteger(payload.byteSize), driver.describe(
|
|
108
|
+
'RFC 0012 §B',
|
|
109
|
+
'byteSize MUST be an integer',
|
|
110
|
+
)).toBe(true);
|
|
111
|
+
expect(payload.byteSize as number).toBeGreaterThanOrEqual(0);
|
|
112
|
+
|
|
113
|
+
if (payload.sourceIds !== undefined) {
|
|
114
|
+
expect(Array.isArray(payload.sourceIds)).toBe(true);
|
|
115
|
+
for (const id of payload.sourceIds as unknown[]) {
|
|
116
|
+
expect(typeof id, 'sourceIds entries MUST be strings').toBe('string');
|
|
117
|
+
expect((id as string).length).toBeGreaterThan(0);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0012 §C — `compacted-from:<id>` provenance tag convention.
|
|
3
|
+
*
|
|
4
|
+
* The distilled entry SHOULD (not MUST) carry a tag of the form
|
|
5
|
+
* `compacted-from:<compactionRunId>` where `<compactionRunId>` is a
|
|
6
|
+
* host-issued opaque identifier. This lets `MemoryAdapter.list`
|
|
7
|
+
* consumers detect compacted entries without needing access to the
|
|
8
|
+
* `memory.compacted` event stream.
|
|
9
|
+
*
|
|
10
|
+
* SOFT ASSERTION: log-and-warn if absent, fail only if a present tag
|
|
11
|
+
* is malformed. Hosts with structurally-constrained tag-spaces (legacy
|
|
12
|
+
* tag-prefix discipline, fixed-vocabulary tagging) MAY omit this —
|
|
13
|
+
* the `memory.compacted` event itself remains the canonical provenance
|
|
14
|
+
* signal.
|
|
15
|
+
*
|
|
16
|
+
* Gating identical to the other RFC 0012 scenarios.
|
|
17
|
+
*
|
|
18
|
+
* @see RFCS/0012-memory-compaction-profile.md §C
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, it, expect } from 'vitest';
|
|
22
|
+
import { driver } from '../lib/driver.js';
|
|
23
|
+
|
|
24
|
+
const MEMORY_REF = 'mem_tenant:default_agent:conformance-rfc0012-tag_longTerm';
|
|
25
|
+
const COMPACTED_FROM_RE = /^compacted-from:[^\s:][^\s]*$/;
|
|
26
|
+
|
|
27
|
+
interface MemoryCaps {
|
|
28
|
+
compaction?: { supported?: boolean };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface MemoryListResponse {
|
|
32
|
+
entries?: Array<{ id?: string; tags?: string[] }>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function isCompactionAdvertised(): Promise<boolean> {
|
|
36
|
+
const disco = await driver.get('/.well-known/openwop');
|
|
37
|
+
const memory = (disco.json as { capabilities?: { memory?: MemoryCaps } }).capabilities?.memory;
|
|
38
|
+
return memory?.compaction?.supported === true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function isTestSeamReachable(): Promise<boolean> {
|
|
42
|
+
const r = await driver.post('/v1/test/memory/compact', {});
|
|
43
|
+
return r.status !== 404;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('memory-compaction-provenance-tag: compacted-from:<id> tag follows §C convention', () => {
|
|
47
|
+
it('compacted entry carries a well-formed compacted-from tag, OR omits it cleanly (no malformed tags)', async () => {
|
|
48
|
+
if (!(await isCompactionAdvertised())) {
|
|
49
|
+
// eslint-disable-next-line no-console
|
|
50
|
+
console.warn('[rfc0012-tag] capabilities.memory.compaction.supported not advertised; skipping');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (!(await isTestSeamReachable())) {
|
|
54
|
+
// eslint-disable-next-line no-console
|
|
55
|
+
console.warn('[rfc0012-tag] test seam unreachable; skipping');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Seed + compact.
|
|
60
|
+
const seedStamp = Date.now();
|
|
61
|
+
const seed = await driver.post('/v1/test/memory/seed', {
|
|
62
|
+
memoryRef: MEMORY_REF,
|
|
63
|
+
entries: [
|
|
64
|
+
{ id: `tag-src-${seedStamp}-1`, content: 'Source content alpha.' },
|
|
65
|
+
{ id: `tag-src-${seedStamp}-2`, content: 'Source content beta.' },
|
|
66
|
+
],
|
|
67
|
+
});
|
|
68
|
+
expect(seed.status).toBe(201);
|
|
69
|
+
|
|
70
|
+
const compactRes = await driver.post('/v1/test/memory/compact', {
|
|
71
|
+
memoryRef: MEMORY_REF,
|
|
72
|
+
});
|
|
73
|
+
expect(compactRes.status).toBe(200);
|
|
74
|
+
|
|
75
|
+
const event = compactRes.json as { payload?: { outputId?: string } };
|
|
76
|
+
const outputId = event.payload?.outputId;
|
|
77
|
+
expect(typeof outputId).toBe('string');
|
|
78
|
+
|
|
79
|
+
// Resolve the entry via the wire MemoryAdapter list surface (no
|
|
80
|
+
// direct get-by-id wire endpoint; we filter list results).
|
|
81
|
+
// Hosts that don't expose memory:list on the wire skip — this is
|
|
82
|
+
// a `MemoryAdapter.list` surface check, which the canonical
|
|
83
|
+
// capabilities.memory.supported claim already covers.
|
|
84
|
+
const listRes = await driver.get(
|
|
85
|
+
`/v1/memory/${encodeURIComponent(MEMORY_REF)}?limit=50`,
|
|
86
|
+
);
|
|
87
|
+
if (listRes.status === 404) {
|
|
88
|
+
// eslint-disable-next-line no-console
|
|
89
|
+
console.warn('[rfc0012-tag] host does not expose memory:list at /v1/memory/{ref}; skipping tag inspection (canonical provenance signal remains the memory.compacted event itself)');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
expect(listRes.status, 'memory:list MUST return 200 when reachable').toBe(200);
|
|
93
|
+
|
|
94
|
+
const body = (listRes.json as MemoryListResponse) ?? {};
|
|
95
|
+
const entries = body.entries ?? [];
|
|
96
|
+
const output = entries.find((e) => e.id === outputId);
|
|
97
|
+
if (!output) {
|
|
98
|
+
// eslint-disable-next-line no-console
|
|
99
|
+
console.warn(`[rfc0012-tag] outputId ${outputId} not visible via memory:list; cannot inspect tags`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const tags = output.tags ?? [];
|
|
103
|
+
|
|
104
|
+
// RFC 0012 §C: SHOULD-tag, soft assertion.
|
|
105
|
+
const provenance = tags.find((t) => t.startsWith('compacted-from:'));
|
|
106
|
+
if (provenance === undefined) {
|
|
107
|
+
// eslint-disable-next-line no-console
|
|
108
|
+
console.warn('[rfc0012-tag] output entry has no compacted-from:<id> tag — RFC 0012 §C is SHOULD, not MUST; pass with warning');
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
expect(provenance, driver.describe(
|
|
112
|
+
'RFC 0012 §C',
|
|
113
|
+
'compacted-from tag MUST match `compacted-from:<id>` shape (non-empty id, no whitespace) when present',
|
|
114
|
+
)).toMatch(COMPACTED_FROM_RE);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0012 §D — SR-1 carry-forward through memory compaction.
|
|
3
|
+
*
|
|
4
|
+
* Verifies the load-bearing security claim of RFC 0012 (Memory
|
|
5
|
+
* Compaction Profile, `Active` 2026-05-13 — comment window closes
|
|
6
|
+
* 2026-05-20): when a host advertising
|
|
7
|
+
* `capabilities.memory.compaction.supported: true` produces a
|
|
8
|
+
* compacted `MemoryEntry`, the derived content MUST pass the
|
|
9
|
+
* same BYOK redaction harness as a fresh `put`. The fact that
|
|
10
|
+
* source entries were SR-1-compliant at original `put` time is
|
|
11
|
+
* NOT evidence to skip redaction on derived content — summarization
|
|
12
|
+
* models can introduce secret-shaped substrings (hallucinated
|
|
13
|
+
* tokens, format-leaks from in-context examples) not present in
|
|
14
|
+
* any source.
|
|
15
|
+
*
|
|
16
|
+
* Gating:
|
|
17
|
+
* - `capabilities.memory.compaction.supported` MUST be `true`.
|
|
18
|
+
* - Host MUST expose the test seam at `POST /v1/test/memory/{seed,
|
|
19
|
+
* compact}` — gated on the host's `OPENWOP_TEST_TRIGGER_COMPACTION`
|
|
20
|
+
* env var. Without it the scenario can't synchronously drive
|
|
21
|
+
* compaction (RFC 0012 normates only `trigger: 'host-managed'`).
|
|
22
|
+
* The seam itself is host-implementation-specific; the conformance
|
|
23
|
+
* suite skips when the seam isn't reachable.
|
|
24
|
+
*
|
|
25
|
+
* @see RFCS/0012-memory-compaction-profile.md §D
|
|
26
|
+
* @see SECURITY/invariants.yaml `memory-compaction-sr-1-carry-forward`
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { describe, it, expect } from 'vitest';
|
|
30
|
+
import { driver } from '../lib/driver.js';
|
|
31
|
+
|
|
32
|
+
const MEMORY_REF = 'mem_tenant:default_agent:conformance-rfc0012-sr1_longTerm';
|
|
33
|
+
|
|
34
|
+
interface MemoryCaps {
|
|
35
|
+
compaction?: { supported?: boolean };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function isCompactionAdvertised(): Promise<boolean> {
|
|
39
|
+
const disco = await driver.get('/.well-known/openwop');
|
|
40
|
+
const memory = (disco.json as { capabilities?: { memory?: MemoryCaps } }).capabilities?.memory;
|
|
41
|
+
return memory?.compaction?.supported === true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function isTestSeamReachable(): Promise<boolean> {
|
|
45
|
+
// Probe the seam with an empty body — expects 400 if reachable
|
|
46
|
+
// (validation_error on missing memoryRef), 404 when disabled.
|
|
47
|
+
const r = await driver.post('/v1/test/memory/compact', {});
|
|
48
|
+
return r.status !== 404;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('memory-compaction-sr1-carry-forward: derived content passes the BYOK redaction harness', () => {
|
|
52
|
+
it('compacted MemoryEntry content MUST NOT carry source-side form-leak signatures', async () => {
|
|
53
|
+
if (!(await isCompactionAdvertised())) {
|
|
54
|
+
// eslint-disable-next-line no-console
|
|
55
|
+
console.warn('[rfc0012-sr1] capabilities.memory.compaction.supported not advertised; skipping');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (!(await isTestSeamReachable())) {
|
|
59
|
+
// eslint-disable-next-line no-console
|
|
60
|
+
console.warn('[rfc0012-sr1] test seam /v1/test/memory/compact unreachable; skipping (set host\'s OPENWOP_TEST_TRIGGER_COMPACTION=true)');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 1. Seed source entries containing:
|
|
65
|
+
// - The canonical `[BYOK:...]` form-leak signature (placeholder
|
|
66
|
+
// surfaces verbatim — should be caught by SR-1 carry-forward).
|
|
67
|
+
// - A non-canonical `<REDACTED:...>` marker that the host's
|
|
68
|
+
// redaction harness should re-canonicalize.
|
|
69
|
+
// - Plain, non-sensitive prose.
|
|
70
|
+
const seed = await driver.post('/v1/test/memory/seed', {
|
|
71
|
+
memoryRef: MEMORY_REF,
|
|
72
|
+
entries: [
|
|
73
|
+
{ id: `sr1-src-${Date.now()}-1`, content: 'User confirmed: [BYOK:hk_live_canary_42]' },
|
|
74
|
+
{ id: `sr1-src-${Date.now()}-2`, content: 'Resolved <REDACTED:db-prod-creds> outage.' },
|
|
75
|
+
{ id: `sr1-src-${Date.now()}-3`, content: 'Customer asked about pricing tiers.' },
|
|
76
|
+
],
|
|
77
|
+
});
|
|
78
|
+
expect(seed.status, 'seed endpoint MUST return 201 when reachable').toBe(201);
|
|
79
|
+
|
|
80
|
+
// 2. Drive compaction synchronously.
|
|
81
|
+
const compactRes = await driver.post('/v1/test/memory/compact', {
|
|
82
|
+
memoryRef: MEMORY_REF,
|
|
83
|
+
});
|
|
84
|
+
expect(compactRes.status, 'compact MUST return 200 with ≥2 source entries').toBe(200);
|
|
85
|
+
|
|
86
|
+
const event = compactRes.json as {
|
|
87
|
+
type?: string;
|
|
88
|
+
payload?: { outputId?: string; memoryRef?: string };
|
|
89
|
+
// Out-of-band field from the test seam carrying the persisted
|
|
90
|
+
// entry bytes; the wire-level `memory.compacted` event does NOT
|
|
91
|
+
// carry content. Required for SR-1 verification — the canonical
|
|
92
|
+
// event payload is shape-only and would pass this scenario
|
|
93
|
+
// trivially without it.
|
|
94
|
+
outputContent?: string;
|
|
95
|
+
};
|
|
96
|
+
expect(event.type, 'event payload MUST be type=memory.compacted').toBe('memory.compacted');
|
|
97
|
+
|
|
98
|
+
if (typeof event.outputContent !== 'string') {
|
|
99
|
+
// eslint-disable-next-line no-console
|
|
100
|
+
console.warn('[rfc0012-sr1] test seam did not return outputContent; the wire-level memory.compacted shape does not surface content so without a host-side seam we cannot verify §D end-to-end. Skipping.');
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The load-bearing assertion: the PERSISTED entry content (what
|
|
105
|
+
// future MemoryAdapter.get / list consumers would see) MUST NOT
|
|
106
|
+
// carry source-side form-leak signatures. A host that skips its
|
|
107
|
+
// BYOK redaction pass on derived content fails here.
|
|
108
|
+
expect(event.outputContent.includes('[BYOK:hk_live_canary_42]'), driver.describe(
|
|
109
|
+
'RFC 0012 §D',
|
|
110
|
+
'derived MemoryEntry.content MUST NOT carry source-side [BYOK:...] form-leak signatures (SR-1 carry-forward)',
|
|
111
|
+
)).toBe(false);
|
|
112
|
+
expect(event.outputContent.includes('<REDACTED:db-prod-creds>'), driver.describe(
|
|
113
|
+
'RFC 0012 §D',
|
|
114
|
+
'derived MemoryEntry.content MUST NOT echo non-canonical <REDACTED:...> markers from sources',
|
|
115
|
+
)).toBe(false);
|
|
116
|
+
|
|
117
|
+
// Positive: the canonical `[REDACTED:...]` placeholder MUST be
|
|
118
|
+
// present where SR-1 carry-forward re-substituted a source-side
|
|
119
|
+
// leak. Pinning this prevents a host from "passing" by simply
|
|
120
|
+
// stripping source content rather than redacting it (which would
|
|
121
|
+
// also lose audit signal).
|
|
122
|
+
expect(event.outputContent, driver.describe(
|
|
123
|
+
'RFC 0012 §D',
|
|
124
|
+
'derived MemoryEntry.content MUST carry canonical [REDACTED:...] placeholders where source-side leaks were re-substituted',
|
|
125
|
+
)).toMatch(/\[REDACTED:[^\]]+\]/);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -2,18 +2,29 @@
|
|
|
2
2
|
* Track 13: multi-region idempotency capability shape (idempotency.md v1.1).
|
|
3
3
|
*
|
|
4
4
|
* Verifies that hosts advertising the multi-region idempotency annex
|
|
5
|
-
* surface a valid `capabilities.idempotency.crossRegion` value
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* surface a valid `capabilities.idempotency.crossRegion` value AND, when
|
|
6
|
+
* claiming `'best-effort'` or `'strict'`, expose the operator-tier
|
|
7
|
+
* metric names per `idempotency.md` §"Operator surface".
|
|
8
|
+
*
|
|
9
|
+
* The annex's partition-replay convergence rule cannot be exercised
|
|
10
|
+
* black-box (it requires multi-region host deployment under a real
|
|
11
|
+
* partition); the algorithm itself is verified in-process via the
|
|
12
|
+
* Postgres host's `multi-region-idempotency.test.ts` smoke against
|
|
13
|
+
* the canonical resolver. This scenario validates the discovery-
|
|
14
|
+
* document shape so clients can rely on the capability for routing
|
|
15
|
+
* decisions.
|
|
9
16
|
*
|
|
10
17
|
* @see spec/v1/idempotency.md §"Multi-region idempotency"
|
|
18
|
+
* @see examples/hosts/postgres/src/multi-region.ts (canonical resolver)
|
|
11
19
|
*/
|
|
12
20
|
|
|
13
21
|
import { describe, it, expect } from 'vitest';
|
|
14
22
|
import { driver } from '../lib/driver.js';
|
|
15
23
|
|
|
16
24
|
const ALLOWED = new Set(['single-region', 'best-effort', 'strict']);
|
|
25
|
+
const REQUIRED_METRICS_WHEN_MULTI_REGION = [
|
|
26
|
+
'openwop.idempotency.cross_region_conflicts_total',
|
|
27
|
+
];
|
|
17
28
|
|
|
18
29
|
interface IdempotencyCaps {
|
|
19
30
|
supported?: boolean;
|
|
@@ -22,6 +33,10 @@ interface IdempotencyCaps {
|
|
|
22
33
|
crossRegion?: string;
|
|
23
34
|
}
|
|
24
35
|
|
|
36
|
+
interface ObservabilityCaps {
|
|
37
|
+
metrics?: { names?: string[] };
|
|
38
|
+
}
|
|
39
|
+
|
|
25
40
|
describe('multi-region-idempotency: capability shape', () => {
|
|
26
41
|
it('idempotency.crossRegion (when advertised) MUST be one of the closed enum', async () => {
|
|
27
42
|
const disco = await driver.get('/.well-known/openwop');
|
|
@@ -49,4 +64,24 @@ describe('multi-region-idempotency: capability shape', () => {
|
|
|
49
64
|
expect(idem.layer2RetentionSeconds).toBeGreaterThan(0);
|
|
50
65
|
}
|
|
51
66
|
});
|
|
67
|
+
|
|
68
|
+
it('multi-region hosts SHOULD expose the cross-region conflict counter per §"Operator surface"', async () => {
|
|
69
|
+
const disco = await driver.get('/.well-known/openwop');
|
|
70
|
+
const caps = (disco.json as { capabilities?: { idempotency?: IdempotencyCaps; observability?: ObservabilityCaps } })
|
|
71
|
+
.capabilities;
|
|
72
|
+
const crossRegion = caps?.idempotency?.crossRegion;
|
|
73
|
+
|
|
74
|
+
if (crossRegion !== 'best-effort' && crossRegion !== 'strict') {
|
|
75
|
+
// Single-region hosts have no conflicts to count — skip.
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const advertised = new Set(caps?.observability?.metrics?.names ?? []);
|
|
80
|
+
for (const name of REQUIRED_METRICS_WHEN_MULTI_REGION) {
|
|
81
|
+
expect(advertised.has(name), driver.describe(
|
|
82
|
+
'idempotency.md §"Operator surface"',
|
|
83
|
+
`multi-region hosts SHOULD advertise metric "${name}" so operators can monitor conflict frequency`,
|
|
84
|
+
)).toBe(true);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
52
87
|
});
|