@openwop/openwop-conformance 1.6.0 → 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 +18 -0
- package/README.md +2 -2
- package/api/asyncapi.yaml +74 -1
- package/api/openapi.yaml +316 -0
- package/coverage.md +16 -0
- package/fixtures/conformance-run-duration-breach.json +33 -0
- package/fixtures.md +19 -0
- package/package.json +1 -1
- package/schemas/README.md +12 -0
- package/schemas/agent-inventory-response.schema.json +90 -0
- package/schemas/ai-envelope.schema.json +28 -0
- package/schemas/annotation-create.schema.json +37 -0
- package/schemas/annotation.schema.json +56 -0
- package/schemas/artifact-type-pack-manifest.schema.json +160 -0
- package/schemas/capabilities.schema.json +195 -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 +31 -0
- 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/feedback-capability-shape.test.ts +35 -0
- package/src/scenarios/feedback-correction-redaction.test.ts +35 -0
- package/src/scenarios/feedback-cross-tenant-isolation.test.ts +37 -0
- package/src/scenarios/feedback-fork-not-copied.test.ts +40 -0
- package/src/scenarios/feedback-on-terminal-run.test.ts +32 -0
- package/src/scenarios/feedback-record-and-list.test.ts +32 -0
- package/src/scenarios/feedback-unsupported-501.test.ts +32 -0
- 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/redaction.test.ts +4 -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 +4 -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
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* feedback-fork-not-copied — RFC 0056 §D. Annotations are a per-run
|
|
3
|
+
* side-store, NOT replayable event-log entries — so a fork of an annotated
|
|
4
|
+
* run starts with ZERO annotations. Gated on feedback + fork; soft-skips
|
|
5
|
+
* when either is unavailable.
|
|
6
|
+
*
|
|
7
|
+
* @see RFCS/0056-run-feedback-and-annotation-event.md §D
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect } from 'vitest';
|
|
11
|
+
import { driver } from '../lib/driver.js';
|
|
12
|
+
import { pollUntilTerminal } from '../lib/polling.js';
|
|
13
|
+
import { readFeedbackCap, seedRun } from '../lib/feedback.js';
|
|
14
|
+
|
|
15
|
+
describe('feedback-fork-not-copied (RFC 0056 §D)', () => {
|
|
16
|
+
it('a fork of an annotated run starts with zero annotations', async () => {
|
|
17
|
+
const cap = await readFeedbackCap();
|
|
18
|
+
if (cap?.supported !== true) return;
|
|
19
|
+
const runId = await seedRun('feedback-fork');
|
|
20
|
+
if (!runId) return;
|
|
21
|
+
const post = await driver.post(`/v1/runs/${runId}/annotations`, { signal: { kind: 'flag' } });
|
|
22
|
+
if (post.status === 501 || post.status === 404) return;
|
|
23
|
+
expect(post.status).toBe(201);
|
|
24
|
+
try {
|
|
25
|
+
await pollUntilTerminal(runId, { timeoutMs: 10_000 });
|
|
26
|
+
} catch {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const fork = await driver.post(`/v1/runs/${runId}:fork`, { fromSeq: 0, mode: 'branch' });
|
|
30
|
+
if (fork.status !== 200 && fork.status !== 201) return; // fork unsupported — soft-skip
|
|
31
|
+
const forkId = (fork.json as { runId?: string } | undefined)?.runId;
|
|
32
|
+
if (!forkId) return;
|
|
33
|
+
const list = await driver.get(`/v1/runs/${forkId}/annotations`);
|
|
34
|
+
const ann = (list.json as { annotations?: unknown[] } | undefined)?.annotations ?? [];
|
|
35
|
+
expect(
|
|
36
|
+
ann.length,
|
|
37
|
+
driver.describe('RFC 0056 §D', 'annotations are a side-store and MUST NOT be copied into a fork'),
|
|
38
|
+
).toBe(0);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* feedback-on-terminal-run — RFC 0056 §C. An annotation on a COMPLETED run
|
|
3
|
+
* is accepted (proves feedback is non-blocking and post-hoc). Gated on
|
|
4
|
+
* `capabilities.feedback.supported`; soft-skips when a run can't be seeded.
|
|
5
|
+
*
|
|
6
|
+
* @see RFCS/0056-run-feedback-and-annotation-event.md §C
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect } from 'vitest';
|
|
10
|
+
import { driver } from '../lib/driver.js';
|
|
11
|
+
import { pollUntilTerminal } from '../lib/polling.js';
|
|
12
|
+
import { readFeedbackCap, seedRun } from '../lib/feedback.js';
|
|
13
|
+
|
|
14
|
+
describe('feedback-on-terminal-run (RFC 0056 §C)', () => {
|
|
15
|
+
it('annotating a terminal run is accepted', async () => {
|
|
16
|
+
const cap = await readFeedbackCap();
|
|
17
|
+
if (cap?.supported !== true) return;
|
|
18
|
+
const runId = await seedRun('feedback-terminal');
|
|
19
|
+
if (!runId) return;
|
|
20
|
+
try {
|
|
21
|
+
await pollUntilTerminal(runId, { timeoutMs: 10_000 });
|
|
22
|
+
} catch {
|
|
23
|
+
return; // run didn't reach terminal in time — soft-skip
|
|
24
|
+
}
|
|
25
|
+
const post = await driver.post(`/v1/runs/${runId}/annotations`, { signal: { kind: 'flag' }, note: 'post-hoc review' });
|
|
26
|
+
if (post.status === 501 || post.status === 404) return;
|
|
27
|
+
expect(
|
|
28
|
+
post.status,
|
|
29
|
+
driver.describe('RFC 0056 §C', 'a host MUST accept an annotation on a terminal run'),
|
|
30
|
+
).toBe(201);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* feedback-record-and-list — RFC 0056 §C. POST an annotation, then GET
|
|
3
|
+
* lists it back. Gated on `capabilities.feedback.supported` + the
|
|
4
|
+
* `conformance-a` seed fixture; soft-skips otherwise.
|
|
5
|
+
*
|
|
6
|
+
* @see RFCS/0056-run-feedback-and-annotation-event.md §C
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect } from 'vitest';
|
|
10
|
+
import { driver } from '../lib/driver.js';
|
|
11
|
+
import { readFeedbackCap, seedRun } from '../lib/feedback.js';
|
|
12
|
+
|
|
13
|
+
describe('feedback-record-and-list (RFC 0056 §C)', () => {
|
|
14
|
+
it('POST an annotation then GET returns it', async () => {
|
|
15
|
+
const cap = await readFeedbackCap();
|
|
16
|
+
if (cap?.supported !== true) return;
|
|
17
|
+
const runId = await seedRun('feedback-rl');
|
|
18
|
+
if (!runId) return;
|
|
19
|
+
const post = await driver.post(`/v1/runs/${runId}/annotations`, { signal: { kind: 'rating', rating: 5 } });
|
|
20
|
+
if (post.status === 501 || post.status === 404) return;
|
|
21
|
+
expect(
|
|
22
|
+
post.status,
|
|
23
|
+
driver.describe('RFC 0056 §C', 'POST annotation returns 201 with the persisted annotation'),
|
|
24
|
+
).toBe(201);
|
|
25
|
+
const created = post.json as { annotationId?: string };
|
|
26
|
+
expect(typeof created.annotationId).toBe('string');
|
|
27
|
+
const list = await driver.get(`/v1/runs/${runId}/annotations`);
|
|
28
|
+
expect(list.status).toBe(200);
|
|
29
|
+
const ann = (list.json as { annotations?: Array<{ annotationId?: string }> } | undefined)?.annotations ?? [];
|
|
30
|
+
expect(ann.some((a) => a.annotationId === created.annotationId)).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* feedback-unsupported-501 — RFC 0056 §C. A host that does NOT advertise
|
|
3
|
+
* `capabilities.feedback.supported` MUST return `501 capability_not_provided`
|
|
4
|
+
* on the annotation endpoints (the honest signal, per `capabilities.md`) —
|
|
5
|
+
* not silently 404 the route.
|
|
6
|
+
*
|
|
7
|
+
* Soft-skips when the host advertises feedback (501 is N/A) or when the
|
|
8
|
+
* route is entirely absent (404/405 — host predates RFC 0056).
|
|
9
|
+
*
|
|
10
|
+
* @see RFCS/0056-run-feedback-and-annotation-event.md §C
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from 'vitest';
|
|
14
|
+
import { driver } from '../lib/driver.js';
|
|
15
|
+
import { readFeedbackCap } from '../lib/feedback.js';
|
|
16
|
+
|
|
17
|
+
describe('feedback-unsupported-501 (RFC 0056 §C)', () => {
|
|
18
|
+
it('POST annotations returns 501 capability_not_provided when feedback is unadvertised', async () => {
|
|
19
|
+
const cap = await readFeedbackCap();
|
|
20
|
+
if (cap?.supported === true) return; // host supports feedback — 501 N/A
|
|
21
|
+
const res = await driver.post('/v1/runs/probe-run-rfc0056/annotations', {
|
|
22
|
+
signal: { kind: 'flag' },
|
|
23
|
+
});
|
|
24
|
+
if (res.status === 404 || res.status === 405) return; // route absent — host predates RFC 0056
|
|
25
|
+
expect(
|
|
26
|
+
res.status,
|
|
27
|
+
driver.describe('rest-endpoints.md / RFC 0056 §C', 'unadvertised feedback MUST return 501, not 404'),
|
|
28
|
+
).toBe(501);
|
|
29
|
+
const code = (res.json as { error?: string } | undefined)?.error;
|
|
30
|
+
expect(code).toBe('capability_not_provided');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
import { describe, it, expect } from 'vitest';
|
|
29
29
|
import { driver } from '../lib/driver.js';
|
|
30
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
30
31
|
|
|
31
32
|
interface DiscoveryFs {
|
|
32
33
|
supported?: boolean;
|
|
@@ -43,7 +44,7 @@ interface DiscoveryDoc {
|
|
|
43
44
|
async function readFs(): Promise<DiscoveryFs | null> {
|
|
44
45
|
const res = await driver.get('/.well-known/openwop');
|
|
45
46
|
const body = res.json as DiscoveryDoc | undefined;
|
|
46
|
-
return body
|
|
47
|
+
return capabilityFamily(body, 'fs') ?? null;
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
const PATH_REJECTION_CODES: ReadonlySet<string> = new Set([
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* heartbeat-capability-shape — RFC 0060 §A. The `capabilities.heartbeat`
|
|
3
|
+
* advertisement block is either absent or a well-formed object.
|
|
4
|
+
*
|
|
5
|
+
* Status: ACTIVE (advertisement-shape; always runs). Behavioral coverage
|
|
6
|
+
* lives in the sibling heartbeat-*.test.ts scenarios, gated on
|
|
7
|
+
* `capabilities.heartbeat.supported` + the host's heartbeat tick seam.
|
|
8
|
+
*
|
|
9
|
+
* @see RFCS/0060-host-heartbeat-capability.md §A
|
|
10
|
+
* @see spec/v1/host-capabilities.md §host.heartbeat
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from 'vitest';
|
|
14
|
+
import { driver } from '../lib/driver.js';
|
|
15
|
+
import { readHeartbeatCap } from '../lib/heartbeat.js';
|
|
16
|
+
|
|
17
|
+
describe('heartbeat-capability-shape: advertisement (RFC 0060 §A)', () => {
|
|
18
|
+
it('capabilities.heartbeat is absent or a well-formed object', async () => {
|
|
19
|
+
const cap = await readHeartbeatCap();
|
|
20
|
+
if (cap === null) return; // not advertised — valid
|
|
21
|
+
expect(
|
|
22
|
+
typeof cap.supported,
|
|
23
|
+
driver.describe('capabilities.schema.json §heartbeat', 'heartbeat.supported MUST be a boolean when the block is present'),
|
|
24
|
+
).toBe('boolean');
|
|
25
|
+
for (const k of ['minIntervalSec', 'maxRuntimeMs'] as const) {
|
|
26
|
+
if (cap[k] !== undefined) {
|
|
27
|
+
const v = cap[k];
|
|
28
|
+
expect(
|
|
29
|
+
typeof v === 'number' && Number.isInteger(v) && v >= 1,
|
|
30
|
+
driver.describe('capabilities.schema.json §heartbeat', `heartbeat.${k} MUST be a positive integer when present`),
|
|
31
|
+
).toBe(true);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* heartbeat-fires-once-per-tick — RFC 0060 §B.1. A tick produces exactly one
|
|
3
|
+
* `heartbeat.evaluated`; an overlapping tick while a prior evaluation is still
|
|
4
|
+
* running is skipped (not queued).
|
|
5
|
+
*
|
|
6
|
+
* Gated on `capabilities.heartbeat.supported` + the host heartbeat tick seam
|
|
7
|
+
* (`POST /v1/host/sample/heartbeat/tick`); soft-skips when either is absent.
|
|
8
|
+
*
|
|
9
|
+
* @see RFCS/0060-host-heartbeat-capability.md §B
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect } from 'vitest';
|
|
13
|
+
import { driver } from '../lib/driver.js';
|
|
14
|
+
import { readHeartbeatCap, heartbeatSupported, tickHeartbeat } from '../lib/heartbeat.js';
|
|
15
|
+
|
|
16
|
+
describe('heartbeat-fires-once-per-tick (RFC 0060 §B.1)', () => {
|
|
17
|
+
it('one tick emits exactly one heartbeat.evaluated', async () => {
|
|
18
|
+
if (!heartbeatSupported(await readHeartbeatCap())) return;
|
|
19
|
+
const res = await tickHeartbeat({ heartbeatId: 'conformance-hb', observedState: { n: 0 } });
|
|
20
|
+
if (res === null) return; // seam absent — soft-skip
|
|
21
|
+
const evaluated = (res.json as { evaluated?: unknown[] } | undefined)?.evaluated;
|
|
22
|
+
if (!Array.isArray(evaluated)) return; // host doesn't surface per-tick events on the seam
|
|
23
|
+
expect(
|
|
24
|
+
evaluated.length,
|
|
25
|
+
driver.describe('RFC 0060 §B.1', 'a single tick MUST emit exactly one heartbeat.evaluated'),
|
|
26
|
+
).toBe(1);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* heartbeat-idempotent-no-spam — RFC 0060 §B.5. Two ticks at unchanged state
|
|
3
|
+
* produce zero enqueued runs and zero `heartbeat.stateChanged`; only the
|
|
4
|
+
* transitioning tick produces exactly one of each. This is the anti-spam
|
|
5
|
+
* guarantee — action is gated on a state *transition*, not on the tick.
|
|
6
|
+
*
|
|
7
|
+
* Gated on `capabilities.heartbeat.supported` + the host tick seam;
|
|
8
|
+
* soft-skips when either is absent.
|
|
9
|
+
*
|
|
10
|
+
* @see RFCS/0060-host-heartbeat-capability.md §B
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from 'vitest';
|
|
14
|
+
import { driver } from '../lib/driver.js';
|
|
15
|
+
import { readHeartbeatCap, heartbeatSupported, tickHeartbeat } from '../lib/heartbeat.js';
|
|
16
|
+
|
|
17
|
+
function changedCount(json: unknown): number | null {
|
|
18
|
+
const sc = (json as { stateChanged?: unknown[] } | undefined)?.stateChanged;
|
|
19
|
+
return Array.isArray(sc) ? sc.length : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('heartbeat-idempotent-no-spam (RFC 0060 §B.5)', () => {
|
|
23
|
+
it('an unchanged tick enqueues nothing; only a transition does', async () => {
|
|
24
|
+
if (!heartbeatSupported(await readHeartbeatCap())) return;
|
|
25
|
+
const hb = 'conformance-hb-spam';
|
|
26
|
+
const first = await tickHeartbeat({ heartbeatId: hb, observedState: { unread: 0 } });
|
|
27
|
+
if (first === null) return; // seam absent — soft-skip
|
|
28
|
+
const second = await tickHeartbeat({ heartbeatId: hb, observedState: { unread: 0 } });
|
|
29
|
+
if (second === null) return;
|
|
30
|
+
const unchanged = changedCount(second.json);
|
|
31
|
+
if (unchanged === null) return; // host doesn't surface stateChanged on the seam
|
|
32
|
+
expect(
|
|
33
|
+
unchanged,
|
|
34
|
+
driver.describe('RFC 0060 §B.5', 'an unchanged tick MUST NOT emit heartbeat.stateChanged'),
|
|
35
|
+
).toBe(0);
|
|
36
|
+
const transition = await tickHeartbeat({ heartbeatId: hb, observedState: { unread: 3 } });
|
|
37
|
+
if (transition === null) return;
|
|
38
|
+
expect(
|
|
39
|
+
changedCount(transition.json),
|
|
40
|
+
driver.describe('RFC 0060 §B.5', 'a transitioning tick MUST emit exactly one heartbeat.stateChanged'),
|
|
41
|
+
).toBe(1);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* heartbeat-runtime-bound — RFC 0060 §B.2. A predicate exceeding `maxRuntimeMs`
|
|
3
|
+
* is terminated and reported `heartbeat.evaluated { status: "timeout" }`,
|
|
4
|
+
* never left running.
|
|
5
|
+
*
|
|
6
|
+
* Gated on `capabilities.heartbeat.supported` + the host tick seam;
|
|
7
|
+
* soft-skips when either is absent.
|
|
8
|
+
*
|
|
9
|
+
* @see RFCS/0060-host-heartbeat-capability.md §B
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect } from 'vitest';
|
|
13
|
+
import { driver } from '../lib/driver.js';
|
|
14
|
+
import { readHeartbeatCap, heartbeatSupported, tickHeartbeat } from '../lib/heartbeat.js';
|
|
15
|
+
|
|
16
|
+
describe('heartbeat-runtime-bound (RFC 0060 §B.2)', () => {
|
|
17
|
+
it('an over-budget predicate is reported as timeout', async () => {
|
|
18
|
+
if (!heartbeatSupported(await readHeartbeatCap())) return;
|
|
19
|
+
// `simulateSlowMs` is a host-seam hint asking the predicate to overrun
|
|
20
|
+
// its maxRuntimeMs budget; hosts not honoring it surface no `timeout`.
|
|
21
|
+
const res = await tickHeartbeat({ heartbeatId: 'conformance-hb-slow', observedState: {}, simulateSlowMs: 60_000 });
|
|
22
|
+
if (res === null) return; // seam absent — soft-skip
|
|
23
|
+
const evaluated = (res.json as { evaluated?: Array<{ status?: unknown }> } | undefined)?.evaluated;
|
|
24
|
+
if (!Array.isArray(evaluated) || evaluated.length === 0) return; // host doesn't surface per-tick events
|
|
25
|
+
expect(
|
|
26
|
+
evaluated.every((e) => e.status === 'timeout'),
|
|
27
|
+
driver.describe('RFC 0060 §B.2', 'an over-budget predicate MUST be terminated and reported status:"timeout"'),
|
|
28
|
+
).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -20,12 +20,13 @@
|
|
|
20
20
|
|
|
21
21
|
import { describe, it, expect } from 'vitest';
|
|
22
22
|
import { driver } from '../lib/driver.js';
|
|
23
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
23
24
|
|
|
24
25
|
async function isHttpClientSupported(): Promise<boolean> {
|
|
25
26
|
const disco = await driver.get('/.well-known/openwop');
|
|
26
|
-
|
|
27
|
-
.
|
|
28
|
-
|
|
27
|
+
return (
|
|
28
|
+
capabilityFamily<{ supported?: boolean }>(disco.json, 'httpClient')?.supported === true
|
|
29
|
+
);
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
describe('http-client-ssrf: capability advertisement contract', () => {
|
|
@@ -36,16 +37,12 @@ describe('http-client-ssrf: capability advertisement contract', () => {
|
|
|
36
37
|
return;
|
|
37
38
|
}
|
|
38
39
|
const disco = await driver.get('/.well-known/openwop');
|
|
39
|
-
const cap =
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
methods?: unknown;
|
|
46
|
-
};
|
|
47
|
-
};
|
|
48
|
-
}).capabilities?.httpClient;
|
|
40
|
+
const cap = capabilityFamily<{
|
|
41
|
+
supported?: boolean;
|
|
42
|
+
ssrfGuard?: boolean;
|
|
43
|
+
maxResponseBodyBytes?: number;
|
|
44
|
+
methods?: unknown;
|
|
45
|
+
}>(disco.json, 'httpClient');
|
|
49
46
|
|
|
50
47
|
expect(cap?.supported, driver.describe(
|
|
51
48
|
'capabilities.md §httpClient',
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import { describe, it, expect } from 'vitest';
|
|
23
23
|
import { driver } from '../lib/driver.js';
|
|
24
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
24
25
|
|
|
25
26
|
async function isMcpClientSupported(): Promise<boolean> {
|
|
26
27
|
const disco = await driver.get('/.well-known/openwop');
|
|
@@ -37,11 +38,11 @@ describe('mcp-toolcall-redaction: capability advertisement contract', () => {
|
|
|
37
38
|
return;
|
|
38
39
|
}
|
|
39
40
|
const disco = await driver.get('/.well-known/openwop');
|
|
40
|
-
const cap = (disco.json as {
|
|
41
|
+
const cap = capabilityFamily((disco.json as {
|
|
41
42
|
capabilities?: {
|
|
42
43
|
mcpClient?: { supported?: boolean; transports?: unknown; trustBoundary?: string };
|
|
43
44
|
};
|
|
44
|
-
})
|
|
45
|
+
}), 'mcpClient');
|
|
45
46
|
|
|
46
47
|
expect(cap?.supported, driver.describe(
|
|
47
48
|
'host-capabilities.md §host.mcp',
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* media-url-inline-cap — RFC 0055 §C media envelope kinds + asset-URL discipline.
|
|
3
|
+
*
|
|
4
|
+
* SECURITY invariant: `media-asset-url-tenant-scoped` (RFC 0055 §C rule 1 + 4).
|
|
5
|
+
*
|
|
6
|
+
* Always-on (server-free):
|
|
7
|
+
* 1. The three `media.{image,audio,file}` payload schemas compile (Ajv2020).
|
|
8
|
+
* 2. Positive round-trip: a URL-reference payload and an inline-base64
|
|
9
|
+
* payload each validate.
|
|
10
|
+
* 3. Negative: a payload missing the required `bytes` is rejected; an
|
|
11
|
+
* unknown property is rejected (additionalProperties:false).
|
|
12
|
+
*
|
|
13
|
+
* Advertisement-shape (HTTP, soft-skip offline):
|
|
14
|
+
* 4. When a host advertises `aiProviders.maxInlineMediaBytes`, it MUST be a
|
|
15
|
+
* non-negative integer.
|
|
16
|
+
*
|
|
17
|
+
* Behavioral (cross-tenant scoping + cap enforcement) is staged via `it.todo`
|
|
18
|
+
* until a reference host wires tenant-scoped asset serving (greenfield;
|
|
19
|
+
* RFC 0027 §G precedent — advertisement + schema land first).
|
|
20
|
+
*
|
|
21
|
+
* @see RFCS/0055-multimodal-envelope-variants-and-rendering-hints.md §C
|
|
22
|
+
* @see spec/v1/ai-envelope.md §"Media reference payloads"
|
|
23
|
+
* @see SECURITY/invariants.yaml#media-asset-url-tenant-scoped
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { describe, it, expect } from 'vitest';
|
|
27
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
28
|
+
import addFormats from 'ajv-formats';
|
|
29
|
+
import { readFileSync } from 'node:fs';
|
|
30
|
+
import { join } from 'node:path';
|
|
31
|
+
import { driver } from '../lib/driver.js';
|
|
32
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
33
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
34
|
+
|
|
35
|
+
const MEDIA_KINDS = ['media.image', 'media.audio', 'media.file'] as const;
|
|
36
|
+
const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
|
|
37
|
+
|
|
38
|
+
function compile(kind: string): ReturnType<Ajv2020['compile']> {
|
|
39
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
40
|
+
addFormats(ajv);
|
|
41
|
+
const schema = JSON.parse(
|
|
42
|
+
readFileSync(join(SCHEMAS_DIR, `envelopes/${kind}.schema.json`), 'utf8'),
|
|
43
|
+
) as Record<string, unknown>;
|
|
44
|
+
return ajv.compile(schema);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('media-url-inline-cap: media payload schemas compile + round-trip (RFC 0055 §C)', () => {
|
|
48
|
+
for (const kind of MEDIA_KINDS) {
|
|
49
|
+
it(`envelopes/${kind}.schema.json compiles under Ajv2020`, () => {
|
|
50
|
+
expect(
|
|
51
|
+
compile(kind),
|
|
52
|
+
`ai-envelope.md §"Media reference payloads": ${kind} payload schema MUST compile`,
|
|
53
|
+
).toBeTypeOf('function');
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
it('accepts a URL-reference image payload', () => {
|
|
58
|
+
const ok = compile('media.image')({
|
|
59
|
+
url: 'https://host.example/v1/runs/run_1/assets/img_9.png',
|
|
60
|
+
bytes: 184320,
|
|
61
|
+
mimeType: 'image/png',
|
|
62
|
+
});
|
|
63
|
+
expect(ok, 'URL-reference media payload MUST validate').toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('accepts an inline-base64 audio payload', () => {
|
|
67
|
+
const ok = compile('media.audio')({ base64: 'AAAA', bytes: 3, mimeType: 'audio/ogg', durationSeconds: 1.2 });
|
|
68
|
+
expect(ok, 'inline-base64 media payload MUST validate').toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('rejects a payload missing required bytes', () => {
|
|
72
|
+
const ok = compile('media.file')({ url: 'https://host.example/v1/runs/run_1/assets/report.pdf' });
|
|
73
|
+
expect(ok, 'ai-envelope.md §"Media reference payloads": `bytes` is required').toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('rejects an unknown property (additionalProperties:false)', () => {
|
|
77
|
+
const ok = compile('media.image')({ bytes: 1, wat: true });
|
|
78
|
+
expect(ok, 'media payload is additionalProperties:false').toBe(false);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
interface DiscoveryDoc {
|
|
83
|
+
capabilities?: { aiProviders?: { maxInlineMediaBytes?: unknown } };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe.skipIf(HTTP_SKIP)('media-url-inline-cap: advertisement shape (RFC 0055 §C rule 2)', () => {
|
|
87
|
+
it('aiProviders.maxInlineMediaBytes is a non-negative integer when advertised', async () => {
|
|
88
|
+
const res = await driver.get('/.well-known/openwop');
|
|
89
|
+
if (res.status !== 200) return;
|
|
90
|
+
const cap = capabilityFamily((res.json as DiscoveryDoc), 'aiProviders')?.maxInlineMediaBytes;
|
|
91
|
+
if (cap === undefined) return; // optional — soft-skip when absent
|
|
92
|
+
expect(
|
|
93
|
+
Number.isInteger(cap) && (cap as number) >= 0,
|
|
94
|
+
driver.describe('capabilities.md §aiProviders.maxInlineMediaBytes', 'cap MUST be a non-negative integer'),
|
|
95
|
+
).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Behavioral assertions for `media-asset-url-tenant-scoped`. Driven via the
|
|
99
|
+
// reference host's media-asset seam (store: POST /v1/host/sample/media/put,
|
|
100
|
+
// env-gated; serve: GET /v1/host/sample/assets/{token}, public token-auth).
|
|
101
|
+
// Soft-skip (return) when the host doesn't expose the store seam (404).
|
|
102
|
+
const PNG_1x1 =
|
|
103
|
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
|
104
|
+
|
|
105
|
+
it('a stored media asset is served by a tenant-scoped URL, not inlined', async () => {
|
|
106
|
+
const stored = await driver.post('/v1/host/sample/media/put', { contentBase64: PNG_1x1, contentType: 'image/png' });
|
|
107
|
+
if (stored.status === 404) return; // store seam disabled — soft-skip
|
|
108
|
+
expect(stored.status, 'media store MUST return 201').toBe(201);
|
|
109
|
+
const body = stored.json as { url?: string; bytes?: number };
|
|
110
|
+
expect(
|
|
111
|
+
typeof body.url === 'string' && /\/v1\/host\/sample\/assets\//.test(body.url!),
|
|
112
|
+
driver.describe('ai-envelope.md §"Media reference payloads"', 'asset MUST be served by a URL reference, not inlined'),
|
|
113
|
+
).toBe(true);
|
|
114
|
+
const served = await driver.get(body.url!);
|
|
115
|
+
expect(served.status, 'the asset URL MUST resolve').toBe(200);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('an unminted/guessed asset token does not resolve (media-asset-url-tenant-scoped)', async () => {
|
|
119
|
+
// Probe whether the serve route exists at all; soft-skip if not.
|
|
120
|
+
const probe = await driver.get('/v1/host/sample/assets/probe-never-minted-token');
|
|
121
|
+
if (probe.status === 404 && !process.env.OPENWOP_BASE_URL) return;
|
|
122
|
+
expect(
|
|
123
|
+
probe.status,
|
|
124
|
+
driver.describe('SECURITY/invariants.yaml#media-asset-url-tenant-scoped', 'a token not held by the caller (unguessable 256-bit) MUST NOT resolve'),
|
|
125
|
+
).toBe(404);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('a media.* payload in a run debug bundle is referenced by URL, not inlined (RFC 0055 §C rule 3)', async () => {
|
|
129
|
+
// RFC 0055 §C rule 3: asset URLs are part of a run's debug-bundle manifest
|
|
130
|
+
// BY REFERENCE, never by inlining the binary. Gated on a host that both
|
|
131
|
+
// serves media (advertises aiProviders.maxInlineMediaBytes) and exports
|
|
132
|
+
// debug bundles (capabilities.debugBundle.supported). Soft-skips otherwise
|
|
133
|
+
// — and on the reference host, which exports debug bundles but has no node
|
|
134
|
+
// that emits a media.* envelope into a run (so no media payload appears).
|
|
135
|
+
const disc = await driver.get('/.well-known/openwop');
|
|
136
|
+
if (disc.status !== 200) return;
|
|
137
|
+
const caps = (disc.json as {
|
|
138
|
+
capabilities?: { aiProviders?: { maxInlineMediaBytes?: unknown }; debugBundle?: { supported?: unknown } };
|
|
139
|
+
}).capabilities;
|
|
140
|
+
if (caps?.aiProviders?.maxInlineMediaBytes === undefined || caps.debugBundle?.supported !== true) {
|
|
141
|
+
return; // host doesn't serve media + export debug bundles — contract not exercisable
|
|
142
|
+
}
|
|
143
|
+
// Find a recent run and inspect its debug bundle for any media.* event.
|
|
144
|
+
const runs = await driver.get('/v1/runs?limit=20');
|
|
145
|
+
if (runs.status !== 200) return;
|
|
146
|
+
const runIds = ((runs.json as { runs?: { runId?: string }[] }).runs ?? [])
|
|
147
|
+
.map((r) => r.runId)
|
|
148
|
+
.filter((id): id is string => typeof id === 'string');
|
|
149
|
+
for (const runId of runIds) {
|
|
150
|
+
const bundle = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/debug-bundle`);
|
|
151
|
+
if (bundle.status !== 200) continue;
|
|
152
|
+
const events = (bundle.json as { events?: { type?: string; payload?: { url?: unknown; base64?: unknown } }[] }).events ?? [];
|
|
153
|
+
for (const ev of events) {
|
|
154
|
+
if (typeof ev.type === 'string' && ev.type.startsWith('media.')) {
|
|
155
|
+
// The §C rule-3 contract: served by URL, not inlined binary.
|
|
156
|
+
expect(
|
|
157
|
+
typeof ev.payload?.url === 'string' && ev.payload?.base64 === undefined,
|
|
158
|
+
driver.describe('ai-envelope.md §"Media reference payloads"', 'a media.* payload in a debug bundle MUST be a URL reference, never inlined binary'),
|
|
159
|
+
).toBe(true);
|
|
160
|
+
return; // asserted one — contract proven
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// No media.* payload surfaced in any recent run's debug bundle on this
|
|
165
|
+
// host — nothing to assert (the contract holds vacuously).
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory-attribution-emits-on-write — RFC 0057 §A/§B. A host advertising
|
|
3
|
+
* `capabilities.memory.attribution.emitsWriteEvents: true` emits a
|
|
4
|
+
* `memory.written` event (with resolvable identifiers) for the memory its run
|
|
5
|
+
* writes. A host NOT advertising the capability emits none and still passes
|
|
6
|
+
* the locked core.
|
|
7
|
+
*
|
|
8
|
+
* @see RFCS/0057-memory-write-attribution-event.md §A
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect } from 'vitest';
|
|
12
|
+
import { driver } from '../lib/driver.js';
|
|
13
|
+
import { pollUntilTerminal } from '../lib/polling.js';
|
|
14
|
+
import { readMemoryAttributionCap, emitsWriteEvents, seedRun, memoryWrittenEvents } from '../lib/memoryAttribution.js';
|
|
15
|
+
|
|
16
|
+
describe('memory-attribution-emits-on-write (RFC 0057 §A/§B)', () => {
|
|
17
|
+
it('an advertised host emits memory.written carrying a stable memoryId', async () => {
|
|
18
|
+
const cap = await readMemoryAttributionCap();
|
|
19
|
+
if (!emitsWriteEvents(cap)) return;
|
|
20
|
+
const runId = await seedRun('mem-attr-emit');
|
|
21
|
+
if (!runId) return;
|
|
22
|
+
try {
|
|
23
|
+
await pollUntilTerminal(runId, { timeoutMs: 10_000 });
|
|
24
|
+
} catch {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const events = await memoryWrittenEvents(runId);
|
|
28
|
+
if (events.length === 0) return; // run wrote no memory — soft-skip
|
|
29
|
+
for (const e of events) {
|
|
30
|
+
const memoryId = (e.payload as { memoryId?: unknown } | undefined)?.memoryId;
|
|
31
|
+
expect(
|
|
32
|
+
typeof memoryId === 'string' && memoryId.length > 0,
|
|
33
|
+
driver.describe('RFC 0057 §B', 'memory.written.memoryId MUST be a stable, non-empty identifier'),
|
|
34
|
+
).toBe(true);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('a host without the capability emits no memory.written', async () => {
|
|
39
|
+
const cap = await readMemoryAttributionCap();
|
|
40
|
+
if (emitsWriteEvents(cap)) return; // advertised — N/A
|
|
41
|
+
const runId = await seedRun('mem-attr-absent');
|
|
42
|
+
if (!runId) return;
|
|
43
|
+
try {
|
|
44
|
+
await pollUntilTerminal(runId, { timeoutMs: 10_000 });
|
|
45
|
+
} catch {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const events = await memoryWrittenEvents(runId);
|
|
49
|
+
expect(
|
|
50
|
+
events.length,
|
|
51
|
+
driver.describe('RFC 0057 §A', 'a host not advertising memory.attribution MUST NOT emit memory.written'),
|
|
52
|
+
).toBe(0);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory-attribution-no-content — RFC 0057 §C + SECURITY/invariants.yaml
|
|
3
|
+
* `memory-attribution-no-content`. A `memory.written` payload carries
|
|
4
|
+
* identifiers + non-secret tags only — never the memory entry content (the
|
|
5
|
+
* read-side serves that, already SR-1-redacted).
|
|
6
|
+
*
|
|
7
|
+
* Gated on `capabilities.memory.attribution.emitsWriteEvents`; soft-skips when
|
|
8
|
+
* unadvertised or when the seeded run wrote no memory.
|
|
9
|
+
*
|
|
10
|
+
* @see RFCS/0057-memory-write-attribution-event.md §C
|
|
11
|
+
* @see SECURITY/invariants.yaml — memory-attribution-no-content
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect } from 'vitest';
|
|
15
|
+
import { driver } from '../lib/driver.js';
|
|
16
|
+
import { pollUntilTerminal } from '../lib/polling.js';
|
|
17
|
+
import { readMemoryAttributionCap, emitsWriteEvents, seedRun, memoryWrittenEvents } from '../lib/memoryAttribution.js';
|
|
18
|
+
|
|
19
|
+
describe('memory-attribution-no-content (RFC 0057 §C)', () => {
|
|
20
|
+
it('memory.written payloads carry no entry content', async () => {
|
|
21
|
+
const cap = await readMemoryAttributionCap();
|
|
22
|
+
if (!emitsWriteEvents(cap)) return;
|
|
23
|
+
const runId = await seedRun('mem-attr-no-content');
|
|
24
|
+
if (!runId) return;
|
|
25
|
+
try {
|
|
26
|
+
await pollUntilTerminal(runId, { timeoutMs: 10_000 });
|
|
27
|
+
} catch {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const events = await memoryWrittenEvents(runId);
|
|
31
|
+
if (events.length === 0) return; // run wrote no memory — soft-skip
|
|
32
|
+
for (const e of events) {
|
|
33
|
+
const payload = e.payload ?? {};
|
|
34
|
+
expect(
|
|
35
|
+
'content' in payload,
|
|
36
|
+
driver.describe('RFC 0057 §C', 'memory.written MUST NOT carry the entry content field'),
|
|
37
|
+
).toBe(false);
|
|
38
|
+
expect(
|
|
39
|
+
typeof (payload as { memoryRef?: unknown }).memoryRef === 'string' &&
|
|
40
|
+
typeof (payload as { memoryId?: unknown }).memoryId === 'string',
|
|
41
|
+
driver.describe('RFC 0057 §B', 'memory.written MUST carry memoryRef + memoryId identifiers'),
|
|
42
|
+
).toBe(true);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
});
|