@stigmer/server 3.16.1-dev.20260917224426 → 3.18.0-dev.20260918103812

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 (56) hide show
  1. package/dist/boot/compose.d.ts +0 -12
  2. package/dist/boot/compose.d.ts.map +1 -1
  3. package/dist/boot/compose.js +6 -2
  4. package/dist/boot/compose.js.map +1 -1
  5. package/dist/domain/agentexecution/lifecycle.js +1 -1
  6. package/dist/domain/agentexecution/lifecycle.js.map +1 -1
  7. package/dist/domain/platform/controller.d.ts +24 -11
  8. package/dist/domain/platform/controller.d.ts.map +1 -1
  9. package/dist/domain/platform/controller.js +47 -3
  10. package/dist/domain/platform/controller.js.map +1 -1
  11. package/dist/domain/workflow/registry/data/task-kind-registry.json +4 -1
  12. package/dist/domain/workflowexecution/lifecycle.js +1 -1
  13. package/dist/domain/workflowexecution/lifecycle.js.map +1 -1
  14. package/dist/extensions/drivers.d.ts +11 -0
  15. package/dist/extensions/drivers.d.ts.map +1 -1
  16. package/dist/extensions/license-status.d.ts +64 -0
  17. package/dist/extensions/license-status.d.ts.map +1 -0
  18. package/dist/extensions/license-status.js +9 -0
  19. package/dist/extensions/license-status.js.map +1 -0
  20. package/dist/extensions/registry.d.ts +6 -0
  21. package/dist/extensions/registry.d.ts.map +1 -1
  22. package/dist/extensions/registry.js +10 -0
  23. package/dist/extensions/registry.js.map +1 -1
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/sandbox/kubernetes.d.ts.map +1 -1
  28. package/dist/sandbox/kubernetes.js +8 -1
  29. package/dist/sandbox/kubernetes.js.map +1 -1
  30. package/dist/sandbox/provisioner.d.ts +20 -5
  31. package/dist/sandbox/provisioner.d.ts.map +1 -1
  32. package/dist/sandbox/provisioner.js.map +1 -1
  33. package/dist/sandbox/steps.d.ts +22 -8
  34. package/dist/sandbox/steps.d.ts.map +1 -1
  35. package/dist/sandbox/steps.js +17 -12
  36. package/dist/sandbox/steps.js.map +1 -1
  37. package/package.json +5 -5
  38. package/src/boot/compose.ts +6 -14
  39. package/src/domain/agentexecution/lifecycle.ts +1 -1
  40. package/src/domain/platform/__tests__/platform.test.ts +147 -46
  41. package/src/domain/platform/controller.ts +77 -13
  42. package/src/domain/workflow/registry/data/task-kind-registry.json +4 -1
  43. package/src/domain/workflowexecution/lifecycle.ts +1 -1
  44. package/src/extensions/__tests__/license-status-points.test.ts +50 -0
  45. package/src/extensions/drivers.ts +11 -0
  46. package/src/extensions/license-status.ts +71 -0
  47. package/src/extensions/registry.ts +19 -0
  48. package/src/index.ts +8 -0
  49. package/src/pipeline/__tests__/billing-contract-rules.test.ts +260 -0
  50. package/src/sandbox/__tests__/docker.integration.test.ts +2 -0
  51. package/src/sandbox/__tests__/kubernetes.test.ts +10 -2
  52. package/src/sandbox/__tests__/local-process.integration.test.ts +23 -6
  53. package/src/sandbox/__tests__/steps.test.ts +72 -19
  54. package/src/sandbox/kubernetes.ts +8 -1
  55. package/src/sandbox/provisioner.ts +20 -5
  56. package/src/sandbox/steps.ts +29 -12
@@ -53,6 +53,7 @@ import type { SecretCodec } from "../encryption/codec.js";
53
53
  import { V1_VERSION } from "../encryption/v1-codec.js";
54
54
  import type { AuthorizationQueryEngine } from "./authorization-queries.js";
55
55
  import type { IdentityFederation } from "./identity-federation.js";
56
+ import type { LicenseStatusProvider } from "./license-status.js";
56
57
  import type { ListReadScope } from "./list-read-scope.js";
57
58
  import type { PolicyGrantScope } from "./policy-grant-scope.js";
58
59
  import type { ModelCatalogProvider } from "../domain/workflow/registry/model-catalog-provider.js";
@@ -272,6 +273,11 @@ export interface ResolvedExtensionDrivers {
272
273
  * tuple-half RPC arms refuse UNIMPLEMENTED with the edition reason.
273
274
  */
274
275
  readonly authorizationQueries: AuthorizationQueryEngine | undefined;
276
+ /**
277
+ * The license-status provider — undefined = compose.ts installs the
278
+ * built-in `absent` answer, so every edition serves getLicenseStatus.
279
+ */
280
+ readonly licenseStatus: LicenseStatusProvider | undefined;
275
281
  }
276
282
 
277
283
  /**
@@ -325,6 +331,8 @@ export function resolveExtensions(
325
331
  let policyGrantScopeDeclaredBy: string | undefined;
326
332
  let authorizationQueries: AuthorizationQueryEngine | undefined;
327
333
  let authorizationQueriesDeclaredBy: string | undefined;
334
+ let licenseStatus: LicenseStatusProvider | undefined;
335
+ let licenseStatusDeclaredBy: string | undefined;
328
336
  const artifactStorageDrivers = new Map<
329
337
  string,
330
338
  ArtifactStorageDriverFactory
@@ -516,6 +524,16 @@ export function resolveExtensions(
516
524
  authorizationQueriesDeclaredBy = unit.name;
517
525
  }
518
526
 
527
+ if (unit.drivers?.licenseStatus !== undefined) {
528
+ if (licenseStatusDeclaredBy !== undefined) {
529
+ throw new Error(
530
+ `extension '${unit.name}' registers a LicenseStatusProvider, but '${licenseStatusDeclaredBy}' already did — exactly one may be composed`,
531
+ );
532
+ }
533
+ licenseStatus = unit.drivers.licenseStatus;
534
+ licenseStatusDeclaredBy = unit.name;
535
+ }
536
+
519
537
  if (unit.drivers?.artifactStorageDrivers !== undefined) {
520
538
  for (const [name, factory] of unit.drivers.artifactStorageDrivers) {
521
539
  if ((BUILT_IN_STORAGE_TYPES as ReadonlyArray<string>).includes(name)) {
@@ -641,6 +659,7 @@ export function resolveExtensions(
641
659
  iamPolicyStore,
642
660
  policyGrantScope,
643
661
  authorizationQueries,
662
+ licenseStatus,
644
663
  },
645
664
  services,
646
665
  workers,
package/src/index.ts CHANGED
@@ -111,6 +111,14 @@ export type {
111
111
  } from "./extensions/resource-authorization.js";
112
112
  export type { OrganizationDirectory } from "./extensions/organization-directory.js";
113
113
  export { ALL_ORGANIZATIONS } from "./extensions/organization-directory.js";
114
+ // The license-status seam (drivers.licenseStatus): the provider an
115
+ // Enterprise composition registers so getLicenseStatus answers from its
116
+ // configured ticket. The built-in `absent` default is the composition
117
+ // root's and is not exported.
118
+ export type {
119
+ LicenseStatusProvider,
120
+ LicenseStatusReport,
121
+ } from "./extensions/license-status.js";
114
122
  // The 20260911.11 identity-account seams (Q-IA-9): the store PORT a
115
123
  // composition drives the domain through (drivers.identityAccountStore;
116
124
  // a driver throws DuplicateAccountError for a held id), the federation
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Pins the message-level rules of the plan, subscription and license
3
+ * contract through the shared validator every edition runs
4
+ * (steps/validation.ts, the same instance the transport interceptor uses).
5
+ *
6
+ * Why here and not in a conformance suite: every billing kind is cloud_only,
7
+ * so the open-source server never serves a Plan or a License and the
8
+ * cross-edition suites, where field rules are normally proven against a
9
+ * running server, cannot reach them. The vocabulary those kinds share with
10
+ * the Enterprise edition (Entitlements, LicenseClaims) is verified offline
11
+ * by a server that holds no store for it at all. The one place in this
12
+ * repository where a rule on these messages can be proven red-then-green is
13
+ * the validator itself, so this file drives it directly, one row per rule,
14
+ * a passing shape beside every refused one.
15
+ *
16
+ * What the rows pin: the instrument decides which PlanTerms a plan may carry;
17
+ * a license's timestamps are ordered (issue, expiry, grace); a limit is
18
+ * present or absent, never zero; a feature is named once. A row that
19
+ * stops matching means the contract moved, and the proto is where it moved.
20
+ */
21
+ import { create } from "@bufbuild/protobuf";
22
+ import type { DescMessage, MessageInitShape } from "@bufbuild/protobuf";
23
+ import { timestampFromDate } from "@bufbuild/protobuf/wkt";
24
+ import {
25
+ PlanInstrument,
26
+ PlanSpecSchema,
27
+ } from "@stigmer/protos/ai/stigmer/billing/plan/v1/spec_pb";
28
+ import { LicenseSpecSchema } from "@stigmer/protos/ai/stigmer/billing/license/v1/spec_pb";
29
+ import {
30
+ EntitlementLimitsSchema,
31
+ EntitlementsSchema,
32
+ Feature,
33
+ } from "@stigmer/protos/ai/stigmer/platform/v1/entitlement_pb";
34
+ import {
35
+ LicenseClaimsSchema,
36
+ LicenseTerm,
37
+ } from "@stigmer/protos/ai/stigmer/platform/v1/license_pb";
38
+ import { describe, expect, it } from "vitest";
39
+
40
+ import { validator } from "../steps/validation.js";
41
+
42
+ const t = (iso: string) => timestampFromDate(new Date(iso));
43
+
44
+ const customer = {
45
+ id: "cus_acme",
46
+ displayName: "Acme Corp",
47
+ contactEmail: "billing@acme.example",
48
+ };
49
+ const entitlements = { features: [Feature.channels] };
50
+ const orderedTerm = {
51
+ issuedAt: t("2026-09-01T00:00:00Z"),
52
+ expiresAt: t("2027-09-01T00:00:00Z"),
53
+ graceUntil: t("2027-10-01T00:00:00Z"),
54
+ };
55
+
56
+ /** One row: a message, its init, and the rule id it must trip, or none. */
57
+ interface Row<Desc extends DescMessage> {
58
+ readonly name: string;
59
+ readonly schema: Desc;
60
+ readonly init: MessageInitShape<Desc>;
61
+ readonly refusedBy?: string;
62
+ }
63
+
64
+ function row<Desc extends DescMessage>(r: Row<Desc>): Row<Desc> {
65
+ return r;
66
+ }
67
+
68
+ const rows: ReadonlyArray<Row<DescMessage>> = [
69
+ row({
70
+ name: "a subscription plan with monthly terms",
71
+ schema: PlanSpecSchema,
72
+ init: {
73
+ instrument: PlanInstrument.subscription,
74
+ entitlements,
75
+ terms: { monthlyMinimumMicros: 1n, usageShareBasisPoints: 100 },
76
+ },
77
+ }),
78
+ row({
79
+ name: "a license plan with an annual price",
80
+ schema: PlanSpecSchema,
81
+ init: {
82
+ instrument: PlanInstrument.license,
83
+ entitlements,
84
+ terms: { annualPriceMicros: 1n },
85
+ },
86
+ }),
87
+ row({
88
+ name: "a plan with no terms at all",
89
+ schema: PlanSpecSchema,
90
+ init: { instrument: PlanInstrument.subscription, entitlements },
91
+ }),
92
+ row({
93
+ name: "a license plan carrying a monthly minimum",
94
+ schema: PlanSpecSchema,
95
+ init: {
96
+ instrument: PlanInstrument.license,
97
+ entitlements,
98
+ terms: { monthlyMinimumMicros: 1n },
99
+ },
100
+ refusedBy: "plan_spec.license_has_no_monthly_terms",
101
+ }),
102
+ row({
103
+ name: "a license plan carrying a usage share",
104
+ schema: PlanSpecSchema,
105
+ init: {
106
+ instrument: PlanInstrument.license,
107
+ entitlements,
108
+ terms: { usageShareBasisPoints: 100 },
109
+ },
110
+ refusedBy: "plan_spec.license_has_no_monthly_terms",
111
+ }),
112
+ row({
113
+ name: "a subscription plan carrying an annual price",
114
+ schema: PlanSpecSchema,
115
+ init: {
116
+ instrument: PlanInstrument.subscription,
117
+ entitlements,
118
+ terms: { annualPriceMicros: 1n },
119
+ },
120
+ refusedBy: "plan_spec.subscription_has_no_annual_price",
121
+ }),
122
+ row({
123
+ name: "a usage share above one hundred percent",
124
+ schema: PlanSpecSchema,
125
+ init: {
126
+ instrument: PlanInstrument.subscription,
127
+ entitlements,
128
+ terms: { usageShareBasisPoints: 10001 },
129
+ },
130
+ refusedBy: "int32.gte_lte",
131
+ }),
132
+ row({
133
+ name: "ordered license claims",
134
+ schema: LicenseClaimsSchema,
135
+ init: {
136
+ licenseId: "lic_1",
137
+ customer,
138
+ term: LicenseTerm.paid,
139
+ entitlements,
140
+ ...orderedTerm,
141
+ },
142
+ }),
143
+ row({
144
+ name: "claims expiring before they were issued",
145
+ schema: LicenseClaimsSchema,
146
+ init: {
147
+ licenseId: "lic_1",
148
+ customer,
149
+ term: LicenseTerm.paid,
150
+ entitlements,
151
+ ...orderedTerm,
152
+ expiresAt: t("2026-08-01T00:00:00Z"),
153
+ graceUntil: t("2026-08-02T00:00:00Z"),
154
+ },
155
+ refusedBy: "license_claims.expires_after_issue",
156
+ }),
157
+ row({
158
+ name: "claims whose grace ends before expiry",
159
+ schema: LicenseClaimsSchema,
160
+ init: {
161
+ licenseId: "lic_1",
162
+ customer,
163
+ term: LicenseTerm.paid,
164
+ entitlements,
165
+ ...orderedTerm,
166
+ graceUntil: t("2027-08-01T00:00:00Z"),
167
+ },
168
+ refusedBy: "license_claims.grace_not_before_expiry",
169
+ }),
170
+ row({
171
+ name: "a customer with no contact address",
172
+ schema: LicenseClaimsSchema,
173
+ init: {
174
+ licenseId: "lic_1",
175
+ customer: { id: "cus_acme", displayName: "Acme Corp" },
176
+ term: LicenseTerm.paid,
177
+ entitlements,
178
+ ...orderedTerm,
179
+ },
180
+ refusedBy: "required",
181
+ }),
182
+ row({
183
+ name: "a customer whose contact address is not an email",
184
+ schema: LicenseClaimsSchema,
185
+ init: {
186
+ licenseId: "lic_1",
187
+ customer: { ...customer, contactEmail: "acme" },
188
+ term: LicenseTerm.paid,
189
+ entitlements,
190
+ ...orderedTerm,
191
+ },
192
+ refusedBy: "string.email",
193
+ }),
194
+ row({
195
+ name: "a license spec whose grace ends before expiry",
196
+ schema: LicenseSpecSchema,
197
+ init: {
198
+ customer,
199
+ entitlements,
200
+ term: LicenseTerm.trial,
201
+ expiresAt: orderedTerm.expiresAt,
202
+ graceUntil: t("2027-08-01T00:00:00Z"),
203
+ },
204
+ refusedBy: "license_spec.grace_not_before_expiry",
205
+ }),
206
+ row({
207
+ name: "a license spec with grace on the expiry instant",
208
+ schema: LicenseSpecSchema,
209
+ init: {
210
+ customer,
211
+ entitlements,
212
+ term: LicenseTerm.trial,
213
+ expiresAt: orderedTerm.expiresAt,
214
+ graceUntil: orderedTerm.expiresAt,
215
+ },
216
+ }),
217
+ row({
218
+ name: "a limit of zero, which is never what an operator meant",
219
+ schema: EntitlementLimitsSchema,
220
+ init: { maxOrganizations: 0 },
221
+ refusedBy: "int32.gt",
222
+ }),
223
+ row({
224
+ name: "an absent limit, which is no limit",
225
+ schema: EntitlementLimitsSchema,
226
+ init: {},
227
+ }),
228
+ row({
229
+ name: "a feature named twice",
230
+ schema: EntitlementsSchema,
231
+ init: { features: [Feature.channels, Feature.channels] },
232
+ refusedBy: "repeated.unique",
233
+ }),
234
+ row({
235
+ name: "the unspecified feature",
236
+ schema: EntitlementsSchema,
237
+ init: { features: [Feature.feature_unspecified] },
238
+ refusedBy: "enum.not_in",
239
+ }),
240
+ ];
241
+
242
+ describe("the billing contract's rules, through the shared validator", () => {
243
+ for (const r of rows) {
244
+ const verdict =
245
+ r.refusedBy === undefined ? "passes" : `is refused by ${r.refusedBy}`;
246
+ it(`${r.name} ${verdict}`, () => {
247
+ const result = validator().validate(r.schema, create(r.schema, r.init));
248
+ if (r.refusedBy === undefined) {
249
+ expect(result.kind).toBe("valid");
250
+ return;
251
+ }
252
+ if (result.kind !== "invalid") {
253
+ throw new Error(`expected a refusal, got ${result.kind}`);
254
+ }
255
+ expect(result.error.violations.map((v) => v.ruleId)).toContain(
256
+ r.refusedBy,
257
+ );
258
+ });
259
+ }
260
+ });
@@ -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",
@@ -6,7 +6,11 @@
6
6
  * can read it, so no real runner install is needed.
7
7
  *
8
8
  * Unix-only like the runner itself; deterministic (poll with timeout,
9
- * never sleep).
9
+ * never sleep). Every poll gets an explicit budget: vitest's default
10
+ * second is enough on an idle laptop and not under the full suite's
11
+ * load, where a /bin/sh child can take longer than that to start and
12
+ * write its first line. Polling returns the moment the condition holds,
13
+ * so the larger budget costs nothing on a quiet host.
10
14
  */
11
15
  import {
12
16
  chmodSync,
@@ -25,6 +29,9 @@ import { createLogger } from "../../boot/logger.js";
25
29
  import { newLocalProcessSandboxProvisioner } from "../local-process.js";
26
30
  import type { SandboxDriverConfig } from "../provisioner.js";
27
31
 
32
+ /** How long a spawned child is given to start, write, or die under load. */
33
+ const CHILD_SETTLE_TIMEOUT_MS = 10_000;
34
+
28
35
  const silentLogger = createLogger({
29
36
  level: "error",
30
37
  pretty: false,
@@ -74,14 +81,18 @@ describe.skipIf(process.platform === "win32")(
74
81
  await driver.ensureSessionSandbox("ses_smoke", {
75
82
  taskQueue: "session:ses_smoke",
76
83
  stigmerToken: "tok-smoke",
84
+ callerClass: "user",
85
+ });
86
+ await vi.waitFor(() => expect(spawnCount()).toBe(1), {
87
+ timeout: CHILD_SETTLE_TIMEOUT_MS,
77
88
  });
78
- await vi.waitFor(() => expect(spawnCount()).toBe(1));
79
89
  expect(await driver.probe("session", "ses_smoke")).toBe("running");
80
90
 
81
91
  // Fast path: a second ensure must not spawn a second process.
82
92
  await driver.ensureSessionSandbox("ses_smoke", {
83
93
  taskQueue: "session:ses_smoke",
84
94
  stigmerToken: "tok-smoke",
95
+ callerClass: "user",
85
96
  });
86
97
  expect(spawnCount()).toBe(1);
87
98
 
@@ -104,16 +115,22 @@ describe.skipIf(process.platform === "win32")(
104
115
 
105
116
  it("deprovision kills the child; probe reports absent; re-ensure respawns", async () => {
106
117
  await driver.deprovisionSessionSandbox("ses_smoke");
107
- await vi.waitFor(async () => {
108
- expect(await driver.probe("session", "ses_smoke")).toBe("absent");
109
- });
118
+ await vi.waitFor(
119
+ async () => {
120
+ expect(await driver.probe("session", "ses_smoke")).toBe("absent");
121
+ },
122
+ { timeout: CHILD_SETTLE_TIMEOUT_MS },
123
+ );
110
124
 
111
125
  // The repair arm without stored state: a fresh ensure respawns.
112
126
  await driver.ensureSessionSandbox("ses_smoke", {
113
127
  taskQueue: "session:ses_smoke",
114
128
  stigmerToken: "",
129
+ callerClass: "user",
130
+ });
131
+ await vi.waitFor(() => expect(spawnCount()).toBe(2), {
132
+ timeout: CHILD_SETTLE_TIMEOUT_MS,
115
133
  });
116
- await vi.waitFor(() => expect(spawnCount()).toBe(2));
117
134
  expect(await driver.probe("session", "ses_smoke")).toBe("running");
118
135
  });
119
136
 
@@ -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",