@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.
Files changed (53) hide show
  1. package/dist/boot/compose.d.ts +12 -0
  2. package/dist/boot/compose.d.ts.map +1 -1
  3. package/dist/boot/compose.js +4 -2
  4. package/dist/boot/compose.js.map +1 -1
  5. package/dist/domain/iampolicy/constants.d.ts +3 -3
  6. package/dist/domain/iampolicy/constants.js +3 -3
  7. package/dist/domain/oauthapp/refresolution/refresolution.js +1 -1
  8. package/dist/domain/oauthapp/refresolution.js +1 -1
  9. package/dist/domain/platform/controller.d.ts +8 -1
  10. package/dist/domain/platform/controller.d.ts.map +1 -1
  11. package/dist/domain/platform/controller.js +2 -3
  12. package/dist/domain/platform/controller.js.map +1 -1
  13. package/dist/domain/plugin/constants.d.ts +7 -1
  14. package/dist/domain/plugin/constants.d.ts.map +1 -1
  15. package/dist/domain/plugin/constants.js +7 -1
  16. package/dist/domain/plugin/constants.js.map +1 -1
  17. package/dist/domain/plugin/materialize/plan.d.ts.map +1 -1
  18. package/dist/domain/plugin/materialize/plan.js +16 -1
  19. package/dist/domain/plugin/materialize/plan.js.map +1 -1
  20. package/dist/domain/plugin/members.d.ts +46 -0
  21. package/dist/domain/plugin/members.d.ts.map +1 -1
  22. package/dist/domain/plugin/members.js +31 -8
  23. package/dist/domain/plugin/members.js.map +1 -1
  24. package/dist/domain/plugin/push.d.ts +5 -3
  25. package/dist/domain/plugin/push.d.ts.map +1 -1
  26. package/dist/domain/plugin/push.js +58 -14
  27. package/dist/domain/plugin/push.js.map +1 -1
  28. package/dist/pipeline/apiresource-labels.d.ts +20 -1
  29. package/dist/pipeline/apiresource-labels.d.ts.map +1 -1
  30. package/dist/pipeline/apiresource-labels.js +17 -0
  31. package/dist/pipeline/apiresource-labels.js.map +1 -1
  32. package/dist/store/interface.d.ts +6 -2
  33. package/dist/store/interface.d.ts.map +1 -1
  34. package/dist/store/interface.js.map +1 -1
  35. package/package.json +5 -5
  36. package/src/boot/compose.ts +16 -2
  37. package/src/domain/iampolicy/constants.ts +3 -3
  38. package/src/domain/oauthapp/__tests__/oauthapp.test.ts +2 -2
  39. package/src/domain/oauthapp/__tests__/refresolution.test.ts +2 -2
  40. package/src/domain/oauthapp/refresolution/refresolution.ts +1 -1
  41. package/src/domain/oauthapp/refresolution.ts +1 -1
  42. package/src/domain/platform/__tests__/platform.test.ts +47 -0
  43. package/src/domain/platform/controller.ts +10 -4
  44. package/src/domain/plugin/__tests__/materialize.test.ts +42 -47
  45. package/src/domain/plugin/__tests__/members.test.ts +149 -0
  46. package/src/domain/plugin/__tests__/plugin.test.ts +219 -8
  47. package/src/domain/plugin/constants.ts +7 -1
  48. package/src/domain/plugin/materialize/plan.ts +20 -1
  49. package/src/domain/plugin/members.ts +76 -7
  50. package/src/domain/plugin/push.ts +80 -20
  51. package/src/pipeline/apiresource-labels.ts +26 -1
  52. package/src/sandbox/__tests__/local-process.integration.test.ts +20 -6
  53. 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; a bad overlay refuses
13
- * before any write. A second composition, under an authorizer that denies
14
- * `can_write_reserved_labels`, pins the reserved-label refusal the
15
- * open-source posture allows by design.
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
- kind: "deny",
719
- reason: "reserved labels are the platform's",
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
- /** Warning kinds the SERVER adds to the library's; the wire carries them as strings. */
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
- : [{ kind: ApiResourceKind.agent, slug: agent.slug, name: agent.name }]),
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
- * which planned slugs are already held in the organization, and by whom
12
- * (this plugin: an upgrade; another plugin or an unmanaged resource: a
13
- * refusal naming the holder); and whether the plugin's members already
14
- * converge on the head's digest (every planned member present, each
15
- * carrying `stigmer.ai/plugin-version` equal to it), which is what lets a
16
- * re-push of the same archive return without a child write.
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 and the conformance suite's
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, a missing permission, the level
23
- * — refuses in the plan chain BEFORE any write; the install chain's own
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, or refused
343
- * naming the holder), the caller's permission for every member kind, and
344
- * the plugin's current members for the convergence and drop decisions.
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
- if (!holder.held || holder.byPlugin === identity.id) {
387
- continue;
388
- }
393
+ const decision = judgeSlug(holder, member, identity.id);
389
394
  const noun = memberNoun(member.kind);
390
- if (holder.byPlugin === undefined) {
391
- throw alreadyExistsError(
392
- noun,
393
- `'${member.slug}' exists in org '${identity.org}' and is not managed by a plugin; rename or delete it first`,
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
- ctx.set(PLUGIN_PLAN_KEY, plan);
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 {