@stigmer/server 3.19.1-dev.20260920100552 → 3.21.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 (91) hide show
  1. package/dist/boot/compose.d.ts.map +1 -1
  2. package/dist/boot/compose.js +0 -1
  3. package/dist/boot/compose.js.map +1 -1
  4. package/dist/boot/inprocess.d.ts +0 -2
  5. package/dist/boot/inprocess.d.ts.map +1 -1
  6. package/dist/boot/inprocess.js +0 -6
  7. package/dist/boot/inprocess.js.map +1 -1
  8. package/dist/domain/agent/controller.d.ts +2 -2
  9. package/dist/domain/agent/controller.d.ts.map +1 -1
  10. package/dist/domain/agent/controller.js +1 -17
  11. package/dist/domain/agent/controller.js.map +1 -1
  12. package/dist/domain/agent/steps.d.ts +0 -1
  13. package/dist/domain/agent/steps.d.ts.map +1 -1
  14. package/dist/domain/agent/steps.js +1 -40
  15. package/dist/domain/agent/steps.js.map +1 -1
  16. package/dist/domain/agentexecution/controller.d.ts.map +1 -1
  17. package/dist/domain/agentexecution/controller.js +7 -9
  18. package/dist/domain/agentexecution/controller.js.map +1 -1
  19. package/dist/domain/agentexecution/create-execution-context-step.d.ts +0 -2
  20. package/dist/domain/agentexecution/create-execution-context-step.d.ts.map +1 -1
  21. package/dist/domain/agentexecution/create-execution-context-step.js +179 -60
  22. package/dist/domain/agentexecution/create-execution-context-step.js.map +1 -1
  23. package/dist/domain/agentexecution/create-steps.d.ts +16 -31
  24. package/dist/domain/agentexecution/create-steps.d.ts.map +1 -1
  25. package/dist/domain/agentexecution/create-steps.js +36 -102
  26. package/dist/domain/agentexecution/create-steps.js.map +1 -1
  27. package/dist/domain/agentexecution/run-target.d.ts +7 -3
  28. package/dist/domain/agentexecution/run-target.d.ts.map +1 -1
  29. package/dist/domain/agentexecution/run-target.js.map +1 -1
  30. package/dist/domain/environment/personal.d.ts +42 -0
  31. package/dist/domain/environment/personal.d.ts.map +1 -0
  32. package/dist/domain/environment/personal.js +96 -0
  33. package/dist/domain/environment/personal.js.map +1 -0
  34. package/dist/domain/mcpserver/connect.d.ts +0 -2
  35. package/dist/domain/mcpserver/connect.d.ts.map +1 -1
  36. package/dist/domain/mcpserver/connect.js +16 -72
  37. package/dist/domain/mcpserver/connect.js.map +1 -1
  38. package/dist/domain/plugin/overlay/sanitize.js +2 -2
  39. package/dist/domain/session/controller.d.ts +0 -7
  40. package/dist/domain/session/controller.d.ts.map +1 -1
  41. package/dist/domain/session/controller.js +11 -16
  42. package/dist/domain/session/controller.js.map +1 -1
  43. package/dist/domain/session/run-target.d.ts +9 -8
  44. package/dist/domain/session/run-target.d.ts.map +1 -1
  45. package/dist/domain/session/run-target.js.map +1 -1
  46. package/dist/domain/session/steps.d.ts +0 -27
  47. package/dist/domain/session/steps.d.ts.map +1 -1
  48. package/dist/domain/session/steps.js +10 -151
  49. package/dist/domain/session/steps.js.map +1 -1
  50. package/dist/domain/workflowexecution/create-steps.d.ts +4 -3
  51. package/dist/domain/workflowexecution/create-steps.d.ts.map +1 -1
  52. package/dist/domain/workflowexecution/create-steps.js +4 -3
  53. package/dist/domain/workflowexecution/create-steps.js.map +1 -1
  54. package/dist/pipeline/apiresource-labels.d.ts +2 -2
  55. package/dist/pipeline/apiresource-labels.js +2 -2
  56. package/dist/pipeline/errors.d.ts +2 -2
  57. package/dist/pipeline/errors.js +2 -2
  58. package/dist/pipeline/steps/guard-reserved-labels.d.ts.map +1 -1
  59. package/dist/pipeline/steps/guard-reserved-labels.js +6 -5
  60. package/dist/pipeline/steps/guard-reserved-labels.js.map +1 -1
  61. package/package.json +6 -6
  62. package/src/authorization/__tests__/fixtures/fga/org-admin-owner-inheritance.fga.yaml +1 -1
  63. package/src/boot/compose.ts +0 -1
  64. package/src/boot/inprocess.ts +0 -9
  65. package/src/domain/agent/__tests__/agent.test.ts +0 -68
  66. package/src/domain/agent/controller.ts +3 -38
  67. package/src/domain/agent/steps.ts +1 -60
  68. package/src/domain/agentexecution/__tests__/create-execution-context-step.test.ts +216 -0
  69. package/src/domain/agentexecution/__tests__/create-steps.test.ts +70 -145
  70. package/src/domain/agentexecution/controller.ts +6 -10
  71. package/src/domain/agentexecution/create-execution-context-step.ts +271 -83
  72. package/src/domain/agentexecution/create-steps.ts +38 -138
  73. package/src/domain/agentexecution/run-target.ts +7 -3
  74. package/src/domain/environment/__tests__/personal.test.ts +143 -0
  75. package/src/domain/environment/personal.ts +141 -0
  76. package/src/domain/mcpserver/connect.ts +21 -82
  77. package/src/domain/plugin/overlay/sanitize.ts +2 -2
  78. package/src/domain/session/__tests__/run-gate.test.ts +19 -52
  79. package/src/domain/session/__tests__/session.test.ts +27 -203
  80. package/src/domain/session/controller.ts +10 -30
  81. package/src/domain/session/run-target.ts +9 -8
  82. package/src/domain/session/steps.ts +9 -235
  83. package/src/domain/workflowexecution/create-steps.ts +4 -3
  84. package/src/pipeline/apiresource-labels.ts +2 -2
  85. package/src/pipeline/errors.ts +2 -2
  86. package/src/pipeline/steps/guard-reserved-labels.ts +6 -5
  87. package/dist/domain/agent/defaultagent.d.ts +0 -37
  88. package/dist/domain/agent/defaultagent.d.ts.map +0 -1
  89. package/dist/domain/agent/defaultagent.js +0 -104
  90. package/dist/domain/agent/defaultagent.js.map +0 -1
  91. package/src/domain/agent/defaultagent.ts +0 -135
@@ -3,6 +3,13 @@
3
3
  * compose_declared_preferences_step.go, and
4
4
  * compose_recalled_memories_step.go. The chain itself is assembled in
5
5
  * controller.ts, mirroring Go buildCreatePipeline order exactly.
6
+ *
7
+ * An execution names its target one of three ways (session_id, agent_id,
8
+ * session_spec.agent_instance_id) or not at all: the all-empty shape is the
9
+ * built-in assistant (agentexecution/v1/spec.proto), for which
10
+ * CreateSessionIfNeeded creates a session with no agent and the runner
11
+ * resolves an agent-less blueprint. Nothing here resolves a stored default
12
+ * agent into that shape.
6
13
  */
7
14
  import { create, fromBinary } from "@bufbuild/protobuf";
8
15
 
@@ -44,15 +51,10 @@ import {
44
51
  notFoundError,
45
52
  rethrownStatusError,
46
53
  } from "../../pipeline/errors.js";
47
- import { ConnectError, Code } from "@connectrpc/connect";
54
+ import { ConnectError } from "@connectrpc/connect";
48
55
  import type { PipelineStep } from "../../pipeline/pipeline.js";
49
56
  import { ResourceNotFoundError } from "../../store/interface.js";
50
57
  import type { Store } from "../../store/interface.js";
51
- import {
52
- DefaultAgentNotConfiguredError,
53
- DefaultAgentNotPublicError,
54
- findDefaultAgent,
55
- } from "../agent/defaultagent.js";
56
58
  import { buildDefaultInstanceRequest } from "../agentinstance/defaultinstance.js";
57
59
 
58
60
  import type { AgentExecutionStatusObserver } from "../../extensions/status-hooks.js";
@@ -94,120 +96,6 @@ export interface SessionCreator {
94
96
  }
95
97
  export type SessionCreatorProvider = () => SessionCreator;
96
98
 
97
- // ---------------------------------------------------------------------------
98
- // ResolveDefaultAgent — create.go resolveDefaultAgentStep.
99
- // ---------------------------------------------------------------------------
100
-
101
- /**
102
- * Resolves the platform's public default agent when neither session_id
103
- * nor agent_id (nor a session_spec instance) is provided — the
104
- * session-first UX, a VALID request shape, not an input error. Resolution
105
- * (candidate set, visibility preference, deterministic incumbent-wins
106
- * tie-break) is owned by the defaultagent module, shared with the
107
- * Agent.GetDefault RPC and session create.
108
- */
109
- export function newResolveDefaultAgentStep(
110
- store: Store,
111
- logger: Logger,
112
- ): PipelineStep<CreateDesc> {
113
- return {
114
- name: "ResolveDefaultAgent",
115
- async execute(ctx) {
116
- const spec = ctx.input.spec;
117
- if (
118
- (spec?.sessionId ?? "") !== "" ||
119
- (spec?.agentId ?? "") !== "" ||
120
- (spec?.sessionSpec?.agentInstanceId ?? "") !== ""
121
- ) {
122
- return;
123
- }
124
-
125
- logger.info(
126
- "Neither session_id nor agent_id provided, resolving platform default agent",
127
- );
128
-
129
- let defaultAgent: Agent;
130
- try {
131
- defaultAgent = await findDefaultAgent(store, logger);
132
- } catch (error) {
133
- if (error instanceof DefaultAgentNotConfiguredError) {
134
- // Caller-actionable message: the create caller can fix this by
135
- // supplying a reference — deliberately different from the
136
- // Agent.GetDefault RPC's message, whose caller cannot.
137
- throw new ConnectError(
138
- "No default agent is configured on this platform. Provide session_id or agent_id explicitly, or seed an agent labeled stigmer.ai/default-agent=true with visibility_public",
139
- Code.NotFound,
140
- );
141
- }
142
- if (error instanceof DefaultAgentNotPublicError) {
143
- throw failedPreconditionError(
144
- "Default agent exists but is not visibility_public",
145
- );
146
- }
147
- // Store/decode failure — an internal fault, not "no default
148
- // agent". The sanitized wire copy keeps the cause off the wire
149
- // (stigmer/stigmer#478).
150
- throw internalError(
151
- error,
152
- "failed to resolve the platform default agent",
153
- );
154
- }
155
-
156
- const resolvedId = defaultAgent.metadata?.id ?? "";
157
- logger.info("Resolved platform default agent", {
158
- agentId: resolvedId,
159
- agentName: defaultAgent.metadata?.name ?? "",
160
- });
161
-
162
- // Set agent_id on newState (not input): later steps and Persist
163
- // operate on newState.
164
- const newState = ctx.newState;
165
- const newSpec = (newState.spec ??= create(AgentExecutionSpecSchema));
166
- newSpec.agentId = resolvedId;
167
- },
168
- };
169
- }
170
-
171
- // ---------------------------------------------------------------------------
172
- // EnsureSessionOrAgentResolved — the invariant guard.
173
- // ---------------------------------------------------------------------------
174
-
175
- /**
176
- * Asserts the post-condition that a session, agent, or
177
- * embedded-session-spec instance reference has been resolved. An
178
- * invariant guard, NOT input validation: ResolveDefaultAgent runs first
179
- * and guarantees one of the three or an error, so reaching this step with
180
- * none set is a server-side programming error — hence Internal, not
181
- * InvalidArgument. Deliberately diverges from WorkflowExecution's
182
- * validateWorkflowOrInstanceStep (InvalidArgument), whose check is
183
- * genuinely reachable (issue #196) — do not "harmonize" the two.
184
- */
185
- export function newEnsureSessionOrAgentResolvedStep(
186
- logger: Logger,
187
- ): PipelineStep<CreateDesc> {
188
- return {
189
- name: "EnsureSessionOrAgentResolved",
190
- execute(ctx) {
191
- const spec = ctx.newState.spec;
192
- const hasSessionId = (spec?.sessionId ?? "") !== "";
193
- const hasAgentId = (spec?.agentId ?? "") !== "";
194
- const hasSpecInstanceId =
195
- (spec?.sessionSpec?.agentInstanceId ?? "") !== "";
196
- if (!hasSessionId && !hasAgentId && !hasSpecInstanceId) {
197
- logger.error(
198
- "Invariant violated: no session, agent, or session_spec instance reference resolved after ResolveDefaultAgent",
199
- );
200
- throw internalError(
201
- new Error(
202
- "neither session_id, agent_id, nor session_spec.agent_instance_id set after ResolveDefaultAgent",
203
- ),
204
- "execution target not resolved",
205
- );
206
- }
207
- },
208
- };
209
- }
210
-
211
99
  // ---------------------------------------------------------------------------
212
100
  // EnsureEngineAvailable lives in engine.ts (Phase 1); re-exported by the
213
101
  // controller for chain assembly.
@@ -233,11 +121,13 @@ export type ExecutionAgentInstanceCreatorProvider =
233
121
 
234
122
  /**
235
123
  * Ensures the referenced agent has a default instance: skips when a
236
- * session or explicit session_spec instance names the target; loads the
237
- * agent via the in-process client, creates the default instance when the
238
- * status lacks one, saves the agent status DIRECTLY to the store
239
- * (matching Go/Java: repo save, not the Update pipeline), and stores the
240
- * instance id in the context for the next step.
124
+ * session or explicit session_spec instance names the target, and when no
125
+ * agent is named at all (the built-in assistant: there is no agent whose
126
+ * instance could be created); otherwise loads the agent via the in-process
127
+ * client, creates the default instance when the status lacks one, saves
128
+ * the agent status DIRECTLY to the store (matching Go/Java: repo save, not
129
+ * the Update pipeline), and stores the instance id in the context for the
130
+ * next step.
241
131
  */
242
132
  export function newCreateDefaultInstanceIfNeededStep(deps: {
243
133
  store: Store;
@@ -261,8 +151,7 @@ export function newCreateDefaultInstanceIfNeededStep(deps: {
261
151
  return;
262
152
  }
263
153
  // An explicit session_spec instance fully specifies the target — no
264
- // agent load or default-instance creation needed (a default-agent
265
- // lookup would stamp misleading metadata).
154
+ // agent load or default-instance creation needed.
266
155
  const specInstanceId = execution.spec?.sessionSpec?.agentInstanceId ?? "";
267
156
  if (specInstanceId !== "") {
268
157
  deps.logger.debug(
@@ -271,6 +160,14 @@ export function newCreateDefaultInstanceIfNeededStep(deps: {
271
160
  );
272
161
  return;
273
162
  }
163
+ // No agent named: the built-in assistant runs in a session with no
164
+ // instance, so there is nothing to load or create here.
165
+ if (agentId === "") {
166
+ deps.logger.debug(
167
+ "No agent named, the built-in assistant runs; skipping default instance check",
168
+ );
169
+ return;
170
+ }
274
171
 
275
172
  // 1. Load agent via in-process gRPC (single source of truth). Go
276
173
  // returns the client error AS-IS here (create.go: "already a gRPC
@@ -385,7 +282,9 @@ export function newCreateDefaultInstanceIfNeededStep(deps: {
385
282
  * bootstrap, stigmer/stigmer#249) is CLONED and forwarded so the session
386
283
  * carries workspace_entries, harness, execution_target, MCP servers, and
387
284
  * skills from a single create call; defaults fill in the instance (when
388
- * the spec names none) and the subject sentinel (when empty).
285
+ * the spec names none and an agent's default instance was resolved; an
286
+ * empty id here and there is the built-in assistant) and the subject
287
+ * sentinel (when empty).
389
288
  */
390
289
  export function buildAutoCreateSessionSpec(
391
290
  callerSpec: SessionSpec | undefined,
@@ -407,11 +306,12 @@ export function buildAutoCreateSessionSpec(
407
306
  /**
408
307
  * Auto-creates the session when session_id is absent: forwards the
409
308
  * caller's session_spec, fills the instance from the previous step's
410
- * context key when needed, owns the session under the CALLER's org (never
411
- * the agent's — cross-org public agents stay usable), updates the
412
- * execution with the created id, and CLEARS session_spec (the Session
413
- * resource is the single source of truth; the persisted execution never
414
- * carries a second copy that could drift).
309
+ * context key when an agent was named, leaves it empty for the built-in
310
+ * assistant, owns the session under the CALLER's org (never the agent's —
311
+ * cross-org agents stay usable), updates the execution with the created
312
+ * id, and CLEARS session_spec (the Session resource is the single source
313
+ * of truth; the persisted execution never carries a second copy that
314
+ * could drift).
415
315
  */
416
316
  export function newCreateSessionIfNeededStep(deps: {
417
317
  logger: Logger;
@@ -440,12 +340,12 @@ export function newCreateSessionIfNeededStep(deps: {
440
340
  hasSessionSpec: callerSpec !== undefined,
441
341
  });
442
342
 
443
- // 1. Resolve the instance when the caller's spec does not name one.
444
- // The previous step resolved it and only skips when the spec
445
- // carries an explicit instance, so the key is present exactly when
446
- // needed.
343
+ // 1. Resolve the instance when the caller's spec does not name one
344
+ // and an agent was named: the previous step put the agent's default
345
+ // instance under the context key exactly then. With no agent the
346
+ // session is created with no instance — the built-in assistant.
447
347
  let defaultInstanceId = "";
448
- if ((callerSpec?.agentInstanceId ?? "") === "") {
348
+ if ((callerSpec?.agentInstanceId ?? "") === "" && agentId !== "") {
449
349
  const resolved = ctx.get(DEFAULT_INSTANCE_ID_KEY);
450
350
  if (typeof resolved !== "string" || resolved === "") {
451
351
  deps.logger.error("DEFAULT_INSTANCE_ID not found in context", {
@@ -16,9 +16,13 @@
16
16
  * questions have one answer; asking the blueprint's is what lets the
17
17
  * gate precede the side effect).
18
18
  *
19
- * None of the three set is EnsureSessionOrAgentResolved's invariant arm
20
- * (ResolveDefaultAgent guarantees one), never a check. Pure over the record
21
- * being built, where ResolveDefaultAgent writes the resolved agent_id.
19
+ * None of the three set is the built-in assistant
20
+ * (agentexecution/v1/spec.proto): a new conversation with no agent, for
21
+ * which there is no blueprint to spend and so no target to check. The
22
+ * Authorize step ahead of the gate admitted it with the organization's
23
+ * can_create_execution_in; CreateSessionIfNeeded then creates the session
24
+ * with no instance, and every later turn arrives in shape 1. Pure over the
25
+ * record being built.
22
26
  */
23
27
  import type { AgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
24
28
 
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Pins the shared personal-environment lookup (personal.ts) on the seam
3
+ * both its callers stand on: outcomes are VALUES (no personal environment;
4
+ * resolved with the required keys still missing named), a declaration's
5
+ * is_secret rides onto the value, optional keys are skipped silently in
6
+ * every absent arm, only stored keys are read (existence before the
7
+ * secret read; own-key membership), and a failing list is the one thrown
8
+ * fault. The connect lane's refusals over these outcomes are pinned in
9
+ * mcpserver/__tests__/connect.test.ts; the build's warnings in
10
+ * agentexecution/__tests__/create-execution-context-step.test.ts.
11
+ */
12
+ import { create } from "@bufbuild/protobuf";
13
+ import { Code, ConnectError } from "@connectrpc/connect";
14
+ import { describe, expect, it } from "vitest";
15
+
16
+ import { EnvironmentSchema } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/api_pb";
17
+ import { EnvironmentListSchema } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/io_pb";
18
+ import {
19
+ EnvVarDeclarationSchema,
20
+ EnvironmentValueSchema,
21
+ } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/spec_pb";
22
+
23
+ import { createLogger } from "../../../boot/logger.js";
24
+ import { PERSONAL_LABEL_KEY, PERSONAL_LABEL_VALUE } from "../constants.js";
25
+ import type { PersonalEnvironmentReader } from "../personal.js";
26
+ import { resolveDeclaredFromPersonalEnvironment } from "../personal.js";
27
+
28
+ const silentLogger = createLogger({
29
+ level: "error",
30
+ pretty: false,
31
+ write: () => {},
32
+ });
33
+
34
+ /** A declaration literal as the generated message. */
35
+ function decl(init: { isSecret: boolean; optional?: boolean }) {
36
+ return create(EnvVarDeclarationSchema, init);
37
+ }
38
+
39
+ function readerOver(
40
+ stored: Record<string, string>,
41
+ reads: string[],
42
+ failing: ReadonlySet<string> = new Set(),
43
+ ): PersonalEnvironmentReader {
44
+ return {
45
+ list: async (request) => {
46
+ expect(request.labels).toEqual({ [PERSONAL_LABEL_KEY]: PERSONAL_LABEL_VALUE });
47
+ return create(EnvironmentListSchema, {
48
+ totalCount: 1,
49
+ items: [
50
+ create(EnvironmentSchema, {
51
+ metadata: { id: "env_p", org: "acme", slug: "personal" },
52
+ spec: {
53
+ data: Object.fromEntries(
54
+ Object.keys(stored).map((k) => [k, { value: "***", isSecret: true }]),
55
+ ),
56
+ },
57
+ }),
58
+ ],
59
+ });
60
+ },
61
+ getSecretValue: async (input) => {
62
+ const key = input.key ?? "";
63
+ reads.push(key);
64
+ if (failing.has(key)) {
65
+ throw new ConnectError("decrypt failed", Code.Internal);
66
+ }
67
+ return create(EnvironmentValueSchema, { value: stored[key] ?? "", isSecret: true });
68
+ },
69
+ };
70
+ }
71
+
72
+ describe("resolveDeclaredFromPersonalEnvironment", () => {
73
+ it("answers no-personal-environment as a value when the org has none", async () => {
74
+ const reader: PersonalEnvironmentReader = {
75
+ list: async () => create(EnvironmentListSchema, { totalCount: 0, items: [] }),
76
+ getSecretValue: async () => {
77
+ throw new Error("unreached");
78
+ },
79
+ };
80
+ const out = await resolveDeclaredFromPersonalEnvironment(reader, silentLogger, "acme", {
81
+ TOKEN: decl({ isSecret: true }),
82
+ });
83
+ expect(out).toEqual({ kind: "no-personal-environment" });
84
+ });
85
+
86
+ it("resolves stored keys with the declaration's is_secret, names missing required keys, skips optional ones", async () => {
87
+ const reads: string[] = [];
88
+ const reader = readerOver({ TOKEN: "t-1", PUBLIC_URL: "https://x", EMPTY: "" }, reads);
89
+ const out = await resolveDeclaredFromPersonalEnvironment(reader, silentLogger, "acme", {
90
+ TOKEN: decl({ isSecret: true }),
91
+ PUBLIC_URL: decl({ isSecret: false }),
92
+ EMPTY: decl({ isSecret: true }),
93
+ ABSENT_REQUIRED: decl({ isSecret: true }),
94
+ ABSENT_OPTIONAL: decl({ isSecret: true, optional: true }),
95
+ });
96
+ expect(out.kind).toBe("resolved");
97
+ if (out.kind !== "resolved") return;
98
+ expect(out.values["TOKEN"]?.value).toBe("t-1");
99
+ expect(out.values["TOKEN"]?.isSecret).toBe(true);
100
+ expect(out.values["PUBLIC_URL"]?.value).toBe("https://x");
101
+ expect(out.values["PUBLIC_URL"]?.isSecret).toBe(false);
102
+ // An empty stored value is absent; required → missing.
103
+ expect(out.values["EMPTY"]).toBeUndefined();
104
+ expect([...out.missing].sort()).toEqual(["ABSENT_REQUIRED", "EMPTY"]);
105
+ // Only stored keys are read: absent declarations cost no secret read.
106
+ expect(reads.sort()).toEqual(["EMPTY", "PUBLIC_URL", "TOKEN"]);
107
+ });
108
+
109
+ it("treats a failing secret read as absent: missing when required, skipped when optional", async () => {
110
+ const reads: string[] = [];
111
+ const reader = readerOver({ A: "a", B: "b" }, reads, new Set(["A", "B"]));
112
+ const out = await resolveDeclaredFromPersonalEnvironment(reader, silentLogger, "acme", {
113
+ A: decl({ isSecret: true }),
114
+ B: decl({ isSecret: true, optional: true }),
115
+ });
116
+ expect(out).toEqual({ kind: "resolved", values: {}, missing: ["A"] });
117
+ });
118
+
119
+ it("never reads a prototype name as a stored key", async () => {
120
+ const reads: string[] = [];
121
+ const reader = readerOver({}, reads);
122
+ const out = await resolveDeclaredFromPersonalEnvironment(reader, silentLogger, "acme", {
123
+ constructor: decl({ isSecret: true }),
124
+ toString: decl({ isSecret: true, optional: true }),
125
+ });
126
+ expect(out).toEqual({ kind: "resolved", values: {}, missing: ["constructor"] });
127
+ expect(reads).toEqual([]);
128
+ });
129
+
130
+ it("throws Internal when the personal environment cannot be listed", async () => {
131
+ const reader: PersonalEnvironmentReader = {
132
+ list: async () => {
133
+ throw new Error("store offline");
134
+ },
135
+ getSecretValue: async () => {
136
+ throw new Error("unreached");
137
+ },
138
+ };
139
+ await expect(
140
+ resolveDeclaredFromPersonalEnvironment(reader, silentLogger, "acme", { A: decl({ isSecret: true }) }),
141
+ ).rejects.toMatchObject({ code: Code.Internal });
142
+ });
143
+ });
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The caller's personal environment as a source of DECLARED variables: the
3
+ * one lookup that answers "which of these declared keys does the person
4
+ * have saved" for every lane that runs a tool on the person's behalf.
5
+ *
6
+ * Two lanes need it and must agree on it. The MCP connect lane (the
7
+ * discovery run a person triggers from the console) resolves a server's
8
+ * declared variables here when no one-time runtime_env was supplied; the
9
+ * agent-execution ExecutionContext build resolves a SESSION-level MCP
10
+ * server's declared variables here when the merge chain did not carry them,
11
+ * because a session's own servers ride no agent instance and so have no
12
+ * environment_refs of their own. Before the build learned this rule a key
13
+ * saved through the console's "save for future" reached the connect lane
14
+ * and never the run.
15
+ *
16
+ * Least privilege by construction: only the keys the caller declares are
17
+ * read, one GetSecretValue per key; nothing here layers a whole
18
+ * environment onto anything. What a missing personal environment or a
19
+ * missing required key MEANS is the caller's to decide — connect refuses
20
+ * (a person asked to connect and cannot without the credential), the
21
+ * build warns (the run fails at the tool with a clearer error) — so both
22
+ * outcomes are returned as values, never thrown. A failing list read is
23
+ * the one infrastructure fault, thrown as Internal.
24
+ *
25
+ * Proven by __tests__/personal.test.ts; the connect lane's wire copy over
26
+ * these outcomes by mcpserver/__tests__/connect.test.ts.
27
+ */
28
+ import { create } from "@bufbuild/protobuf";
29
+ import type { MessageInitShape } from "@bufbuild/protobuf";
30
+
31
+ import type { EnvironmentList } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/io_pb";
32
+ import type {
33
+ EnvironmentSecretValueInputSchema,
34
+ ListEnvironmentsRequestSchema,
35
+ } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/io_pb";
36
+ import type {
37
+ EnvVarDeclaration,
38
+ EnvironmentValue,
39
+ } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/spec_pb";
40
+ import type { ExecutionValue } from "@stigmer/protos/ai/stigmer/agentic/executioncontext/v1/spec_pb";
41
+ import { ExecutionValueSchema } from "@stigmer/protos/ai/stigmer/agentic/executioncontext/v1/spec_pb";
42
+
43
+ import type { Logger } from "../../boot/logger.js";
44
+ import { internalError } from "../../pipeline/errors.js";
45
+ import { PERSONAL_LABEL_KEY, PERSONAL_LABEL_VALUE } from "./constants.js";
46
+
47
+ /**
48
+ * The narrow environment read surface this lookup consumes: the list RPC
49
+ * (to find the personal environment by its label) and the secret read
50
+ * (decrypted; the ordinary Environment surface redacts, oss#405). Both
51
+ * consuming domains' in-process edges already satisfy it.
52
+ */
53
+ export interface PersonalEnvironmentReader {
54
+ list(
55
+ request: MessageInitShape<typeof ListEnvironmentsRequestSchema>,
56
+ ): Promise<EnvironmentList>;
57
+ getSecretValue(
58
+ input: MessageInitShape<typeof EnvironmentSecretValueInputSchema>,
59
+ ): Promise<EnvironmentValue>;
60
+ }
61
+
62
+ /** What the lookup found; the caller decides what each outcome means. */
63
+ export type PersonalEnvironmentResolution =
64
+ | { readonly kind: "no-personal-environment" }
65
+ | {
66
+ readonly kind: "resolved";
67
+ /** The declared keys the personal environment held, as execution values. */
68
+ readonly values: { readonly [key: string]: ExecutionValue };
69
+ /**
70
+ * The REQUIRED declared keys the personal environment did not hold
71
+ * (absent, unreadable, or empty). Optional keys never appear here.
72
+ */
73
+ readonly missing: readonly string[];
74
+ };
75
+
76
+ /**
77
+ * Resolves the given declarations against the caller's personal
78
+ * environment in `org`. A declaration's `is_secret` rides onto the value;
79
+ * a declared key the environment lacks is skipped when optional and
80
+ * reported in `missing` when required. A secret read that fails is logged
81
+ * and treated as absent, the same way for both lanes.
82
+ */
83
+ export async function resolveDeclaredFromPersonalEnvironment(
84
+ reader: PersonalEnvironmentReader,
85
+ logger: Logger,
86
+ org: string,
87
+ declarations: { readonly [key: string]: EnvVarDeclaration },
88
+ ): Promise<PersonalEnvironmentResolution> {
89
+ let listResponse: EnvironmentList;
90
+ try {
91
+ listResponse = await reader.list({
92
+ org,
93
+ labels: { [PERSONAL_LABEL_KEY]: PERSONAL_LABEL_VALUE },
94
+ });
95
+ } catch (error) {
96
+ throw internalError(error, "failed to list personal environments");
97
+ }
98
+ if (listResponse.totalCount === 0 || listResponse.items.length === 0) {
99
+ return { kind: "no-personal-environment" };
100
+ }
101
+
102
+ const personalEnv = listResponse.items[0];
103
+ const personalEnvId = personalEnv?.metadata?.id ?? "";
104
+ // The stored keys are present even when their values are redacted, so
105
+ // existence is checkable before the secret read. Own-key membership
106
+ // (never the prototype chain): an exotic key name must not read as held.
107
+ const stored = personalEnv?.spec?.data ?? {};
108
+
109
+ const values: { [key: string]: ExecutionValue } = {};
110
+ const missing: string[] = [];
111
+ for (const [key, decl] of Object.entries(declarations)) {
112
+ if (!Object.hasOwn(stored, key)) {
113
+ if (!decl.optional) missing.push(key);
114
+ continue;
115
+ }
116
+ let secretValue: EnvironmentValue;
117
+ try {
118
+ secretValue = await reader.getSecretValue({
119
+ environmentId: personalEnvId,
120
+ key,
121
+ });
122
+ } catch (error) {
123
+ logger.warn("Failed to get secret value from personal environment", {
124
+ key,
125
+ personal_env_id: personalEnvId,
126
+ error: error instanceof Error ? error.message : String(error),
127
+ });
128
+ if (!decl.optional) missing.push(key);
129
+ continue;
130
+ }
131
+ if (secretValue.value === "") {
132
+ if (!decl.optional) missing.push(key);
133
+ continue;
134
+ }
135
+ values[key] = create(ExecutionValueSchema, {
136
+ value: secretValue.value,
137
+ isSecret: decl.isSecret,
138
+ });
139
+ }
140
+ return { kind: "resolved", values, missing };
141
+ }