@openwop/openwop-conformance 1.10.0 → 1.12.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 +48 -0
- package/README.md +2 -2
- package/api/asyncapi.yaml +70 -0
- package/api/openapi.yaml +268 -1
- package/coverage.md +33 -2
- package/fixtures/oauth-providers/synthetic.json +38 -0
- package/fixtures.md +10 -0
- package/package.json +1 -1
- package/schemas/README.md +12 -0
- package/schemas/agent-deployment-transition.schema.json +49 -0
- package/schemas/agent-deployment.schema.json +54 -0
- package/schemas/agent-eval-suite.schema.json +140 -0
- package/schemas/agent-inventory-response.schema.json +25 -0
- package/schemas/agent-manifest.schema.json +5 -0
- package/schemas/agent-org-chart.schema.json +82 -0
- package/schemas/agent-ref.schema.json +12 -2
- package/schemas/agent-roster-entry.schema.json +81 -0
- package/schemas/agent-roster-response.schema.json +21 -0
- package/schemas/budget-policy.schema.json +18 -0
- package/schemas/capabilities.schema.json +277 -0
- package/schemas/credential-provenance.schema.json +18 -0
- package/schemas/eval-summary.schema.json +92 -0
- package/schemas/node-pack-manifest.schema.json +17 -0
- package/schemas/org-chart-responsibility-view.schema.json +26 -0
- package/schemas/run-event-payloads.schema.json +286 -3
- package/schemas/run-event.schema.json +19 -0
- package/schemas/tool-descriptor.schema.json +63 -0
- package/schemas/trigger-subscription.schema.json +26 -0
- package/src/lib/agentOrgChart.ts +82 -0
- package/src/lib/agentRoster.ts +76 -0
- package/src/lib/liveRuntime.ts +59 -0
- package/src/lib/profiles.ts +157 -0
- package/src/lib/runtimeRequires.ts +38 -0
- package/src/lib/safeFetch.ts +87 -0
- package/src/lib/triggerBridge.ts +74 -0
- package/src/scenarios/agent-deployment-shape.test.ts +139 -0
- package/src/scenarios/agent-eval-suite-shape.test.ts +167 -0
- package/src/scenarios/agent-live-allowlist-enforced.test.ts +53 -0
- package/src/scenarios/agent-live-invocation-bracket.test.ts +98 -0
- package/src/scenarios/agent-live-runtime-shape.test.ts +98 -0
- package/src/scenarios/agent-live-structured-output.test.ts +58 -0
- package/src/scenarios/agent-org-chart-scoping.test.ts +137 -0
- package/src/scenarios/agent-org-chart-shape.test.ts +127 -0
- package/src/scenarios/agent-platform-profile.test.ts +158 -0
- package/src/scenarios/agent-roster-attribution.test.ts +179 -0
- package/src/scenarios/agent-roster-shape.test.ts +146 -0
- package/src/scenarios/budget-policy-shape.test.ts +136 -0
- package/src/scenarios/egress-provenance-shape.test.ts +137 -0
- package/src/scenarios/memory-capability-model-shape.test.ts +186 -0
- package/src/scenarios/oauth-authorization-code-roundtrip.test.ts +145 -0
- package/src/scenarios/org-position-no-authority-escalation.test.ts +78 -0
- package/src/scenarios/runtime-requires-install-gate.test.ts +92 -0
- package/src/scenarios/runtime-requires-shape.test.ts +134 -0
- package/src/scenarios/safefetch-behavior.test.ts +99 -0
- package/src/scenarios/safefetch-live-audit.test.ts +175 -0
- package/src/scenarios/spec-corpus-validity.test.ts +19 -3
- package/src/scenarios/tool-descriptor-shape.test.ts +133 -0
- package/src/scenarios/trigger-bridge-delivery.test.ts +126 -0
- package/src/scenarios/trigger-bridge-shape.test.ts +135 -0
- package/src/scenarios/x-openwop-form-pack-manifest.test.ts +155 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent org-chart — normative read, responsibility roll-up + tenant scoping
|
|
3
|
+
* (RFC 0087 §A/§C/§D) — behavioral.
|
|
4
|
+
*
|
|
5
|
+
* Gated on `capabilities.agents.orgChart.supported` (root-first per RFC 0073).
|
|
6
|
+
* Soft-skips when unadvertised (default) / hard-fails under
|
|
7
|
+
* `OPENWOP_REQUIRE_BEHAVIOR=true` via `behaviorGate`. The always-on wire-shape
|
|
8
|
+
* coverage lives in `agent-org-chart-shape.test.ts`; this asserts host
|
|
9
|
+
* BEHAVIOR against the live `/v1/agents/org-chart` surface:
|
|
10
|
+
*
|
|
11
|
+
* 1. NORMATIVE read — `GET /v1/agents/org-chart` returns the
|
|
12
|
+
* `agent-org-chart.schema.json` shape `{ owner, departments, members }`;
|
|
13
|
+
* departments form a tree (every `parentDepartmentId` resolves; no cycle);
|
|
14
|
+
* members reference roster entries (`host:<id>` rosterId) and the
|
|
15
|
+
* `reportsTo` graph is acyclic. Black-box on any org-chart host.
|
|
16
|
+
* 2. §D RESPONSIBILITY ROLL-UP — `GET /v1/agents/org-chart/{departmentId}`
|
|
17
|
+
* returns `{ department, members, responsibilities }` where
|
|
18
|
+
* `responsibilities` is a deduped `string[]` (the union of the subtree
|
|
19
|
+
* members' RFC 0086 portfolios); `recursive=false` scopes to direct
|
|
20
|
+
* members without changing the response shape.
|
|
21
|
+
* 3. TENANT SCOPING (§C / RFC 0074) — a `GET /v1/agents/org-chart/{id}` for a
|
|
22
|
+
* department outside the caller's owner triple 404s (probed only when
|
|
23
|
+
* `OPENWOP_CROSS_TENANT_ORG_CHART_DEPARTMENT_ID` is supplied; soft-skip
|
|
24
|
+
* otherwise — the org-chart analog of the roster scoping env var).
|
|
25
|
+
*
|
|
26
|
+
* Spec references:
|
|
27
|
+
* - https://github.com/openwop/openwop/blob/main/spec/v1/agent-org-chart.md
|
|
28
|
+
* - https://github.com/openwop/openwop/blob/main/RFCS/0087-agent-org-chart.md
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { describe, it, expect } from 'vitest';
|
|
32
|
+
import { driver } from '../lib/driver.js';
|
|
33
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
34
|
+
import { readOrgChartCap, getOrgChart, getDepartmentView } from '../lib/agentOrgChart.js';
|
|
35
|
+
|
|
36
|
+
const ROSTER_ID_RE = /^host:[a-z0-9][a-z0-9._-]*$/;
|
|
37
|
+
|
|
38
|
+
describe('agent-org-chart-scoping (RFC 0087 §A/§C/§D)', () => {
|
|
39
|
+
it('serves the normative org-chart + responsibility roll-up, tree-shaped and tenant-scoped', async () => {
|
|
40
|
+
const cap = await readOrgChartCap();
|
|
41
|
+
if (!behaviorGate('openwop-org-chart-scoping', cap?.supported === true)) return;
|
|
42
|
+
|
|
43
|
+
const installScope = typeof cap?.installScope === 'string' ? cap.installScope : 'tenant';
|
|
44
|
+
expect(
|
|
45
|
+
installScope === 'host' || installScope === 'tenant',
|
|
46
|
+
driver.describe('RFC 0087 §E / RFC 0074', "agents.orgChart.installScope (when present) MUST be 'host' or 'tenant'"),
|
|
47
|
+
).toBe(true);
|
|
48
|
+
|
|
49
|
+
// ---- Leg 1: normative read (black-box) -------------------------------
|
|
50
|
+
const chart = await getOrgChart();
|
|
51
|
+
if (chart === null) return; // advertised but read not served yet — soft-skip
|
|
52
|
+
const departments = chart.departments ?? [];
|
|
53
|
+
const members = chart.members ?? [];
|
|
54
|
+
expect(
|
|
55
|
+
Array.isArray(departments) && Array.isArray(members),
|
|
56
|
+
driver.describe('agent-org-chart.schema.json', 'GET /v1/agents/org-chart MUST return departments[] + members[]'),
|
|
57
|
+
).toBe(true);
|
|
58
|
+
|
|
59
|
+
const deptIds = new Set(departments.map((d) => d.departmentId).filter((x): x is string => typeof x === 'string'));
|
|
60
|
+
for (const d of departments) {
|
|
61
|
+
const parent = d.parentDepartmentId;
|
|
62
|
+
if (parent !== undefined && parent !== null) {
|
|
63
|
+
expect(
|
|
64
|
+
deptIds.has(parent),
|
|
65
|
+
driver.describe('agent-org-chart.md §A', 'every parentDepartmentId MUST resolve to a department in the chart (a tree)'),
|
|
66
|
+
).toBe(true);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Department tree is acyclic (walk parents from each node, bound by node count).
|
|
70
|
+
for (const d of departments) {
|
|
71
|
+
const seen = new Set<string>();
|
|
72
|
+
let cur: string | null | undefined = d.departmentId;
|
|
73
|
+
let steps = 0;
|
|
74
|
+
while (typeof cur === 'string' && steps <= departments.length) {
|
|
75
|
+
if (seen.has(cur)) break;
|
|
76
|
+
seen.add(cur);
|
|
77
|
+
cur = departments.find((x) => x.departmentId === cur)?.parentDepartmentId ?? null;
|
|
78
|
+
steps++;
|
|
79
|
+
}
|
|
80
|
+
expect(
|
|
81
|
+
steps <= departments.length,
|
|
82
|
+
driver.describe('agent-org-chart.md §A', 'the department parent graph MUST be acyclic'),
|
|
83
|
+
).toBe(true);
|
|
84
|
+
}
|
|
85
|
+
for (const m of members) {
|
|
86
|
+
expect(
|
|
87
|
+
typeof m.rosterId === 'string' && ROSTER_ID_RE.test(m.rosterId),
|
|
88
|
+
driver.describe('agent-org-chart.md §A', 'each member MUST reference a roster entry (host:<id> rosterId)'),
|
|
89
|
+
).toBe(true);
|
|
90
|
+
if (typeof m.departmentId === 'string') {
|
|
91
|
+
expect(
|
|
92
|
+
deptIds.size === 0 || deptIds.has(m.departmentId),
|
|
93
|
+
driver.describe('agent-org-chart.md §A', "a member's departmentId MUST be a department in the chart"),
|
|
94
|
+
).toBe(true);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---- Leg 2: §D responsibility roll-up --------------------------------
|
|
99
|
+
const probeDeptId = departments[0]?.departmentId;
|
|
100
|
+
if (typeof probeDeptId === 'string') {
|
|
101
|
+
const { status, view } = await getDepartmentView(probeDeptId);
|
|
102
|
+
if (status === 200 && view) {
|
|
103
|
+
expect(
|
|
104
|
+
Array.isArray(view.responsibilities),
|
|
105
|
+
driver.describe('agent-org-chart.md §D', 'the responsibility view MUST carry a responsibilities[] roll-up'),
|
|
106
|
+
).toBe(true);
|
|
107
|
+
const r = view.responsibilities ?? [];
|
|
108
|
+
expect(
|
|
109
|
+
r.length === new Set(r).size,
|
|
110
|
+
driver.describe('agent-org-chart.md §D', 'responsibilities MUST be a deduped union (no duplicate workflow ids)'),
|
|
111
|
+
).toBe(true);
|
|
112
|
+
expect(
|
|
113
|
+
r.every((w) => typeof w === 'string'),
|
|
114
|
+
driver.describe('org-chart-responsibility-view.schema.json', 'responsibilities[] entries MUST be workflow-id strings'),
|
|
115
|
+
).toBe(true);
|
|
116
|
+
// recursive=false MUST keep the response shape (a subset roll-up).
|
|
117
|
+
const direct = await getDepartmentView(probeDeptId, false);
|
|
118
|
+
if (direct.status === 200 && direct.view) {
|
|
119
|
+
expect(
|
|
120
|
+
Array.isArray(direct.view.responsibilities),
|
|
121
|
+
driver.describe('agent-org-chart.md §D', 'recursive=false MUST return the same shape, scoped to direct members'),
|
|
122
|
+
).toBe(true);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---- Leg 3: tenant scoping (RFC 0074) --------------------------------
|
|
128
|
+
const crossTenantDept = process.env.OPENWOP_CROSS_TENANT_ORG_CHART_DEPARTMENT_ID;
|
|
129
|
+
if (typeof crossTenantDept === 'string' && crossTenantDept.length > 0) {
|
|
130
|
+
const probe = await getDepartmentView(crossTenantDept);
|
|
131
|
+
expect(
|
|
132
|
+
probe.status === 404,
|
|
133
|
+
driver.describe('agent-org-chart.md §C / RFC 0074', 'GET /v1/agents/org-chart/{id} for a cross-tenant department MUST 404 (no cross-tenant disclosure)'),
|
|
134
|
+
).toBe(true);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent org-chart — record + capability + the non-authority guarantee (RFC 0087).
|
|
3
|
+
*
|
|
4
|
+
* Always-on, server-free schema-shape probe. Verifies that:
|
|
5
|
+
* - `capabilities.agents.orgChart` is declared with its `supported` /
|
|
6
|
+
* `installScope` / `departmentNesting` / `responsibilityView` sub-flags.
|
|
7
|
+
* - `agent-org-chart.schema.json` compiles and round-trips a conforming
|
|
8
|
+
* chart, and rejects malformed ones (a non-`host:` member rosterId).
|
|
9
|
+
* - the §B structural non-authority guarantee: the schema REJECTS an
|
|
10
|
+
* authority-bearing field on a member (`scopes` / `canDispatch` /
|
|
11
|
+
* `permissions`) — every object is `additionalProperties:false`, so a
|
|
12
|
+
* host cannot express position-as-authority through it. This is the public
|
|
13
|
+
* test for the protocol-tier SECURITY invariant
|
|
14
|
+
* `org-position-no-authority-escalation`.
|
|
15
|
+
*
|
|
16
|
+
* Behavioral assertions (a manager's tool over-reach is refused; an RFC 0049
|
|
17
|
+
* decision is invariant to org position; the cross-tenant 404; the §D roll-up
|
|
18
|
+
* over live roster portfolios) are gated on `capabilities.agents.orgChart.supported`
|
|
19
|
+
* and land at Active → Accepted (reference-host org store deferred per RFC 0087
|
|
20
|
+
* §Conformance — the host-extension at `/v1/host/sample/org-chart`, #371, is the
|
|
21
|
+
* reference demonstration). This scenario asserts the wire contract, not host behavior.
|
|
22
|
+
*
|
|
23
|
+
* Spec references:
|
|
24
|
+
* - https://github.com/openwop/openwop/blob/main/spec/v1/agent-org-chart.md
|
|
25
|
+
* - https://github.com/openwop/openwop/blob/main/RFCS/0087-agent-org-chart.md
|
|
26
|
+
* - https://github.com/openwop/openwop/blob/main/SECURITY/invariants.yaml (org-position-no-authority-escalation)
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { describe, it, expect } from 'vitest';
|
|
30
|
+
import { readFileSync } from 'node:fs';
|
|
31
|
+
import { join } from 'node:path';
|
|
32
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
33
|
+
import addFormats from 'ajv-formats';
|
|
34
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
35
|
+
|
|
36
|
+
/** Server-free assertion-message helper. */
|
|
37
|
+
const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
|
|
38
|
+
|
|
39
|
+
function loadSchema(name: string): Record<string, unknown> {
|
|
40
|
+
return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const CHART = {
|
|
44
|
+
owner: { tenantId: 'acme', workspaceId: 'growth' },
|
|
45
|
+
departments: [
|
|
46
|
+
{
|
|
47
|
+
departmentId: 'dept-marketing',
|
|
48
|
+
name: 'Marketing',
|
|
49
|
+
parentDepartmentId: null,
|
|
50
|
+
roles: [
|
|
51
|
+
{ roleId: 'role-cm', name: 'Campaign Manager' },
|
|
52
|
+
{ roleId: 'role-bw', name: 'Brief Writer' },
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
members: [
|
|
57
|
+
{ rosterId: 'host:sally-marketing', departmentId: 'dept-marketing', roleId: 'role-bw', reportsTo: 'host:morgan-cmo' },
|
|
58
|
+
{ rosterId: 'host:morgan-cmo', departmentId: 'dept-marketing', roleId: 'role-cm', reportsTo: null },
|
|
59
|
+
],
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
describe('agent-org-chart-shape: capability advertisement (RFC 0087, server-free)', () => {
|
|
63
|
+
it('the capabilities schema declares agents.orgChart with its sub-flags', () => {
|
|
64
|
+
const caps = loadSchema('capabilities.schema.json');
|
|
65
|
+
const agents = (caps.properties as Record<string, { properties?: Record<string, { properties?: Record<string, unknown> }> }>).agents;
|
|
66
|
+
const orgChart = agents?.properties?.orgChart;
|
|
67
|
+
expect(orgChart, why('capabilities.md §agents', 'agents.orgChart MUST be declared')).toBeDefined();
|
|
68
|
+
for (const flag of ['supported', 'installScope', 'departmentNesting', 'responsibilityView']) {
|
|
69
|
+
expect(orgChart?.properties?.[flag], why('agent-org-chart.md §E', `agents.orgChart.${flag} MUST be declared`)).toBeDefined();
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('agent-org-chart-shape: chart record (RFC 0087 §A, server-free)', () => {
|
|
75
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
76
|
+
addFormats(ajv);
|
|
77
|
+
const chart = ajv.compile(loadSchema('agent-org-chart.schema.json'));
|
|
78
|
+
|
|
79
|
+
it('AgentOrgChart validates a conforming chart', () => {
|
|
80
|
+
expect(chart(CHART), why('RFC 0087 §A', 'a conforming org-chart MUST validate')).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('rejects a non-host: member rosterId and a chart missing required arrays', () => {
|
|
84
|
+
const badMember = { ...CHART, members: [{ rosterId: 'core.openwop.agents.sally', departmentId: 'dept-marketing', roleId: 'role-bw', reportsTo: null }] };
|
|
85
|
+
expect(chart(badMember), why('RFC 0087 §A', 'a non-`host:` member rosterId MUST be rejected')).toBe(false);
|
|
86
|
+
expect(chart({ owner: { tenantId: 'acme' }, departments: [] }), why('RFC 0087 §A', 'a chart without `members` MUST be rejected')).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('agent-org-chart-shape: §B non-authority guarantee (RFC 0087, server-free)', () => {
|
|
91
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
92
|
+
addFormats(ajv);
|
|
93
|
+
const chart = ajv.compile(loadSchema('agent-org-chart.schema.json'));
|
|
94
|
+
|
|
95
|
+
it('the schema rejects an authority-bearing field on a member (org-position-no-authority-escalation)', () => {
|
|
96
|
+
for (const authorityField of ['scopes', 'canDispatch', 'permissions', 'authority']) {
|
|
97
|
+
const withAuthority = {
|
|
98
|
+
...CHART,
|
|
99
|
+
members: [{ ...CHART.members[1], [authorityField]: ['anything'] }],
|
|
100
|
+
};
|
|
101
|
+
expect(
|
|
102
|
+
chart(withAuthority),
|
|
103
|
+
why('SECURITY invariant org-position-no-authority-escalation', `a member carrying \`${authorityField}\` MUST be rejected (additionalProperties:false — position confers no authority)`),
|
|
104
|
+
).toBe(false);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('a conforming member object carries exactly the descriptive key set — nothing authority-bearing', () => {
|
|
109
|
+
const memberKeys = Object.keys(CHART.members[1]!).sort();
|
|
110
|
+
expect(memberKeys, why('RFC 0087 §B', 'a member is descriptive only: {departmentId, reportsTo, roleId, rosterId}')).toEqual(['departmentId', 'reportsTo', 'roleId', 'rosterId']);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('the GET /v1/agents/org-chart/{departmentId} responsibility-view response validates (RFC 0087 §D)', () => {
|
|
114
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
115
|
+
addFormats(ajv);
|
|
116
|
+
ajv.addSchema(loadSchema('agent-org-chart.schema.json'), 'https://openwop.dev/spec/v1/agent-org-chart.schema.json');
|
|
117
|
+
const view = ajv.compile(loadSchema('org-chart-responsibility-view.schema.json'));
|
|
118
|
+
const good = {
|
|
119
|
+
department: CHART.departments[0],
|
|
120
|
+
members: CHART.members,
|
|
121
|
+
responsibilities: ['marketing-email-campaign', 'social-post-scheduler'],
|
|
122
|
+
};
|
|
123
|
+
expect(view(good), why('RFC 0087 §D', 'a conforming responsibility-view response MUST validate')).toBe(true);
|
|
124
|
+
expect(view({ ...good, unexpected: true }), why('RFC 0087 §D', 'an extra top-level property MUST be rejected')).toBe(false);
|
|
125
|
+
expect(view({ department: CHART.departments[0], members: CHART.members }), why('RFC 0087 §D', '`responsibilities` is required')).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* openwop-agent-platform — operational-annex predicate + status derivation (RFC 0085).
|
|
3
|
+
*
|
|
4
|
+
* Always-on, server-free derivation probe. Verifies that:
|
|
5
|
+
* - `isAgentPlatformPartial` / `isAgentPlatformFull` / `agentPlatformStatus`
|
|
6
|
+
* derive `none` / `partial` / `full` correctly from representative discovery
|
|
7
|
+
* payloads (RFC 0085 §B).
|
|
8
|
+
* - the floor's replay-OR-nondeterminism term is honored: a host with no
|
|
9
|
+
* `replay.supported` but `nondeterminismPolicy.declared: true` still meets the
|
|
10
|
+
* floor.
|
|
11
|
+
* - the `full` tier requires the governance terms (RBAC + tenant installScope +
|
|
12
|
+
* memory.attribution + debug-bundle + trigger-bridge + egress-policy); a host
|
|
13
|
+
* missing any reports `partial`, never `full` (the honest-advertisement rule).
|
|
14
|
+
* - `capabilities.nondeterminismPolicy.declared` is declared in the schema.
|
|
15
|
+
*
|
|
16
|
+
* The LIVE aggregate-evidence assertion (does every required constituent scenario
|
|
17
|
+
* actually pass against a host claiming `full`?) is the `Active → Accepted` step
|
|
18
|
+
* per RFC 0085 §C — naturally gated on a reference host reaching partial/full, and
|
|
19
|
+
* deferred here. This scenario asserts the discovery-predicate derivation only.
|
|
20
|
+
*
|
|
21
|
+
* Spec references:
|
|
22
|
+
* - https://github.com/openwop/openwop/blob/main/spec/v1/agent-platform-profile.md
|
|
23
|
+
* - https://github.com/openwop/openwop/blob/main/RFCS/0085-agent-platform-meta-profile.md
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { describe, it, expect } from 'vitest';
|
|
27
|
+
import { readFileSync } from 'node:fs';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
30
|
+
import { isAgentPlatformPartial, isAgentPlatformFull, agentPlatformStatus, agentPlatformSatisfiedTerms } from '../lib/profiles.js';
|
|
31
|
+
|
|
32
|
+
const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
|
|
33
|
+
|
|
34
|
+
const CORE = {
|
|
35
|
+
protocolVersion: '1.0',
|
|
36
|
+
supportedEnvelopes: ['clarification.request'],
|
|
37
|
+
schemaVersions: {},
|
|
38
|
+
limits: { clarificationRounds: 1, schemaRounds: 1, envelopesPerTurn: 1 },
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** A discovery payload meeting the §B floor (partial). */
|
|
42
|
+
function floorPayload(extra: Record<string, unknown> = {}): Record<string, unknown> {
|
|
43
|
+
return {
|
|
44
|
+
...CORE,
|
|
45
|
+
agents: { manifestRuntime: { supported: true }, liveRuntime: { supported: true } },
|
|
46
|
+
toolCatalog: { supported: true },
|
|
47
|
+
toolHooks: { supported: true },
|
|
48
|
+
httpClient: { safeFetch: { supported: true } },
|
|
49
|
+
providerUsage: { supported: true },
|
|
50
|
+
prompts: { supported: true },
|
|
51
|
+
memory: { supported: true },
|
|
52
|
+
feedback: { supported: true },
|
|
53
|
+
replay: { supported: true },
|
|
54
|
+
...extra,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('agent-platform-profile: floor (partial) predicate (RFC 0085 §B, server-free)', () => {
|
|
59
|
+
it('a host meeting all floor flags is partial', () => {
|
|
60
|
+
const c = floorPayload();
|
|
61
|
+
expect(isAgentPlatformPartial(c), why('agent-platform-profile.md §B', 'all floor flags ⇒ partial')).toBe(true);
|
|
62
|
+
expect(agentPlatformStatus(c)).toBe('partial');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('missing a single floor flag (feedback) ⇒ none', () => {
|
|
66
|
+
const c = floorPayload({ feedback: { supported: false } });
|
|
67
|
+
expect(isAgentPlatformPartial(c), why('agent-platform-profile.md §B', 'a missing floor flag ⇒ not partial')).toBe(false);
|
|
68
|
+
expect(agentPlatformStatus(c)).toBe('none');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('replay-OR-nondeterminism: no replay but declared nondeterminism still meets the floor', () => {
|
|
72
|
+
const c = floorPayload({ replay: { supported: false }, nondeterminismPolicy: { declared: true } });
|
|
73
|
+
expect(isAgentPlatformPartial(c), why('agent-platform-profile.md §B', 'declared nondeterminism satisfies the replay-OR term')).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('neither replay nor declared nondeterminism ⇒ floor unmet', () => {
|
|
77
|
+
const c = floorPayload({ replay: { supported: false } });
|
|
78
|
+
expect(isAgentPlatformPartial(c), why('agent-platform-profile.md §B', 'neither replay nor declared policy ⇒ not partial')).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('agent-platform-profile: full predicate + honest-advertisement (RFC 0085 §B/§D, server-free)', () => {
|
|
83
|
+
const fullExtra = {
|
|
84
|
+
authorization: { supported: true },
|
|
85
|
+
agents: { manifestRuntime: { supported: true, installScope: 'tenant' }, liveRuntime: { supported: true } },
|
|
86
|
+
memory: { supported: true, attribution: { supported: true } },
|
|
87
|
+
debugBundle: { supported: true },
|
|
88
|
+
triggerBridge: { supported: true },
|
|
89
|
+
httpClient: { safeFetch: { supported: true }, egressPolicy: { supported: true } },
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
it('a host meeting floor + all governance terms is full', () => {
|
|
93
|
+
const c = floorPayload(fullExtra);
|
|
94
|
+
expect(isAgentPlatformFull(c), why('agent-platform-profile.md §B', 'floor + governance ⇒ full')).toBe(true);
|
|
95
|
+
expect(agentPlatformStatus(c)).toBe('full');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('a host advertising governance flags but missing tenant installScope reports partial, not full', () => {
|
|
99
|
+
const c = floorPayload({
|
|
100
|
+
...fullExtra,
|
|
101
|
+
agents: { manifestRuntime: { supported: true, installScope: 'host' }, liveRuntime: { supported: true } },
|
|
102
|
+
});
|
|
103
|
+
expect(isAgentPlatformFull(c), why('agent-platform-profile.md §D', 'missing a governance term ⇒ MUST NOT be full')).toBe(false);
|
|
104
|
+
expect(agentPlatformStatus(c)).toBe('partial');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('eval/deploy/budget are NOT hard full terms (a full host without them is still full)', () => {
|
|
108
|
+
const c = floorPayload(fullExtra); // no agents.evalSuite / agents.deployment / budget
|
|
109
|
+
expect(isAgentPlatformFull(c), why('agent-platform-profile.md §B', 'platform-plus tier is advisory, not a hard full term')).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('agent-platform-profile: satisfiedTerms[] non-contiguous adoption (RFC 0085 §D, server-free)', () => {
|
|
114
|
+
it('a host honoring full-tier terms but failing floor terms is status none yet has a non-empty satisfiedTerms[]', () => {
|
|
115
|
+
// The real-host (MyndHyve) shape: RBAC + memory.attribution + tenant installScope (3 full terms)
|
|
116
|
+
// satisfied, while liveRuntime / toolCatalog / providerUsage / memory floor terms are absent.
|
|
117
|
+
const c = {
|
|
118
|
+
...CORE,
|
|
119
|
+
agents: { manifestRuntime: { supported: true, installScope: 'tenant' } }, // no liveRuntime
|
|
120
|
+
authorization: { supported: true },
|
|
121
|
+
memory: { attribution: { supported: true } }, // attribution but NOT memory.supported
|
|
122
|
+
toolHooks: { supported: true },
|
|
123
|
+
httpClient: { safeFetch: { supported: true } },
|
|
124
|
+
prompts: { supported: true },
|
|
125
|
+
feedback: { supported: true },
|
|
126
|
+
replay: { supported: true },
|
|
127
|
+
} as Record<string, unknown>;
|
|
128
|
+
expect(agentPlatformStatus(c), why('agent-platform-profile.md §D', 'floor unmet ⇒ none')).toBe('none');
|
|
129
|
+
const terms = agentPlatformSatisfiedTerms(c);
|
|
130
|
+
expect(terms.includes('full:authorization'), why('§D', 'a satisfied full term is reported even at none')).toBe(true);
|
|
131
|
+
expect(terms.includes('full:memory.attribution')).toBe(true);
|
|
132
|
+
expect(terms.includes('full:tenant-installScope')).toBe(true);
|
|
133
|
+
expect(terms.includes('floor:agents.liveRuntime'), why('§D', 'an unmet floor term is NOT reported')).toBe(false);
|
|
134
|
+
expect(terms.length).toBeGreaterThan(0); // distinguishable from a 0/16 do-nothing host
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('a full host reports all sixteen terms satisfied', () => {
|
|
138
|
+
const c = floorPayload({
|
|
139
|
+
authorization: { supported: true },
|
|
140
|
+
agents: { manifestRuntime: { supported: true, installScope: 'tenant' }, liveRuntime: { supported: true } },
|
|
141
|
+
memory: { supported: true, attribution: { supported: true } },
|
|
142
|
+
debugBundle: { supported: true },
|
|
143
|
+
triggerBridge: { supported: true },
|
|
144
|
+
httpClient: { safeFetch: { supported: true }, egressPolicy: { supported: true } },
|
|
145
|
+
});
|
|
146
|
+
expect(agentPlatformSatisfiedTerms(c).length, why('§D', 'a full host satisfies all 16 terms')).toBe(16);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('agent-platform-profile: capability shape (RFC 0085, server-free)', () => {
|
|
151
|
+
it('capabilities.nondeterminismPolicy.declared is declared', () => {
|
|
152
|
+
const caps = JSON.parse(readFileSync(join(SCHEMAS_DIR, 'capabilities.schema.json'), 'utf8')) as { properties?: Record<string, { properties?: Record<string, unknown> }> };
|
|
153
|
+
expect(
|
|
154
|
+
caps.properties?.nondeterminismPolicy?.properties?.declared,
|
|
155
|
+
why('agent-platform-profile.md §B', 'capabilities.nondeterminismPolicy.declared MUST be declared'),
|
|
156
|
+
).toBeDefined();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standing-agent roster attribution + ordering (RFC 0086 §B/§C) — behavioral.
|
|
3
|
+
*
|
|
4
|
+
* Gated on `capabilities.agents.roster.supported` (root-first per RFC 0073).
|
|
5
|
+
* Soft-skips when unadvertised (default) / hard-fails under
|
|
6
|
+
* `OPENWOP_REQUIRE_BEHAVIOR=true` via `behaviorGate`. The companion always-on
|
|
7
|
+
* wire-shape coverage lives in `agent-roster-shape.test.ts`; this scenario
|
|
8
|
+
* asserts host BEHAVIOR:
|
|
9
|
+
*
|
|
10
|
+
* 1. NORMATIVE read — `GET /v1/agents/roster` (RFC 0086 §B) returns the
|
|
11
|
+
* `agent-roster-response` shape (roster[] + `total == roster.length`), and
|
|
12
|
+
* every entry carries a `host:<id>` `rosterId`, a `persona`, an
|
|
13
|
+
* `agentRef.agentId`, and an `owner.tenantId`. Runs black-box against the
|
|
14
|
+
* normative path on any roster host.
|
|
15
|
+
* 2. ATTRIBUTION + ORDERING (seam-gated) — a portfolio fire emits
|
|
16
|
+
* `roster.run.initiated` as the run's FIRST attribution event, BEFORE any
|
|
17
|
+
* `agent.invocation.*` / `agent.*` event (§C), content-free (no work-item
|
|
18
|
+
* `body`/`prompt`/credential — the `roster-attribution-no-content`
|
|
19
|
+
* invariant), with `rosterId`/`persona`/`agentId`/`workflowId`/
|
|
20
|
+
* `triggerSource`. A durable work-item fire additionally carries
|
|
21
|
+
* `triggerSubscriptionId` (RFC 0083) traceable on the run's `causationId`.
|
|
22
|
+
* 3. TENANT SCOPING (§B / RFC 0074) — a `GET /v1/agents/roster/{id}` for an id
|
|
23
|
+
* outside the caller's owner triple 404s (probed only when a cross-tenant id
|
|
24
|
+
* is supplied via `OPENWOP_CROSS_TENANT_ROSTER_ID`; soft-skip otherwise).
|
|
25
|
+
*
|
|
26
|
+
* The fire + event-log seams are OPTIONAL (reference roster store deferred per
|
|
27
|
+
* RFC 0086 §Conformance); each leg soft-skips independently so a host that
|
|
28
|
+
* serves only the normative read still exercises leg 1.
|
|
29
|
+
*
|
|
30
|
+
* Spec references:
|
|
31
|
+
* - https://github.com/openwop/openwop/blob/main/spec/v1/agent-roster.md
|
|
32
|
+
* - https://github.com/openwop/openwop/blob/main/RFCS/0086-standing-agent-roster-and-workflow-portfolio.md
|
|
33
|
+
* - https://github.com/openwop/openwop/blob/main/SECURITY/invariants.yaml (roster-attribution-no-content)
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { describe, it, expect } from 'vitest';
|
|
37
|
+
import { driver } from '../lib/driver.js';
|
|
38
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
39
|
+
import { readRosterCap, listRoster, getRosterEntry, fireRosterPortfolio } from '../lib/agentRoster.js';
|
|
40
|
+
import {
|
|
41
|
+
queryTestEvents,
|
|
42
|
+
isEventLogSeamAvailable,
|
|
43
|
+
resetTestSeam,
|
|
44
|
+
type TestEvent,
|
|
45
|
+
} from '../lib/event-log-query.js';
|
|
46
|
+
|
|
47
|
+
const ROSTER_ID_RE = /^host:[a-z0-9][a-z0-9._-]*$/;
|
|
48
|
+
|
|
49
|
+
/** Lowest-sequence event matching one of `types`; undefined when none present. */
|
|
50
|
+
function firstOf(events: TestEvent[], types: string[]): TestEvent | undefined {
|
|
51
|
+
return events
|
|
52
|
+
.filter((e) => types.includes(e.type))
|
|
53
|
+
.sort((a, b) => a.sequence - b.sequence)[0];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('agent-roster-attribution (RFC 0086 §B/§C)', () => {
|
|
57
|
+
it('serves the normative roster, attributes a portfolio fire content-free + ordered, and tenant-scopes', async () => {
|
|
58
|
+
const cap = await readRosterCap();
|
|
59
|
+
if (!behaviorGate('openwop-roster-attribution', cap?.supported === true)) return;
|
|
60
|
+
|
|
61
|
+
// RFC 0074 carry-forward: installScope MUST be host|tenant when present.
|
|
62
|
+
const installScope = typeof cap?.installScope === 'string' ? cap.installScope : 'host';
|
|
63
|
+
expect(
|
|
64
|
+
installScope === 'host' || installScope === 'tenant',
|
|
65
|
+
driver.describe('RFC 0086 §F / RFC 0074 §B', "agents.roster.installScope (when present) MUST be 'host' or 'tenant'"),
|
|
66
|
+
).toBe(true);
|
|
67
|
+
|
|
68
|
+
// ---- Leg 1: normative read (black-box on any roster host) -------------
|
|
69
|
+
const body = await listRoster();
|
|
70
|
+
if (body === null) return; // host advertises roster but doesn't serve the read yet — soft-skip
|
|
71
|
+
const roster = body.roster ?? [];
|
|
72
|
+
expect(
|
|
73
|
+
Array.isArray(roster),
|
|
74
|
+
driver.describe('agent-roster.md §B', 'GET /v1/agents/roster MUST return a roster[] array'),
|
|
75
|
+
).toBe(true);
|
|
76
|
+
expect(
|
|
77
|
+
body.total === roster.length,
|
|
78
|
+
driver.describe('agent-roster-response.schema.json', 'total MUST equal roster.length'),
|
|
79
|
+
).toBe(true);
|
|
80
|
+
for (const entry of roster) {
|
|
81
|
+
expect(
|
|
82
|
+
typeof entry.rosterId === 'string' && ROSTER_ID_RE.test(entry.rosterId),
|
|
83
|
+
driver.describe('agent-roster-entry.schema.json', 'each entry MUST carry a host:<id> rosterId'),
|
|
84
|
+
).toBe(true);
|
|
85
|
+
expect(
|
|
86
|
+
typeof entry.persona === 'string' && entry.persona.length > 0,
|
|
87
|
+
driver.describe('agent-roster.md §A', 'each entry MUST carry a non-empty persona'),
|
|
88
|
+
).toBe(true);
|
|
89
|
+
expect(
|
|
90
|
+
typeof entry.agentRef?.agentId === 'string',
|
|
91
|
+
driver.describe('agent-roster.md §A', 'each entry MUST reference an agentRef.agentId'),
|
|
92
|
+
).toBe(true);
|
|
93
|
+
expect(
|
|
94
|
+
typeof entry.owner?.tenantId === 'string',
|
|
95
|
+
driver.describe('agent-roster.md §B / RFC 0074', 'each entry MUST carry an owner.tenantId scope'),
|
|
96
|
+
).toBe(true);
|
|
97
|
+
// RFC 0082 §A XOR: an agentRef MUST NOT pin both version and channel.
|
|
98
|
+
expect(
|
|
99
|
+
!(entry.agentRef?.version !== undefined && entry.agentRef?.channel !== undefined),
|
|
100
|
+
driver.describe('RFC 0082 §A', 'agentRef MUST NOT carry both version and channel'),
|
|
101
|
+
).toBe(true);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---- Leg 2: attribution + ordering (seam-gated) ----------------------
|
|
105
|
+
if (await isEventLogSeamAvailable()) {
|
|
106
|
+
// Scheduled portfolio fire.
|
|
107
|
+
const fired = await fireRosterPortfolio({ triggerSource: 'schedule' });
|
|
108
|
+
if (fired?.runId) {
|
|
109
|
+
const q = await queryTestEvents(fired.runId);
|
|
110
|
+
if (q.ok) {
|
|
111
|
+
const init = firstOf(q.events, ['roster.run.initiated']);
|
|
112
|
+
expect(
|
|
113
|
+
init !== undefined,
|
|
114
|
+
driver.describe('agent-roster.md §C', 'a portfolio fire MUST emit roster.run.initiated'),
|
|
115
|
+
).toBe(true);
|
|
116
|
+
|
|
117
|
+
if (init) {
|
|
118
|
+
// Ordering: roster.run.initiated precedes ANY agent invocation/event.
|
|
119
|
+
const firstAgent = firstOf(q.events, [
|
|
120
|
+
'agent.invocation.started',
|
|
121
|
+
'agent.reasoned',
|
|
122
|
+
'agent.decided',
|
|
123
|
+
]);
|
|
124
|
+
if (firstAgent) {
|
|
125
|
+
expect(
|
|
126
|
+
init.sequence < firstAgent.sequence,
|
|
127
|
+
driver.describe('agent-roster.md §C', 'roster.run.initiated MUST precede any agent.* event in the run'),
|
|
128
|
+
).toBe(true);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Content-free: required ids present; NO work-item body/prompt/credential.
|
|
132
|
+
const p = init.payload;
|
|
133
|
+
for (const key of ['rosterId', 'persona', 'agentId', 'workflowId', 'triggerSource']) {
|
|
134
|
+
expect(
|
|
135
|
+
typeof p[key] === 'string' && (p[key] as string).length > 0,
|
|
136
|
+
driver.describe('run-event-payloads.schema.json#rosterRunInitiated', `roster.run.initiated MUST carry ${key}`),
|
|
137
|
+
).toBe(true);
|
|
138
|
+
}
|
|
139
|
+
for (const forbidden of ['body', 'prompt', 'input', 'payload', 'apiKey', 'secret', 'credentials', 'token']) {
|
|
140
|
+
expect(
|
|
141
|
+
!(forbidden in p),
|
|
142
|
+
driver.describe('SECURITY roster-attribution-no-content', `roster.run.initiated MUST be content-free (no ${forbidden})`),
|
|
143
|
+
).toBe(true);
|
|
144
|
+
}
|
|
145
|
+
expect(
|
|
146
|
+
typeof p.rosterId === 'string' && ROSTER_ID_RE.test(p.rosterId),
|
|
147
|
+
driver.describe('agent-roster.md §C', 'roster.run.initiated.rosterId MUST be a host:<id> AgentRef id'),
|
|
148
|
+
).toBe(true);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Durable work-item fire: carries the RFC 0083 triggerSubscriptionId + causation.
|
|
154
|
+
const work = await fireRosterPortfolio({ triggerSource: 'webhook', asWorkItem: true });
|
|
155
|
+
if (work?.runId) {
|
|
156
|
+
const q = await queryTestEvents(work.runId, { type: 'roster.run.initiated' });
|
|
157
|
+
if (q.ok && q.events[0]) {
|
|
158
|
+
const p = q.events[0].payload;
|
|
159
|
+
expect(
|
|
160
|
+
typeof p.triggerSubscriptionId === 'string' && (p.triggerSubscriptionId as string).length > 0,
|
|
161
|
+
driver.describe('agent-roster.md §D / RFC 0083', 'a durable work-item fire MUST carry triggerSubscriptionId for trigger→run→roster ancestry'),
|
|
162
|
+
).toBe(true);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
await resetTestSeam();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---- Leg 3: tenant scoping (RFC 0074) --------------------------------
|
|
170
|
+
const crossTenantId = process.env.OPENWOP_CROSS_TENANT_ROSTER_ID;
|
|
171
|
+
if (typeof crossTenantId === 'string' && crossTenantId.length > 0) {
|
|
172
|
+
const probe = await getRosterEntry(crossTenantId);
|
|
173
|
+
expect(
|
|
174
|
+
probe.status === 404,
|
|
175
|
+
driver.describe('agent-roster.md §B / RFC 0074', "GET /v1/agents/roster/{id} for a cross-tenant id MUST 404 (no cross-tenant disclosure)"),
|
|
176
|
+
).toBe(true);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
});
|