@stigmer/server 3.14.0 → 3.14.1
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/dist/domain/agentexecution/constants.d.ts +16 -0
- package/dist/domain/agentexecution/constants.d.ts.map +1 -1
- package/dist/domain/agentexecution/constants.js +22 -0
- package/dist/domain/agentexecution/constants.js.map +1 -1
- package/dist/domain/agentexecution/controller.d.ts.map +1 -1
- package/dist/domain/agentexecution/controller.js +10 -3
- package/dist/domain/agentexecution/controller.js.map +1 -1
- package/dist/domain/agentexecution/run-target.d.ts +26 -0
- package/dist/domain/agentexecution/run-target.d.ts.map +1 -0
- package/dist/domain/agentexecution/run-target.js +31 -0
- package/dist/domain/agentexecution/run-target.js.map +1 -0
- package/dist/domain/session/constants.d.ts +15 -0
- package/dist/domain/session/constants.d.ts.map +1 -0
- package/dist/domain/session/constants.js +17 -0
- package/dist/domain/session/constants.js.map +1 -0
- package/dist/domain/session/controller.d.ts.map +1 -1
- package/dist/domain/session/controller.js +10 -0
- package/dist/domain/session/controller.js.map +1 -1
- package/dist/domain/session/run-target.d.ts +15 -0
- package/dist/domain/session/run-target.d.ts.map +1 -0
- package/dist/domain/session/run-target.js +14 -0
- package/dist/domain/session/run-target.js.map +1 -0
- package/dist/domain/workflowexecution/constants.d.ts +9 -0
- package/dist/domain/workflowexecution/constants.d.ts.map +1 -1
- package/dist/domain/workflowexecution/constants.js +13 -0
- package/dist/domain/workflowexecution/constants.js.map +1 -1
- package/dist/domain/workflowexecution/controller.d.ts.map +1 -1
- package/dist/domain/workflowexecution/controller.js +8 -0
- package/dist/domain/workflowexecution/controller.js.map +1 -1
- package/dist/domain/workflowexecution/run-target.d.ts +18 -0
- package/dist/domain/workflowexecution/run-target.d.ts.map +1 -0
- package/dist/domain/workflowexecution/run-target.js +23 -0
- package/dist/domain/workflowexecution/run-target.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/pipeline/steps/authorize-run-target.d.ts +105 -0
- package/dist/pipeline/steps/authorize-run-target.d.ts.map +1 -0
- package/dist/pipeline/steps/authorize-run-target.js +61 -0
- package/dist/pipeline/steps/authorize-run-target.js.map +1 -0
- package/dist/query/search/criteria.d.ts +10 -3
- package/dist/query/search/criteria.d.ts.map +1 -1
- package/dist/query/search/criteria.js +11 -4
- package/dist/query/search/criteria.js.map +1 -1
- package/package.json +4 -4
- package/src/domain/agentexecution/__tests__/run-gate.test.ts +245 -0
- package/src/domain/agentexecution/__tests__/run-target.test.ts +117 -0
- package/src/domain/agentexecution/constants.ts +25 -0
- package/src/domain/agentexecution/controller.ts +10 -3
- package/src/domain/agentexecution/run-target.ts +64 -0
- package/src/domain/session/__tests__/run-gate.test.ts +219 -0
- package/src/domain/session/__tests__/run-target.test.ts +43 -0
- package/src/domain/session/constants.ts +17 -0
- package/src/domain/session/controller.ts +10 -0
- package/src/domain/session/run-target.ts +30 -0
- package/src/domain/workflowexecution/__tests__/run-gate.test.ts +200 -0
- package/src/domain/workflowexecution/__tests__/run-target.test.ts +80 -0
- package/src/domain/workflowexecution/constants.ts +17 -0
- package/src/domain/workflowexecution/controller.ts +10 -0
- package/src/domain/workflowexecution/run-target.ts +47 -0
- package/src/extensions/__tests__/extension-composition.test.ts +49 -0
- package/src/index.ts +7 -0
- package/src/pipeline/__tests__/support.ts +34 -0
- package/src/pipeline/steps/__tests__/authorize-run-target.test.ts +261 -0
- package/src/pipeline/steps/authorize-run-target.ts +148 -0
- package/src/query/search/__tests__/criteria.test.ts +18 -1
- package/src/query/search/criteria.ts +11 -4
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the run gate's SPLICE in session-create over the real router (P1
|
|
3
|
+
* sp.run-gate): the AuthorizeRunTarget step sits after
|
|
4
|
+
* ResolveDefaultAgentInstance and before Persist, so (1) a denied caller
|
|
5
|
+
* answers PERMISSION_DENIED with the domain's byte-pinned copy and leaves
|
|
6
|
+
* no session row; (2) the check names the instance the CHAIN resolved,
|
|
7
|
+
* not only the one the caller typed — the session-first shape (empty
|
|
8
|
+
* spec) is checked against the platform default agent's default instance;
|
|
9
|
+
* (3) an allowed caller creates the session exactly as before. The
|
|
10
|
+
* authorizer under test answers only run-gate checks (runGateOnlyAuthorizer),
|
|
11
|
+
* so every refusal here is the run gate's and nobody else's.
|
|
12
|
+
*/
|
|
13
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
17
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
18
|
+
import type { Client, Transport } from "@connectrpc/connect";
|
|
19
|
+
import { Code, ConnectError, createClient } from "@connectrpc/connect";
|
|
20
|
+
import { createGrpcTransport } from "@connectrpc/connect-node";
|
|
21
|
+
|
|
22
|
+
import { AgentCommandController } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/command_pb";
|
|
23
|
+
import { AgentSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
|
|
24
|
+
import { AgentQueryController } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/query_pb";
|
|
25
|
+
import { SessionCommandController } from "@stigmer/protos/ai/stigmer/agentic/session/v1/command_pb";
|
|
26
|
+
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
27
|
+
import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
|
|
28
|
+
import { IamPermission } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
|
|
29
|
+
|
|
30
|
+
import { loadConfig } from "../../../boot/config.js";
|
|
31
|
+
import { composeServer } from "../../../boot/compose.js";
|
|
32
|
+
import type { ComposedServer } from "../../../boot/compose.js";
|
|
33
|
+
import { createLogger } from "../../../boot/logger.js";
|
|
34
|
+
import type { AuthzDecision } from "../../../extensions/authorizer.js";
|
|
35
|
+
import { runGateOnlyAuthorizer } from "../../../pipeline/__tests__/support.js";
|
|
36
|
+
import {
|
|
37
|
+
DEFAULT_AGENT_LABEL,
|
|
38
|
+
DEFAULT_AGENT_LABEL_VALUE,
|
|
39
|
+
} from "../../agent/defaultagent.js";
|
|
40
|
+
import { runAgentInstanceDeniedMessage } from "../constants.js";
|
|
41
|
+
|
|
42
|
+
const silentLogger = createLogger({
|
|
43
|
+
level: "error",
|
|
44
|
+
pretty: false,
|
|
45
|
+
write: () => {},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const API_VERSION = "agentic.stigmer.ai/v1";
|
|
49
|
+
const ORG = "acme";
|
|
50
|
+
|
|
51
|
+
let dir: string;
|
|
52
|
+
let server: ComposedServer;
|
|
53
|
+
let decision: AuthzDecision = { kind: "allow" };
|
|
54
|
+
const gate = runGateOnlyAuthorizer(() => decision);
|
|
55
|
+
let agentCommand: Client<typeof AgentCommandController>;
|
|
56
|
+
let agentQuery: Client<typeof AgentQueryController>;
|
|
57
|
+
let sessionCommand: Client<typeof SessionCommandController>;
|
|
58
|
+
|
|
59
|
+
beforeAll(async () => {
|
|
60
|
+
dir = mkdtempSync(path.join(tmpdir(), "session-run-gate-test-"));
|
|
61
|
+
server = await composeServer({
|
|
62
|
+
config: loadConfig({
|
|
63
|
+
STIGMER_MODEL_REGISTRY_REFRESH: "off",
|
|
64
|
+
TEMPORAL_HOST_PORT: "127.0.0.1:1",
|
|
65
|
+
DB_PATH: path.join(dir, "stigmer.db"),
|
|
66
|
+
STORAGE_PATH: path.join(dir, "storage"),
|
|
67
|
+
ARTIFACT_LOCAL_BASE_PATH: path.join(dir, "artifacts"),
|
|
68
|
+
}),
|
|
69
|
+
logger: silentLogger,
|
|
70
|
+
portOverride: 0,
|
|
71
|
+
host: "127.0.0.1",
|
|
72
|
+
extensions: [{ name: "run-gate-test", authorizer: gate.authorizer }],
|
|
73
|
+
});
|
|
74
|
+
const port = await server.start();
|
|
75
|
+
const transport: Transport = createGrpcTransport({
|
|
76
|
+
baseUrl: `http://127.0.0.1:${port}`,
|
|
77
|
+
});
|
|
78
|
+
agentCommand = createClient(AgentCommandController, transport);
|
|
79
|
+
agentQuery = createClient(AgentQueryController, transport);
|
|
80
|
+
sessionCommand = createClient(SessionCommandController, transport);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
afterAll(async () => {
|
|
84
|
+
await server.shutdown();
|
|
85
|
+
rmSync(dir, { recursive: true, force: true });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
async function sessionCount(): Promise<number> {
|
|
89
|
+
return (await server.store.listResources(ApiResourceKind.session)).length;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function createAgent(name: string): Promise<string> {
|
|
93
|
+
const agent = await agentCommand.create({
|
|
94
|
+
apiVersion: API_VERSION,
|
|
95
|
+
kind: "Agent",
|
|
96
|
+
metadata: { name, org: ORG },
|
|
97
|
+
spec: { instructions: "You are the agent the run-gate tests target." },
|
|
98
|
+
});
|
|
99
|
+
return agent.metadata!.id;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The default-agent label is operator-seeded state; stamp it on the row. */
|
|
103
|
+
async function markDefaultAgent(agentId: string): Promise<void> {
|
|
104
|
+
const stored = await server.store.getResource(
|
|
105
|
+
ApiResourceKind.agent,
|
|
106
|
+
agentId,
|
|
107
|
+
AgentSchema,
|
|
108
|
+
);
|
|
109
|
+
stored.metadata!.labels[DEFAULT_AGENT_LABEL] = DEFAULT_AGENT_LABEL_VALUE;
|
|
110
|
+
stored.metadata!.visibility = ApiResourceVisibility.visibility_public;
|
|
111
|
+
await server.store.saveResource(
|
|
112
|
+
ApiResourceKind.agent,
|
|
113
|
+
agentId,
|
|
114
|
+
AgentSchema,
|
|
115
|
+
stored,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function captureError(
|
|
120
|
+
run: () => Promise<unknown>,
|
|
121
|
+
): Promise<ConnectError> {
|
|
122
|
+
try {
|
|
123
|
+
await run();
|
|
124
|
+
} catch (error) {
|
|
125
|
+
return ConnectError.from(error);
|
|
126
|
+
}
|
|
127
|
+
throw new Error("expected the create to reject");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
describe("session-create run gate (composed server)", () => {
|
|
131
|
+
it("denies a caller-named instance with the pinned copy and persists nothing", async () => {
|
|
132
|
+
decision = { kind: "deny", reason: "" };
|
|
133
|
+
gate.runGateChecks.length = 0;
|
|
134
|
+
const before = await sessionCount();
|
|
135
|
+
|
|
136
|
+
const err = await captureError(() =>
|
|
137
|
+
sessionCommand.create({
|
|
138
|
+
apiVersion: API_VERSION,
|
|
139
|
+
kind: "Session",
|
|
140
|
+
metadata: { name: "Denied session", org: ORG },
|
|
141
|
+
spec: { agentInstanceId: "agi_01private" },
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
expect(err.code).toBe(Code.PermissionDenied);
|
|
146
|
+
expect(err.rawMessage).toBe(runAgentInstanceDeniedMessage("agi_01private"));
|
|
147
|
+
expect(gate.runGateChecks).toEqual([
|
|
148
|
+
{
|
|
149
|
+
permission: IamPermission.can_execute,
|
|
150
|
+
resourceKind: ApiResourceKind.agent_instance,
|
|
151
|
+
resourceId: "agi_01private",
|
|
152
|
+
},
|
|
153
|
+
]);
|
|
154
|
+
expect(await sessionCount(), "no row behind a denial").toBe(before);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("checks the instance the chain RESOLVED for the session-first shape", async () => {
|
|
158
|
+
decision = { kind: "deny", reason: "" };
|
|
159
|
+
gate.runGateChecks.length = 0;
|
|
160
|
+
const agentId = await createAgent("Default agent for the run gate");
|
|
161
|
+
await markDefaultAgent(agentId);
|
|
162
|
+
|
|
163
|
+
const err = await captureError(() =>
|
|
164
|
+
sessionCommand.create({
|
|
165
|
+
apiVersion: API_VERSION,
|
|
166
|
+
kind: "Session",
|
|
167
|
+
metadata: { name: "Session-first", org: ORG },
|
|
168
|
+
spec: {},
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
expect(err.code).toBe(Code.PermissionDenied);
|
|
173
|
+
// ResolveDefaultAgentInstance created (or found) the default agent's
|
|
174
|
+
// default instance BEFORE the gate — the inherited pre-gate side
|
|
175
|
+
// effect the slot table records — and the gate names exactly it.
|
|
176
|
+
const agent = await agentQuery.get({ value: agentId });
|
|
177
|
+
const resolvedInstanceId = agent.status?.defaultInstanceId ?? "";
|
|
178
|
+
expect(resolvedInstanceId).not.toBe("");
|
|
179
|
+
expect(gate.runGateChecks).toHaveLength(1);
|
|
180
|
+
expect(gate.runGateChecks[0].resourceId).toBe(resolvedInstanceId);
|
|
181
|
+
expect(err.rawMessage).toBe(
|
|
182
|
+
runAgentInstanceDeniedMessage(resolvedInstanceId),
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("not-found answers NOT_FOUND naming the instance (the stigmer#224 order)", async () => {
|
|
187
|
+
decision = { kind: "not-found" };
|
|
188
|
+
const err = await captureError(() =>
|
|
189
|
+
sessionCommand.create({
|
|
190
|
+
apiVersion: API_VERSION,
|
|
191
|
+
kind: "Session",
|
|
192
|
+
metadata: { name: "Missing instance", org: ORG },
|
|
193
|
+
spec: { agentInstanceId: "agi_01missing" },
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
expect(err.code).toBe(Code.NotFound);
|
|
197
|
+
expect(err.rawMessage).toContain("agi_01missing");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("an allowed caller creates the session exactly as before", async () => {
|
|
201
|
+
decision = { kind: "allow" };
|
|
202
|
+
gate.runGateChecks.length = 0;
|
|
203
|
+
const agentId = await createAgent("Allowed agent");
|
|
204
|
+
const agent = await agentQuery.get({ value: agentId });
|
|
205
|
+
const instanceId = agent.status?.defaultInstanceId ?? "";
|
|
206
|
+
expect(instanceId, "agent create seeds a default instance").not.toBe("");
|
|
207
|
+
|
|
208
|
+
const session = await sessionCommand.create({
|
|
209
|
+
apiVersion: API_VERSION,
|
|
210
|
+
kind: "Session",
|
|
211
|
+
metadata: { name: "Allowed session", org: ORG },
|
|
212
|
+
spec: { agentInstanceId: instanceId },
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
expect(session.metadata?.id).toMatch(/^ses_/);
|
|
216
|
+
expect(session.spec?.agentInstanceId).toBe(instanceId);
|
|
217
|
+
expect(gate.runGateChecks.map((c) => c.resourceId)).toEqual([instanceId]);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the session run-target resolver (P1 sp.run-gate): a session's run
|
|
3
|
+
* target is the agent instance it binds to — agent_instance#can_execute on
|
|
4
|
+
* spec.agent_instance_id, with the domain's byte-pinned deny copy — and a
|
|
5
|
+
* session with no instance yet has no target (ResolveDefaultAgentInstance
|
|
6
|
+
* guarantees one before the step runs; an empty id reaching the resolver
|
|
7
|
+
* is the guard's arm, not a check to make).
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
import { create } from "@bufbuild/protobuf";
|
|
11
|
+
|
|
12
|
+
import { SessionSchema } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
|
|
13
|
+
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
14
|
+
import { IamPermission } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
|
|
15
|
+
|
|
16
|
+
import { runAgentInstanceDeniedMessage } from "../constants.js";
|
|
17
|
+
import { sessionRunTarget } from "../run-target.js";
|
|
18
|
+
|
|
19
|
+
describe("sessionRunTarget", () => {
|
|
20
|
+
it("targets the bound agent instance with can_execute and the pinned copy", () => {
|
|
21
|
+
const target = sessionRunTarget(
|
|
22
|
+
create(SessionSchema, { spec: { agentInstanceId: "agi_01abc" } }),
|
|
23
|
+
);
|
|
24
|
+
expect(target).toEqual({
|
|
25
|
+
permission: IamPermission.can_execute,
|
|
26
|
+
resourceKind: ApiResourceKind.agent_instance,
|
|
27
|
+
resourceId: "agi_01abc",
|
|
28
|
+
deniedMessage: "unauthorized to run agent instance 'agi_01abc'",
|
|
29
|
+
});
|
|
30
|
+
expect(target?.deniedMessage).toBe(
|
|
31
|
+
runAgentInstanceDeniedMessage("agi_01abc"),
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("answers no target when the session names no instance", () => {
|
|
36
|
+
expect(sessionRunTarget(create(SessionSchema, {}))).toBeUndefined();
|
|
37
|
+
expect(
|
|
38
|
+
sessionRunTarget(
|
|
39
|
+
create(SessionSchema, { spec: { agentInstanceId: "" } }),
|
|
40
|
+
),
|
|
41
|
+
).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session byte-pinned wire copy — every string a client can observe from
|
|
3
|
+
* this domain that is not already a proto annotation's error_msg. Wire
|
|
4
|
+
* once merged: the conformance run-gate suite asserts these characters.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The run gate's deny copy when a session names an agent instance the
|
|
9
|
+
* caller may not run (P1 sp.run-gate, ruling Q-RG-4). NEW copy quotes the
|
|
10
|
+
* handle single-quoted (the ratified 2026-08-26 quoting rule). Identical to
|
|
11
|
+
* the agent-execution domain's instance copy on purpose — one fact, one
|
|
12
|
+
* sentence — the ENGINE_UNAVAILABLE_MESSAGE precedent for cross-domain
|
|
13
|
+
* twins: each domain owns its constant, the twin is named here.
|
|
14
|
+
*/
|
|
15
|
+
export function runAgentInstanceDeniedMessage(agentInstanceId: string): string {
|
|
16
|
+
return `unauthorized to run agent instance '${agentInstanceId}'`;
|
|
17
|
+
}
|
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
authorizeDirect,
|
|
56
56
|
newAuthorizeStep,
|
|
57
57
|
} from "../../pipeline/steps/authorize.js";
|
|
58
|
+
import { newAuthorizeRunTargetStep } from "../../pipeline/steps/authorize-run-target.js";
|
|
58
59
|
import { newGuardReservedLabelsStep } from "../../pipeline/steps/guard-reserved-labels.js";
|
|
59
60
|
import {
|
|
60
61
|
newBuildNewStateStep,
|
|
@@ -97,6 +98,7 @@ import { ResourceNotFoundError } from "../../store/interface.js";
|
|
|
97
98
|
import type { Store } from "../../store/interface.js";
|
|
98
99
|
import type { SandboxLane } from "../../sandbox/lane.js";
|
|
99
100
|
import { deprovisionSessionSandboxBestEffort } from "../../sandbox/steps.js";
|
|
101
|
+
import { sessionRunTarget } from "./run-target.js";
|
|
100
102
|
import { sessionSearchExtractor } from "./search-extractor.js";
|
|
101
103
|
import type { ListReadScope } from "../../extensions/list-read-scope.js";
|
|
102
104
|
import {
|
|
@@ -179,6 +181,13 @@ function kindOf(ctx: HandlerContext): ApiResourceKind {
|
|
|
179
181
|
* runs BEFORE ValidateProto: when agent_instance_id is omitted, the
|
|
180
182
|
* resolution fills it in before validation sees the spec.
|
|
181
183
|
*
|
|
184
|
+
* AuthorizeRunTarget (the run gate, P1 sp.run-gate) follows it directly:
|
|
185
|
+
* the first step after the instance the session binds to is known, so a
|
|
186
|
+
* caller who may not run that instance is refused before validation reads
|
|
187
|
+
* anything further and before any step the chain owns side-effects. The
|
|
188
|
+
* default-instance creation INSIDE the resolution step still precedes it —
|
|
189
|
+
* the inherited pre-gate side effect recorded below, unchanged here.
|
|
190
|
+
*
|
|
182
191
|
* The pre-side-effect gate slot splices before Persist, after the last
|
|
183
192
|
* pure step (O4 plan-gate ruling Q2, mirroring the Java baseline's
|
|
184
193
|
* post-resolution gate position). The default-instance resolution above
|
|
@@ -212,6 +221,7 @@ async function createSession(
|
|
|
212
221
|
deps.authorizationLifecycle,
|
|
213
222
|
),
|
|
214
223
|
)
|
|
224
|
+
.addStep(newAuthorizeRunTargetStep(deps.authorizer, sessionRunTarget))
|
|
215
225
|
.addStep(newValidateProtoStep())
|
|
216
226
|
.addStep(newValidateVisibilityStep())
|
|
217
227
|
.addStep(newResolveSlugStep())
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session run-target resolver (P1 sp.run-gate): a session is a
|
|
3
|
+
* conversation bound to ONE agent instance, so its run target is that
|
|
4
|
+
* instance — agent_instance#can_execute on spec.agent_instance_id.
|
|
5
|
+
*
|
|
6
|
+
* Pure over the record being built. ResolveDefaultAgentInstance runs before
|
|
7
|
+
* the gate and guarantees the id (the caller's, or the platform default
|
|
8
|
+
* agent's default instance), so an empty id here is that step's own
|
|
9
|
+
* refusal already thrown, never a check to make. Read from newState, where
|
|
10
|
+
* the resolution writes — the same clone gotcha steps.ts records.
|
|
11
|
+
*/
|
|
12
|
+
import type { Session } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
RUN_GATE_CHECKS,
|
|
16
|
+
type RunTarget,
|
|
17
|
+
} from "../../pipeline/steps/authorize-run-target.js";
|
|
18
|
+
import { runAgentInstanceDeniedMessage } from "./constants.js";
|
|
19
|
+
|
|
20
|
+
export function sessionRunTarget(session: Session): RunTarget | undefined {
|
|
21
|
+
const agentInstanceId = session.spec?.agentInstanceId ?? "";
|
|
22
|
+
if (agentInstanceId === "") {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
...RUN_GATE_CHECKS.agentInstance,
|
|
27
|
+
resourceId: agentInstanceId,
|
|
28
|
+
deniedMessage: runAgentInstanceDeniedMessage(agentInstanceId),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the run gate's SPLICE in workflow-execution-create over the real
|
|
3
|
+
* router (P1 sp.run-gate): AuthorizeRunTarget sits after
|
|
4
|
+
* ValidateWorkflowOrInstance and BEFORE EnsureEngineAvailable and
|
|
5
|
+
* CreateDefaultInstanceIfNeeded (the chain's first side effect and its
|
|
6
|
+
* first store read). Pinned here: (1) each shape is checked against its
|
|
7
|
+
* own target with its own byte-pinned copy; (2) a denial leaves no
|
|
8
|
+
* execution row and reads nothing — a nonexistent workflow_id is DENIED,
|
|
9
|
+
* not NOT_FOUND, because the gate answers before the lookup; (3) the gate
|
|
10
|
+
* precedes the engine gate: an ALLOWED create on this engineless server
|
|
11
|
+
* answers the engine's UNAVAILABLE; (4) the shape-less request is still
|
|
12
|
+
* INVALID_ARGUMENT from ValidateWorkflowOrInstance, which runs first. The
|
|
13
|
+
* authorizer under test answers only run-gate checks.
|
|
14
|
+
*/
|
|
15
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
|
|
19
|
+
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
20
|
+
import type { Client, Transport } from "@connectrpc/connect";
|
|
21
|
+
import { Code, ConnectError, createClient } from "@connectrpc/connect";
|
|
22
|
+
import { createGrpcTransport } from "@connectrpc/connect-node";
|
|
23
|
+
|
|
24
|
+
import { WorkflowExecutionCommandController } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/command_pb";
|
|
25
|
+
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
26
|
+
import { IamPermission } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
|
|
27
|
+
|
|
28
|
+
import { loadConfig } from "../../../boot/config.js";
|
|
29
|
+
import { composeServer } from "../../../boot/compose.js";
|
|
30
|
+
import type { ComposedServer } from "../../../boot/compose.js";
|
|
31
|
+
import { createLogger } from "../../../boot/logger.js";
|
|
32
|
+
import type { AuthzDecision } from "../../../extensions/authorizer.js";
|
|
33
|
+
import { runGateOnlyAuthorizer } from "../../../pipeline/__tests__/support.js";
|
|
34
|
+
import {
|
|
35
|
+
ENGINE_UNAVAILABLE_MESSAGE,
|
|
36
|
+
runWorkflowDeniedMessage,
|
|
37
|
+
runWorkflowInstanceDeniedMessage,
|
|
38
|
+
} from "../constants.js";
|
|
39
|
+
|
|
40
|
+
const silentLogger = createLogger({
|
|
41
|
+
level: "error",
|
|
42
|
+
pretty: false,
|
|
43
|
+
write: () => {},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const API_VERSION = "agentic.stigmer.ai/v1";
|
|
47
|
+
const ORG = "acme";
|
|
48
|
+
|
|
49
|
+
let dir: string;
|
|
50
|
+
let server: ComposedServer;
|
|
51
|
+
let decision: AuthzDecision = { kind: "allow" };
|
|
52
|
+
const gate = runGateOnlyAuthorizer(() => decision);
|
|
53
|
+
let command: Client<typeof WorkflowExecutionCommandController>;
|
|
54
|
+
|
|
55
|
+
beforeAll(async () => {
|
|
56
|
+
dir = mkdtempSync(path.join(tmpdir(), "wfexec-run-gate-test-"));
|
|
57
|
+
vi.stubEnv("STIGMER_ENCRYPTION_KEY", Buffer.alloc(32, 7).toString("base64"));
|
|
58
|
+
vi.stubEnv(
|
|
59
|
+
"STIGMER_RUNNER_TOKEN_KEY",
|
|
60
|
+
Buffer.alloc(32, 8).toString("base64"),
|
|
61
|
+
);
|
|
62
|
+
server = await composeServer({
|
|
63
|
+
config: loadConfig({
|
|
64
|
+
STIGMER_MODEL_REGISTRY_REFRESH: "off",
|
|
65
|
+
TEMPORAL_HOST_PORT: "127.0.0.1:1",
|
|
66
|
+
DB_PATH: path.join(dir, "stigmer.db"),
|
|
67
|
+
ARTIFACT_LOCAL_BASE_PATH: path.join(dir, "artifacts"),
|
|
68
|
+
}),
|
|
69
|
+
logger: silentLogger,
|
|
70
|
+
portOverride: 0,
|
|
71
|
+
host: "127.0.0.1",
|
|
72
|
+
extensions: [{ name: "run-gate-test", authorizer: gate.authorizer }],
|
|
73
|
+
});
|
|
74
|
+
const port = await server.start();
|
|
75
|
+
const transport: Transport = createGrpcTransport({
|
|
76
|
+
baseUrl: `http://127.0.0.1:${port}`,
|
|
77
|
+
});
|
|
78
|
+
command = createClient(WorkflowExecutionCommandController, transport);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
afterAll(async () => {
|
|
82
|
+
try {
|
|
83
|
+
await server?.shutdown();
|
|
84
|
+
rmSync(dir, { recursive: true, force: true });
|
|
85
|
+
} finally {
|
|
86
|
+
vi.unstubAllEnvs();
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
async function captureError(
|
|
91
|
+
run: () => Promise<unknown>,
|
|
92
|
+
): Promise<ConnectError> {
|
|
93
|
+
try {
|
|
94
|
+
await run();
|
|
95
|
+
} catch (error) {
|
|
96
|
+
return ConnectError.from(error);
|
|
97
|
+
}
|
|
98
|
+
throw new Error("expected the create to reject");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function createInput(spec: {
|
|
102
|
+
workflowId?: string;
|
|
103
|
+
workflowInstanceId?: string;
|
|
104
|
+
}) {
|
|
105
|
+
return {
|
|
106
|
+
apiVersion: API_VERSION,
|
|
107
|
+
kind: "WorkflowExecution",
|
|
108
|
+
metadata: { name: "run-gate-wf-exec", org: ORG },
|
|
109
|
+
spec,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
describe("workflow-execution-create run gate (composed server)", () => {
|
|
114
|
+
it("workflow_id: denies with the workflow copy before any lookup, persists nothing", async () => {
|
|
115
|
+
decision = { kind: "deny", reason: "" };
|
|
116
|
+
gate.runGateChecks.length = 0;
|
|
117
|
+
const before = (
|
|
118
|
+
await server.store.listResources(ApiResourceKind.workflow_execution)
|
|
119
|
+
).length;
|
|
120
|
+
|
|
121
|
+
// A workflow that does not exist: a lookup-first chain would answer
|
|
122
|
+
// NOT_FOUND; the gate answers first.
|
|
123
|
+
const err = await captureError(() =>
|
|
124
|
+
command.create(createInput({ workflowId: "wf_01private" })),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
expect(err.code).toBe(Code.PermissionDenied);
|
|
128
|
+
expect(err.rawMessage).toBe(runWorkflowDeniedMessage("wf_01private"));
|
|
129
|
+
expect(gate.runGateChecks).toEqual([
|
|
130
|
+
{
|
|
131
|
+
permission: IamPermission.can_execute,
|
|
132
|
+
resourceKind: ApiResourceKind.workflow,
|
|
133
|
+
resourceId: "wf_01private",
|
|
134
|
+
},
|
|
135
|
+
]);
|
|
136
|
+
expect(
|
|
137
|
+
(await server.store.listResources(ApiResourceKind.workflow_execution))
|
|
138
|
+
.length,
|
|
139
|
+
"no execution row behind a denial",
|
|
140
|
+
).toBe(before);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("workflow_instance_id: denies with the instance copy on workflow_instance#can_execute", async () => {
|
|
144
|
+
decision = { kind: "deny", reason: "" };
|
|
145
|
+
gate.runGateChecks.length = 0;
|
|
146
|
+
|
|
147
|
+
const err = await captureError(() =>
|
|
148
|
+
command.create(createInput({ workflowInstanceId: "wfi_01private" })),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
expect(err.code).toBe(Code.PermissionDenied);
|
|
152
|
+
expect(err.rawMessage).toBe(
|
|
153
|
+
runWorkflowInstanceDeniedMessage("wfi_01private"),
|
|
154
|
+
);
|
|
155
|
+
expect(gate.runGateChecks).toEqual([
|
|
156
|
+
{
|
|
157
|
+
permission: IamPermission.can_execute,
|
|
158
|
+
resourceKind: ApiResourceKind.workflow_instance,
|
|
159
|
+
resourceId: "wfi_01private",
|
|
160
|
+
},
|
|
161
|
+
]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("not-found answers NOT_FOUND naming the target (the stigmer#224 order)", async () => {
|
|
165
|
+
decision = { kind: "not-found" };
|
|
166
|
+
const err = await captureError(() =>
|
|
167
|
+
command.create(createInput({ workflowId: "wf_01missing" })),
|
|
168
|
+
);
|
|
169
|
+
expect(err.code).toBe(Code.NotFound);
|
|
170
|
+
expect(err.rawMessage).toContain("wf_01missing");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("the gate precedes the engine gate: an ALLOWED create reaches EnsureEngineAvailable", async () => {
|
|
174
|
+
decision = { kind: "allow" };
|
|
175
|
+
gate.runGateChecks.length = 0;
|
|
176
|
+
|
|
177
|
+
const err = await captureError(() =>
|
|
178
|
+
command.create(createInput({ workflowId: "wf_01allowed" })),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
expect(err.code).toBe(Code.Unavailable);
|
|
182
|
+
expect(err.rawMessage).toBe(ENGINE_UNAVAILABLE_MESSAGE);
|
|
183
|
+
expect(gate.runGateChecks.map((c) => c.resourceId)).toEqual([
|
|
184
|
+
"wf_01allowed",
|
|
185
|
+
]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("the shape-less request is still ValidateWorkflowOrInstance's INVALID_ARGUMENT, and the gate makes no check", async () => {
|
|
189
|
+
decision = { kind: "deny", reason: "" };
|
|
190
|
+
gate.runGateChecks.length = 0;
|
|
191
|
+
|
|
192
|
+
const err = await captureError(() => command.create(createInput({})));
|
|
193
|
+
|
|
194
|
+
expect(err.code).toBe(Code.InvalidArgument);
|
|
195
|
+
expect(err.rawMessage).toBe(
|
|
196
|
+
"either workflow_id or workflow_instance_id must be provided",
|
|
197
|
+
);
|
|
198
|
+
expect(gate.runGateChecks).toEqual([]);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the workflow-execution run-target resolver (P1 sp.run-gate): the
|
|
3
|
+
* chain's own precedence — an explicit workflow_instance_id fully names
|
|
4
|
+
* the target (CreateDefaultInstanceIfNeeded returns early on it), else
|
|
5
|
+
* workflow_id names the blueprint whose default instance the chain will
|
|
6
|
+
* resolve — each with its own byte-pinned deny copy. Neither set has no
|
|
7
|
+
* target (ValidateWorkflowOrInstance refuses that shape as INVALID_ARGUMENT
|
|
8
|
+
* before this step runs).
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it } from "vitest";
|
|
11
|
+
import { create } from "@bufbuild/protobuf";
|
|
12
|
+
|
|
13
|
+
import { WorkflowExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/api_pb";
|
|
14
|
+
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
15
|
+
import { IamPermission } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
runWorkflowDeniedMessage,
|
|
19
|
+
runWorkflowInstanceDeniedMessage,
|
|
20
|
+
} from "../constants.js";
|
|
21
|
+
import { workflowExecutionRunTarget } from "../run-target.js";
|
|
22
|
+
|
|
23
|
+
describe("workflowExecutionRunTarget", () => {
|
|
24
|
+
it("workflow_instance_id → workflow_instance#can_execute", () => {
|
|
25
|
+
expect(
|
|
26
|
+
workflowExecutionRunTarget(
|
|
27
|
+
create(WorkflowExecutionSchema, {
|
|
28
|
+
spec: { workflowInstanceId: "wfi_01abc" },
|
|
29
|
+
}),
|
|
30
|
+
),
|
|
31
|
+
).toEqual({
|
|
32
|
+
permission: IamPermission.can_execute,
|
|
33
|
+
resourceKind: ApiResourceKind.workflow_instance,
|
|
34
|
+
resourceId: "wfi_01abc",
|
|
35
|
+
deniedMessage: runWorkflowInstanceDeniedMessage("wfi_01abc"),
|
|
36
|
+
});
|
|
37
|
+
expect(runWorkflowInstanceDeniedMessage("wfi_01abc")).toBe(
|
|
38
|
+
"unauthorized to run workflow instance 'wfi_01abc'",
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("workflow_id → workflow#can_execute", () => {
|
|
43
|
+
expect(
|
|
44
|
+
workflowExecutionRunTarget(
|
|
45
|
+
create(WorkflowExecutionSchema, { spec: { workflowId: "wf_01abc" } }),
|
|
46
|
+
),
|
|
47
|
+
).toEqual({
|
|
48
|
+
permission: IamPermission.can_execute,
|
|
49
|
+
resourceKind: ApiResourceKind.workflow,
|
|
50
|
+
resourceId: "wf_01abc",
|
|
51
|
+
deniedMessage: runWorkflowDeniedMessage("wf_01abc"),
|
|
52
|
+
});
|
|
53
|
+
expect(runWorkflowDeniedMessage("wf_01abc")).toBe(
|
|
54
|
+
"unauthorized to run workflow 'wf_01abc'",
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("precedence is the chain's: the explicit instance wins over the blueprint", () => {
|
|
59
|
+
const target = workflowExecutionRunTarget(
|
|
60
|
+
create(WorkflowExecutionSchema, {
|
|
61
|
+
spec: { workflowId: "wf_01abc", workflowInstanceId: "wfi_01abc" },
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
expect(target?.resourceKind).toBe(ApiResourceKind.workflow_instance);
|
|
65
|
+
expect(target?.resourceId).toBe("wfi_01abc");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("answers no target when neither reference is set", () => {
|
|
69
|
+
expect(
|
|
70
|
+
workflowExecutionRunTarget(create(WorkflowExecutionSchema, {})),
|
|
71
|
+
).toBeUndefined();
|
|
72
|
+
expect(
|
|
73
|
+
workflowExecutionRunTarget(
|
|
74
|
+
create(WorkflowExecutionSchema, {
|
|
75
|
+
spec: { workflowId: "", workflowInstanceId: "" },
|
|
76
|
+
}),
|
|
77
|
+
),
|
|
78
|
+
).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -16,6 +16,23 @@
|
|
|
16
16
|
export const ENGINE_UNAVAILABLE_MESSAGE =
|
|
17
17
|
"The execution engine is temporarily unavailable. Please try again shortly.";
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* The run gate's deny copy per request shape (P1 sp.run-gate, ruling
|
|
21
|
+
* Q-RG-4; wire once merged, asserted by the conformance run-gate suite).
|
|
22
|
+
* NEW copy quotes the handle single-quoted (the ratified 2026-08-26 rule).
|
|
23
|
+
* The agent-execution domain's `runAgent*` functions are these copies'
|
|
24
|
+
* twins; each domain owns its own.
|
|
25
|
+
*/
|
|
26
|
+
export function runWorkflowDeniedMessage(workflowId: string): string {
|
|
27
|
+
return `unauthorized to run workflow '${workflowId}'`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function runWorkflowInstanceDeniedMessage(
|
|
31
|
+
workflowInstanceId: string,
|
|
32
|
+
): string {
|
|
33
|
+
return `unauthorized to run workflow instance '${workflowInstanceId}'`;
|
|
34
|
+
}
|
|
35
|
+
|
|
19
36
|
/**
|
|
20
37
|
* The lifecycle steps' engineless refusal (lifecycle_steps.go, five
|
|
21
38
|
* sites: pause/resume/cancel/terminate signal steps and recover's
|