@stigmer/server 3.16.0 → 3.17.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/dist/boot/compose.d.ts.map +1 -1
- package/dist/boot/compose.js +6 -0
- package/dist/boot/compose.js.map +1 -1
- package/dist/domain/platform/controller.d.ts +26 -6
- package/dist/domain/platform/controller.d.ts.map +1 -1
- package/dist/domain/platform/controller.js +44 -1
- package/dist/domain/platform/controller.js.map +1 -1
- package/dist/domain/plugin/materialize/agent.d.ts +10 -1
- package/dist/domain/plugin/materialize/agent.d.ts.map +1 -1
- package/dist/domain/plugin/materialize/agent.js +35 -1
- package/dist/domain/plugin/materialize/agent.js.map +1 -1
- package/dist/domain/plugin/materialize/plan.d.ts.map +1 -1
- package/dist/domain/plugin/materialize/plan.js +1 -1
- package/dist/domain/plugin/materialize/plan.js.map +1 -1
- package/dist/domain/project/reconcile.d.ts.map +1 -1
- package/dist/domain/project/reconcile.js +14 -10
- package/dist/domain/project/reconcile.js.map +1 -1
- package/dist/domain/workflow/registry/data/task-kind-registry.json +4 -1
- package/dist/extensions/drivers.d.ts +11 -0
- package/dist/extensions/drivers.d.ts.map +1 -1
- package/dist/extensions/license-status.d.ts +64 -0
- package/dist/extensions/license-status.d.ts.map +1 -0
- package/dist/extensions/license-status.js +9 -0
- package/dist/extensions/license-status.js.map +1 -0
- package/dist/extensions/registry.d.ts +6 -0
- package/dist/extensions/registry.d.ts.map +1 -1
- package/dist/extensions/registry.js +10 -0
- package/dist/extensions/registry.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/boot/compose.ts +6 -0
- package/src/domain/platform/__tests__/platform.test.ts +148 -1
- package/src/domain/platform/controller.ts +76 -6
- package/src/domain/plugin/__tests__/materialize.test.ts +60 -1
- package/src/domain/plugin/__tests__/plugin.test.ts +6 -0
- package/src/domain/plugin/materialize/agent.ts +38 -0
- package/src/domain/plugin/materialize/plan.ts +7 -1
- package/src/domain/project/__tests__/reconcile.test.ts +51 -5
- package/src/domain/project/reconcile.ts +20 -15
- package/src/domain/workflow/registry/data/task-kind-registry.json +4 -1
- package/src/extensions/__tests__/license-status-points.test.ts +50 -0
- package/src/extensions/drivers.ts +11 -0
- package/src/extensions/license-status.ts +71 -0
- package/src/extensions/registry.ts +19 -0
- package/src/index.ts +8 -0
- package/src/pipeline/__tests__/billing-contract-rules.test.ts +260 -0
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Platform controller — ports pkg/domain/platform: the server's
|
|
3
3
|
* self-description surface (NOT a resource domain — no store, no
|
|
4
|
-
* pipelines;
|
|
4
|
+
* pipelines; four direct handlers on PlatformQueryController).
|
|
5
5
|
*
|
|
6
|
-
* Proven by platform.conformance.test.ts (getServerInfo
|
|
7
|
-
* getRunnerBootstrapConfig shapes; getRunnerScopedToken
|
|
8
|
-
* excluded there — its arms are exercised mid-execution)
|
|
9
|
-
* __tests__/platform.test.ts (the fail-soft mint matrix, the
|
|
10
|
-
* composed-server pattern).
|
|
6
|
+
* Proven by platform.conformance.test.ts (getServerInfo,
|
|
7
|
+
* getRunnerBootstrapConfig and getLicenseStatus shapes; getRunnerScopedToken
|
|
8
|
+
* is deliberately excluded there — its arms are exercised mid-execution)
|
|
9
|
+
* and __tests__/platform.test.ts (the fail-soft mint matrix, the license
|
|
10
|
+
* status arms, the #15 composed-server pattern).
|
|
11
|
+
*
|
|
12
|
+
* getLicenseStatus is the one arm every edition answers from a driver
|
|
13
|
+
* point: a server's license is a fact about the server, like its edition,
|
|
14
|
+
* and the provider behind `deps.licenseStatus` is the one place that
|
|
15
|
+
* knows it (extensions/license-status.ts). The controller owns the clock
|
|
16
|
+
* — it hands the provider one instant and stamps checked_at from the same
|
|
17
|
+
* one — and copies the provider's presence contract to the wire unchanged.
|
|
11
18
|
*
|
|
12
19
|
* getRunnerScopedToken is the mint side of the runner-token lane
|
|
13
20
|
* (oss#535) — the seam src/runnerauth/. What the token IS depends on the
|
|
@@ -41,8 +48,11 @@
|
|
|
41
48
|
*/
|
|
42
49
|
import type { ConnectRouter, HandlerContext } from "@connectrpc/connect";
|
|
43
50
|
import { create } from "@bufbuild/protobuf";
|
|
51
|
+
import { timestampFromDate } from "@bufbuild/protobuf/wkt";
|
|
44
52
|
|
|
53
|
+
import { LicenseState } from "@stigmer/protos/ai/stigmer/platform/v1/license_pb";
|
|
45
54
|
import {
|
|
55
|
+
GetLicenseStatusOutputSchema,
|
|
46
56
|
GetRunnerBootstrapConfigOutputSchema,
|
|
47
57
|
GetRunnerScopedTokenOutputSchema,
|
|
48
58
|
GetServerInfoOutputSchema,
|
|
@@ -50,6 +60,7 @@ import {
|
|
|
50
60
|
ServerEdition,
|
|
51
61
|
} from "@stigmer/protos/ai/stigmer/platform/v1/server_info_pb";
|
|
52
62
|
import type {
|
|
63
|
+
GetLicenseStatusOutput,
|
|
53
64
|
GetRunnerBootstrapConfigOutput,
|
|
54
65
|
GetRunnerScopedTokenInput,
|
|
55
66
|
GetRunnerScopedTokenOutput,
|
|
@@ -57,6 +68,7 @@ import type {
|
|
|
57
68
|
} from "@stigmer/protos/ai/stigmer/platform/v1/server_info_pb";
|
|
58
69
|
|
|
59
70
|
import type { Logger } from "../../boot/logger.js";
|
|
71
|
+
import type { LicenseStatusProvider } from "../../extensions/license-status.js";
|
|
60
72
|
import { callerIdentityOf } from "../../pipeline/interceptors/auth.js";
|
|
61
73
|
import type {
|
|
62
74
|
RunnerCredentialProvider,
|
|
@@ -88,6 +100,18 @@ export interface PlatformControllerDeps {
|
|
|
88
100
|
* cloud composition answers `cloud` without forking this controller.
|
|
89
101
|
*/
|
|
90
102
|
readonly edition: ServerEdition;
|
|
103
|
+
/**
|
|
104
|
+
* What license this server holds (extensions/license-status.ts). The
|
|
105
|
+
* composition root installs the built-in `absent` provider when no unit
|
|
106
|
+
* registers one, so the field is required: a server that cannot answer
|
|
107
|
+
* the question is a wiring error, never a silent no-op.
|
|
108
|
+
*/
|
|
109
|
+
readonly licenseStatus: LicenseStatusProvider;
|
|
110
|
+
/**
|
|
111
|
+
* The clock getLicenseStatus evaluates against — injected so a test can
|
|
112
|
+
* pin that the provider's instant and checked_at are the same one.
|
|
113
|
+
*/
|
|
114
|
+
readonly now: () => Date;
|
|
91
115
|
readonly logger: Logger;
|
|
92
116
|
}
|
|
93
117
|
|
|
@@ -98,6 +122,7 @@ export function registerPlatformServices(
|
|
|
98
122
|
): void {
|
|
99
123
|
router.service(PlatformQueryController, {
|
|
100
124
|
getServerInfo: () => getServerInfo(deps),
|
|
125
|
+
getLicenseStatus: () => getLicenseStatus(deps),
|
|
101
126
|
getRunnerBootstrapConfig: (_input, ctx) =>
|
|
102
127
|
getRunnerBootstrapConfig(deps, ctx),
|
|
103
128
|
getRunnerScopedToken: (input, ctx) =>
|
|
@@ -113,6 +138,51 @@ function getServerInfo(deps: PlatformControllerDeps): GetServerInfoOutput {
|
|
|
113
138
|
});
|
|
114
139
|
}
|
|
115
140
|
|
|
141
|
+
/**
|
|
142
|
+
* The state of the license this server holds, as the composed provider
|
|
143
|
+
* reports it at one instant.
|
|
144
|
+
*
|
|
145
|
+
* The report's arm (extensions/license-status.ts) is copied to the wire
|
|
146
|
+
* field for field, so the output's presence contract holds by construction:
|
|
147
|
+
* claims exactly when a ticket verified, key_id whenever one was presented.
|
|
148
|
+
* checked_at is stamped from the SAME instant the provider evaluated
|
|
149
|
+
* against, so the answer can never say "valid as of now" about a moment
|
|
150
|
+
* other than the one it was asked about. No caller check beyond
|
|
151
|
+
* authentication: the annotation is is_skip_authorization because the
|
|
152
|
+
* banner this feeds is for every signed-in person, and the handler adds
|
|
153
|
+
* none (the RPC comment states it).
|
|
154
|
+
*/
|
|
155
|
+
async function getLicenseStatus(
|
|
156
|
+
deps: PlatformControllerDeps,
|
|
157
|
+
): Promise<GetLicenseStatusOutput> {
|
|
158
|
+
const now = deps.now();
|
|
159
|
+
const report = await deps.licenseStatus.status(now);
|
|
160
|
+
const output = create(GetLicenseStatusOutputSchema, {
|
|
161
|
+
state: report.state,
|
|
162
|
+
checkedAt: timestampFromDate(now),
|
|
163
|
+
});
|
|
164
|
+
switch (report.state) {
|
|
165
|
+
case LicenseState.absent:
|
|
166
|
+
return output;
|
|
167
|
+
case LicenseState.invalid:
|
|
168
|
+
output.keyId = report.keyId;
|
|
169
|
+
return output;
|
|
170
|
+
case LicenseState.valid:
|
|
171
|
+
case LicenseState.expiring:
|
|
172
|
+
case LicenseState.grace:
|
|
173
|
+
case LicenseState.expired:
|
|
174
|
+
output.keyId = report.keyId;
|
|
175
|
+
output.claims = report.claims;
|
|
176
|
+
return output;
|
|
177
|
+
default: {
|
|
178
|
+
const exhaustive: never = report;
|
|
179
|
+
throw new Error(
|
|
180
|
+
`unhandled license report: ${JSON.stringify(exhaustive)}`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
116
186
|
/**
|
|
117
187
|
* Go GetRunnerBootstrapConfig: the Temporal coordinates an embedded runner
|
|
118
188
|
* should connect to so it can self-bootstrap from a token alone. The
|
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
* agent, the `mcpServers` key-to-slug rule, a skill archive rooted at its
|
|
8
8
|
* SKILL.md with the plugin's labels on the request, a version the tag
|
|
9
9
|
* pattern rejects becoming a warning, an overlay that redefines the
|
|
10
|
-
* transport refused,
|
|
10
|
+
* transport refused, the composed agent declaring its tools' variables
|
|
11
|
+
* (OAuth targets excluded, an authored agent left as written), and the
|
|
12
|
+
* convergence and drop rules over members.
|
|
11
13
|
*/
|
|
12
14
|
import { describe, expect, it } from "vitest";
|
|
13
15
|
|
|
@@ -19,8 +21,11 @@ import type { PluginPackage } from "@stigmer/plugin-package";
|
|
|
19
21
|
import { cursorPlugin, openPlugin } from "@stigmer/plugin-package/testing";
|
|
20
22
|
import type { PluginFixture } from "@stigmer/plugin-package/testing";
|
|
21
23
|
import { create } from "@bufbuild/protobuf";
|
|
24
|
+
import { AgentSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
|
|
25
|
+
import { AgentSpecSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
|
|
22
26
|
import { McpServerSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
|
|
23
27
|
import {
|
|
28
|
+
McpServerAuthSchema,
|
|
24
29
|
McpServerSpecSchema,
|
|
25
30
|
StdioServerConfigSchema,
|
|
26
31
|
} from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/spec_pb";
|
|
@@ -224,6 +229,60 @@ describe("planMaterialization", () => {
|
|
|
224
229
|
expect(builtPlan.skills[0]!.request.tag).toBe("");
|
|
225
230
|
});
|
|
226
231
|
|
|
232
|
+
it("declares the servers' variables on the composed agent so a session asks for them", () => {
|
|
233
|
+
expect(plan.agent!.resource.spec?.env).toEqual({
|
|
234
|
+
GITHUB_TOKEN: expect.objectContaining({ isSecret: true, optional: false }),
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("leaves an OAuth-managed target out of the agent's declarations", () => {
|
|
239
|
+
const overlay = create(McpServerSchema, {
|
|
240
|
+
spec: create(McpServerSpecSchema, {
|
|
241
|
+
auth: create(McpServerAuthSchema, { targetEnvVar: "GITHUB_TOKEN" }),
|
|
242
|
+
}),
|
|
243
|
+
});
|
|
244
|
+
const withOAuth = planMaterialization(
|
|
245
|
+
pkg,
|
|
246
|
+
inMemoryPluginFiles(thermos),
|
|
247
|
+
{
|
|
248
|
+
workflows: [],
|
|
249
|
+
mcpServers: [
|
|
250
|
+
{
|
|
251
|
+
path: "ai.stigmer/mcp-servers/my_github.yaml",
|
|
252
|
+
server: "my_github",
|
|
253
|
+
resource: overlay,
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
},
|
|
257
|
+
IDENTITY,
|
|
258
|
+
"",
|
|
259
|
+
);
|
|
260
|
+
// The server still declares it (the connect flow reads it there); the
|
|
261
|
+
// agent does not, because the value comes from a managed environment.
|
|
262
|
+
expect(withOAuth.mcpServers[0]!.resource.spec?.env).toHaveProperty("GITHUB_TOKEN");
|
|
263
|
+
expect(withOAuth.agent!.resource.spec?.env).toEqual({});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("takes an authored agent as written, declarations included", () => {
|
|
267
|
+
const authored = create(AgentSchema, {
|
|
268
|
+
spec: create(AgentSpecSchema, {
|
|
269
|
+
instructions: "The author's own instructions for this agent.",
|
|
270
|
+
}),
|
|
271
|
+
});
|
|
272
|
+
const withOverlay = planMaterialization(
|
|
273
|
+
pkg,
|
|
274
|
+
inMemoryPluginFiles(thermos),
|
|
275
|
+
{
|
|
276
|
+
agent: { path: "ai.stigmer/agent.yaml", resource: authored },
|
|
277
|
+
workflows: [],
|
|
278
|
+
mcpServers: [],
|
|
279
|
+
},
|
|
280
|
+
IDENTITY,
|
|
281
|
+
"",
|
|
282
|
+
);
|
|
283
|
+
expect(withOverlay.agent!.resource.spec?.env).toEqual({});
|
|
284
|
+
});
|
|
285
|
+
|
|
227
286
|
it("refuses a server overlay that redefines the transport", () => {
|
|
228
287
|
const overlay = create(McpServerSchema, {
|
|
229
288
|
spec: create(McpServerSpecSchema, {
|
|
@@ -296,6 +296,12 @@ describe("Plugin push — materialisation", () => {
|
|
|
296
296
|
isSecret: true,
|
|
297
297
|
optional: false,
|
|
298
298
|
});
|
|
299
|
+
// The agent declares the same variable, which is what makes a session
|
|
300
|
+
// ask for it and the execution's least-privilege filter pass it.
|
|
301
|
+
expect(agent.spec?.env["GITHUB_TOKEN"]).toMatchObject({
|
|
302
|
+
isSecret: true,
|
|
303
|
+
optional: false,
|
|
304
|
+
});
|
|
299
305
|
});
|
|
300
306
|
|
|
301
307
|
it("installs an MCP-only plugin as its servers and no agent", async () => {
|
|
@@ -14,6 +14,18 @@
|
|
|
14
14
|
* `mcp_server_usage`, every `agents/*.md` as a `sub_agent` with access to
|
|
15
15
|
* all the plugin's servers and the skills it asked for.
|
|
16
16
|
*
|
|
17
|
+
* The composed agent declares in `env` the union of its servers' variables,
|
|
18
|
+
* OAuth-managed target variables excluded. An execution filters its
|
|
19
|
+
* environment to the AGENT's declared keys, and every client's session
|
|
20
|
+
* start asks the user for the AGENT's declared keys, so an agent declaring
|
|
21
|
+
* nothing over a server declaring `${API_TOKEN}` is never asked for the
|
|
22
|
+
* token and fails at its first tool call. The declaration on the agent is
|
|
23
|
+
* what routes the user's values to the execution; the declaration on the
|
|
24
|
+
* server stays too (the connect flow and the runner's per-server filter
|
|
25
|
+
* read it there). An OAuth target is excluded because its value is injected
|
|
26
|
+
* from a managed environment, never asked of the user. An author's
|
|
27
|
+
* `agent.yaml` is taken as written: they chose what to declare.
|
|
28
|
+
*
|
|
17
29
|
* Model hints are recorded, never applied: a dialect's `model` becomes a
|
|
18
30
|
* status warning and the sub-agent runs on the session's model. The
|
|
19
31
|
* catalog seam exposes neither family nor cost tier, the runner drops a
|
|
@@ -25,6 +37,7 @@
|
|
|
25
37
|
import { create } from "@bufbuild/protobuf";
|
|
26
38
|
|
|
27
39
|
import type { PluginPackage, PluginSubAgent } from "@stigmer/plugin-package";
|
|
40
|
+
import type { EnvVarDeclaration } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/spec_pb";
|
|
28
41
|
import { AgentSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
|
|
29
42
|
import type { Agent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
|
|
30
43
|
import {
|
|
@@ -47,6 +60,7 @@ import { renderDefaultAgentInstructions } from "./default-agent-instructions.js"
|
|
|
47
60
|
import { memberMetadata } from "./identity.js";
|
|
48
61
|
import type { PluginIdentity } from "./identity.js";
|
|
49
62
|
import { mcpServerSlugOf } from "./mcp-servers.js";
|
|
63
|
+
import type { PlannedMcpServer } from "./mcp-servers.js";
|
|
50
64
|
import { skillSlugOf } from "./skills.js";
|
|
51
65
|
|
|
52
66
|
export interface PlannedAgent {
|
|
@@ -67,6 +81,7 @@ export function carriesAgent(plugin: PluginPackage): boolean {
|
|
|
67
81
|
export function planAgent(
|
|
68
82
|
plugin: PluginPackage,
|
|
69
83
|
overlayAgent: Agent | undefined,
|
|
84
|
+
mcpServers: readonly PlannedMcpServer[],
|
|
70
85
|
identity: PluginIdentity,
|
|
71
86
|
warnings: PluginWarning[],
|
|
72
87
|
): PlannedAgent | undefined {
|
|
@@ -95,6 +110,7 @@ export function planAgent(
|
|
|
95
110
|
const spec = create(AgentSpecSchema, {
|
|
96
111
|
description: plugin.description ?? "",
|
|
97
112
|
instructions: renderDefaultAgentInstructions(plugin),
|
|
113
|
+
env: toolVariables(mcpServers),
|
|
98
114
|
skillRefs: [...skillSlugByName.values()].map((slug) =>
|
|
99
115
|
create(ApiResourceReferenceSchema, {
|
|
100
116
|
org: identity.org,
|
|
@@ -127,6 +143,28 @@ export function planAgent(
|
|
|
127
143
|
};
|
|
128
144
|
}
|
|
129
145
|
|
|
146
|
+
/**
|
|
147
|
+
* The variables the agent's tools need from the user: every server's
|
|
148
|
+
* declarations, minus each server's OAuth target. Two servers declaring
|
|
149
|
+
* one name share one declaration; the first server's wins, and the
|
|
150
|
+
* library already refuses a plugin whose variables disagree about a name.
|
|
151
|
+
*/
|
|
152
|
+
export function toolVariables(
|
|
153
|
+
mcpServers: readonly PlannedMcpServer[],
|
|
154
|
+
): Record<string, EnvVarDeclaration> {
|
|
155
|
+
const declarations: Record<string, EnvVarDeclaration> = {};
|
|
156
|
+
for (const server of mcpServers) {
|
|
157
|
+
const spec = server.resource.spec;
|
|
158
|
+
if (spec === undefined) continue;
|
|
159
|
+
const oauthTarget = spec.auth?.targetEnvVar ?? "";
|
|
160
|
+
for (const [name, declaration] of Object.entries(spec.env)) {
|
|
161
|
+
if (name === oauthTarget || name in declarations) continue;
|
|
162
|
+
declarations[name] = declaration;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return declarations;
|
|
166
|
+
}
|
|
167
|
+
|
|
130
168
|
function planSubAgent(
|
|
131
169
|
subAgent: PluginSubAgent,
|
|
132
170
|
identity: PluginIdentity,
|
|
@@ -96,7 +96,13 @@ export function planMaterialization(
|
|
|
96
96
|
),
|
|
97
97
|
identity,
|
|
98
98
|
);
|
|
99
|
-
const agent = planAgent(
|
|
99
|
+
const agent = planAgent(
|
|
100
|
+
plugin,
|
|
101
|
+
overlays.agent?.resource,
|
|
102
|
+
mcpServers,
|
|
103
|
+
identity,
|
|
104
|
+
warnings,
|
|
105
|
+
);
|
|
100
106
|
const workflows = planWorkflows(overlays.workflows, identity);
|
|
101
107
|
|
|
102
108
|
const members: PlannedMember[] = [
|
|
@@ -237,10 +237,12 @@ describe("orphan deletion (Go service_test.go)", () => {
|
|
|
237
237
|
]);
|
|
238
238
|
});
|
|
239
239
|
|
|
240
|
-
it("orphan lookup is
|
|
241
|
-
// Seeded under org "local"; the orphan
|
|
242
|
-
//
|
|
243
|
-
//
|
|
240
|
+
it("orphan lookup is scoped to the reference's org: another org's same-slug resource is never touched", async () => {
|
|
241
|
+
// Slugs are org-scoped identifiers. Seeded under org "local"; the orphan
|
|
242
|
+
// reference names a DIFFERENT org, so it must not resolve to this row.
|
|
243
|
+
// (The Go port matched by slug alone; on a backend with a system org
|
|
244
|
+
// beside the user's, a prune could then delete the wrong org's
|
|
245
|
+
// resource — stigmer/stigmer#1151.)
|
|
244
246
|
await seedAgent("agent-id-x", "cross-org-agent");
|
|
245
247
|
const deleter = new RecordingDeleter();
|
|
246
248
|
const previous = [
|
|
@@ -251,9 +253,53 @@ describe("orphan deletion (Go service_test.go)", () => {
|
|
|
251
253
|
}),
|
|
252
254
|
];
|
|
253
255
|
const result = await service(deleter).reconcile(previous, [], DEFAULT_RECONCILIATION_OPTIONS);
|
|
256
|
+
expect(result.removed).toHaveLength(0);
|
|
257
|
+
expect(result.errors).toHaveLength(1);
|
|
258
|
+
expect(result.errors[0]?.resourceKey).toBe("agent:cross-org-agent");
|
|
259
|
+
expect(result.errors[0]?.message).toBe("failed to resolve resource for deletion");
|
|
260
|
+
expect(deleter.calls).toHaveLength(0);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("orphan lookup picks the reference's org when two orgs hold the slug", async () => {
|
|
264
|
+
await seedAgent("agent-id-local", "shared-slug");
|
|
265
|
+
await store.saveResource(
|
|
266
|
+
ApiResourceKind.agent,
|
|
267
|
+
"agent-id-other",
|
|
268
|
+
AgentSchema,
|
|
269
|
+
create(AgentSchema, {
|
|
270
|
+
apiVersion: "agentic.stigmer.ai/v1",
|
|
271
|
+
kind: "Agent",
|
|
272
|
+
metadata: { id: "agent-id-other", name: "shared-slug", slug: "shared-slug", org: "another-org" },
|
|
273
|
+
}),
|
|
274
|
+
);
|
|
275
|
+
const deleter = new RecordingDeleter();
|
|
276
|
+
const previous = [
|
|
277
|
+
create(ApiResourceReferenceSchema, {
|
|
278
|
+
org: "another-org",
|
|
279
|
+
kind: ApiResourceKind.agent,
|
|
280
|
+
slug: "shared-slug",
|
|
281
|
+
}),
|
|
282
|
+
];
|
|
283
|
+
const result = await service(deleter).reconcile(previous, [], DEFAULT_RECONCILIATION_OPTIONS);
|
|
284
|
+
expect(result.removed).toHaveLength(1);
|
|
285
|
+
expect(deleter.calls).toEqual([
|
|
286
|
+
{ kind: ApiResourceKind.agent, resourceId: "agent-id-other" },
|
|
287
|
+
]);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("a reference without an org keeps the unscoped lookup (a pre-scoping client's project)", async () => {
|
|
291
|
+
await seedAgent("agent-id-y", "legacy-agent");
|
|
292
|
+
const deleter = new RecordingDeleter();
|
|
293
|
+
const previous = [
|
|
294
|
+
create(ApiResourceReferenceSchema, {
|
|
295
|
+
kind: ApiResourceKind.agent,
|
|
296
|
+
slug: "legacy-agent",
|
|
297
|
+
}),
|
|
298
|
+
];
|
|
299
|
+
const result = await service(deleter).reconcile(previous, [], DEFAULT_RECONCILIATION_OPTIONS);
|
|
254
300
|
expect(result.removed).toHaveLength(1);
|
|
255
301
|
expect(deleter.calls).toEqual([
|
|
256
|
-
{ kind: ApiResourceKind.agent, resourceId: "agent-id-
|
|
302
|
+
{ kind: ApiResourceKind.agent, resourceId: "agent-id-y" },
|
|
257
303
|
]);
|
|
258
304
|
});
|
|
259
305
|
|
|
@@ -37,6 +37,7 @@ import { ReconciliationSummarySchema } from "@stigmer/protos/ai/stigmer/tenancy/
|
|
|
37
37
|
import type { ReconciliationSummary } from "@stigmer/protos/ai/stigmer/tenancy/project/v1/status_pb";
|
|
38
38
|
|
|
39
39
|
import type { Logger } from "../../boot/logger.js";
|
|
40
|
+
import { findResourceBySlug } from "../../pipeline/steps/helpers.js";
|
|
40
41
|
import { metadataOf } from "../../pipeline/steps/shapes.js";
|
|
41
42
|
import type { Store } from "../../store/interface.js";
|
|
42
43
|
|
|
@@ -205,28 +206,32 @@ async function deleteOrphans(
|
|
|
205
206
|
}
|
|
206
207
|
|
|
207
208
|
/**
|
|
208
|
-
* Resolves an orphan reference to its resource id
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
209
|
+
* Resolves an orphan reference to its resource id, within the org the
|
|
210
|
+
* reference names. Slugs are org-scoped identifiers: a local backend holds
|
|
211
|
+
* the `stigmer` system org beside the user's, a self-host holds many, and
|
|
212
|
+
* the same slug may exist in several. The Go port matched by slug alone and
|
|
213
|
+
* could prune another org's resource (stigmer/stigmer#1151); the CLI has
|
|
214
|
+
* always stamped `org` on every member reference, so scoping is a change
|
|
215
|
+
* of lookup, not of contract. A reference without an org (a project written
|
|
216
|
+
* before members carried one) keeps the unscoped match, the helper's
|
|
217
|
+
* documented empty-org arm.
|
|
212
218
|
*/
|
|
213
219
|
async function resolveResourceId(
|
|
214
220
|
store: Store,
|
|
215
221
|
ref: ApiResourceReference,
|
|
216
222
|
): Promise<string> {
|
|
217
223
|
const schema = schemaForKind(ref.kind);
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
224
|
+
const resource = await findResourceBySlug(
|
|
225
|
+
store,
|
|
226
|
+
ref.kind,
|
|
227
|
+
schema,
|
|
228
|
+
ref.slug,
|
|
229
|
+
ref.org,
|
|
230
|
+
);
|
|
231
|
+
if (resource === undefined) {
|
|
232
|
+
const where = ref.org === "" ? "" : ` in org '${ref.org}'`;
|
|
227
233
|
throw new Error(
|
|
228
|
-
`resource ${kindName(ref.kind)}/${ref.slug} not found
|
|
229
|
-
{ cause },
|
|
234
|
+
`resource ${kindName(ref.kind)}/${ref.slug} not found${where}`,
|
|
230
235
|
);
|
|
231
236
|
}
|
|
232
237
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the licenseStatus driver point in the single-instance shape the
|
|
3
|
+
* registry already enforces for organizationDirectory and identityFederation:
|
|
4
|
+
*
|
|
5
|
+
* - `drivers.licenseStatus` — the provider that answers what license this
|
|
6
|
+
* server holds (an Enterprise unit registers the one that verifies a
|
|
7
|
+
* configured ticket); single instance; absent = the built-in `absent`
|
|
8
|
+
* provider installs at the compose.ts consumption site, so every
|
|
9
|
+
* edition answers getLicenseStatus and only one ever answers anything
|
|
10
|
+
* but absent.
|
|
11
|
+
*
|
|
12
|
+
* The controller's use of the resolved provider is pinned in
|
|
13
|
+
* domain/platform/__tests__/platform.test.ts; this file pins the registry.
|
|
14
|
+
*/
|
|
15
|
+
import { LicenseState } from "@stigmer/protos/ai/stigmer/platform/v1/license_pb";
|
|
16
|
+
import { describe, expect, it } from "vitest";
|
|
17
|
+
|
|
18
|
+
import type { LicenseStatusProvider } from "../license-status.js";
|
|
19
|
+
import { resolveExtensions } from "../registry.js";
|
|
20
|
+
|
|
21
|
+
// An invalid report is the smallest complete one a provider can give: the
|
|
22
|
+
// registry test needs an instance to carry through, not a verified ticket.
|
|
23
|
+
const provider: LicenseStatusProvider = {
|
|
24
|
+
status: () =>
|
|
25
|
+
Promise.resolve({ state: LicenseState.invalid, keyId: "lk_test" }),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe("the licenseStatus driver point", () => {
|
|
29
|
+
it("is undefined with no extensions — the built-in absent provider serves", () => {
|
|
30
|
+
expect(resolveExtensions([]).drivers.licenseStatus).toBeUndefined();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("carries the one registered instance through", () => {
|
|
34
|
+
const resolved = resolveExtensions([
|
|
35
|
+
{ name: "enterprise-license", drivers: { licenseStatus: provider } },
|
|
36
|
+
]);
|
|
37
|
+
expect(resolved.drivers.licenseStatus).toBe(provider);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("throws on a second registration, naming both units", () => {
|
|
41
|
+
expect(() =>
|
|
42
|
+
resolveExtensions([
|
|
43
|
+
{ name: "license-a", drivers: { licenseStatus: provider } },
|
|
44
|
+
{ name: "license-b", drivers: { licenseStatus: provider } },
|
|
45
|
+
]),
|
|
46
|
+
).toThrowError(
|
|
47
|
+
/extension 'license-b' registers a LicenseStatusProvider, but 'license-a' already did/,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -53,6 +53,7 @@ import type { RunnerCredentialProvider } from "../runnerauth/runner-credential-p
|
|
|
53
53
|
import type { SandboxProvisionerFactory } from "../sandbox/provisioner.js";
|
|
54
54
|
import type { AuthorizationQueryEngine } from "./authorization-queries.js";
|
|
55
55
|
import type { IdentityFederation } from "./identity-federation.js";
|
|
56
|
+
import type { LicenseStatusProvider } from "./license-status.js";
|
|
56
57
|
import type { ListReadScope } from "./list-read-scope.js";
|
|
57
58
|
import type { OrganizationDirectory } from "./organization-directory.js";
|
|
58
59
|
import type { PolicyGrantScope } from "./policy-grant-scope.js";
|
|
@@ -219,4 +220,14 @@ export interface ExtensionDrivers {
|
|
|
219
220
|
* with the edition reason — the identityFederation absent shape.
|
|
220
221
|
*/
|
|
221
222
|
readonly authorizationQueries?: AuthorizationQueryEngine;
|
|
223
|
+
/**
|
|
224
|
+
* The license-status provider (single-instance point): what license
|
|
225
|
+
* this server holds, answered by PlatformQueryController.getLicenseStatus
|
|
226
|
+
* in every edition. An Enterprise unit registers the provider that
|
|
227
|
+
* verifies its configured ticket; when absent, the built-in `absent`
|
|
228
|
+
* answer installs at the compose.ts consumption site, so open source
|
|
229
|
+
* and the cloud (which hold no key) answer the same question the same
|
|
230
|
+
* way (extensions/license-status.ts carries the contract).
|
|
231
|
+
*/
|
|
232
|
+
readonly licenseStatus?: LicenseStatusProvider;
|
|
222
233
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The license-status extension point: what license does this server hold?
|
|
3
|
+
*
|
|
4
|
+
* Every edition answers the question through
|
|
5
|
+
* PlatformQueryController.getLicenseStatus, because a server's license is a
|
|
6
|
+
* fact about the server, like its edition, and the console banner that
|
|
7
|
+
* reads it ("licensed to Acme until March") must work wherever it is shown.
|
|
8
|
+
* Only one edition ever has a license to report: the Enterprise composition
|
|
9
|
+
* registers the provider that verifies the ticket it was configured with.
|
|
10
|
+
* Open source and the cloud hold no key, so with no provider composed the
|
|
11
|
+
* built-in `absent` answer below installs at the boot/compose.ts
|
|
12
|
+
* consumption site (the default-lives-with-the-consumer doctrine; see
|
|
13
|
+
* drivers.ts).
|
|
14
|
+
*
|
|
15
|
+
* The port speaks the generated claim types rather than a domain twin. The
|
|
16
|
+
* claims ARE the wire — the ticket's payload is the proto JSON of
|
|
17
|
+
* LicenseClaims — so a hand-written copy would be exactly the duplicate of a
|
|
18
|
+
* generated type the SDK and server guides forbid.
|
|
19
|
+
*
|
|
20
|
+
* The controller owns the clock: it hands the provider ONE instant and
|
|
21
|
+
* stamps the answer's checked_at from the same one, so a report and its
|
|
22
|
+
* timestamp can never disagree about when the license was evaluated. A
|
|
23
|
+
* provider therefore takes `now` and never reads the wall clock itself.
|
|
24
|
+
*
|
|
25
|
+
* The report is a union keyed on the state, not a bag of optional fields,
|
|
26
|
+
* so the wire's presence contract (GetLicenseStatusOutput) is a fact the
|
|
27
|
+
* compiler holds rather than a sentence a provider has to remember: an
|
|
28
|
+
* `invalid` report cannot carry the claims that did not verify, a `valid`
|
|
29
|
+
* one cannot omit them, and `unspecified` cannot be reported at all.
|
|
30
|
+
*/
|
|
31
|
+
import type { LicenseClaims } from "@stigmer/protos/ai/stigmer/platform/v1/license_pb";
|
|
32
|
+
import { LicenseState } from "@stigmer/protos/ai/stigmer/platform/v1/license_pb";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The states in which a ticket's signature verified and its claims are
|
|
36
|
+
* trusted; what separates them is only where the clock stands in the term.
|
|
37
|
+
*/
|
|
38
|
+
export type VerifiedLicenseState =
|
|
39
|
+
| LicenseState.valid
|
|
40
|
+
| LicenseState.expiring
|
|
41
|
+
| LicenseState.grace
|
|
42
|
+
| LicenseState.expired;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A provider's answer, evaluated at the instant it was given. One arm per
|
|
46
|
+
* presence shape: no ticket configured; a ticket that named a key and did
|
|
47
|
+
* not verify; a ticket that verified, with the key it named and the claims
|
|
48
|
+
* it carried. The controller copies the arm to the wire field for field.
|
|
49
|
+
*/
|
|
50
|
+
export type LicenseStatusReport =
|
|
51
|
+
| { readonly state: LicenseState.absent }
|
|
52
|
+
| { readonly state: LicenseState.invalid; readonly keyId: string }
|
|
53
|
+
| {
|
|
54
|
+
readonly state: VerifiedLicenseState;
|
|
55
|
+
readonly keyId: string;
|
|
56
|
+
readonly claims: LicenseClaims;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** The provider contract (single-instance point, ExtensionDrivers.licenseStatus). */
|
|
60
|
+
export interface LicenseStatusProvider {
|
|
61
|
+
/** The license's state as of `now`, the instant the answer is stamped with. */
|
|
62
|
+
status(now: Date): Promise<LicenseStatusReport>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The built-in answer for a server that holds no license: open source and
|
|
67
|
+
* the cloud, and an Enterprise composition that registered no provider.
|
|
68
|
+
*/
|
|
69
|
+
export const ABSENT_LICENSE_STATUS: LicenseStatusProvider = {
|
|
70
|
+
status: () => Promise.resolve({ state: LicenseState.absent }),
|
|
71
|
+
};
|
|
@@ -53,6 +53,7 @@ import type { SecretCodec } from "../encryption/codec.js";
|
|
|
53
53
|
import { V1_VERSION } from "../encryption/v1-codec.js";
|
|
54
54
|
import type { AuthorizationQueryEngine } from "./authorization-queries.js";
|
|
55
55
|
import type { IdentityFederation } from "./identity-federation.js";
|
|
56
|
+
import type { LicenseStatusProvider } from "./license-status.js";
|
|
56
57
|
import type { ListReadScope } from "./list-read-scope.js";
|
|
57
58
|
import type { PolicyGrantScope } from "./policy-grant-scope.js";
|
|
58
59
|
import type { ModelCatalogProvider } from "../domain/workflow/registry/model-catalog-provider.js";
|
|
@@ -272,6 +273,11 @@ export interface ResolvedExtensionDrivers {
|
|
|
272
273
|
* tuple-half RPC arms refuse UNIMPLEMENTED with the edition reason.
|
|
273
274
|
*/
|
|
274
275
|
readonly authorizationQueries: AuthorizationQueryEngine | undefined;
|
|
276
|
+
/**
|
|
277
|
+
* The license-status provider — undefined = compose.ts installs the
|
|
278
|
+
* built-in `absent` answer, so every edition serves getLicenseStatus.
|
|
279
|
+
*/
|
|
280
|
+
readonly licenseStatus: LicenseStatusProvider | undefined;
|
|
275
281
|
}
|
|
276
282
|
|
|
277
283
|
/**
|
|
@@ -325,6 +331,8 @@ export function resolveExtensions(
|
|
|
325
331
|
let policyGrantScopeDeclaredBy: string | undefined;
|
|
326
332
|
let authorizationQueries: AuthorizationQueryEngine | undefined;
|
|
327
333
|
let authorizationQueriesDeclaredBy: string | undefined;
|
|
334
|
+
let licenseStatus: LicenseStatusProvider | undefined;
|
|
335
|
+
let licenseStatusDeclaredBy: string | undefined;
|
|
328
336
|
const artifactStorageDrivers = new Map<
|
|
329
337
|
string,
|
|
330
338
|
ArtifactStorageDriverFactory
|
|
@@ -516,6 +524,16 @@ export function resolveExtensions(
|
|
|
516
524
|
authorizationQueriesDeclaredBy = unit.name;
|
|
517
525
|
}
|
|
518
526
|
|
|
527
|
+
if (unit.drivers?.licenseStatus !== undefined) {
|
|
528
|
+
if (licenseStatusDeclaredBy !== undefined) {
|
|
529
|
+
throw new Error(
|
|
530
|
+
`extension '${unit.name}' registers a LicenseStatusProvider, but '${licenseStatusDeclaredBy}' already did — exactly one may be composed`,
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
licenseStatus = unit.drivers.licenseStatus;
|
|
534
|
+
licenseStatusDeclaredBy = unit.name;
|
|
535
|
+
}
|
|
536
|
+
|
|
519
537
|
if (unit.drivers?.artifactStorageDrivers !== undefined) {
|
|
520
538
|
for (const [name, factory] of unit.drivers.artifactStorageDrivers) {
|
|
521
539
|
if ((BUILT_IN_STORAGE_TYPES as ReadonlyArray<string>).includes(name)) {
|
|
@@ -641,6 +659,7 @@ export function resolveExtensions(
|
|
|
641
659
|
iamPolicyStore,
|
|
642
660
|
policyGrantScope,
|
|
643
661
|
authorizationQueries,
|
|
662
|
+
licenseStatus,
|
|
644
663
|
},
|
|
645
664
|
services,
|
|
646
665
|
workers,
|