@stigmer/server 3.17.0 → 3.18.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 +12 -0
- package/dist/boot/compose.d.ts.map +1 -1
- package/dist/boot/compose.js +4 -2
- package/dist/boot/compose.js.map +1 -1
- package/dist/domain/iampolicy/constants.d.ts +3 -3
- package/dist/domain/iampolicy/constants.js +3 -3
- package/dist/domain/oauthapp/refresolution/refresolution.js +1 -1
- package/dist/domain/oauthapp/refresolution.js +1 -1
- package/dist/domain/platform/controller.d.ts +8 -1
- package/dist/domain/platform/controller.d.ts.map +1 -1
- package/dist/domain/platform/controller.js +2 -3
- package/dist/domain/platform/controller.js.map +1 -1
- package/dist/domain/plugin/constants.d.ts +7 -1
- package/dist/domain/plugin/constants.d.ts.map +1 -1
- package/dist/domain/plugin/constants.js +7 -1
- package/dist/domain/plugin/constants.js.map +1 -1
- package/dist/domain/plugin/materialize/plan.d.ts.map +1 -1
- package/dist/domain/plugin/materialize/plan.js +16 -1
- package/dist/domain/plugin/materialize/plan.js.map +1 -1
- package/dist/domain/plugin/members.d.ts +46 -0
- package/dist/domain/plugin/members.d.ts.map +1 -1
- package/dist/domain/plugin/members.js +31 -8
- package/dist/domain/plugin/members.js.map +1 -1
- package/dist/domain/plugin/push.d.ts +5 -3
- package/dist/domain/plugin/push.d.ts.map +1 -1
- package/dist/domain/plugin/push.js +58 -14
- package/dist/domain/plugin/push.js.map +1 -1
- package/dist/pipeline/apiresource-labels.d.ts +20 -1
- package/dist/pipeline/apiresource-labels.d.ts.map +1 -1
- package/dist/pipeline/apiresource-labels.js +17 -0
- package/dist/pipeline/apiresource-labels.js.map +1 -1
- package/dist/store/interface.d.ts +6 -2
- package/dist/store/interface.d.ts.map +1 -1
- package/dist/store/interface.js.map +1 -1
- package/package.json +5 -5
- package/src/boot/compose.ts +16 -2
- package/src/domain/iampolicy/constants.ts +3 -3
- package/src/domain/oauthapp/__tests__/oauthapp.test.ts +2 -2
- package/src/domain/oauthapp/__tests__/refresolution.test.ts +2 -2
- package/src/domain/oauthapp/refresolution/refresolution.ts +1 -1
- package/src/domain/oauthapp/refresolution.ts +1 -1
- package/src/domain/platform/__tests__/platform.test.ts +47 -0
- package/src/domain/platform/controller.ts +10 -4
- package/src/domain/plugin/__tests__/materialize.test.ts +42 -47
- package/src/domain/plugin/__tests__/members.test.ts +149 -0
- package/src/domain/plugin/__tests__/plugin.test.ts +219 -8
- package/src/domain/plugin/constants.ts +7 -1
- package/src/domain/plugin/materialize/plan.ts +20 -1
- package/src/domain/plugin/members.ts +76 -7
- package/src/domain/plugin/push.ts +80 -20
- package/src/pipeline/apiresource-labels.ts +26 -1
- package/src/sandbox/__tests__/local-process.integration.test.ts +20 -6
- package/src/store/interface.ts +6 -2
|
@@ -9,10 +9,14 @@
|
|
|
9
9
|
* delete of a member is refused naming the plugin while the plugin's own
|
|
10
10
|
* upgrade drops what the archive dropped; uninstall removes every member
|
|
11
11
|
* and is refused while a user's own agent still references one; a slug an
|
|
12
|
-
* unmanaged resource holds refuses the install
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* unmanaged resource holds refuses the install, and a user's row is never
|
|
13
|
+
* adopted whichever side declares system content; a bad overlay refuses
|
|
14
|
+
* before any write. A second composition, under an authorizer that decides
|
|
15
|
+
* `can_write_reserved_labels` per test, pins the reserved-label refusal the
|
|
16
|
+
* open-source posture allows by design, and the one adoption the platform
|
|
17
|
+
* makes: an operator's plugin taking over the system-content row it
|
|
18
|
+
* replaces in place, the row's id and default instance kept, a member's
|
|
19
|
+
* identical push refused before the slug is judged.
|
|
16
20
|
*/
|
|
17
21
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
18
22
|
import net from "node:net";
|
|
@@ -38,6 +42,7 @@ import {
|
|
|
38
42
|
AgentSpecSchema,
|
|
39
43
|
McpServerUsageSchema,
|
|
40
44
|
} from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
|
|
45
|
+
import { AgentInstanceQueryController } from "@stigmer/protos/ai/stigmer/agentic/agentinstance/v1/query_pb";
|
|
41
46
|
import { McpServerCommandController } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/command_pb";
|
|
42
47
|
import { McpServerQueryController } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/query_pb";
|
|
43
48
|
import { PluginCommandController } from "@stigmer/protos/ai/stigmer/agentic/plugin/v1/command_pb";
|
|
@@ -59,6 +64,7 @@ import { createLogger } from "../../../boot/logger.js";
|
|
|
59
64
|
import {
|
|
60
65
|
PLUGIN_LABEL,
|
|
61
66
|
PLUGIN_VERSION_LABEL,
|
|
67
|
+
SYSTEM_LABEL,
|
|
62
68
|
} from "../../../pipeline/apiresource-labels.js";
|
|
63
69
|
|
|
64
70
|
const silentLogger = createLogger({
|
|
@@ -507,6 +513,66 @@ describe("Plugin members are the plugin's to redefine", () => {
|
|
|
507
513
|
);
|
|
508
514
|
});
|
|
509
515
|
|
|
516
|
+
it("never adopts a user's agent: a same-slug row without the system label is refused even when the plugin declares system content, and a system row is refused by a plugin that does not", async () => {
|
|
517
|
+
// The open-source authorizer grants reserved-label writes to everyone,
|
|
518
|
+
// so this is the posture where the label alone would be a weak guard;
|
|
519
|
+
// the rule needs BOTH sides to carry it.
|
|
520
|
+
const name = uniqueName("users-own");
|
|
521
|
+
await agents.create(
|
|
522
|
+
createMessage(AgentSchema, {
|
|
523
|
+
apiVersion: "agentic.stigmer.ai/v1",
|
|
524
|
+
kind: "Agent",
|
|
525
|
+
metadata: createMessage(ApiResourceMetadataSchema, { org: ORG, name }),
|
|
526
|
+
spec: createMessage(AgentSpecSchema, {
|
|
527
|
+
instructions: "Mine, written by hand, under my own name.",
|
|
528
|
+
}),
|
|
529
|
+
}),
|
|
530
|
+
);
|
|
531
|
+
await expectCode(
|
|
532
|
+
plugins.push({ org: ORG, artifact: archiveOf(systemOverlay(name)) }),
|
|
533
|
+
Code.AlreadyExists,
|
|
534
|
+
`'${name}' exists in org '${ORG}' and is not managed by a plugin`,
|
|
535
|
+
);
|
|
536
|
+
|
|
537
|
+
const seeded = uniqueName("seeded-row");
|
|
538
|
+
await agents.create(
|
|
539
|
+
createMessage(AgentSchema, {
|
|
540
|
+
apiVersion: "agentic.stigmer.ai/v1",
|
|
541
|
+
kind: "Agent",
|
|
542
|
+
metadata: createMessage(ApiResourceMetadataSchema, {
|
|
543
|
+
org: ORG,
|
|
544
|
+
name: seeded,
|
|
545
|
+
labels: { [SYSTEM_LABEL]: "true" },
|
|
546
|
+
}),
|
|
547
|
+
spec: createMessage(AgentSpecSchema, {
|
|
548
|
+
instructions: "System content a plain plugin may not take over.",
|
|
549
|
+
}),
|
|
550
|
+
}),
|
|
551
|
+
);
|
|
552
|
+
await expectCode(
|
|
553
|
+
plugins.push({
|
|
554
|
+
org: ORG,
|
|
555
|
+
artifact: archiveOf(
|
|
556
|
+
withFile(
|
|
557
|
+
thermosLike(seeded),
|
|
558
|
+
"ai.stigmer/agent.yaml",
|
|
559
|
+
[
|
|
560
|
+
"apiVersion: agentic.stigmer.ai/v1",
|
|
561
|
+
"kind: Agent",
|
|
562
|
+
"metadata:",
|
|
563
|
+
` name: ${seeded}`,
|
|
564
|
+
"spec:",
|
|
565
|
+
" instructions: A plain overlay that declares no system content.",
|
|
566
|
+
"",
|
|
567
|
+
].join("\n"),
|
|
568
|
+
),
|
|
569
|
+
),
|
|
570
|
+
}),
|
|
571
|
+
Code.AlreadyExists,
|
|
572
|
+
`'${seeded}' exists in org '${ORG}' and is not managed by a plugin`,
|
|
573
|
+
);
|
|
574
|
+
});
|
|
575
|
+
|
|
510
576
|
it("refuses to install a slug another plugin holds, naming that plugin", async () => {
|
|
511
577
|
const first = uniqueName("holder");
|
|
512
578
|
const second = uniqueName("claimant");
|
|
@@ -705,7 +771,14 @@ describe("Plugin push under an authorizer that enforces reserved labels", () =>
|
|
|
705
771
|
let enforcing: ComposedServer;
|
|
706
772
|
let enforcingPlugins: Client<typeof PluginCommandController>;
|
|
707
773
|
let enforcingQuery: Client<typeof PluginQueryController>;
|
|
774
|
+
let enforcingAgents: Client<typeof AgentCommandController>;
|
|
775
|
+
let enforcingAgentQuery: Client<typeof AgentQueryController>;
|
|
776
|
+
let enforcingInstanceQuery: Client<typeof AgentInstanceQueryController>;
|
|
708
777
|
const observed: AuthzCheck[] = [];
|
|
778
|
+
// Who the caller is to this authorizer: a member (reserved labels denied,
|
|
779
|
+
// the hosted default) or the platform operator (granted). The tests flip
|
|
780
|
+
// it, so one composition shows both sides of every reserved-label rule.
|
|
781
|
+
let reservedLabels: "deny" | "allow" = "deny";
|
|
709
782
|
|
|
710
783
|
beforeAll(async () => {
|
|
711
784
|
enforcingDir = mkdtempSync(path.join(tmpdir(), "plugin-domain-enforcing-"));
|
|
@@ -714,10 +787,11 @@ describe("Plugin push under an authorizer that enforces reserved labels", () =>
|
|
|
714
787
|
authorize(_caller, check) {
|
|
715
788
|
if (check.permission === IamPermission.can_write_reserved_labels) {
|
|
716
789
|
observed.push(check);
|
|
717
|
-
return Promise.resolve(
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
790
|
+
return Promise.resolve(
|
|
791
|
+
reservedLabels === "allow"
|
|
792
|
+
? { kind: "allow" }
|
|
793
|
+
: { kind: "deny", reason: "reserved labels are the platform's" },
|
|
794
|
+
);
|
|
721
795
|
}
|
|
722
796
|
return Promise.resolve({ kind: "allow" });
|
|
723
797
|
},
|
|
@@ -741,6 +815,12 @@ describe("Plugin push under an authorizer that enforces reserved labels", () =>
|
|
|
741
815
|
});
|
|
742
816
|
enforcingPlugins = createClient(PluginCommandController, transport);
|
|
743
817
|
enforcingQuery = createClient(PluginQueryController, transport);
|
|
818
|
+
enforcingAgents = createClient(AgentCommandController, transport);
|
|
819
|
+
enforcingAgentQuery = createClient(AgentQueryController, transport);
|
|
820
|
+
enforcingInstanceQuery = createClient(
|
|
821
|
+
AgentInstanceQueryController,
|
|
822
|
+
transport,
|
|
823
|
+
);
|
|
744
824
|
});
|
|
745
825
|
|
|
746
826
|
afterAll(async () => {
|
|
@@ -799,4 +879,135 @@ describe("Plugin push under an authorizer that enforces reserved labels", () =>
|
|
|
799
879
|
});
|
|
800
880
|
expect(members).toHaveLength(3);
|
|
801
881
|
});
|
|
882
|
+
|
|
883
|
+
it("adopts a system-content row the operator's plugin replaces: same id, same default instance, the plugin's definition, one warning", async () => {
|
|
884
|
+
reservedLabels = "allow";
|
|
885
|
+
const name = uniqueName("plg-adopt");
|
|
886
|
+
// The row the platform seeded before plugins existed: system content,
|
|
887
|
+
// no plugin label, a default instance of its own.
|
|
888
|
+
const seeded = await enforcingAgents.create(
|
|
889
|
+
createMessage(AgentSchema, {
|
|
890
|
+
apiVersion: "agentic.stigmer.ai/v1",
|
|
891
|
+
kind: "Agent",
|
|
892
|
+
metadata: createMessage(ApiResourceMetadataSchema, {
|
|
893
|
+
org: ORG,
|
|
894
|
+
name,
|
|
895
|
+
labels: { [SYSTEM_LABEL]: "true" },
|
|
896
|
+
}),
|
|
897
|
+
spec: createMessage(AgentSpecSchema, {
|
|
898
|
+
instructions: "The instructions the seedpack wrote, long ago.",
|
|
899
|
+
}),
|
|
900
|
+
}),
|
|
901
|
+
);
|
|
902
|
+
const seededId = seeded.metadata!.id;
|
|
903
|
+
const seededInstanceId = seeded.status?.defaultInstanceId ?? "";
|
|
904
|
+
expect(seededInstanceId).not.toBe("");
|
|
905
|
+
|
|
906
|
+
const installed = await enforcingPlugins.push({
|
|
907
|
+
org: ORG,
|
|
908
|
+
artifact: archiveOf(systemOverlay(name)),
|
|
909
|
+
});
|
|
910
|
+
expect(installed.status?.state).toBe(PluginState.READY);
|
|
911
|
+
const adopted = installed.status!.warnings.filter(
|
|
912
|
+
(warning) => warning.kind === "member-adopted",
|
|
913
|
+
);
|
|
914
|
+
expect(adopted).toHaveLength(1);
|
|
915
|
+
expect(adopted[0]!.path).toBe("ai.stigmer/agent.yaml");
|
|
916
|
+
expect(adopted[0]!.message).toContain(`agent '${name}' (${seededId})`);
|
|
917
|
+
expect(adopted[0]!.message).toContain("conversations continue");
|
|
918
|
+
|
|
919
|
+
const after = await enforcingAgentQuery.get({ value: seededId });
|
|
920
|
+
expect(after.metadata?.labels[PLUGIN_LABEL]).toBe(installed.metadata!.id);
|
|
921
|
+
expect(after.metadata?.labels[PLUGIN_VERSION_LABEL]).toBe(
|
|
922
|
+
installed.status!.digest,
|
|
923
|
+
);
|
|
924
|
+
expect(after.metadata?.labels[SYSTEM_LABEL]).toBe("true");
|
|
925
|
+
expect(after.spec?.instructions).toBe(
|
|
926
|
+
"You are the platform's own agent, re-homed under a plugin.",
|
|
927
|
+
);
|
|
928
|
+
expect(after.status?.defaultInstanceId).toBe(seededInstanceId);
|
|
929
|
+
const instance = await enforcingInstanceQuery.get({
|
|
930
|
+
value: seededInstanceId,
|
|
931
|
+
});
|
|
932
|
+
expect(instance.spec?.agentId).toBe(seededId);
|
|
933
|
+
|
|
934
|
+
const { members } = await enforcingQuery.listMembers({
|
|
935
|
+
value: installed.metadata!.id,
|
|
936
|
+
});
|
|
937
|
+
expect(
|
|
938
|
+
members.find((member) => member.kind === ApiResourceKind.agent)?.id,
|
|
939
|
+
).toBe(seededId);
|
|
940
|
+
|
|
941
|
+
// The second push of the same archive converges without a write.
|
|
942
|
+
const again = await enforcingPlugins.push({
|
|
943
|
+
org: ORG,
|
|
944
|
+
artifact: archiveOf(systemOverlay(name)),
|
|
945
|
+
});
|
|
946
|
+
expect(again.status?.digest).toBe(installed.status?.digest);
|
|
947
|
+
expect(
|
|
948
|
+
(await enforcingAgentQuery.get({ value: seededId })).status
|
|
949
|
+
?.defaultInstanceId,
|
|
950
|
+
).toBe(seededInstanceId);
|
|
951
|
+
reservedLabels = "deny";
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
it("refuses the same archive from a member before the slug is judged, and leaves the seeded row untouched", async () => {
|
|
955
|
+
reservedLabels = "allow";
|
|
956
|
+
const name = uniqueName("plg-member");
|
|
957
|
+
const seeded = await enforcingAgents.create(
|
|
958
|
+
createMessage(AgentSchema, {
|
|
959
|
+
apiVersion: "agentic.stigmer.ai/v1",
|
|
960
|
+
kind: "Agent",
|
|
961
|
+
metadata: createMessage(ApiResourceMetadataSchema, {
|
|
962
|
+
org: ORG,
|
|
963
|
+
name,
|
|
964
|
+
labels: { [SYSTEM_LABEL]: "true" },
|
|
965
|
+
}),
|
|
966
|
+
spec: createMessage(AgentSpecSchema, {
|
|
967
|
+
instructions: "The instructions the seedpack wrote, long ago.",
|
|
968
|
+
}),
|
|
969
|
+
}),
|
|
970
|
+
);
|
|
971
|
+
reservedLabels = "deny";
|
|
972
|
+
const error = await expectCode(
|
|
973
|
+
enforcingPlugins.push({
|
|
974
|
+
org: ORG,
|
|
975
|
+
artifact: archiveOf(systemOverlay(name)),
|
|
976
|
+
}),
|
|
977
|
+
Code.InvalidArgument,
|
|
978
|
+
);
|
|
979
|
+
expect(error.rawMessage).toContain(
|
|
980
|
+
`cannot be set by a plugin (ai.stigmer/agent.yaml: ${SYSTEM_LABEL})`,
|
|
981
|
+
);
|
|
982
|
+
const untouched = await enforcingAgentQuery.get({
|
|
983
|
+
value: seeded.metadata!.id,
|
|
984
|
+
});
|
|
985
|
+
expect(untouched.metadata?.labels[PLUGIN_LABEL]).toBeUndefined();
|
|
986
|
+
expect(untouched.spec?.instructions).toBe(
|
|
987
|
+
"The instructions the seedpack wrote, long ago.",
|
|
988
|
+
);
|
|
989
|
+
});
|
|
802
990
|
});
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* A plugin whose agent overlay declares system content under the given
|
|
994
|
+
* name: the shape of the official `assistant` plugin, whose agent replaces
|
|
995
|
+
* the row the retired seedpack wrote under the same slug.
|
|
996
|
+
*/
|
|
997
|
+
function systemOverlay(name: string): PluginFixture {
|
|
998
|
+
return withFile(
|
|
999
|
+
thermosLike(name),
|
|
1000
|
+
"ai.stigmer/agent.yaml",
|
|
1001
|
+
[
|
|
1002
|
+
"apiVersion: agentic.stigmer.ai/v1",
|
|
1003
|
+
"kind: Agent",
|
|
1004
|
+
"metadata:",
|
|
1005
|
+
` name: ${name}`,
|
|
1006
|
+
" labels:",
|
|
1007
|
+
` ${SYSTEM_LABEL}: "true"`,
|
|
1008
|
+
"spec:",
|
|
1009
|
+
" instructions: You are the platform's own agent, re-homed under a plugin.",
|
|
1010
|
+
"",
|
|
1011
|
+
].join("\n"),
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
@@ -44,9 +44,15 @@ export type MaterializedKindName = (typeof MATERIALIZATION_ORDER)[number];
|
|
|
44
44
|
export const TRANSFER_LANE_NOT_CONFIGURED =
|
|
45
45
|
"plugin artifact transfer lane is not configured on this server";
|
|
46
46
|
|
|
47
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* Warning kinds the SERVER adds to the library's; the wire carries them as
|
|
49
|
+
* strings, and the list in PluginWarning.kind's comment (plugin/v1/status.proto)
|
|
50
|
+
* is kept equal to this one because the SDK docs are generated from it.
|
|
51
|
+
*/
|
|
48
52
|
export const SERVER_WARNING_KINDS = {
|
|
49
53
|
componentIgnored: "component-ignored",
|
|
54
|
+
/** A system-content row the plugin took over in place (members.ts, judgeSlug). */
|
|
55
|
+
memberAdopted: "member-adopted",
|
|
50
56
|
modelHintUnresolved: "model-hint-unresolved",
|
|
51
57
|
subAgentNameBuiltin: "sub-agent-name-builtin",
|
|
52
58
|
versionNotTaggable: "version-not-taggable",
|
|
@@ -19,6 +19,11 @@ import { PluginWarningSchema } from "@stigmer/protos/ai/stigmer/agentic/plugin/v
|
|
|
19
19
|
import type { PluginWarning } from "@stigmer/protos/ai/stigmer/agentic/plugin/v1/status_pb";
|
|
20
20
|
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
21
21
|
|
|
22
|
+
import {
|
|
23
|
+
RESERVED_LABEL_TRUE,
|
|
24
|
+
SYSTEM_LABEL,
|
|
25
|
+
isSystemContent,
|
|
26
|
+
} from "../../../pipeline/apiresource-labels.js";
|
|
22
27
|
import { SERVER_WARNING_KINDS, VERSION_TAG_PATTERN } from "../constants.js";
|
|
23
28
|
import type { PlannedMember } from "../members.js";
|
|
24
29
|
import type { ParsedOverlays } from "../overlay/documents.js";
|
|
@@ -105,24 +110,38 @@ export function planMaterialization(
|
|
|
105
110
|
);
|
|
106
111
|
const workflows = planWorkflows(overlays.workflows, identity);
|
|
107
112
|
|
|
113
|
+
// `system` is read from the labels each member will be written with (the
|
|
114
|
+
// overlay author's, the plugin's two merged over them). A skill's request
|
|
115
|
+
// carries only the plugin's two, so a skill never declares system content
|
|
116
|
+
// and can never adopt a row; the overlay kinds can.
|
|
108
117
|
const members: PlannedMember[] = [
|
|
109
118
|
...skills.map((skill) => ({
|
|
110
119
|
kind: ApiResourceKind.skill,
|
|
111
120
|
slug: skill.slug,
|
|
112
121
|
name: skill.name,
|
|
122
|
+
system: skill.request.labels[SYSTEM_LABEL] === RESERVED_LABEL_TRUE,
|
|
113
123
|
})),
|
|
114
124
|
...mcpServers.map((server) => ({
|
|
115
125
|
kind: ApiResourceKind.mcp_server,
|
|
116
126
|
slug: server.slug,
|
|
117
127
|
name: server.name,
|
|
128
|
+
system: isSystemContent(server.resource.metadata),
|
|
118
129
|
})),
|
|
119
130
|
...(agent === undefined
|
|
120
131
|
? []
|
|
121
|
-
: [
|
|
132
|
+
: [
|
|
133
|
+
{
|
|
134
|
+
kind: ApiResourceKind.agent,
|
|
135
|
+
slug: agent.slug,
|
|
136
|
+
name: agent.name,
|
|
137
|
+
system: isSystemContent(agent.resource.metadata),
|
|
138
|
+
},
|
|
139
|
+
]),
|
|
122
140
|
...workflows.map((workflow) => ({
|
|
123
141
|
kind: ApiResourceKind.workflow,
|
|
124
142
|
slug: workflow.slug,
|
|
125
143
|
name: workflow.name,
|
|
144
|
+
system: isSystemContent(workflow.resource.metadata),
|
|
126
145
|
})),
|
|
127
146
|
];
|
|
128
147
|
|
|
@@ -8,14 +8,17 @@
|
|
|
8
8
|
* pattern.
|
|
9
9
|
*
|
|
10
10
|
* The same module answers the two questions a push asks before any write:
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* converge on the head's digest (every planned
|
|
15
|
-
* carrying `stigmer.ai/plugin-version` equal to it),
|
|
16
|
-
* re-push of the same archive return without a child
|
|
11
|
+
* what to do about each planned slug in the organization (`judgeSlug`:
|
|
12
|
+
* free; ours, an upgrade; system content the plugin replaces, adopted;
|
|
13
|
+
* another plugin's or a user's, refused naming the holder); and whether the
|
|
14
|
+
* plugin's members already converge on the head's digest (every planned
|
|
15
|
+
* member present, each carrying `stigmer.ai/plugin-version` equal to it),
|
|
16
|
+
* which is what lets a re-push of the same archive return without a child
|
|
17
|
+
* write.
|
|
17
18
|
*
|
|
18
|
-
* Proven by __tests__/members.test.ts
|
|
19
|
+
* Proven by __tests__/members.test.ts (the slug decision on plain values,
|
|
20
|
+
* convergence, the dropped set), __tests__/plugin.test.ts (adoption and
|
|
21
|
+
* refusal through a composed server) and the conformance suite's
|
|
19
22
|
* listMembers, collision and reconcile arms.
|
|
20
23
|
*/
|
|
21
24
|
import { fromBinary } from "@bufbuild/protobuf";
|
|
@@ -31,6 +34,7 @@ import type { ApiResourceMetadata } from "@stigmer/protos/ai/stigmer/commons/api
|
|
|
31
34
|
import {
|
|
32
35
|
PLUGIN_LABEL,
|
|
33
36
|
PLUGIN_VERSION_LABEL,
|
|
37
|
+
isSystemContent,
|
|
34
38
|
pluginIdOf,
|
|
35
39
|
} from "../../pipeline/apiresource-labels.js";
|
|
36
40
|
import { findResourceBySlug } from "../../pipeline/steps/helpers.js";
|
|
@@ -56,6 +60,8 @@ export interface Member {
|
|
|
56
60
|
readonly name: string;
|
|
57
61
|
/** The `stigmer.ai/plugin-version` value it was last materialised from. */
|
|
58
62
|
readonly version: string;
|
|
63
|
+
/** Whether the stored row carries `stigmer.ai/system` (isSystemContent). */
|
|
64
|
+
readonly system: boolean;
|
|
59
65
|
}
|
|
60
66
|
|
|
61
67
|
/** A member the plan intends to exist after this push. */
|
|
@@ -63,6 +69,8 @@ export interface PlannedMember {
|
|
|
63
69
|
readonly kind: ApiResourceKind;
|
|
64
70
|
readonly slug: string;
|
|
65
71
|
readonly name: string;
|
|
72
|
+
/** Whether the member the plugin brings declares `stigmer.ai/system`. */
|
|
73
|
+
readonly system: boolean;
|
|
66
74
|
}
|
|
67
75
|
|
|
68
76
|
function memberOf(
|
|
@@ -75,6 +83,7 @@ function memberOf(
|
|
|
75
83
|
slug: metadata.slug,
|
|
76
84
|
name: metadata.name,
|
|
77
85
|
version: metadata.labels[PLUGIN_VERSION_LABEL] ?? "",
|
|
86
|
+
system: isSystemContent(metadata),
|
|
78
87
|
};
|
|
79
88
|
}
|
|
80
89
|
|
|
@@ -145,6 +154,66 @@ export async function slugHolder(
|
|
|
145
154
|
};
|
|
146
155
|
}
|
|
147
156
|
|
|
157
|
+
/**
|
|
158
|
+
* What a push does about one planned slug, decided before any write.
|
|
159
|
+
*
|
|
160
|
+
* free nothing holds it; the member is created
|
|
161
|
+
* ours this plugin's own member; the push is an upgrade
|
|
162
|
+
* adopt system content no plugin manages, and the plugin
|
|
163
|
+
* brings system content for the same slug: the child's
|
|
164
|
+
* apply (upsert-by-slug) rewrites the row in place, so
|
|
165
|
+
* its id, its default instance, every personal instance
|
|
166
|
+
* and every session bound to them survive
|
|
167
|
+
* held-unmanaged a resource no plugin manages and the rule above does
|
|
168
|
+
* not admit; refused naming it
|
|
169
|
+
* held-by-plugin another plugin's member; refused naming that plugin
|
|
170
|
+
*
|
|
171
|
+
* Adoption is deliberately narrow, "system replaces system": the held row's
|
|
172
|
+
* label narrows which rows are eligible (a user's row never carries it on
|
|
173
|
+
* an edition that enforces reserved labels), and the plugin's own claim to
|
|
174
|
+
* the label was charged to `can_write_reserved_labels` by the sanitiser
|
|
175
|
+
* moments before, so the grant is the caller's live permission and never
|
|
176
|
+
* the label alone (pipeline/apiresource-labels.ts). It never transfers a
|
|
177
|
+
* member between plugins: another plugin's slug is refused whatever labels
|
|
178
|
+
* either side carries. The one case it exists for is the platform re-homing
|
|
179
|
+
* the content it seeded before plugins existed (the default agent; the
|
|
180
|
+
* platform's own server) without orphaning a single conversation.
|
|
181
|
+
*/
|
|
182
|
+
export type SlugDecision =
|
|
183
|
+
| { readonly kind: "free" }
|
|
184
|
+
| { readonly kind: "ours"; readonly holder: Member }
|
|
185
|
+
| { readonly kind: "adopt"; readonly holder: Member }
|
|
186
|
+
| { readonly kind: "held-unmanaged"; readonly holder: Member }
|
|
187
|
+
| {
|
|
188
|
+
readonly kind: "held-by-plugin";
|
|
189
|
+
readonly holder: Member;
|
|
190
|
+
readonly pluginId: string;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
export function judgeSlug(
|
|
194
|
+
holder: SlugHolder,
|
|
195
|
+
planned: PlannedMember,
|
|
196
|
+
pluginId: string,
|
|
197
|
+
): SlugDecision {
|
|
198
|
+
if (!holder.held) {
|
|
199
|
+
return { kind: "free" };
|
|
200
|
+
}
|
|
201
|
+
if (holder.byPlugin === pluginId) {
|
|
202
|
+
return { kind: "ours", holder: holder.holder };
|
|
203
|
+
}
|
|
204
|
+
if (holder.byPlugin !== undefined) {
|
|
205
|
+
return {
|
|
206
|
+
kind: "held-by-plugin",
|
|
207
|
+
holder: holder.holder,
|
|
208
|
+
pluginId: holder.byPlugin,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
if (holder.holder.system && planned.system) {
|
|
212
|
+
return { kind: "adopt", holder: holder.holder };
|
|
213
|
+
}
|
|
214
|
+
return { kind: "held-unmanaged", holder: holder.holder };
|
|
215
|
+
}
|
|
216
|
+
|
|
148
217
|
/**
|
|
149
218
|
* Whether the stored members already ARE the plan at this digest: every
|
|
150
219
|
* planned member present, none extra, each stamped with the digest.
|
|
@@ -19,8 +19,10 @@
|
|
|
19
19
|
* digest, READY, every member present and stamped with it) the controller
|
|
20
20
|
* returns the head and the install chain never runs, so a re-push writes
|
|
21
21
|
* nothing. Everything that can refuse — the archive, the package, the
|
|
22
|
-
* overlay, a reserved label, a held slug
|
|
23
|
-
*
|
|
22
|
+
* overlay, a reserved label, a held slug (unless it is system content the
|
|
23
|
+
* plugin replaces, which is adopted in place: members.ts, judgeSlug), a
|
|
24
|
+
* missing permission, the level — refuses in the plan chain BEFORE any
|
|
25
|
+
* write; the install chain's own
|
|
24
26
|
* failures are a child's, wrapped with the plugin's name, and leave a head
|
|
25
27
|
* that says FAILED so the next push of the same archive converges.
|
|
26
28
|
*
|
|
@@ -52,6 +54,7 @@ import {
|
|
|
52
54
|
PluginStatusSchema,
|
|
53
55
|
PluginWarningSchema,
|
|
54
56
|
} from "@stigmer/protos/ai/stigmer/agentic/plugin/v1/status_pb";
|
|
57
|
+
import type { PluginWarning } from "@stigmer/protos/ai/stigmer/agentic/plugin/v1/status_pb";
|
|
55
58
|
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
56
59
|
import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
|
|
57
60
|
import {
|
|
@@ -95,7 +98,7 @@ import { requireOperatorMaySetPublic } from "../../pipeline/steps/visibility-gat
|
|
|
95
98
|
import { ResourceNotFoundError } from "../../store/interface.js";
|
|
96
99
|
import type { Store } from "../../store/interface.js";
|
|
97
100
|
import { openPluginArchive } from "./archive.js";
|
|
98
|
-
import { VERSION_TAG_PATTERN } from "./constants.js";
|
|
101
|
+
import { SERVER_WARNING_KINDS, VERSION_TAG_PATTERN } from "./constants.js";
|
|
99
102
|
import type { OpenedPluginArchive } from "./archive.js";
|
|
100
103
|
import type { PluginIdentity } from "./materialize/identity.js";
|
|
101
104
|
import { McpServerOverlayError } from "./materialize/mcp-servers.js";
|
|
@@ -105,10 +108,11 @@ import type { PluginMaterializerProvider } from "./materialize/ports.js";
|
|
|
105
108
|
import {
|
|
106
109
|
droppedMembers,
|
|
107
110
|
findMembers,
|
|
111
|
+
judgeSlug,
|
|
108
112
|
membersConverge,
|
|
109
113
|
slugHolder,
|
|
110
114
|
} from "./members.js";
|
|
111
|
-
import type { Member } from "./members.js";
|
|
115
|
+
import type { Member, PlannedMember } from "./members.js";
|
|
112
116
|
import { parseOverlays } from "./overlay/documents.js";
|
|
113
117
|
import type { ParsedOverlays } from "./overlay/documents.js";
|
|
114
118
|
import { OverlayParseError } from "./overlay/parse.js";
|
|
@@ -339,9 +343,11 @@ export function requestedVisibility(
|
|
|
339
343
|
|
|
340
344
|
/**
|
|
341
345
|
* PlanMaterialization — everything the push will write, checked whole:
|
|
342
|
-
* every child slug against the organization (free, ours,
|
|
343
|
-
* naming the holder), the caller's permission for
|
|
344
|
-
* the plugin's current members for the convergence
|
|
346
|
+
* every child slug against the organization (free, ours, adopted system
|
|
347
|
+
* content, or refused naming the holder), the caller's permission for
|
|
348
|
+
* every member kind, and the plugin's current members for the convergence
|
|
349
|
+
* and drop decisions. An adopted slug is recorded as a warning on the plan,
|
|
350
|
+
* so the install receipt says which rows the plugin took over.
|
|
345
351
|
*/
|
|
346
352
|
export function newPlanMaterializationStep(
|
|
347
353
|
store: Store,
|
|
@@ -376,6 +382,7 @@ export function newPlanMaterializationStep(
|
|
|
376
382
|
throw error;
|
|
377
383
|
}
|
|
378
384
|
|
|
385
|
+
const adopted: PluginWarning[] = [];
|
|
379
386
|
for (const member of plan.members) {
|
|
380
387
|
let holder;
|
|
381
388
|
try {
|
|
@@ -383,20 +390,41 @@ export function newPlanMaterializationStep(
|
|
|
383
390
|
} catch (error) {
|
|
384
391
|
throw internalError(error, "failed to check a member slug");
|
|
385
392
|
}
|
|
386
|
-
|
|
387
|
-
continue;
|
|
388
|
-
}
|
|
393
|
+
const decision = judgeSlug(holder, member, identity.id);
|
|
389
394
|
const noun = memberNoun(member.kind);
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
+
switch (decision.kind) {
|
|
396
|
+
case "free":
|
|
397
|
+
case "ours":
|
|
398
|
+
break;
|
|
399
|
+
case "adopt":
|
|
400
|
+
adopted.push(
|
|
401
|
+
create(PluginWarningSchema, {
|
|
402
|
+
kind: SERVER_WARNING_KINDS.memberAdopted,
|
|
403
|
+
path: declaringDocumentOf(overlays, member),
|
|
404
|
+
message:
|
|
405
|
+
`${noun} '${member.slug}' (${decision.holder.id}) was seeded by the platform before this plugin and is now managed by it; ` +
|
|
406
|
+
"its definition is the plugin's, and its instances and conversations continue",
|
|
407
|
+
}),
|
|
408
|
+
);
|
|
409
|
+
break;
|
|
410
|
+
case "held-unmanaged":
|
|
411
|
+
throw alreadyExistsError(
|
|
412
|
+
noun,
|
|
413
|
+
`'${member.slug}' exists in org '${identity.org}' and is not managed by a plugin; rename or delete it first`,
|
|
414
|
+
);
|
|
415
|
+
case "held-by-plugin":
|
|
416
|
+
throw alreadyExistsError(
|
|
417
|
+
noun,
|
|
418
|
+
`'${member.slug}' is held by plugin '${await pluginSlugOf(store, decision.pluginId)}'`,
|
|
419
|
+
);
|
|
420
|
+
default: {
|
|
421
|
+
const exhaustive: never = decision;
|
|
422
|
+
throw internalError(
|
|
423
|
+
new Error(`unknown slug decision ${JSON.stringify(exhaustive)}`),
|
|
424
|
+
"failed to check a member slug",
|
|
425
|
+
);
|
|
426
|
+
}
|
|
395
427
|
}
|
|
396
|
-
throw alreadyExistsError(
|
|
397
|
-
noun,
|
|
398
|
-
`'${member.slug}' is held by plugin '${await pluginSlugOf(store, holder.byPlugin)}'`,
|
|
399
|
-
);
|
|
400
428
|
}
|
|
401
429
|
|
|
402
430
|
const missing: string[] = [];
|
|
@@ -452,7 +480,12 @@ export function newPlanMaterializationStep(
|
|
|
452
480
|
} catch (error) {
|
|
453
481
|
throw internalError(error, "failed to list plugin members");
|
|
454
482
|
}
|
|
455
|
-
|
|
483
|
+
// An adoption is something this push noticed and did not refuse, so it
|
|
484
|
+
// rides the plan's warnings onto the install receipt like the rest.
|
|
485
|
+
ctx.set(PLUGIN_PLAN_KEY, {
|
|
486
|
+
...plan,
|
|
487
|
+
warnings: [...plan.warnings, ...adopted],
|
|
488
|
+
} satisfies MaterializationPlan);
|
|
456
489
|
ctx.set(EXISTING_MEMBERS_KEY, members);
|
|
457
490
|
},
|
|
458
491
|
};
|
|
@@ -986,6 +1019,33 @@ function memberNoun(kind: ApiResourceKind): string {
|
|
|
986
1019
|
return MEMBER_NOUNS.get(kind) ?? ApiResourceKind[kind] ?? String(kind);
|
|
987
1020
|
}
|
|
988
1021
|
|
|
1022
|
+
/**
|
|
1023
|
+
* The overlay document that declared an adopted member, for the warning's
|
|
1024
|
+
* path: only an overlay can carry the system label, so a skill (whose
|
|
1025
|
+
* request carries the plugin's labels alone) never reaches this.
|
|
1026
|
+
*/
|
|
1027
|
+
function declaringDocumentOf(
|
|
1028
|
+
overlays: ParsedOverlays,
|
|
1029
|
+
member: PlannedMember,
|
|
1030
|
+
): string {
|
|
1031
|
+
switch (member.kind) {
|
|
1032
|
+
case ApiResourceKind.agent:
|
|
1033
|
+
return overlays.agent?.path ?? "";
|
|
1034
|
+
case ApiResourceKind.mcp_server:
|
|
1035
|
+
return (
|
|
1036
|
+
overlays.mcpServers.find((document) => document.server === member.name)
|
|
1037
|
+
?.path ?? ""
|
|
1038
|
+
);
|
|
1039
|
+
case ApiResourceKind.workflow:
|
|
1040
|
+
return (
|
|
1041
|
+
overlays.workflows.find((document) => document.name === member.name)
|
|
1042
|
+
?.path ?? ""
|
|
1043
|
+
);
|
|
1044
|
+
default:
|
|
1045
|
+
return "";
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
989
1049
|
/** The holding plugin's slug for a refusal; a dangling id names itself. */
|
|
990
1050
|
async function pluginSlugOf(store: Store, pluginId: string): Promise<string> {
|
|
991
1051
|
try {
|