@stigmer/server 3.18.0 → 3.19.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 (116) hide show
  1. package/README.md +1 -1
  2. package/dist/boot/compose.d.ts.map +1 -1
  3. package/dist/boot/compose.js +42 -6
  4. package/dist/boot/compose.js.map +1 -1
  5. package/dist/boot/config.d.ts +6 -2
  6. package/dist/boot/config.d.ts.map +1 -1
  7. package/dist/boot/config.js.map +1 -1
  8. package/dist/boot/oauth-redirect-uri.d.ts +54 -0
  9. package/dist/boot/oauth-redirect-uri.d.ts.map +1 -0
  10. package/dist/boot/oauth-redirect-uri.js +37 -0
  11. package/dist/boot/oauth-redirect-uri.js.map +1 -0
  12. package/dist/domain/agentexecution/lifecycle.js +1 -1
  13. package/dist/domain/agentexecution/lifecycle.js.map +1 -1
  14. package/dist/domain/mcpserver/complete-endpoint-auth.d.ts +16 -0
  15. package/dist/domain/mcpserver/complete-endpoint-auth.d.ts.map +1 -0
  16. package/dist/domain/mcpserver/complete-endpoint-auth.js +260 -0
  17. package/dist/domain/mcpserver/complete-endpoint-auth.js.map +1 -0
  18. package/dist/domain/mcpserver/complete-oauth-connect.d.ts.map +1 -1
  19. package/dist/domain/mcpserver/complete-oauth-connect.js +1 -1
  20. package/dist/domain/mcpserver/complete-oauth-connect.js.map +1 -1
  21. package/dist/domain/mcpserver/connect.d.ts +25 -0
  22. package/dist/domain/mcpserver/connect.d.ts.map +1 -1
  23. package/dist/domain/mcpserver/connect.js +8 -23
  24. package/dist/domain/mcpserver/connect.js.map +1 -1
  25. package/dist/domain/mcpserver/controller.d.ts.map +1 -1
  26. package/dist/domain/mcpserver/controller.js +12 -0
  27. package/dist/domain/mcpserver/controller.js.map +1 -1
  28. package/dist/domain/mcpserver/initiate-oauth-connect.d.ts.map +1 -1
  29. package/dist/domain/mcpserver/initiate-oauth-connect.js +38 -14
  30. package/dist/domain/mcpserver/initiate-oauth-connect.js.map +1 -1
  31. package/dist/domain/mcpserver/oauth/dcr.d.ts +10 -1
  32. package/dist/domain/mcpserver/oauth/dcr.d.ts.map +1 -1
  33. package/dist/domain/mcpserver/oauth/dcr.js +1 -9
  34. package/dist/domain/mcpserver/oauth/dcr.js.map +1 -1
  35. package/dist/domain/mcpserver/oauth/discovery.d.ts +43 -20
  36. package/dist/domain/mcpserver/oauth/discovery.d.ts.map +1 -1
  37. package/dist/domain/mcpserver/oauth/discovery.js +69 -54
  38. package/dist/domain/mcpserver/oauth/discovery.js.map +1 -1
  39. package/dist/domain/mcpserver/oauth/preflight.d.ts +27 -1
  40. package/dist/domain/mcpserver/oauth/preflight.d.ts.map +1 -1
  41. package/dist/domain/mcpserver/oauth/preflight.js +1 -26
  42. package/dist/domain/mcpserver/oauth/preflight.js.map +1 -1
  43. package/dist/domain/mcpserver/oauth/refresh.d.ts +2 -1
  44. package/dist/domain/mcpserver/oauth/refresh.d.ts.map +1 -1
  45. package/dist/domain/mcpserver/oauth/refresh.js +1 -1
  46. package/dist/domain/mcpserver/oauth/refresh.js.map +1 -1
  47. package/dist/domain/mcpserver/oauth/token.d.ts +9 -2
  48. package/dist/domain/mcpserver/oauth/token.d.ts.map +1 -1
  49. package/dist/domain/mcpserver/oauth/token.js +2 -8
  50. package/dist/domain/mcpserver/oauth/token.js.map +1 -1
  51. package/dist/domain/workflowexecution/lifecycle.js +1 -1
  52. package/dist/domain/workflowexecution/lifecycle.js.map +1 -1
  53. package/dist/extensions/drivers.d.ts +17 -0
  54. package/dist/extensions/drivers.d.ts.map +1 -1
  55. package/dist/extensions/outbound-egress.d.ts +49 -0
  56. package/dist/extensions/outbound-egress.d.ts.map +1 -0
  57. package/dist/extensions/outbound-egress.js +51 -0
  58. package/dist/extensions/outbound-egress.js.map +1 -0
  59. package/dist/extensions/registry.d.ts +6 -0
  60. package/dist/extensions/registry.d.ts.map +1 -1
  61. package/dist/extensions/registry.js +10 -0
  62. package/dist/extensions/registry.js.map +1 -1
  63. package/dist/index.d.ts +2 -0
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +1 -0
  66. package/dist/index.js.map +1 -1
  67. package/dist/pipeline/apiresource-labels.d.ts +14 -0
  68. package/dist/pipeline/apiresource-labels.d.ts.map +1 -1
  69. package/dist/pipeline/apiresource-labels.js +14 -0
  70. package/dist/pipeline/apiresource-labels.js.map +1 -1
  71. package/dist/sandbox/kubernetes.d.ts.map +1 -1
  72. package/dist/sandbox/kubernetes.js +8 -1
  73. package/dist/sandbox/kubernetes.js.map +1 -1
  74. package/dist/sandbox/provisioner.d.ts +20 -5
  75. package/dist/sandbox/provisioner.d.ts.map +1 -1
  76. package/dist/sandbox/provisioner.js.map +1 -1
  77. package/dist/sandbox/steps.d.ts +22 -8
  78. package/dist/sandbox/steps.d.ts.map +1 -1
  79. package/dist/sandbox/steps.js +17 -12
  80. package/dist/sandbox/steps.js.map +1 -1
  81. package/package.json +6 -5
  82. package/src/boot/__tests__/oauth-redirect-uri.test.ts +38 -0
  83. package/src/boot/compose.ts +47 -8
  84. package/src/boot/config.ts +6 -2
  85. package/src/boot/oauth-redirect-uri.ts +53 -0
  86. package/src/domain/agentexecution/lifecycle.ts +1 -1
  87. package/src/domain/mcpserver/__tests__/complete-endpoint-auth.test.ts +270 -0
  88. package/src/domain/mcpserver/__tests__/connect-authorization.test.ts +3 -0
  89. package/src/domain/mcpserver/__tests__/connect.test.ts +31 -0
  90. package/src/domain/mcpserver/__tests__/oauth-handshake.test.ts +1 -1
  91. package/src/domain/mcpserver/complete-endpoint-auth.ts +277 -0
  92. package/src/domain/mcpserver/complete-oauth-connect.ts +1 -0
  93. package/src/domain/mcpserver/connect.ts +21 -15
  94. package/src/domain/mcpserver/controller.ts +16 -0
  95. package/src/domain/mcpserver/initiate-oauth-connect.ts +37 -13
  96. package/src/domain/mcpserver/oauth/__tests__/discovery.test.ts +120 -91
  97. package/src/domain/mcpserver/oauth/__tests__/token-refresh.test.ts +3 -1
  98. package/src/domain/mcpserver/oauth/dcr.ts +2 -1
  99. package/src/domain/mcpserver/oauth/discovery.ts +120 -89
  100. package/src/domain/mcpserver/oauth/preflight.ts +2 -1
  101. package/src/domain/mcpserver/oauth/refresh.ts +2 -1
  102. package/src/domain/mcpserver/oauth/token.ts +4 -3
  103. package/src/domain/workflowexecution/lifecycle.ts +1 -1
  104. package/src/extensions/__tests__/outbound-egress-points.test.ts +58 -0
  105. package/src/extensions/drivers.ts +17 -0
  106. package/src/extensions/outbound-egress.ts +55 -0
  107. package/src/extensions/registry.ts +19 -0
  108. package/src/index.ts +10 -0
  109. package/src/pipeline/apiresource-labels.ts +16 -0
  110. package/src/sandbox/__tests__/docker.integration.test.ts +2 -0
  111. package/src/sandbox/__tests__/kubernetes.test.ts +10 -2
  112. package/src/sandbox/__tests__/local-process.integration.test.ts +3 -0
  113. package/src/sandbox/__tests__/steps.test.ts +72 -19
  114. package/src/sandbox/kubernetes.ts +8 -1
  115. package/src/sandbox/provisioner.ts +20 -5
  116. package/src/sandbox/steps.ts +29 -12
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Pins the outboundEgress driver point in the single-instance shape the
3
+ * registry already enforces for policyGrantScope and licenseStatus:
4
+ *
5
+ * - `drivers.outboundEgress` — the address policy the composition root
6
+ * builds the McpServer slice's one guarded fetch from; single instance;
7
+ * absent = open source's relaxed posture installs at the compose.ts
8
+ * consumption site.
9
+ *
10
+ * And the two exported postures: relaxed refuses only the link-local
11
+ * (cloud metadata) range, strict refuses loopback, private and unspecified
12
+ * too, and neither owns a copy of the ranges (both read the shared table).
13
+ */
14
+ import { describe, expect, it } from "vitest";
15
+
16
+ import { relaxedEgressPolicy, strictEgressPolicy } from "../outbound-egress.js";
17
+ import { resolveExtensions } from "../registry.js";
18
+
19
+ describe("the outboundEgress driver point", () => {
20
+ it("is undefined with no extensions — the relaxed posture installs at the consumption site", () => {
21
+ expect(resolveExtensions([]).drivers.outboundEgress).toBeUndefined();
22
+ });
23
+
24
+ it("carries the one registered instance through", () => {
25
+ const policy = strictEgressPolicy();
26
+ const resolved = resolveExtensions([{ name: "cloud-core", drivers: { outboundEgress: policy } }]);
27
+ expect(resolved.drivers.outboundEgress).toBe(policy);
28
+ });
29
+
30
+ it("throws on a second registration, naming both units", () => {
31
+ expect(() =>
32
+ resolveExtensions([
33
+ { name: "egress-a", drivers: { outboundEgress: strictEgressPolicy() } },
34
+ { name: "egress-b", drivers: { outboundEgress: relaxedEgressPolicy() } },
35
+ ]),
36
+ ).toThrowError(/extension 'egress-b' registers an OutboundEgressPolicy, but 'egress-a' already did/);
37
+ });
38
+ });
39
+
40
+ describe("the two postures", () => {
41
+ it("relaxed refuses only the link-local range, so a local MCP server stays reachable", () => {
42
+ const relaxed = relaxedEgressPolicy();
43
+ expect(relaxed.name).toBe("relaxed");
44
+ expect(relaxed.blockedReason("127.0.0.1")).toBeNull();
45
+ expect(relaxed.blockedReason("10.0.0.1")).toBeNull();
46
+ expect(relaxed.blockedReason("169.254.169.254")).toBe("link-local (cloud metadata)");
47
+ });
48
+
49
+ it("strict refuses loopback, private, link-local and unspecified addresses", () => {
50
+ const strict = strictEgressPolicy();
51
+ expect(strict.name).toBe("strict");
52
+ expect(strict.blockedReason("127.0.0.1")).toBe("loopback");
53
+ expect(strict.blockedReason("10.0.0.1")).toBe("private (RFC 1918)");
54
+ expect(strict.blockedReason("169.254.169.254")).toBe("link-local (cloud metadata)");
55
+ expect(strict.blockedReason("0.0.0.0")).toBe("unspecified");
56
+ expect(strict.blockedReason("104.16.0.1")).toBeNull();
57
+ });
58
+ });
@@ -31,6 +31,10 @@
31
31
  * port, which kinds an edition grants on, and the tuple-half query
32
32
  * engine only an authorization backend can answer) — landed with
33
33
  * 20260913.01, gate ruling Q-OR-10
34
+ * - outbound egress (which addresses the control plane may dial when
35
+ * it reaches a URL a user supplied: the MCP endpoint it probes at save
36
+ * time and the login server it reaches on Sign in) — landed with the
37
+ * save-time OAuth completion for URL-only MCP servers, 2026-09-19
34
38
  *
35
39
  * Merge rules (enforced by resolveExtensions, DD-006 §2b): the two
36
40
  * provider kinds are single-instance points — a second declaring unit is
@@ -54,6 +58,7 @@ import type { SandboxProvisionerFactory } from "../sandbox/provisioner.js";
54
58
  import type { AuthorizationQueryEngine } from "./authorization-queries.js";
55
59
  import type { IdentityFederation } from "./identity-federation.js";
56
60
  import type { LicenseStatusProvider } from "./license-status.js";
61
+ import type { OutboundEgressPolicy } from "./outbound-egress.js";
57
62
  import type { ListReadScope } from "./list-read-scope.js";
58
63
  import type { OrganizationDirectory } from "./organization-directory.js";
59
64
  import type { PolicyGrantScope } from "./policy-grant-scope.js";
@@ -230,4 +235,16 @@ export interface ExtensionDrivers {
230
235
  * way (extensions/license-status.ts carries the contract).
231
236
  */
232
237
  readonly licenseStatus?: LicenseStatusProvider;
238
+ /**
239
+ * The outbound-egress policy (single-instance point): which addresses
240
+ * this edition's control plane may dial when it reaches a URL a user
241
+ * supplied. The composition root builds one guarded fetch from it and
242
+ * hands it to the McpServer connect slice, the only fetch its OAuth code
243
+ * and its save-time endpoint probe hold. When absent, open source's
244
+ * `relaxedEgressPolicy()` installs at the compose.ts consumption site:
245
+ * everything but the link-local (cloud metadata) range is allowed, so a
246
+ * server beside a self-hosted control plane keeps working
247
+ * (extensions/outbound-egress.ts carries the contract).
248
+ */
249
+ readonly outboundEgress?: OutboundEgressPolicy;
233
250
  }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The outbound-egress driver point: WHICH addresses this edition's control
3
+ * plane may dial when it reaches a URL a user supplied. Single instance,
4
+ * registered as `drivers.outboundEgress` (the policyGrantScope shape: a
5
+ * driver point whose absence is open source's own behaviour). Absent, the
6
+ * composition root installs `relaxedEgressPolicy()`: everything but the
7
+ * link-local range is allowed, because a server beside a self-hosted
8
+ * control plane (`http://localhost:3000/mcp`, the proto's own example) is
9
+ * the normal case and the link-local range, which carries the cloud
10
+ * metadata endpoint, costs a local user nothing to refuse. The cloud
11
+ * registers `strictEgressPolicy()`: loopback, private, link-local and
12
+ * unspecified refused, because its control plane sits on infrastructure
13
+ * with reach to internal services.
14
+ *
15
+ * Who reads it: nobody in the domains directly. The composition root builds
16
+ * ONE guarded fetch from the policy (`@stigmer/outbound/egress`
17
+ * `guardedFetch`, which judges the first URL and every redirect hop) and
18
+ * hands it to the McpServer connect slice as `outboundFetch`, the only
19
+ * fetch the McpServer domain's OAuth code and its save-time endpoint probe
20
+ * hold. Before this point existed every OAuth fetch (discovery, dynamic
21
+ * client registration, the authorize preflight, token exchange and
22
+ * refresh) defaulted to the raw global `fetch` in every edition, which is
23
+ * the exposure the point closes.
24
+ *
25
+ * The contract every policy is held to:
26
+ *
27
+ * - It judges ADDRESSES, not names: the check resolves a hostname and
28
+ * asks about every address it has, so a name with one public and one
29
+ * private record is refused. A policy that wanted a hostname allowlist
30
+ * would be a different seam.
31
+ * - It is TOTAL: an unparseable address is refused ("unrecognized"),
32
+ * never a throw.
33
+ * - It is SYNCHRONOUS: an edition's egress posture is a fact about where
34
+ * it runs, never I/O.
35
+ *
36
+ * Both constructors are thin over the shared classification in
37
+ * `@stigmer/outbound/egress`, so a composition registers a posture and owns
38
+ * no copy of the ranges; the runner's `web_fetch` guard reads the same
39
+ * table, so the two processes refuse the same addresses under the same
40
+ * posture name.
41
+ */
42
+ import { egressPolicyForPosture, type EgressPolicy } from "@stigmer/outbound/egress";
43
+
44
+ /** The egress contract (single-instance point, ExtensionDrivers.outboundEgress). */
45
+ export type OutboundEgressPolicy = EgressPolicy;
46
+
47
+ /** Open source's own posture: only the link-local (cloud metadata) range is refused. */
48
+ export function relaxedEgressPolicy(): OutboundEgressPolicy {
49
+ return egressPolicyForPosture("relaxed");
50
+ }
51
+
52
+ /** The managed-cloud posture: loopback, private, link-local and unspecified addresses refused. */
53
+ export function strictEgressPolicy(): OutboundEgressPolicy {
54
+ return egressPolicyForPosture("strict");
55
+ }
@@ -54,6 +54,7 @@ import { V1_VERSION } from "../encryption/v1-codec.js";
54
54
  import type { AuthorizationQueryEngine } from "./authorization-queries.js";
55
55
  import type { IdentityFederation } from "./identity-federation.js";
56
56
  import type { LicenseStatusProvider } from "./license-status.js";
57
+ import type { OutboundEgressPolicy } from "./outbound-egress.js";
57
58
  import type { ListReadScope } from "./list-read-scope.js";
58
59
  import type { PolicyGrantScope } from "./policy-grant-scope.js";
59
60
  import type { ModelCatalogProvider } from "../domain/workflow/registry/model-catalog-provider.js";
@@ -278,6 +279,11 @@ export interface ResolvedExtensionDrivers {
278
279
  * built-in `absent` answer, so every edition serves getLicenseStatus.
279
280
  */
280
281
  readonly licenseStatus: LicenseStatusProvider | undefined;
282
+ /**
283
+ * The outbound-egress policy — undefined = compose.ts installs open
284
+ * source's relaxed posture (only the link-local range refused).
285
+ */
286
+ readonly outboundEgress: OutboundEgressPolicy | undefined;
281
287
  }
282
288
 
283
289
  /**
@@ -333,6 +339,8 @@ export function resolveExtensions(
333
339
  let authorizationQueriesDeclaredBy: string | undefined;
334
340
  let licenseStatus: LicenseStatusProvider | undefined;
335
341
  let licenseStatusDeclaredBy: string | undefined;
342
+ let outboundEgress: OutboundEgressPolicy | undefined;
343
+ let outboundEgressDeclaredBy: string | undefined;
336
344
  const artifactStorageDrivers = new Map<
337
345
  string,
338
346
  ArtifactStorageDriverFactory
@@ -534,6 +542,16 @@ export function resolveExtensions(
534
542
  licenseStatusDeclaredBy = unit.name;
535
543
  }
536
544
 
545
+ if (unit.drivers?.outboundEgress !== undefined) {
546
+ if (outboundEgressDeclaredBy !== undefined) {
547
+ throw new Error(
548
+ `extension '${unit.name}' registers an OutboundEgressPolicy, but '${outboundEgressDeclaredBy}' already did — exactly one may be composed`,
549
+ );
550
+ }
551
+ outboundEgress = unit.drivers.outboundEgress;
552
+ outboundEgressDeclaredBy = unit.name;
553
+ }
554
+
537
555
  if (unit.drivers?.artifactStorageDrivers !== undefined) {
538
556
  for (const [name, factory] of unit.drivers.artifactStorageDrivers) {
539
557
  if ((BUILT_IN_STORAGE_TYPES as ReadonlyArray<string>).includes(name)) {
@@ -660,6 +678,7 @@ export function resolveExtensions(
660
678
  policyGrantScope,
661
679
  authorizationQueries,
662
680
  licenseStatus,
681
+ outboundEgress,
663
682
  },
664
683
  services,
665
684
  workers,
package/src/index.ts CHANGED
@@ -119,6 +119,16 @@ export type {
119
119
  LicenseStatusProvider,
120
120
  LicenseStatusReport,
121
121
  } from "./extensions/license-status.js";
122
+ // The outbound-egress seam (drivers.outboundEgress): which addresses the
123
+ // control plane may dial when it reaches a URL a user supplied. Both
124
+ // postures are exported so a composition registers one line and owns no
125
+ // copy of the address ranges (the classification lives in
126
+ // @stigmer/outbound/egress, shared with the runner's web_fetch guard).
127
+ export type { OutboundEgressPolicy } from "./extensions/outbound-egress.js";
128
+ export {
129
+ relaxedEgressPolicy,
130
+ strictEgressPolicy,
131
+ } from "./extensions/outbound-egress.js";
122
132
  // The 20260911.11 identity-account seams (Q-IA-9): the store PORT a
123
133
  // composition drives the domain through (drivers.identityAccountStore;
124
134
  // a driver throws DuplicateAccountError for a held id), the federation
@@ -82,6 +82,22 @@ export const PLUGIN_LABEL = `${RESERVED_LABEL_PREFIX}plugin`;
82
82
  */
83
83
  export const PLUGIN_VERSION_LABEL = `${RESERVED_LABEL_PREFIX}plugin-version`;
84
84
 
85
+ /**
86
+ * How an MCP server's `spec.auth` came to be. The one value, `endpoint`,
87
+ * says the server itself completed it: the author supplied a URL and
88
+ * nothing about authentication, the endpoint answered the save-time probe
89
+ * with an OAuth challenge, and the server wrote the token variable, its
90
+ * declaration and the Bearer header (domain/mcpserver/complete-endpoint-auth.ts).
91
+ * The label is provenance, not permission: it lets the next save tell a
92
+ * completion it may reuse from an author's own `auth` it must not touch.
93
+ * Stamped through the server-stamped mechanism; dropped when the author
94
+ * writes their own `auth`.
95
+ */
96
+ export const MCP_AUTH_LABEL = `${RESERVED_LABEL_PREFIX}mcp-auth`;
97
+
98
+ /** The MCP_AUTH_LABEL value for a completion the endpoint's own challenge produced. */
99
+ export const MCP_AUTH_ENDPOINT = "endpoint";
100
+
85
101
  /** The plugin id a resource's labels claim, or undefined when unlabelled. */
86
102
  export function pluginIdOf(
87
103
  metadata: ApiResourceMetadata | undefined,
@@ -68,6 +68,7 @@ describe.skipIf(!optedIn || !dockerAnswers())(
68
68
  await driver.ensureSessionSandbox(sessionId, {
69
69
  taskQueue: `session:${sessionId}`,
70
70
  stigmerToken: "tok-docker-smoke",
71
+ callerClass: "user",
71
72
  });
72
73
  expect(await driver.probe("session", sessionId)).toBe("running");
73
74
 
@@ -91,6 +92,7 @@ describe.skipIf(!optedIn || !dockerAnswers())(
91
92
  await driver.ensureSessionSandbox(sessionId, {
92
93
  taskQueue: `session:${sessionId}`,
93
94
  stigmerToken: "tok-docker-smoke",
95
+ callerClass: "user",
94
96
  });
95
97
  expect(await driver.probe("session", sessionId)).toBe("running");
96
98
 
@@ -89,7 +89,11 @@ function fakeGateway(
89
89
  };
90
90
  }
91
91
 
92
- const env = { taskQueue: "session:ses_1", stigmerToken: "tok-1" };
92
+ const env = {
93
+ taskQueue: "session:ses_1",
94
+ stigmerToken: "tok-1",
95
+ callerClass: "user",
96
+ };
93
97
 
94
98
  describe("the ensure state machine", () => {
95
99
  it("absent → Secret, PVC, Deployment — in that order, no readiness wait", async () => {
@@ -211,7 +215,11 @@ describe("the manifest shapes (the Java SandboxManifestFactory pins)", () => {
211
215
  });
212
216
 
213
217
  it("a token-less sandbox omits the token env but keeps the (empty) Secret", () => {
214
- const tokenless = { taskQueue: "session:ses_1", stigmerToken: "" };
218
+ const tokenless = {
219
+ taskQueue: "session:ses_1",
220
+ stigmerToken: "",
221
+ callerClass: "user",
222
+ };
215
223
  const deployment = buildSandboxDeployment(
216
224
  "session",
217
225
  "ses_1",
@@ -81,6 +81,7 @@ describe.skipIf(process.platform === "win32")(
81
81
  await driver.ensureSessionSandbox("ses_smoke", {
82
82
  taskQueue: "session:ses_smoke",
83
83
  stigmerToken: "tok-smoke",
84
+ callerClass: "user",
84
85
  });
85
86
  await vi.waitFor(() => expect(spawnCount()).toBe(1), {
86
87
  timeout: CHILD_SETTLE_TIMEOUT_MS,
@@ -91,6 +92,7 @@ describe.skipIf(process.platform === "win32")(
91
92
  await driver.ensureSessionSandbox("ses_smoke", {
92
93
  taskQueue: "session:ses_smoke",
93
94
  stigmerToken: "tok-smoke",
95
+ callerClass: "user",
94
96
  });
95
97
  expect(spawnCount()).toBe(1);
96
98
 
@@ -124,6 +126,7 @@ describe.skipIf(process.platform === "win32")(
124
126
  await driver.ensureSessionSandbox("ses_smoke", {
125
127
  taskQueue: "session:ses_smoke",
126
128
  stigmerToken: "",
129
+ callerClass: "user",
127
130
  });
128
131
  await vi.waitFor(() => expect(spawnCount()).toBe(2), {
129
132
  timeout: CHILD_SETTLE_TIMEOUT_MS,
@@ -11,7 +11,10 @@
11
11
  * - the terminal observer deprovisions exactly on transitions INTO a
12
12
  * terminal phase, fire-and-forget, and swallows (logs) teardown
13
13
  * failures;
14
- * - a disabled mint lane launches token-less rather than failing.
14
+ * - a disabled mint lane launches token-less rather than failing;
15
+ * - the caller splits two ways on both lanes: the identity id reaches
16
+ * the credential mint, the caller class reaches the driver's
17
+ * environment unchanged (a composed lane such as `guest` included).
15
18
  */
16
19
  import { mkdtempSync, rmSync } from "node:fs";
17
20
  import { tmpdir } from "node:os";
@@ -53,14 +56,25 @@ import {
53
56
  ensureWorkflowSandboxForExecution,
54
57
  newWorkflowSandboxTerminalObserver,
55
58
  SANDBOX_PROVISIONING_FAILED_PREFIX,
59
+ type SandboxCaller,
56
60
  } from "../steps.js";
57
61
 
58
62
  /**
59
- * The caller identity the ensure bodies thread into the credential mint
60
- * (C4): the OSS execution-scoped mint ignores it, so these tests pass a
61
- * fixed value and the token assertions stay binding-shaped.
63
+ * The caller the ensure bodies split two ways: the identity id into the
64
+ * credential mint (C4 — the OSS execution-scoped mint ignores it, so the
65
+ * token assertions stay binding-shaped) and the class onto the driver's
66
+ * environment. `user` is the plain lane; the class-propagation cases hand
67
+ * in a composed lane's word to prove the body copies, never normalises.
62
68
  */
63
69
  const TEST_CALLER_IDENTITY_ID = "ida_test_caller";
70
+ const TEST_CALLER: SandboxCaller = {
71
+ identityId: TEST_CALLER_IDENTITY_ID,
72
+ callerClass: "user",
73
+ };
74
+ const GUEST_CALLER: SandboxCaller = {
75
+ identityId: "ida_guest_caller",
76
+ callerClass: "guest",
77
+ };
64
78
 
65
79
  const silentLogger = createLogger({
66
80
  level: "error",
@@ -235,7 +249,7 @@ describe("the session lane (ensureSessionSandboxForExecution)", () => {
235
249
  temporalConfig: sessionRoutingCloudDefault,
236
250
  },
237
251
  execution,
238
- TEST_CALLER_IDENTITY_ID,
252
+ TEST_CALLER,
239
253
  );
240
254
  expect(provisioner.ensured).toEqual([
241
255
  {
@@ -244,11 +258,32 @@ describe("the session lane (ensureSessionSandboxForExecution)", () => {
244
258
  env: {
245
259
  taskQueue: `session:${sessionId}`,
246
260
  stigmerToken: `tok-${execution.metadata?.id ?? ""}`,
261
+ callerClass: "user",
247
262
  },
248
263
  },
249
264
  ]);
250
265
  });
251
266
 
267
+ it("hands the caller's class to the driver unchanged and the caller's id to the mint", async () => {
268
+ const provisioner = fakeProvisioner();
269
+ const credentials = capabilityCredentials();
270
+ const { execution } = await seed(ExecutionTarget.CLOUD);
271
+ await ensureSessionSandboxForExecution(
272
+ {
273
+ store,
274
+ logger: silentLogger,
275
+ lane: lane(provisioner, credentials),
276
+ temporalConfig: sessionRoutingCloudDefault,
277
+ },
278
+ execution,
279
+ GUEST_CALLER,
280
+ );
281
+ expect(provisioner.ensured[0]?.env.callerClass).toBe("guest");
282
+ expect(credentials.minted[0]?.callerIdentityId).toBe(
283
+ GUEST_CALLER.identityId,
284
+ );
285
+ });
286
+
252
287
  it("delegates the mint to the capability provider with the full provisioning context (C4)", async () => {
253
288
  const provisioner = fakeProvisioner();
254
289
  const credentials = capabilityCredentials();
@@ -277,7 +312,7 @@ describe("the session lane (ensureSessionSandboxForExecution)", () => {
277
312
  temporalConfig: sessionRoutingCloudDefault,
278
313
  },
279
314
  execution,
280
- TEST_CALLER_IDENTITY_ID,
315
+ TEST_CALLER,
281
316
  );
282
317
 
283
318
  expect(credentials.minted).toEqual([
@@ -303,7 +338,7 @@ describe("the session lane (ensureSessionSandboxForExecution)", () => {
303
338
  temporalConfig: sessionRoutingCloudDefault,
304
339
  },
305
340
  execution,
306
- TEST_CALLER_IDENTITY_ID,
341
+ TEST_CALLER,
307
342
  );
308
343
  expect(provisioner.ensured).toEqual([]);
309
344
  });
@@ -327,7 +362,7 @@ describe("the session lane (ensureSessionSandboxForExecution)", () => {
327
362
  temporalConfig: sessionRoutingCloudDefault,
328
363
  },
329
364
  execution,
330
- TEST_CALLER_IDENTITY_ID,
365
+ TEST_CALLER,
331
366
  );
332
367
  });
333
368
 
@@ -345,7 +380,7 @@ describe("the session lane (ensureSessionSandboxForExecution)", () => {
345
380
  temporalConfig: sessionRoutingCloudDefault,
346
381
  },
347
382
  execution,
348
- TEST_CALLER_IDENTITY_ID,
383
+ TEST_CALLER,
349
384
  );
350
385
  expect(provisioner.ensured).toEqual([]);
351
386
  });
@@ -366,7 +401,7 @@ describe("the session lane (ensureSessionSandboxForExecution)", () => {
366
401
  ),
367
402
  },
368
403
  execution,
369
- TEST_CALLER_IDENTITY_ID,
404
+ TEST_CALLER,
370
405
  );
371
406
  expect(provisioner.ensured).toEqual([]);
372
407
  });
@@ -382,7 +417,7 @@ describe("the session lane (ensureSessionSandboxForExecution)", () => {
382
417
  temporalConfig: sessionRoutingCloudDefault,
383
418
  },
384
419
  execution,
385
- TEST_CALLER_IDENTITY_ID,
420
+ TEST_CALLER,
386
421
  );
387
422
  expect(provisioner.ensured[0]?.env.stigmerToken).toBe("");
388
423
  });
@@ -401,7 +436,7 @@ describe("the session lane (ensureSessionSandboxForExecution)", () => {
401
436
  temporalConfig: sessionRoutingCloudDefault,
402
437
  },
403
438
  execution,
404
- TEST_CALLER_IDENTITY_ID,
439
+ TEST_CALLER,
405
440
  );
406
441
  const stamped = await store.getResource(
407
442
  ApiResourceKind.agent_execution,
@@ -439,7 +474,7 @@ describe("the session lane (ensureSessionSandboxForExecution)", () => {
439
474
  temporalConfig: sessionRoutingCloudDefault,
440
475
  },
441
476
  execution,
442
- TEST_CALLER_IDENTITY_ID,
477
+ TEST_CALLER,
443
478
  );
444
479
  const after = await store.getResource(
445
480
  ApiResourceKind.agent_execution,
@@ -467,17 +502,35 @@ describe("the workflow lane (ensureWorkflowSandboxForExecution)", () => {
467
502
  temporalConfig: executionRoutingConfig,
468
503
  },
469
504
  workflowExecution(ExecutionTarget.CLOUD),
470
- TEST_CALLER_IDENTITY_ID,
505
+ TEST_CALLER,
471
506
  );
472
507
  expect(provisioner.ensured).toEqual([
473
508
  {
474
509
  scope: "workflow",
475
510
  id: "wfx_sbx_1",
476
- env: { taskQueue: "wfexec:wfx_sbx_1", stigmerToken: "tok-wfx_sbx_1" },
511
+ env: {
512
+ taskQueue: "wfexec:wfx_sbx_1",
513
+ stigmerToken: "tok-wfx_sbx_1",
514
+ callerClass: "user",
515
+ },
477
516
  },
478
517
  ]);
479
518
  });
480
519
 
520
+ it("hands the caller's class to the driver unchanged on the workflow lane", async () => {
521
+ const provisioner = fakeProvisioner();
522
+ await ensureWorkflowSandboxForExecution(
523
+ {
524
+ logger: silentLogger,
525
+ lane: lane(provisioner),
526
+ temporalConfig: executionRoutingConfig,
527
+ },
528
+ workflowExecution(ExecutionTarget.CLOUD),
529
+ GUEST_CALLER,
530
+ );
531
+ expect(provisioner.ensured[0]?.env.callerClass).toBe("guest");
532
+ });
533
+
481
534
  it("delegates the mint to the capability provider on the workflow scope (C4)", async () => {
482
535
  const provisioner = fakeProvisioner();
483
536
  const credentials = capabilityCredentials();
@@ -491,7 +544,7 @@ describe("the workflow lane (ensureWorkflowSandboxForExecution)", () => {
491
544
  metadata: { id: "wfx_sbx_cap", name: "wfx_sbx_cap", org: "org-test" },
492
545
  spec: { executionTarget: ExecutionTarget.CLOUD },
493
546
  }),
494
- TEST_CALLER_IDENTITY_ID,
547
+ TEST_CALLER,
495
548
  );
496
549
  expect(credentials.minted).toEqual([
497
550
  {
@@ -514,7 +567,7 @@ describe("the workflow lane (ensureWorkflowSandboxForExecution)", () => {
514
567
  temporalConfig: executionRoutingConfig,
515
568
  },
516
569
  workflowExecution(ExecutionTarget.LOCAL),
517
- TEST_CALLER_IDENTITY_ID,
570
+ TEST_CALLER,
518
571
  );
519
572
  await ensureWorkflowSandboxForExecution(
520
573
  {
@@ -528,7 +581,7 @@ describe("the workflow lane (ensureWorkflowSandboxForExecution)", () => {
528
581
  ),
529
582
  },
530
583
  workflowExecution(ExecutionTarget.CLOUD),
531
- TEST_CALLER_IDENTITY_ID,
584
+ TEST_CALLER,
532
585
  );
533
586
  expect(provisioner.ensured).toEqual([]);
534
587
  });
@@ -545,7 +598,7 @@ describe("the workflow lane (ensureWorkflowSandboxForExecution)", () => {
545
598
  temporalConfig: executionRoutingConfig,
546
599
  },
547
600
  workflowExecution(ExecutionTarget.CLOUD),
548
- TEST_CALLER_IDENTITY_ID,
601
+ TEST_CALLER,
549
602
  );
550
603
  expect.unreachable("the workflow lane must throw on failure");
551
604
  } catch (error) {
@@ -85,7 +85,14 @@ const RUNNER_RESOURCES = {
85
85
  limits: { cpu: "2", memory: "2Gi" },
86
86
  } as const;
87
87
 
88
- /** The persistent-workspace scopes (SandboxScope.persistentWorkspace). */
88
+ /**
89
+ * The persistent-workspace scopes (SandboxScope.persistentWorkspace).
90
+ * Durability here is decided by SCOPE alone: this driver reads neither the
91
+ * environment's caller class nor any per-session policy, so every session
92
+ * keeps its PVC whoever asked. A driver that gives some caller classes a
93
+ * workspace that dies with the conversation is a composition's choice
94
+ * built on SandboxEnvironment.callerClass, not a built-in behaviour.
95
+ */
89
96
  const PERSISTENT_SCOPES: ReadonlySet<SandboxScope> = new Set([
90
97
  "session",
91
98
  "workflow",
@@ -39,13 +39,16 @@
39
39
  * runner process polls the queues.
40
40
  */
41
41
  import type { Logger } from "../boot/logger.js";
42
+ import type { CallerClass } from "../extensions/identity.js";
42
43
 
43
44
  /**
44
- * The runtime handed to a provisioner for one sandbox: which queue the
45
- * sandboxed runner must poll and the credential it authenticates with.
46
- * Deliberately minimal (the Java SandboxEnvironment carries the same two
47
- * load-bearing fields plus cloud-only accounting) — endpoints, images,
48
- * and resource shapes are DRIVER configuration, not per-sandbox state.
45
+ * What is TRUE about one provisioning request, handed to a driver: which
46
+ * queue the sandboxed runner must poll, the credential it authenticates
47
+ * with, and the class of the caller whose request is being served. Facts
48
+ * only — what a driver DOES with them (the workspace's durability, the
49
+ * pod's shape, whether a warm pool is consulted) is driver policy and
50
+ * never travels here. Endpoints, images and resource shapes are driver
51
+ * configuration, not per-sandbox state.
49
52
  */
50
53
  export interface SandboxEnvironment {
51
54
  /** The Temporal task queue the sandboxed runner polls (session:{id} / wfexec:{id}). */
@@ -56,6 +59,18 @@ export interface SandboxEnvironment {
56
59
  * and EC decrypt falls back to redaction, the oss#535 posture.
57
60
  */
58
61
  readonly stigmerToken: string;
62
+ /**
63
+ * The class of the caller whose request provisioned this sandbox — the
64
+ * identity interceptor's word for it (`user`, `internal`, a composed
65
+ * verifier's own lane such as a guest or a scheduled fire). A driver
66
+ * that gives some session classes a workspace that does not outlive the
67
+ * conversation reads it here; the built-in drivers (`local-process`,
68
+ * `docker`, `kubernetes`) ignore it and keep every session persistent.
69
+ * Required rather than optional so a construction site that forgets it
70
+ * fails to compile instead of silently reading as a persistent user
71
+ * session to every driver.
72
+ */
73
+ readonly callerClass: CallerClass;
59
74
  }
60
75
 
61
76
  /** One sandbox's observed live state (the Q5 probe result). */