@openwop/openwop-conformance 1.53.1 → 1.57.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 +20 -0
- package/README.md +2 -2
- package/api/openapi.yaml +9 -0
- package/coverage.md +3 -0
- package/package.json +1 -1
- package/schemas/README.md +1 -0
- package/schemas/agent-manifest.schema.json +23 -1
- package/schemas/capabilities.schema.json +80 -3
- package/schemas/connection-pack-manifest.schema.json +5 -0
- package/schemas/frontend-plugin-manifest.schema.json +9 -3
- package/schemas/residency.schema.json +16 -0
- package/schemas/run-snapshot.schema.json +6 -1
- package/schemas/workflow-chain-pack-manifest.schema.json +85 -6
- package/schemas/workflow-definition.schema.json +4 -3
- package/src/lib/anonymousActor.ts +99 -0
- package/src/lib/workflow-chain-expansion.ts +342 -0
- package/src/scenarios/agent-manifest-role-profile.test.ts +116 -0
- package/src/scenarios/anonymous-actor-audit-opaque.test.ts +71 -0
- package/src/scenarios/anonymous-actor-default-deny.test.ts +87 -0
- package/src/scenarios/anonymous-actor-egress-guarded.test.ts +54 -0
- package/src/scenarios/anonymous-actor-no-secret-reach.test.ts +78 -0
- package/src/scenarios/anonymous-actor-shape.test.ts +173 -0
- package/src/scenarios/anonymous-actor-write-gated.test.ts +83 -0
- package/src/scenarios/chain-produced-var-roundtrip.test.ts +152 -0
- package/src/scenarios/chain-subchain-cycle-rejected.test.ts +141 -0
- package/src/scenarios/chain-subchain-fanout.test.ts +139 -0
- package/src/scenarios/chain-subchain-sibling.test.ts +147 -0
- package/src/scenarios/chain-subchain-unsupported-refused.test.ts +66 -0
- package/src/scenarios/connection-pack-manifest-valid.test.ts +33 -0
- package/src/scenarios/data-residency-admission.test.ts +138 -0
- package/src/scenarios/edge-condition-truthy-falsy.test.ts +104 -0
- package/src/scenarios/frontend-plugin-packs.test.ts +24 -0
|
@@ -43,6 +43,33 @@ export interface WorkflowChain {
|
|
|
43
43
|
dag: { nodes: ReadonlyArray<FragmentNode>; edges?: ReadonlyArray<FragmentEdge> };
|
|
44
44
|
outputs?: Record<string, { type: string; description: string }>;
|
|
45
45
|
capabilities?: ReadonlyArray<'streamable' | 'cacheable' | 'side-effectful' | 'mcp-exportable'>;
|
|
46
|
+
/** RFC 0133 §1. Child chains this chain composes at run time. A node
|
|
47
|
+
* references one via `config.subChainRef`; the host co-registers it as
|
|
48
|
+
* its own workflow and dispatches it as a child run. Optional — a chain
|
|
49
|
+
* with none behaves exactly as under RFC 0013. */
|
|
50
|
+
subChains?: ReadonlyArray<SubChainRef>;
|
|
51
|
+
/** RFC 0133 §2. Run-scoped values a node writes to the executor's variable
|
|
52
|
+
* bag and downstream nodes read via a `{ type:"variable" }` input binding.
|
|
53
|
+
* Distinct from author-time `parameters`; carry NO author-time value. */
|
|
54
|
+
producedVariables?: ReadonlyArray<ProducedVariable>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** RFC 0133 §1.1. A reference to a child chain the parent composes. `ref` is
|
|
58
|
+
* EITHER a sibling `chainId` string (a chain in the SAME pack) OR an object
|
|
59
|
+
* naming an externally published chain (resolved + signature-verified like any
|
|
60
|
+
* pack dependency). */
|
|
61
|
+
export interface SubChainRef {
|
|
62
|
+
ref: string | { packName: string; chainId: string; version: string };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** RFC 0133 §2.2. A run-scoped variable a chain declares: written by one node
|
|
66
|
+
* (`producedBy`) and read by others by `name`. `type` is a JSON-Schema type
|
|
67
|
+
* token for validation/inspection. Run-scoped — no author-time value. */
|
|
68
|
+
export interface ProducedVariable {
|
|
69
|
+
name: string;
|
|
70
|
+
producedBy: string;
|
|
71
|
+
type: string;
|
|
72
|
+
description?: string;
|
|
46
73
|
}
|
|
47
74
|
|
|
48
75
|
export interface FragmentNode {
|
|
@@ -512,3 +539,318 @@ export function expandChainDeferred(
|
|
|
512
539
|
configurableSchema,
|
|
513
540
|
};
|
|
514
541
|
}
|
|
542
|
+
|
|
543
|
+
// ---------------------------------------------------------------------------
|
|
544
|
+
// RFC 0133 — Workflow-chain composition: sub-chains + produced variables.
|
|
545
|
+
//
|
|
546
|
+
// Two additive extensions to the RFC 0013 model, each usable alone:
|
|
547
|
+
//
|
|
548
|
+
// §1 SUB-CHAINS — a chain declares child chains it composes (`subChains[]`)
|
|
549
|
+
// and references them from a runtime dispatch node via `config.subChainRef`.
|
|
550
|
+
// Unlike RFC 0013's author-time inline splice, a referenced sub-chain is
|
|
551
|
+
// CO-INSTANTIATED as its own registered workflow and dispatched by the
|
|
552
|
+
// parent at run time (the parent genuinely holds the child). Expansion
|
|
553
|
+
// mints a DETERMINISTIC, TENANT-SCOPED child id from (tenantId, childChainId, version) so
|
|
554
|
+
// a repeat instantiation converges and a shared child registers once, then
|
|
555
|
+
// rewrites the referencing node's `config.subChainRef` → `config.workflowId`
|
|
556
|
+
// (the field the runtime already reads). Recursion is bounded by a cycle
|
|
557
|
+
// check + `maxSubChainDepth` (DoS guard — SECURITY `sub-chain-expansion-bounded`).
|
|
558
|
+
//
|
|
559
|
+
// §2 PRODUCED VARIABLES — a chain declares run-scoped values a node writes to
|
|
560
|
+
// the executor's variable bag (`producedVariables[]`), emitted on expansion
|
|
561
|
+
// into `WorkflowDefinition.variables[]`. Any `{ type:"variable" }` input
|
|
562
|
+
// read is validated closed-world against the declared set (+ materialized
|
|
563
|
+
// params); an undeclared read is a `variable_undeclared` manifest error.
|
|
564
|
+
//
|
|
565
|
+
// @see spec/v1/workflow-chain-packs.md §"Sub-chain composition (RFC 0133)"
|
|
566
|
+
// + §"Produced (run-scoped) variables (RFC 0133)"
|
|
567
|
+
// @see RFCS/0133-workflow-chain-composition.md
|
|
568
|
+
// ---------------------------------------------------------------------------
|
|
569
|
+
|
|
570
|
+
/** RECOMMENDED default recursion bound for sub-chain co-expansion (RFC 0133
|
|
571
|
+
* §1.3 — confirms UQ3). A host MAY advertise a different `maxDepth` via
|
|
572
|
+
* `capabilities.workflowChainPacks.subChains.maxDepth`. */
|
|
573
|
+
export const DEFAULT_MAX_SUB_CHAIN_DEPTH = 8;
|
|
574
|
+
|
|
575
|
+
/** Thrown when a `config.subChainRef` (or a `subChains[].ref`) names a sibling
|
|
576
|
+
* chainId that is not present in the same pack, or an external ref that fails
|
|
577
|
+
* resolution/verification. Wire code `sub_chain_unresolved` (HTTP 400) per
|
|
578
|
+
* `workflow-chain-packs.md` §"Error codes" (RFC 0133). */
|
|
579
|
+
export class SubChainUnresolvedError extends Error {
|
|
580
|
+
readonly code = 'sub_chain_unresolved';
|
|
581
|
+
readonly httpStatus = 400;
|
|
582
|
+
constructor(readonly ref: string, readonly chainId: string) {
|
|
583
|
+
super(`sub_chain_unresolved: '${ref}' referenced from chain '${chainId}'`);
|
|
584
|
+
this.name = 'SubChainUnresolvedError';
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Thrown when a chain transitively composes itself (a cycle in the compose
|
|
589
|
+
* graph). Wire code `sub_chain_cycle` (HTTP 400) per `workflow-chain-packs.md`
|
|
590
|
+
* §"Error codes" (RFC 0133). Together with `SubChainDepthExceededError` it is a
|
|
591
|
+
* public test for SECURITY invariant `sub-chain-expansion-bounded`. */
|
|
592
|
+
export class SubChainCycleError extends Error {
|
|
593
|
+
readonly code = 'sub_chain_cycle';
|
|
594
|
+
readonly httpStatus = 400;
|
|
595
|
+
constructor(readonly chainId: string, readonly detail: string) {
|
|
596
|
+
super(`sub_chain_cycle: chain '${chainId}' — ${detail}`);
|
|
597
|
+
this.name = 'SubChainCycleError';
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Thrown when co-expansion nesting exceeds `maxSubChainDepth` — the DoS depth
|
|
602
|
+
* backstop, DISTINCT from an actual cycle so an operator sees WHICH bound fired.
|
|
603
|
+
* Wire code `sub_chain_max_depth_exceeded` (HTTP 400) per `workflow-chain-packs.md`
|
|
604
|
+
* §"Error codes" (RFC 0133). A public test for `sub-chain-expansion-bounded`. */
|
|
605
|
+
export class SubChainDepthExceededError extends Error {
|
|
606
|
+
readonly code = 'sub_chain_max_depth_exceeded';
|
|
607
|
+
readonly httpStatus = 400;
|
|
608
|
+
constructor(readonly chainId: string, readonly maxDepth: number) {
|
|
609
|
+
super(`sub_chain_max_depth_exceeded: chain '${chainId}' exceeds maxSubChainDepth ${maxDepth}`);
|
|
610
|
+
this.name = 'SubChainDepthExceededError';
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** Thrown when a node input binding `{ type:"variable", variableName }` reads a
|
|
615
|
+
* name that is neither a declared `producedVariables[].name` nor a materialized
|
|
616
|
+
* parameter — the closed-world guard that closes the "reads a value nothing
|
|
617
|
+
* produces" hole. Wire code `variable_undeclared` (HTTP 400) per
|
|
618
|
+
* `workflow-chain-packs.md` §"Error codes" (RFC 0133). */
|
|
619
|
+
export class VariableUndeclaredError extends Error {
|
|
620
|
+
readonly code = 'variable_undeclared';
|
|
621
|
+
readonly httpStatus = 400;
|
|
622
|
+
constructor(readonly variableName: string, readonly chainId: string) {
|
|
623
|
+
super(`variable_undeclared: '${variableName}' read in chain '${chainId}'`);
|
|
624
|
+
this.name = 'VariableUndeclaredError';
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** Thrown when a `producedVariables[].producedBy` names a node id that does not
|
|
629
|
+
* exist in the same fragment — a malformed producer declaration (the value would
|
|
630
|
+
* be produced by nothing). Distinct from `variable_undeclared` (a bad READER) so
|
|
631
|
+
* an operator sees which side is malformed. Wire code `produced_var_producer_unknown`
|
|
632
|
+
* (HTTP 400) per `workflow-chain-packs.md` §"Error codes" (RFC 0133). */
|
|
633
|
+
export class ProducedVarProducerUnknownError extends Error {
|
|
634
|
+
readonly code = 'produced_var_producer_unknown';
|
|
635
|
+
readonly httpStatus = 400;
|
|
636
|
+
constructor(readonly variableName: string, readonly producedBy: string, readonly chainId: string) {
|
|
637
|
+
super(`produced_var_producer_unknown: '${variableName}' producedBy unknown node '${producedBy}' in chain '${chainId}'`);
|
|
638
|
+
this.name = 'ProducedVarProducerUnknownError';
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** The canonical `subChains[].ref` chainId — a string ref is the sibling
|
|
643
|
+
* chainId; an object ref's `chainId` is the external chain's id. */
|
|
644
|
+
function refChainId(ref: SubChainRef['ref']): string {
|
|
645
|
+
return typeof ref === 'string' ? ref : ref.chainId;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Deterministic child workflow id minted from **(tenantId, childChainId, version)**
|
|
649
|
+
* per RFC 0133 §1.3 step 2. TENANT-SCOPED by construction: the `registerWorkflow`
|
|
650
|
+
* registry is global by-id, so a tenant-less id would let two tenants instantiating
|
|
651
|
+
* the same parent→child COLLIDE on one global workflow (a cross-tenant isolation
|
|
652
|
+
* break — SECURITY `sub-chain-child-tenant-scoped`). Keying on `tenantId` also makes
|
|
653
|
+
* the dedup correct ACROSS PARENTS within a tenant: a child chain composed by two
|
|
654
|
+
* different parents in the same tenant registers exactly once (a repeat instantiation
|
|
655
|
+
* converges). `version` distinguishes `lesson-batch@1` from `lesson-batch@2`. Dots +
|
|
656
|
+
* unsafe chars are slugged (storage-key safety). */
|
|
657
|
+
export function mintChildWorkflowId(tenantId: string, childChainId: string, version: string): string {
|
|
658
|
+
const slug = (s: string): string => s.replace(/[^0-9A-Za-z._-]/g, '_').replace(/\./g, '_');
|
|
659
|
+
return `wfc_${slug(tenantId)}__${slug(childChainId)}__${slug(version)}`;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** A child chain co-registered as its own workflow during parent expansion. */
|
|
663
|
+
export interface CoRegisteredChild {
|
|
664
|
+
/** Deterministic minted id (see `mintChildWorkflowId`). */
|
|
665
|
+
childWorkflowId: string;
|
|
666
|
+
/** The composed chain's `chainId`. */
|
|
667
|
+
chainId: string;
|
|
668
|
+
/** The child's own expanded fragment (registered as a standalone workflow). */
|
|
669
|
+
fragment: ExpandedFragment;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
export interface ChainTreeExpansion {
|
|
673
|
+
/** The parent fragment — every `config.subChainRef` rewritten to the minted
|
|
674
|
+
* child `config.workflowId`. */
|
|
675
|
+
parent: ExpandedFragment;
|
|
676
|
+
/** Every co-registered child, deduplicated by `childWorkflowId` (a child
|
|
677
|
+
* referenced twice registers once). */
|
|
678
|
+
children: ReadonlyArray<CoRegisteredChild>;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
export interface CoExpansionContext {
|
|
682
|
+
/** Unique tag for this parent instantiation — seeds the parent node-id prefix
|
|
683
|
+
* (per-drop node-id uniqueness). Does NOT seed the child workflow id (that is
|
|
684
|
+
* tenant-scoped — see `tenantId`). */
|
|
685
|
+
parentExpansionId: string;
|
|
686
|
+
/** The instantiating tenant. The deterministic CHILD workflow id is scoped to
|
|
687
|
+
* this tenant so two tenants never collide on the global by-id registry
|
|
688
|
+
* (SECURITY `sub-chain-child-tenant-scoped`) and a child shared across parents
|
|
689
|
+
* within the tenant dedups to one registration. */
|
|
690
|
+
tenantId: string;
|
|
691
|
+
params: Record<string, unknown>;
|
|
692
|
+
isTypeIdResolvable: (typeId: string) => boolean;
|
|
693
|
+
/** Sibling chains available in the SAME pack, keyed by `chainId`. External
|
|
694
|
+
* refs (object form) are resolved host-side and supplied here too when the
|
|
695
|
+
* host wants them co-expanded in-process; a ref absent from this map is
|
|
696
|
+
* `sub_chain_unresolved`. */
|
|
697
|
+
siblingChains: ReadonlyMap<string, WorkflowChain>;
|
|
698
|
+
/** Recursion bound (default `DEFAULT_MAX_SUB_CHAIN_DEPTH`). */
|
|
699
|
+
maxDepth?: number;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** Collect every distinct `config.subChainRef` reachable from a chain's nodes. */
|
|
703
|
+
function subChainRefsInNodes(chain: WorkflowChain): string[] {
|
|
704
|
+
const refs: string[] = [];
|
|
705
|
+
for (const n of chain.dag.nodes) {
|
|
706
|
+
const r = n.config?.['subChainRef'];
|
|
707
|
+
if (typeof r === 'string' && !refs.includes(r)) refs.push(r);
|
|
708
|
+
}
|
|
709
|
+
return refs;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Co-expand a parent chain and every sub-chain it composes (RFC 0133 §1.3).
|
|
714
|
+
* Resolves each `config.subChainRef` to a sibling chain, recursively co-expands
|
|
715
|
+
* it, mints a deterministic child workflow id, and rewrites the referencing
|
|
716
|
+
* node's `config.subChainRef` → `config.workflowId`. Bounded by a cycle check
|
|
717
|
+
* and `maxDepth`.
|
|
718
|
+
*
|
|
719
|
+
* @throws SubChainUnresolvedError when a ref names no sibling chain.
|
|
720
|
+
* @throws SubChainCycleError on a compose cycle or a depth-bound breach.
|
|
721
|
+
* @throws ChainUnresolvableTypeIdError when any node typeId fails resolution.
|
|
722
|
+
*/
|
|
723
|
+
export function expandChainTree(
|
|
724
|
+
root: WorkflowChain,
|
|
725
|
+
ctx: CoExpansionContext,
|
|
726
|
+
): ChainTreeExpansion {
|
|
727
|
+
const maxDepth = ctx.maxDepth ?? DEFAULT_MAX_SUB_CHAIN_DEPTH;
|
|
728
|
+
const children = new Map<string, CoRegisteredChild>();
|
|
729
|
+
|
|
730
|
+
// DFS over the compose graph with an on-stack set for cycle detection.
|
|
731
|
+
const onStack = new Set<string>();
|
|
732
|
+
|
|
733
|
+
const expandOne = (chain: WorkflowChain, depth: number): ExpandedFragment => {
|
|
734
|
+
if (depth > maxDepth) {
|
|
735
|
+
throw new SubChainDepthExceededError(chain.chainId, maxDepth);
|
|
736
|
+
}
|
|
737
|
+
if (onStack.has(chain.chainId)) {
|
|
738
|
+
throw new SubChainCycleError(chain.chainId, 'chain transitively composes itself');
|
|
739
|
+
}
|
|
740
|
+
onStack.add(chain.chainId);
|
|
741
|
+
|
|
742
|
+
// Validate declared subChains[] resolve, and recurse into each referenced
|
|
743
|
+
// sibling BEFORE rewriting the parent so child ids exist to splice in.
|
|
744
|
+
const declared = new Set((chain.subChains ?? []).map((s) => refChainId(s.ref)));
|
|
745
|
+
for (const ref of subChainRefsInNodes(chain)) {
|
|
746
|
+
// A node ref MUST be a declared subChains[] entry (RFC 0133 §1.2).
|
|
747
|
+
if (!declared.has(ref)) throw new SubChainUnresolvedError(ref, chain.chainId);
|
|
748
|
+
const sibling = ctx.siblingChains.get(ref);
|
|
749
|
+
if (!sibling) throw new SubChainUnresolvedError(ref, chain.chainId);
|
|
750
|
+
// Tenant-scoped, version-pinned deterministic child id (§1.3 step 2).
|
|
751
|
+
const childId = mintChildWorkflowId(ctx.tenantId, ref, sibling.version);
|
|
752
|
+
if (!children.has(childId)) {
|
|
753
|
+
// Recurse first; the child fragment is registered under the minted id.
|
|
754
|
+
const childFragment = expandOne(sibling, depth + 1);
|
|
755
|
+
children.set(childId, { childWorkflowId: childId, chainId: ref, fragment: childFragment });
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Expand this chain's own fragment (reuses the RFC 0013 algorithm), then
|
|
760
|
+
// rewrite each subChainRef node: `config.subChainRef` → `config.workflowId`
|
|
761
|
+
// (the minted child id the runtime dispatches).
|
|
762
|
+
const expansionId = chain.chainId === root.chainId
|
|
763
|
+
? ctx.parentExpansionId
|
|
764
|
+
: `${ctx.parentExpansionId}_${chain.chainId.replace(/\./g, '_')}`;
|
|
765
|
+
const fragment = expandChain(chain, {
|
|
766
|
+
expansionId,
|
|
767
|
+
params: ctx.params,
|
|
768
|
+
isTypeIdResolvable: ctx.isTypeIdResolvable,
|
|
769
|
+
});
|
|
770
|
+
const rewrittenNodes = fragment.nodes.map((n) => {
|
|
771
|
+
const ref = (n.config as Record<string, unknown> | undefined)?.['subChainRef'];
|
|
772
|
+
if (typeof ref !== 'string') return n;
|
|
773
|
+
const refVersion = ctx.siblingChains.get(ref)?.version ?? '0.0.0';
|
|
774
|
+
const { subChainRef: _drop, ...restConfig } = n.config as Record<string, unknown>;
|
|
775
|
+
return { ...n, config: { ...restConfig, workflowId: mintChildWorkflowId(ctx.tenantId, ref, refVersion) } };
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
onStack.delete(chain.chainId);
|
|
779
|
+
return { nodes: rewrittenNodes, edges: fragment.edges, idMap: fragment.idMap };
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
const parent = expandOne(root, 0);
|
|
783
|
+
return { parent, children: [...children.values()] };
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** Emit a chain's declared `producedVariables[]` as run-scoped `variables[]`
|
|
787
|
+
* entries (name + type, NO value) for the expanded `WorkflowDefinition`
|
|
788
|
+
* (RFC 0133 §2.3). The executor's existing variable bag carries them exactly
|
|
789
|
+
* as in a hand-authored workflow — no new runtime surface. */
|
|
790
|
+
export function emitProducedVariables(
|
|
791
|
+
chain: WorkflowChain,
|
|
792
|
+
): ReadonlyArray<{ name: string; type: string }> {
|
|
793
|
+
return (chain.producedVariables ?? []).map((p) => ({ name: p.name, type: p.type }));
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** Walk a value for `{ type:"variable", variableName }` input bindings, collecting
|
|
797
|
+
* every referenced variable name. */
|
|
798
|
+
function collectVariableReads(value: unknown, out: Set<string>): void {
|
|
799
|
+
if (Array.isArray(value)) {
|
|
800
|
+
for (const v of value) collectVariableReads(v, out);
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
if (value !== null && typeof value === 'object') {
|
|
804
|
+
const obj = value as Record<string, unknown>;
|
|
805
|
+
if (obj['type'] === 'variable' && typeof obj['variableName'] === 'string') {
|
|
806
|
+
out.add(obj['variableName']);
|
|
807
|
+
}
|
|
808
|
+
for (const v of Object.values(obj)) collectVariableReads(v, out);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Closed-world validation of a chain's run-variable reads (RFC 0133 §2.2). Every
|
|
814
|
+
* `{ type:"variable", variableName }` binding in any node's `inputs` MUST reference
|
|
815
|
+
* a declared `producedVariables[].name` OR a materialized parameter name. An
|
|
816
|
+
* undeclared read throws `VariableUndeclaredError` — closing the "reads a value
|
|
817
|
+
* nothing produces" hole.
|
|
818
|
+
*
|
|
819
|
+
* @param materializedParams parameter names the host materialized as variables
|
|
820
|
+
* (deferred mode, RFC 0124); empty for expansion-time substitution.
|
|
821
|
+
* @throws VariableUndeclaredError on the first undeclared variable read.
|
|
822
|
+
*/
|
|
823
|
+
export function validateVariableReads(
|
|
824
|
+
chain: WorkflowChain,
|
|
825
|
+
materializedParams: ReadonlySet<string> = new Set(),
|
|
826
|
+
): void {
|
|
827
|
+
// Producer-existence (RFC 0133 §2.2): every producedVariables[].producedBy MUST
|
|
828
|
+
// name a real node id in the fragment — a producedBy naming a non-existent node
|
|
829
|
+
// is a malformed producer (the value would be written by nothing).
|
|
830
|
+
const nodeIds = new Set(chain.dag.nodes.map((n) => n.id));
|
|
831
|
+
for (const p of chain.producedVariables ?? []) {
|
|
832
|
+
if (!nodeIds.has(p.producedBy)) {
|
|
833
|
+
throw new ProducedVarProducerUnknownError(p.name, p.producedBy, chain.chainId);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Disjointness (RFC 0133 §2.2): a producedVariables name MUST NOT collide with a
|
|
838
|
+
// `parameters` property name — the author-time and run-scoped channels are disjoint.
|
|
839
|
+
const paramNames = new Set<string>(materializedParams);
|
|
840
|
+
const paramProps = (chain.parameters as { properties?: Record<string, unknown> } | undefined)?.properties;
|
|
841
|
+
if (paramProps) for (const k of Object.keys(paramProps)) paramNames.add(k);
|
|
842
|
+
for (const p of chain.producedVariables ?? []) {
|
|
843
|
+
if (paramNames.has(p.name)) throw new VariableUndeclaredError(p.name, chain.chainId);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const declared = new Set<string>(materializedParams);
|
|
847
|
+
for (const p of chain.producedVariables ?? []) declared.add(p.name);
|
|
848
|
+
const reads = new Set<string>();
|
|
849
|
+
for (const n of chain.dag.nodes) {
|
|
850
|
+
if (n.inputs !== undefined) collectVariableReads(n.inputs, reads);
|
|
851
|
+
if (n.config !== undefined) collectVariableReads(n.config, reads);
|
|
852
|
+
}
|
|
853
|
+
for (const name of reads) {
|
|
854
|
+
if (!declared.has(name)) throw new VariableUndeclaredError(name, chain.chainId);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent-manifest `role` + the Skill profile (RFC 0131).
|
|
3
|
+
*
|
|
4
|
+
* Always-on, server-free schema-shape probe of the additive optional
|
|
5
|
+
* `AgentManifest.role` and the schema-encoded Skill profile (§B). The profile is
|
|
6
|
+
* a JSON-Schema `if role==="skill" then {required:["handoff"], memoryShape.
|
|
7
|
+
* {conversation,longTerm} !== true}` conditional, so a violating skill manifest
|
|
8
|
+
* FAILS validation at publish/install — a malformed manifest (RFC 0003 §C author
|
|
9
|
+
* error), NOT an RFC 0072 §C `degraded[]` runtime tier. This is the public
|
|
10
|
+
* witness for the SECURITY invariant `agent-skill-profile-stateless`.
|
|
11
|
+
*
|
|
12
|
+
* Verifies:
|
|
13
|
+
* - `role` is EXPLICIT, never inferred: a manifest with NO `role` and any
|
|
14
|
+
* `memoryShape` (incl. conversation+longTerm) validates — unconstrained,
|
|
15
|
+
* exactly today's meaning. `handoff` presence does NOT reclassify it.
|
|
16
|
+
* - a `role:"skill"` manifest WITH `handoff` + scratchpad-only memory validates.
|
|
17
|
+
* - a `role:"skill"` manifest with `memoryShape.longTerm:true` FAILS validation.
|
|
18
|
+
* - a `role:"skill"` manifest with `memoryShape.conversation:true` FAILS.
|
|
19
|
+
* - a `role:"skill"` manifest MISSING `handoff` FAILS validation.
|
|
20
|
+
* - a `role:"assistant"` manifest with conversation+longTerm (+ optional
|
|
21
|
+
* handoff) validates — no profile binds it.
|
|
22
|
+
* - `role` outside the `["skill","assistant"]` enum FAILS.
|
|
23
|
+
*
|
|
24
|
+
* Spec references:
|
|
25
|
+
* - https://github.com/openwop/openwop/blob/main/RFCS/0131-agent-manifest-role-and-skill-profile.md
|
|
26
|
+
* - https://github.com/openwop/openwop/blob/main/spec/v1/agent-memory.md (§B — reject vs §C degrade)
|
|
27
|
+
* - https://github.com/openwop/openwop/blob/main/RFCS/0072-agent-inventory-and-dispatch.md (the degraded[] marker this RFC does NOT overload)
|
|
28
|
+
* - https://github.com/openwop/openwop/blob/main/SECURITY/invariants.yaml (agent-skill-profile-stateless)
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { describe, it, expect } from 'vitest';
|
|
32
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
33
|
+
import { join } from 'node:path';
|
|
34
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
35
|
+
import addFormats from 'ajv-formats';
|
|
36
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
37
|
+
|
|
38
|
+
const BASE = 'https://openwop.dev/spec/v1/';
|
|
39
|
+
const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
|
|
40
|
+
function loadSchema(name: string): Record<string, unknown> {
|
|
41
|
+
return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('agent-manifest-role-profile: AgentManifest.role + Skill profile (RFC 0131, server-free)', () => {
|
|
45
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
46
|
+
addFormats(ajv);
|
|
47
|
+
for (const f of readdirSync(SCHEMAS_DIR)) {
|
|
48
|
+
if (f.endsWith('.schema.json')) {
|
|
49
|
+
try {
|
|
50
|
+
ajv.addSchema(loadSchema(f));
|
|
51
|
+
} catch {
|
|
52
|
+
/* duplicate/ignore */
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const manifest = ajv.getSchema(`${BASE}agent-manifest.schema.json`)!;
|
|
57
|
+
|
|
58
|
+
const base = { agentId: 'core.openwop.agents.demo', persona: 'Demo', modelClass: 'general', systemPrompt: 'do it' };
|
|
59
|
+
const handoff = { taskSchemaRef: 'schemas/task.json', returnSchemaRef: 'schemas/return.json' };
|
|
60
|
+
|
|
61
|
+
it('NO role + any memoryShape (conversation+longTerm) validates — explicit, never inferred', () => {
|
|
62
|
+
expect(
|
|
63
|
+
manifest({ ...base, memoryShape: { scratchpad: true, conversation: true, longTerm: true } }),
|
|
64
|
+
why('RFC 0131 §A', 'absent role ⇒ unconstrained; nothing reclassifies (today’s meaning, unchanged)'),
|
|
65
|
+
).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('NO role + handoff + rich memory validates — handoff presence does NOT imply skill', () => {
|
|
69
|
+
expect(
|
|
70
|
+
manifest({ ...base, handoff, memoryShape: { conversation: true, longTerm: true } }),
|
|
71
|
+
why('RFC 0131 §A / Motivation 2', 'handoff is an interop contract, orthogonal to role — no inference'),
|
|
72
|
+
).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('role:"skill" WITH handoff + scratchpad-only memory validates', () => {
|
|
76
|
+
expect(
|
|
77
|
+
manifest({ ...base, role: 'skill', handoff, memoryShape: { scratchpad: true, conversation: false, longTerm: false } }),
|
|
78
|
+
why('RFC 0131 §B', 'a well-formed skill (handoff + scratchpad-only) MUST validate'),
|
|
79
|
+
).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('role:"skill" with memoryShape.longTerm:true FAILS validation (malformed, not degraded)', () => {
|
|
83
|
+
expect(
|
|
84
|
+
manifest({ ...base, role: 'skill', handoff, memoryShape: { scratchpad: true, longTerm: true } }),
|
|
85
|
+
why('RFC 0131 §B', 'a skill declaring longTerm memory is malformed — reject at publish/install'),
|
|
86
|
+
).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('role:"skill" with memoryShape.conversation:true FAILS validation', () => {
|
|
90
|
+
expect(
|
|
91
|
+
manifest({ ...base, role: 'skill', handoff, memoryShape: { conversation: true } }),
|
|
92
|
+
why('RFC 0131 §B', 'a skill declaring conversation memory is malformed — reject'),
|
|
93
|
+
).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('role:"skill" MISSING handoff FAILS validation', () => {
|
|
97
|
+
expect(
|
|
98
|
+
manifest({ ...base, role: 'skill', memoryShape: { scratchpad: true } }),
|
|
99
|
+
why('RFC 0131 §B', 'a skill MUST declare handoff (task→return capability)'),
|
|
100
|
+
).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('role:"assistant" with conversation+longTerm (and handoff) validates — no profile binds it', () => {
|
|
104
|
+
expect(
|
|
105
|
+
manifest({ ...base, role: 'assistant', handoff, memoryShape: { scratchpad: true, conversation: true, longTerm: true } }),
|
|
106
|
+
why('RFC 0131 §A', 'an assistant’s conversation + long-term memory are legitimate; it MAY ship handoff'),
|
|
107
|
+
).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('role outside the ["skill","assistant"] enum FAILS', () => {
|
|
111
|
+
expect(
|
|
112
|
+
manifest({ ...base, role: 'agent' }),
|
|
113
|
+
why('RFC 0131 §A', '"agent" is not an enum value (it is the overloaded word the RFC removes)'),
|
|
114
|
+
).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anonymous-actor audit is opaque + non-PII (RFC 0132 §D) — backs the
|
|
3
|
+
* `anon-actor-audit-opaque` SECURITY invariant (RFC 0048 identifier-opacity + SR-1).
|
|
4
|
+
*
|
|
5
|
+
* Every anonymous-actor tool call emits an `authorization.decided` record (RFC
|
|
6
|
+
* 0049 — no new event minted) attributable to the opaque anon-session
|
|
7
|
+
* `principal`. The `principal` MUST be opaque, non-cross-linkable, and non-PII
|
|
8
|
+
* (no IP, email, device fingerprint), and the record MUST carry no credential
|
|
9
|
+
* material. The run snapshot echoes `owner.principalKind: "anonymous"`.
|
|
10
|
+
*
|
|
11
|
+
* Capability-gated on `capabilities.anonymousActor.supported`; soft-skips when
|
|
12
|
+
* unadvertised or when the seam is unwired (404). Hard-fails under
|
|
13
|
+
* `OPENWOP_REQUIRE_BEHAVIOR=true`. Passing non-vacuously graduates
|
|
14
|
+
* `anon-actor-audit-opaque` reference-impl → protocol tier.
|
|
15
|
+
*
|
|
16
|
+
* @see RFCS/0132-anonymous-actor-authorization.md §A, §D
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it, expect } from 'vitest';
|
|
20
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
21
|
+
import { driver } from '../lib/driver.js';
|
|
22
|
+
import { isAnonymousActorAdvertised, anonDispatch } from '../lib/anonymousActor.js';
|
|
23
|
+
|
|
24
|
+
const PROFILE = 'openwop-anonymous-actor';
|
|
25
|
+
|
|
26
|
+
/** Reject anything that looks like PII the anon id MUST NOT embed. */
|
|
27
|
+
const PII_PATTERNS: Array<[RegExp, string]> = [
|
|
28
|
+
[/\b\d{1,3}(?:\.\d{1,3}){3}\b/, 'an IPv4 address'],
|
|
29
|
+
[/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/, 'an email address'],
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
describe('anonymous-actor-audit-opaque (RFC 0132 §D)', () => {
|
|
33
|
+
it('the authorization.decided principal is opaque, non-PII, and the record carries no credential', async () => {
|
|
34
|
+
if (!behaviorGate(PROFILE, await isAnonymousActorAdvertised())) return;
|
|
35
|
+
const res = await anonDispatch({ tool: 'catalog.read' });
|
|
36
|
+
if (res.status === 404 || res.status === 405) return; // seam unwired — soft-skip
|
|
37
|
+
|
|
38
|
+
const decided = res.json?.authorizationDecided?.payload;
|
|
39
|
+
expect(
|
|
40
|
+
decided?.principal,
|
|
41
|
+
driver.describe('RFC 0132 §D', 'an anon tool call MUST emit authorization.decided with a principal'),
|
|
42
|
+
).toBeTruthy();
|
|
43
|
+
|
|
44
|
+
const principal = decided?.principal ?? '';
|
|
45
|
+
for (const [pattern, label] of PII_PATTERNS) {
|
|
46
|
+
expect(
|
|
47
|
+
pattern.test(principal),
|
|
48
|
+
driver.describe('SECURITY anon-actor-audit-opaque', `the anon principal MUST NOT embed ${label}`),
|
|
49
|
+
).toBe(false);
|
|
50
|
+
}
|
|
51
|
+
// The record MUST NOT carry credential material (reason is redaction-safe).
|
|
52
|
+
const serialized = JSON.stringify(decided ?? {});
|
|
53
|
+
for (const [pattern, label] of PII_PATTERNS) {
|
|
54
|
+
expect(
|
|
55
|
+
pattern.test(serialized),
|
|
56
|
+
driver.describe('SECURITY anon-actor-audit-opaque', `the audit record MUST NOT carry ${label}`),
|
|
57
|
+
).toBe(false);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('the run snapshot echoes owner.principalKind "anonymous"', async () => {
|
|
62
|
+
if (!behaviorGate(PROFILE, await isAnonymousActorAdvertised())) return;
|
|
63
|
+
const res = await anonDispatch({ tool: 'catalog.read' });
|
|
64
|
+
if (res.status === 404 || res.status === 405) return; // seam unwired — soft-skip
|
|
65
|
+
if (!res.json?.owner) return; // seam does not echo an owner triple — nothing to assert
|
|
66
|
+
expect(
|
|
67
|
+
res.json.owner.principalKind,
|
|
68
|
+
driver.describe('RFC 0132 §A', 'an anon-authorized run MUST set owner.principalKind "anonymous"'),
|
|
69
|
+
).toBe('anonymous');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anonymous-actor default-deny grant (RFC 0132 §C.1) — backs the
|
|
3
|
+
* `anon-actor-no-default-baseline` SECURITY invariant.
|
|
4
|
+
*
|
|
5
|
+
* An anonymous actor is granted ONLY the tools explicitly listed in the
|
|
6
|
+
* resolved surface allowlist — never a default-on tool baseline, never a tool
|
|
7
|
+
* granted to authenticated agents by default. The effective granted set is
|
|
8
|
+
* discoverable through the RFC 0078 tool-catalog read scoped to the anon
|
|
9
|
+
* principal, which fails EMPTY when the surface grants nothing (never a
|
|
10
|
+
* baseline). A call to a non-granted tool MUST resolve to
|
|
11
|
+
* `authorization.decided { allowed:false, reason:"anon-not-granted" }` with NO
|
|
12
|
+
* dispatch.
|
|
13
|
+
*
|
|
14
|
+
* Capability-gated on `capabilities.anonymousActor.supported` +
|
|
15
|
+
* `behaviorGate('openwop-anonymous-actor', …)`; soft-skips when unadvertised or
|
|
16
|
+
* when the reference public-surface seam is unwired (404). Hard-fails under
|
|
17
|
+
* `OPENWOP_REQUIRE_BEHAVIOR=true`. When it passes non-vacuously the
|
|
18
|
+
* `anon-actor-no-default-baseline` invariant graduates reference-impl → protocol
|
|
19
|
+
* tier (RFC 0079 precedent).
|
|
20
|
+
*
|
|
21
|
+
* @see RFCS/0132-anonymous-actor-authorization.md §C.1
|
|
22
|
+
* @see conformance/coverage.md §"Capability-gated scenarios"
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, it, expect } from 'vitest';
|
|
26
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
27
|
+
import { driver } from '../lib/driver.js';
|
|
28
|
+
import {
|
|
29
|
+
isAnonymousActorAdvertised,
|
|
30
|
+
anonDispatch,
|
|
31
|
+
anonToolCatalog,
|
|
32
|
+
readAnonymousActorCap,
|
|
33
|
+
} from '../lib/anonymousActor.js';
|
|
34
|
+
|
|
35
|
+
const PROFILE = 'openwop-anonymous-actor';
|
|
36
|
+
|
|
37
|
+
/** A tool no public surface would ever grant — an authenticated default-on baseline action. */
|
|
38
|
+
const UNGRANTED_TOOL = 'crm.contact.delete';
|
|
39
|
+
|
|
40
|
+
describe('anonymous-actor-default-deny (RFC 0132 §C.1)', () => {
|
|
41
|
+
it('the anon tool catalog returns only the explicit surface grant — never a default baseline', async () => {
|
|
42
|
+
if (!behaviorGate(PROFILE, await isAnonymousActorAdvertised())) return;
|
|
43
|
+
const cat = await anonToolCatalog();
|
|
44
|
+
if (cat.status === 404 || cat.status === 405) return; // seam unwired — soft-skip
|
|
45
|
+
expect(
|
|
46
|
+
cat.status,
|
|
47
|
+
driver.describe('RFC 0132 §C.1', 'the anon tool-catalog read MUST resolve (scoped to the anon principal)'),
|
|
48
|
+
).toBe(200);
|
|
49
|
+
// Default-deny: the ungranted baseline tool MUST NOT appear in the anon catalog.
|
|
50
|
+
const names = cat.tools.map((t) => t.name);
|
|
51
|
+
expect(
|
|
52
|
+
names,
|
|
53
|
+
driver.describe('RFC 0132 §C.1', 'a default-on baseline tool MUST NOT appear in the anon grant'),
|
|
54
|
+
).not.toContain(UNGRANTED_TOOL);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('calling a non-granted tool denies with reason "anon-not-granted" and does not dispatch', async () => {
|
|
58
|
+
if (!behaviorGate(PROFILE, await isAnonymousActorAdvertised())) return;
|
|
59
|
+
const res = await anonDispatch({ tool: UNGRANTED_TOOL });
|
|
60
|
+
if (res.status === 404 || res.status === 405) return; // seam unwired — soft-skip
|
|
61
|
+
const decided = res.json?.authorizationDecided?.payload;
|
|
62
|
+
expect(
|
|
63
|
+
decided?.allowed,
|
|
64
|
+
driver.describe('RFC 0132 §C.1', 'a non-granted anon tool MUST be denied (allowed:false), never default-allowed'),
|
|
65
|
+
).toBe(false);
|
|
66
|
+
expect(
|
|
67
|
+
decided?.reason,
|
|
68
|
+
driver.describe('RFC 0132 §C.1', 'a default-deny denial carries the machine reason "anon-not-granted"'),
|
|
69
|
+
).toBe('anon-not-granted');
|
|
70
|
+
// No dispatch — the seam MUST NOT return a tool result for a denied call.
|
|
71
|
+
expect(
|
|
72
|
+
res.json?.result,
|
|
73
|
+
driver.describe('RFC 0132 §C.1', 'a denied anon tool MUST NOT dispatch (no result)'),
|
|
74
|
+
).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('a host that advertises bounded-write-egress also advertises a mandatory control (truthful-advertisement)', async () => {
|
|
78
|
+
const cap = await readAnonymousActorCap();
|
|
79
|
+
if (!behaviorGate(PROFILE, cap?.supported === true)) return;
|
|
80
|
+
if ((cap?.tiers ?? []).includes('bounded-write-egress')) {
|
|
81
|
+
expect(
|
|
82
|
+
(cap?.writeEgressControls ?? []).length,
|
|
83
|
+
driver.describe('RFC 0132 §B.2', 'bounded-write-egress MUST advertise ≥1 writeEgressControl'),
|
|
84
|
+
).toBeGreaterThan(0);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|