@stigmer/server 3.30.0 → 3.31.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 (58) hide show
  1. package/README.md +1 -1
  2. package/dist/boot/compose.d.ts.map +1 -1
  3. package/dist/boot/compose.js +5 -0
  4. package/dist/boot/compose.js.map +1 -1
  5. package/dist/boot/config.d.ts +10 -0
  6. package/dist/boot/config.d.ts.map +1 -1
  7. package/dist/boot/config.js +2 -0
  8. package/dist/boot/config.js.map +1 -1
  9. package/dist/domain/oauthapp/controller.d.ts +16 -8
  10. package/dist/domain/oauthapp/controller.d.ts.map +1 -1
  11. package/dist/domain/oauthapp/controller.js +28 -11
  12. package/dist/domain/oauthapp/controller.js.map +1 -1
  13. package/dist/domain/oauthapp/refresolution.d.ts.map +1 -1
  14. package/dist/domain/oauthapp/refresolution.js +3 -2
  15. package/dist/domain/oauthapp/refresolution.js.map +1 -1
  16. package/dist/domain/oauthapp/steps.d.ts +5 -5
  17. package/dist/domain/oauthapp/steps.js +6 -6
  18. package/dist/domain/oauthapp/steps.js.map +1 -1
  19. package/dist/sandbox/docker.d.ts.map +1 -1
  20. package/dist/sandbox/docker.js +13 -5
  21. package/dist/sandbox/docker.js.map +1 -1
  22. package/dist/sandbox/kubernetes.d.ts +1 -1
  23. package/dist/sandbox/kubernetes.d.ts.map +1 -1
  24. package/dist/sandbox/kubernetes.js +18 -4
  25. package/dist/sandbox/kubernetes.js.map +1 -1
  26. package/dist/sandbox/local-process.d.ts.map +1 -1
  27. package/dist/sandbox/local-process.js +8 -1
  28. package/dist/sandbox/local-process.js.map +1 -1
  29. package/dist/sandbox/provisioner.d.ts +12 -0
  30. package/dist/sandbox/provisioner.d.ts.map +1 -1
  31. package/dist/sandbox/provisioner.js.map +1 -1
  32. package/dist/temporal/manager.d.ts +10 -0
  33. package/dist/temporal/manager.d.ts.map +1 -1
  34. package/dist/temporal/manager.js +10 -0
  35. package/dist/temporal/manager.js.map +1 -1
  36. package/package.json +6 -6
  37. package/src/boot/__tests__/config.test.ts +23 -1
  38. package/src/boot/compose.ts +5 -0
  39. package/src/boot/config.ts +13 -0
  40. package/src/domain/oauthapp/__tests__/oauthapp.test.ts +17 -10
  41. package/src/domain/oauthapp/controller.ts +51 -18
  42. package/src/domain/oauthapp/refresolution.ts +3 -2
  43. package/src/domain/oauthapp/steps.ts +6 -6
  44. package/src/sandbox/__tests__/docker.integration.test.ts +2 -0
  45. package/src/sandbox/__tests__/kubernetes.test.ts +27 -0
  46. package/src/sandbox/__tests__/local-process.integration.test.ts +10 -0
  47. package/src/sandbox/__tests__/provisioner.test.ts +2 -0
  48. package/src/sandbox/docker.ts +11 -5
  49. package/src/sandbox/kubernetes.ts +18 -3
  50. package/src/sandbox/local-process.ts +11 -1
  51. package/src/sandbox/provisioner.ts +12 -0
  52. package/src/temporal/__tests__/manager.test.ts +59 -1
  53. package/src/temporal/manager.ts +13 -0
  54. package/dist/domain/oauthapp/refresolution/refresolution.d.ts +0 -12
  55. package/dist/domain/oauthapp/refresolution/refresolution.d.ts.map +0 -1
  56. package/dist/domain/oauthapp/refresolution/refresolution.js +0 -79
  57. package/dist/domain/oauthapp/refresolution/refresolution.js.map +0 -1
  58. package/src/domain/oauthapp/refresolution/refresolution.ts +0 -94
@@ -11,15 +11,17 @@
11
11
  * ciphertext (no double encryption);
12
12
  * - the keyless WARN-degrade path stores plaintext yet still redacts on
13
13
  * read, and the enc:v<N>: shape rejection stays UNCONDITIONAL (oss#395);
14
- * - the delete RPC returns the STORED secret unredacted — Go behavior
15
- * ported byte-faithfully (delete is absent from RedactOAuthApp's
16
- * response list) and disclosed in the PR;
14
+ * - the delete RPC answers the removed app REDACTED, never the stored
15
+ * secret: not the ciphertext on a keyed server, not the plaintext on
16
+ * a keyless one (stigmer/stigmer#1257; the Go port returned it);
17
17
  * - the referential delete guard's resolution semantics (stigmer#584),
18
18
  * proven by seeding McpServer rows directly through the store — the
19
19
  * McpServer RPC surface belongs to #9 and is not required here.
20
20
  *
21
21
  * Keys are injected via env (vi.stubEnv) so the ladder short-circuits
22
- * before its file steps — the real ~/.stigmer is never touched.
22
+ * before its file steps — the real ~/.stigmer is never touched. Who may
23
+ * read or list an app is the enforcing lane's conformance suite's
24
+ * (role-enforcement.conformance.test.ts).
23
25
  */
24
26
  import { mkdtempSync, rmSync } from "node:fs";
25
27
  import { tmpdir } from "node:os";
@@ -50,7 +52,7 @@ import {
50
52
  REDACTED_MARKER,
51
53
  deleteBlockedByMcpServerMessage,
52
54
  } from "../constants.js";
53
- import { resolveOAuthAppRef } from "../refresolution/refresolution.js";
55
+ import { resolveOAuthAppRef } from "../refresolution.js";
54
56
 
55
57
  const silentLogger = createLogger({ level: "error", pretty: false, write: () => {} });
56
58
 
@@ -285,14 +287,12 @@ describe("oauthapp domain (encryption enabled)", () => {
285
287
  expect(listed.entries[1]?.metadata?.id).toBe(first.metadata?.id);
286
288
  });
287
289
 
288
- it("delete returns the STORED resource unredacted — the ported Go behavior (disclosed)", async () => {
290
+ it("delete answers the removed app redacted — never the stored ciphertext", async () => {
289
291
  const created = await ts.command.create(appInput());
290
292
  const deleted = await ts.command.delete({ resourceId: created.metadata!.id });
291
293
 
292
- // Go's delete path never calls RedactOAuthApp: the response carries the
293
- // stored ciphertext (plaintext on a keyless server). Pinned so the
294
- // faithful port cannot silently "fix" the divergence candidate.
295
- expect(deleted.spec?.clientSecret.startsWith(ENCRYPTED_PREFIX)).toBe(true);
294
+ expect(deleted.metadata?.id).toBe(created.metadata?.id);
295
+ expect(deleted.spec?.clientSecret).toBe(REDACTED_MARKER);
296
296
 
297
297
  const err = await grpcError(() => ts.query.get({ value: created.metadata!.id }));
298
298
  expect(err.code).toBe(Code.NotFound);
@@ -405,6 +405,13 @@ describe("oauthapp domain (encryption disabled)", () => {
405
405
  expect(stored.spec?.clientSecret).toBe("test-client-secret");
406
406
  });
407
407
 
408
+ it("delete answers the removed app redacted — never the stored plaintext", async () => {
409
+ const created = await ts.command.create(appInput());
410
+ const deleted = await ts.command.delete({ resourceId: created.metadata!.id });
411
+
412
+ expect(deleted.spec?.clientSecret).toBe(REDACTED_MARKER);
413
+ });
414
+
408
415
  it("still rejects ciphertext-shaped secrets — the oss#395 boundary is not gated on key state", async () => {
409
416
  const err = await grpcError(() =>
410
417
  ts.command.create(appInput({ clientSecret: "enc:v1:c211Z2dsZWQ=" })),
@@ -12,15 +12,20 @@
12
12
  * The secret contract: client_secret is AES-256-GCM encrypted at rest via
13
13
  * the SAME SecretService instance the Environment controller uses (Go
14
14
  * wires one service for both), and redacted to ***REDACTED*** on every
15
- * read surface; the marker round-trips on update/apply as "keep the stored
16
- * secret"; client-supplied enc:v<N>:-shaped values are refused on every
17
- * write door (oss#395). Deletion is blocked while an McpServer's
18
- * oauth_app_ref resolves to the app (stigmer/stigmer#584).
15
+ * response, the delete's included; the marker round-trips on update/apply
16
+ * as "keep the stored secret"; client-supplied enc:v<N>:-shaped values are
17
+ * refused on every write door (oss#395). Deletion is blocked while an
18
+ * McpServer's oauth_app_ref resolves to the app (stigmer/stigmer#584).
19
19
  *
20
- * Versus Stigmer Cloud, OSS excludes the Authorize, CreateIamPolicies, and
21
- * Publish steps (no multi-tenant auth, IAM/FGA, or event publishing), and
22
- * OAuthApp is deliberately not search-indexed (a configuration resource;
23
- * `not_search_indexed` in the kind registry).
20
+ * Every chain opens with Authorize; create and delete run the shared
21
+ * tuple-lifecycle steps against the composed lifecycle; getByReference
22
+ * loads, then authorizes the loaded app exactly as `get` would
23
+ * (AuthorizeResolvedTarget); listByOrg narrows through the list read
24
+ * scope, so a caller who may not `get` an app does not see it listed
25
+ * either: the model shows an app to its creator and the organization's
26
+ * admins, never to members or viewers (fga/model/iam/oauth_app.fga,
27
+ * stigmer/stigmer#1257). OAuthApp is deliberately not search-indexed (a
28
+ * configuration resource; `not_search_indexed` in the kind registry).
24
29
  */
25
30
  import type { ConnectRouter, HandlerContext } from "@connectrpc/connect";
26
31
  import { create, fromBinary } from "@bufbuild/protobuf";
@@ -43,6 +48,8 @@ import { OAuthAppQueryController } from "@stigmer/protos/ai/stigmer/iam/oauthapp
43
48
 
44
49
  import type { Logger } from "../../boot/logger.js";
45
50
  import type { Authorizer } from "../../extensions/authorizer.js";
51
+ import type { ListReadScope } from "../../extensions/list-read-scope.js";
52
+ import { restrictListByReadScope } from "../../extensions/list-read-scope.js";
46
53
  import type { ResourceAuthorizationLifecycle } from "../../extensions/resource-authorization.js";
47
54
  import type { SecretService } from "../../encryption/encryption.js";
48
55
  import { internalError } from "../../pipeline/errors.js";
@@ -105,6 +112,8 @@ export interface OAuthAppControllerDeps {
105
112
  readonly authorizer: Authorizer;
106
113
  /** The composed tuple-lifecycle driver — undefined = the shared steps no-op (C2). */
107
114
  readonly authorizationLifecycle: ResourceAuthorizationLifecycle | undefined;
115
+ /** The composed list read scope — listByOrg narrows through it; undefined = the OSS full scan (stigmer/stigmer#1257). */
116
+ readonly listReadScope: ListReadScope | undefined;
108
117
  }
109
118
 
110
119
  /** Registers both OAuthApp services on the router (routes stage). */
@@ -261,8 +270,10 @@ async function apply(
261
270
  * between load and delete. The RESOURCE_ID_KEY is set manually because
262
271
  * ApiResourceDeleteInput carries resourceId, not the value field
263
272
  * ExtractResourceId expects (the environment-domain pattern). Returns the
264
- * deleted resource for the audit trail — unredacted, exactly as Go does
265
- * (see redactOAuthApp's header for the disclosure).
273
+ * deleted app for the audit trail, redacted like every other response (the
274
+ * ChannelApp and Environment delete shape): the chain has already
275
+ * destroyed the sealed secret, so the stored value could only leak
276
+ * (stigmer/stigmer#1257).
266
277
  */
267
278
  async function deleteOAuthApp(
268
279
  deps: OAuthAppControllerDeps,
@@ -311,7 +322,9 @@ async function deleteOAuthApp(
311
322
  "deleted OAuthApp not found in context",
312
323
  );
313
324
  }
314
- return deleted as OAuthApp;
325
+ const app = deleted as OAuthApp;
326
+ redactOAuthApp(app);
327
+ return app;
315
328
  }
316
329
 
317
330
  /** Get — LoadTarget by id, then redact (Go buildGetPipeline). */
@@ -382,9 +395,11 @@ async function getByReference(
382
395
  const LIST_RESULT_KEY = "listResult";
383
396
 
384
397
  /**
385
- * ListByOrg — all OAuthApps whose metadata.org matches, each redacted,
386
- * sorted created_at descending (Go listByOrgStep). No pagination:
387
- * typically 1–5 per org. No authorization filtering in OSS.
398
+ * ListByOrg — the organization's OAuthApps the caller may view, each
399
+ * redacted, sorted created_at descending (Go listByOrgStep). No
400
+ * pagination: typically 1–5 per org. The annotation admits anyone who can
401
+ * view the organization; the list read scope then keeps only the apps a
402
+ * `get` would return (stigmer/stigmer#1257).
388
403
  */
389
404
  async function listByOrg(
390
405
  deps: OAuthAppControllerDeps,
@@ -408,7 +423,7 @@ async function listByOrg(
408
423
  ),
409
424
  )
410
425
  .addStep(newValidateProtoStep())
411
- .addStep(newListByOrgStep(deps.store, deps.logger))
426
+ .addStep(newListByOrgStep(deps.store, deps.listReadScope, deps.logger))
412
427
  .build()
413
428
  .execute(reqCtx);
414
429
 
@@ -422,9 +437,17 @@ async function listByOrg(
422
437
  return list as OAuthApps;
423
438
  }
424
439
 
425
- /** The domain-local list step: load all, filter by org, redact, sort. */
440
+ /**
441
+ * The domain-local list step: load all, keep the request's org, narrow to
442
+ * the caller's apps, redact, sort. The org predicate stays the step's own
443
+ * because with no scope composed the helper returns its input unchanged
444
+ * and never reads the org (the trusted-local full scan); with one, the
445
+ * scope is the last per-row predicate, so a composed driver is asked about
446
+ * the org's apps, never every tenant's (the environment list's order).
447
+ */
426
448
  function newListByOrgStep(
427
449
  store: Store,
450
+ listReadScope: ListReadScope | undefined,
428
451
  logger: Logger,
429
452
  ): PipelineStep<typeof OAuthAppQueryController.method.listByOrg.input> {
430
453
  return {
@@ -443,7 +466,7 @@ function newListByOrgStep(
443
466
  throw internalError(error, "failed to list OAuthApps");
444
467
  }
445
468
 
446
- const apps: OAuthApp[] = [];
469
+ const requested: OAuthApp[] = [];
447
470
  for (const data of resources) {
448
471
  let app: OAuthApp;
449
472
  try {
@@ -457,8 +480,18 @@ function newListByOrgStep(
457
480
  if ((app.metadata?.org ?? "") !== org) {
458
481
  continue;
459
482
  }
483
+ requested.push(app);
484
+ }
485
+
486
+ const apps = await restrictListByReadScope(
487
+ listReadScope,
488
+ ctx.callerIdentity,
489
+ ApiResourceKind.oauth_app,
490
+ requested,
491
+ "",
492
+ );
493
+ for (const app of apps) {
460
494
  redactOAuthApp(app);
461
- apps.push(app);
462
495
  }
463
496
 
464
497
  apps.sort((a, b) =>
@@ -7,8 +7,9 @@
7
7
  * answers): the read-path oauth_status enricher (mcpserver, #9), the
8
8
  * OAuth initiate and token-refresh paths (#19 — refresh MUST use the app
9
9
  * initiate selected or it runs against the wrong vendor credentials), and
10
- * the OAuthApp delete guard (#13). This file seeds src/domain/oauthapp/;
11
- * the domain's controllers arrive with #13.
10
+ * the OAuthApp delete guard (#13). Every consumer imports it from here,
11
+ * so there is one ladder to change (stigmer/stigmer#1173 removed a
12
+ * second copy the delete guard had read).
12
13
  *
13
14
  * Resolution semantics: OSS has a flat OAuthApp store — no org-override
14
15
  * chain like the cloud's OAuthAppResolutionService, so the ref is the
@@ -33,18 +33,18 @@ import {
33
33
  REDACTED_MARKER,
34
34
  deleteBlockedByMcpServerMessage,
35
35
  } from "./constants.js";
36
- import { resolveOAuthAppRef } from "./refresolution/refresolution.js";
36
+ import { resolveOAuthAppRef } from "./refresolution.js";
37
37
 
38
38
  /**
39
39
  * Replaces client_secret with the redaction marker (Go RedactOAuthApp).
40
40
  *
41
41
  * A function rather than a pipeline step because redaction applies at
42
42
  * different points per operation: create/update after persist on newState;
43
- * get/getByReference after load on the target; listByOrg per entry. Go's
44
- * response list deliberately excludes DELETE — the delete RPC returns the
45
- * STORED resource with its encrypted secret intact, ported byte-faithfully
46
- * and disclosed in the PR (a cross-edition follow-up candidate: with
47
- * encryption disabled the stored value is plaintext).
43
+ * get/getByReference after load on the target; listByOrg per entry; delete
44
+ * on the removed row after its chain. Every response is redacted, the
45
+ * delete's included: the Go port returned the stored secret from delete —
46
+ * ciphertext, or plaintext on a keyless server — which no reader needs
47
+ * once the chain has destroyed the sealed value (stigmer/stigmer#1257).
48
48
  */
49
49
  export function redactOAuthApp(app: OAuthApp): void {
50
50
  if (app.spec !== undefined && app.spec.clientSecret !== "") {
@@ -47,6 +47,8 @@ describe.skipIf(!optedIn || !dockerAnswers())(
47
47
  // them (the runner tolerates an unreachable backend at boot).
48
48
  backendEndpoint: "http://host.docker.internal:7234",
49
49
  temporalAddress: "host.docker.internal:7233",
50
+ temporalNamespace: "default",
51
+ temporalConnectionEnv: {},
50
52
  runnerImage:
51
53
  process.env["STIGMER_SANDBOX_RUNNER_IMAGE"] ??
52
54
  "ghcr.io/stigmer/runner:latest",
@@ -34,6 +34,8 @@ const silentLogger = { info: () => {} };
34
34
  const config: SandboxDriverConfig = {
35
35
  backendEndpoint: "http://stigmer-server.stigmer.svc:7234",
36
36
  temporalAddress: "temporal.stigmer.svc:7233",
37
+ temporalNamespace: "stigmer",
38
+ temporalConnectionEnv: {},
37
39
  runnerImage: "ghcr.io/stigmer/runner:latest",
38
40
  runnerCommand: "stigmer-runner",
39
41
  kubernetesNamespace: "stigmer-sandboxes",
@@ -205,6 +207,7 @@ describe("the manifest shapes (the Java SandboxManifestFactory pins)", () => {
205
207
  expect(envByName.get("TEMPORAL_SERVICE_ADDRESS")?.value).toBe(
206
208
  config.temporalAddress,
207
209
  );
210
+ expect(envByName.get("TEMPORAL_NAMESPACE")?.value).toBe("stigmer");
208
211
  expect(envByName.get("WORKSPACE_ROOT_DIR")?.value).toBe("/workspace");
209
212
  expect(envByName.get("STIGMER_TOKEN")?.valueFrom?.secretKeyRef?.name).toBe(
210
213
  `${sandboxBaseName("session", "ses_1")}-env`,
@@ -233,4 +236,28 @@ describe("the manifest shapes (the Java SandboxManifestFactory pins)", () => {
233
236
  const secret = buildSandboxSecret("session", "ses_1", "");
234
237
  expect(secret.stringData).toEqual({});
235
238
  });
239
+
240
+ it("an authenticated Temporal's settings live in the Secret and are only named by the Deployment", () => {
241
+ const connectionEnv = {
242
+ STIGMER_TEMPORAL_TLS: "true",
243
+ STIGMER_TEMPORAL_API_KEY: "api-key-1",
244
+ STIGMER_TEMPORAL_TLS_SERVER_CA_CERT_DATA: "-----BEGIN CERTIFICATE-----\nca\n-----END CERTIFICATE-----\n",
245
+ };
246
+ const authenticated = { ...config, temporalConnectionEnv: connectionEnv };
247
+ const env = { taskQueue: "session:ses_1", stigmerToken: "tok", callerClass: "user" };
248
+
249
+ const secret = buildSandboxSecret("session", "ses_1", "tok", connectionEnv);
250
+ expect(secret.stringData).toEqual({ ...connectionEnv, STIGMER_TOKEN: "tok" });
251
+
252
+ const container = buildSandboxDeployment("session", "ses_1", env, authenticated).spec?.template.spec
253
+ ?.containers[0];
254
+ for (const name of Object.keys(connectionEnv)) {
255
+ const entry = (container?.env ?? []).find((candidate) => candidate.name === name);
256
+ expect(entry?.value, `${name} must not be a plain manifest value`).toBeUndefined();
257
+ expect(entry?.valueFrom?.secretKeyRef).toEqual({
258
+ name: `${sandboxBaseName("session", "ses_1")}-env`,
259
+ key: name,
260
+ });
261
+ }
262
+ });
236
263
  });
@@ -54,6 +54,8 @@ describe.skipIf(process.platform === "win32")(
54
54
  const config: SandboxDriverConfig = {
55
55
  backendEndpoint: "http://127.0.0.1:7234",
56
56
  temporalAddress: "127.0.0.1:7233",
57
+ temporalNamespace: "stigmer-smoke",
58
+ temporalConnectionEnv: { STIGMER_TEMPORAL_TLS: "true", STIGMER_TEMPORAL_API_KEY: "k-smoke" },
57
59
  runnerImage: "unused-by-this-driver",
58
60
  runnerCommand: script,
59
61
  kubernetesNamespace: "unused-by-this-driver",
@@ -78,6 +80,9 @@ describe.skipIf(process.platform === "win32")(
78
80
  }
79
81
 
80
82
  it("ensure spawns once, injects the runner env contract, and is idempotent", async () => {
83
+ // A server configured by path: the child must get the rendered
84
+ // settings only, never the inherited _PATH item beside them.
85
+ process.env["STIGMER_TEMPORAL_TLS_SERVER_CA_CERT_PATH"] = "/etc/inherited-ca.pem";
81
86
  await driver.ensureSessionSandbox("ses_smoke", {
82
87
  taskQueue: "session:ses_smoke",
83
88
  stigmerToken: "tok-smoke",
@@ -111,6 +116,11 @@ describe.skipIf(process.platform === "win32")(
111
116
  `TEMPORAL_SERVICE_ADDRESS=${config.temporalAddress}`,
112
117
  );
113
118
  expect(dump).toMatch(/WORKSPACE_ROOT_DIR=.+session-ses_smoke/);
119
+ expect(dump).toContain("TEMPORAL_NAMESPACE=stigmer-smoke");
120
+ expect(dump).toContain("STIGMER_TEMPORAL_TLS=true");
121
+ expect(dump).toContain("STIGMER_TEMPORAL_API_KEY=k-smoke");
122
+ expect(dump).not.toContain("STIGMER_TEMPORAL_TLS_SERVER_CA_CERT_PATH");
123
+ delete process.env["STIGMER_TEMPORAL_TLS_SERVER_CA_CERT_PATH"];
114
124
  });
115
125
 
116
126
  it("deprovision kills the child; probe reports absent; re-ensure respawns", async () => {
@@ -34,6 +34,8 @@ const silentLogger = createLogger({
34
34
  const driverConfig: SandboxDriverConfig = {
35
35
  backendEndpoint: "http://host.docker.internal:7234",
36
36
  temporalAddress: "host.docker.internal:7233",
37
+ temporalNamespace: "default",
38
+ temporalConnectionEnv: {},
37
39
  runnerImage: "ghcr.io/stigmer/runner:latest",
38
40
  runnerCommand: "stigmer-runner",
39
41
  kubernetesNamespace: "stigmer-sandboxes",
@@ -142,14 +142,20 @@ export const newDockerSandboxProvisioner: SandboxProvisionerFactory = ({
142
142
  "--env",
143
143
  `TEMPORAL_SERVICE_ADDRESS=${config.temporalAddress}`,
144
144
  "--env",
145
+ `TEMPORAL_NAMESPACE=${config.temporalNamespace}`,
146
+ "--env",
145
147
  `WORKSPACE_ROOT_DIR=${CONTAINER_WORKSPACE_DIR}`,
146
148
  ];
149
+ // Value-less --env inherits from the CLI's environment (module header:
150
+ // the token must never appear in argv). The Temporal connection
151
+ // settings ride the same channel: they carry credentials, and PEM text
152
+ // does not belong on a command line.
153
+ const secretEnv: Record<string, string> = { ...config.temporalConnectionEnv };
154
+ if (env.stigmerToken !== "") secretEnv["STIGMER_TOKEN"] = env.stigmerToken;
147
155
  let runEnv: NodeJS.ProcessEnv | undefined;
148
- if (env.stigmerToken !== "") {
149
- // Value-less --env inherits from the CLI's environment (module
150
- // header: the token must never appear in argv).
151
- runArgs.push("--env", "STIGMER_TOKEN");
152
- runEnv = { ...process.env, STIGMER_TOKEN: env.stigmerToken };
156
+ if (Object.keys(secretEnv).length > 0) {
157
+ for (const name of Object.keys(secretEnv)) runArgs.push("--env", name);
158
+ runEnv = { ...process.env, ...secretEnv };
153
159
  }
154
160
  runArgs.push(config.runnerImage, ...RUNNER_CONTAINER_COMMAND);
155
161
  await docker(runArgs, runEnv);
@@ -6,7 +6,8 @@
6
6
  * @kubernetes/client-node, the official client — this driver is the one
7
7
  * the cloud composition will eventually run in production, C4).
8
8
  *
9
- * Each sandbox = a per-sandbox Secret (STIGMER_TOKEN) + a single-replica
9
+ * Each sandbox = a per-sandbox Secret (STIGMER_TOKEN, plus the Temporal
10
+ * connection settings when the server's Temporal is authenticated) + a single-replica
10
11
  * Deployment (strategy Recreate) + a PVC-backed /workspace for the
11
12
  * persistent scopes (session, workflow — the Java persistent-scope
12
13
  * split; connect rides emptyDir). One shared namespace, never
@@ -255,6 +256,7 @@ export function buildSandboxSecret(
255
256
  scope: SandboxScope,
256
257
  id: string,
257
258
  stigmerToken: string,
259
+ temporalConnectionEnv: Readonly<Record<string, string>> = {},
258
260
  ): V1Secret {
259
261
  return {
260
262
  metadata: {
@@ -264,7 +266,10 @@ export function buildSandboxSecret(
264
266
  type: "Opaque",
265
267
  // A token-less sandbox still gets its (empty) Secret — the object
266
268
  // must exist for uniform cleanup (the Java posture).
267
- stringData: stigmerToken !== "" ? { STIGMER_TOKEN: stigmerToken } : {},
269
+ stringData: {
270
+ ...temporalConnectionEnv,
271
+ ...(stigmerToken !== "" ? { STIGMER_TOKEN: stigmerToken } : {}),
272
+ },
268
273
  };
269
274
  }
270
275
 
@@ -308,6 +313,7 @@ export function buildSandboxDeployment(
308
313
  { name: "STIGMER_TASK_QUEUE", value: env.taskQueue },
309
314
  { name: "STIGMER_BACKEND_ENDPOINT", value: config.backendEndpoint },
310
315
  { name: "TEMPORAL_SERVICE_ADDRESS", value: config.temporalAddress },
316
+ { name: "TEMPORAL_NAMESPACE", value: config.temporalNamespace },
311
317
  { name: "WORKSPACE_ROOT_DIR", value: WORKSPACE_MOUNT_PATH },
312
318
  ];
313
319
  if (env.stigmerToken !== "") {
@@ -318,6 +324,15 @@ export function buildSandboxDeployment(
318
324
  },
319
325
  });
320
326
  }
327
+ // The Temporal connection settings sit in the same per-sandbox Secret as
328
+ // the token (buildSandboxSecret): they carry credentials, so the
329
+ // Deployment names them and never holds their values.
330
+ for (const name of Object.keys(config.temporalConnectionEnv)) {
331
+ containerEnv.push({
332
+ name,
333
+ valueFrom: { secretKeyRef: { name: `${baseName}-env`, key: name } },
334
+ });
335
+ }
321
336
 
322
337
  return {
323
338
  metadata: { name: baseName, labels },
@@ -417,7 +432,7 @@ export function newKubernetesSandboxProvisionerOverGateway(
417
432
  // references it), claim (reused when it survived a repair), then
418
433
  // the Deployment. No readiness wait (module header).
419
434
  await gateway.applySecret(
420
- buildSandboxSecret(scope, id, env.stigmerToken),
435
+ buildSandboxSecret(scope, id, env.stigmerToken, config.temporalConnectionEnv),
421
436
  );
422
437
  if (PERSISTENT_SCOPES.has(scope)) {
423
438
  await gateway.applyPvc(buildWorkspacePvc(scope, id));
@@ -33,6 +33,8 @@ import { mkdirSync } from "node:fs";
33
33
  import { homedir } from "node:os";
34
34
  import path from "node:path";
35
35
 
36
+ import { TEMPORAL_CONNECTION_ENV_NAMES } from "@stigmer/temporal-codecs";
37
+
36
38
  import type { Logger } from "../boot/logger.js";
37
39
  import type {
38
40
  SandboxEnvironment,
@@ -86,10 +88,18 @@ export const newLocalProcessSandboxProvisioner: SandboxProvisionerFactory = ({
86
88
  );
87
89
  mkdirSync(workspaceRoot, { recursive: true });
88
90
 
91
+ // The server's own Temporal connection settings are replaced by their
92
+ // rendered form, never inherited beside it: an inherited _PATH item
93
+ // next to its rendered _DATA twin is a boot refusal in the runner.
94
+ const inherited = Object.fromEntries(
95
+ Object.entries(process.env).filter(([name]) => !TEMPORAL_CONNECTION_ENV_NAMES.includes(name)),
96
+ );
89
97
  const childEnv: NodeJS.ProcessEnv = {
90
- ...process.env,
98
+ ...inherited,
99
+ ...config.temporalConnectionEnv,
91
100
  MODE: "local",
92
101
  STIGMER_TASK_QUEUE: env.taskQueue,
102
+ TEMPORAL_NAMESPACE: config.temporalNamespace,
93
103
  WORKSPACE_ROOT_DIR: workspaceRoot,
94
104
  };
95
105
  if (config.backendEndpoint !== "") {
@@ -136,6 +136,18 @@ export interface SandboxDriverConfig {
136
136
  readonly backendEndpoint: string;
137
137
  /** Temporal address as reachable from inside a sandbox (TEMPORAL_SERVICE_ADDRESS). */
138
138
  readonly temporalAddress: string;
139
+ /** The Temporal namespace the sandbox's runner polls in (TEMPORAL_NAMESPACE). */
140
+ readonly temporalNamespace: string;
141
+ /**
142
+ * How the sandbox's runner authenticates to that Temporal: the server's
143
+ * own `STIGMER_TEMPORAL_*` settings rendered by `temporalConnectionEnv`
144
+ * (`@stigmer/temporal-codecs`), every PEM item in its `_DATA` form so no
145
+ * file has to exist inside the sandbox. Empty for a plaintext Temporal.
146
+ * The values include credentials, so each driver passes them through the
147
+ * channel it uses for the token, never through argv or a plain manifest
148
+ * value.
149
+ */
150
+ readonly temporalConnectionEnv: Readonly<Record<string, string>>;
139
151
  /** The runner image for container-based drivers (docker/kubernetes). */
140
152
  readonly runnerImage: string;
141
153
  /** The runner executable for the local-process driver. */
@@ -11,6 +11,9 @@
11
11
  * lives forever — latent until #21/#22 add factories);
12
12
  * - the Go availability parity: getClient() is undefined only until
13
13
  * the first successful connect;
14
+ * - both connections, the client's and the workers' native one, carry
15
+ * the configured connection security (TLS and API key) beside the
16
+ * address, and a plaintext manager passes neither;
14
17
  * - deps.createWorker builds through THIS package's Worker.create with
15
18
  * the manager's connection, namespace, and codec chain pre-wired
16
19
  * (the finding-16 seam — factories never see the NativeConnection);
@@ -34,14 +37,34 @@ const sdkSpy = vi.hoisted(() => ({
34
37
  createCalls: [] as Record<string, unknown>[],
35
38
  /** The connection object the mocked NativeConnection.connect returned last. */
36
39
  lastConnection: undefined as unknown,
40
+ /** Options of every NativeConnection.connect and Connection.connect call. */
41
+ nativeConnectCalls: [] as Record<string, unknown>[],
42
+ clientConnectCalls: [] as Record<string, unknown>[],
37
43
  }));
38
44
 
45
+ vi.mock("@temporalio/client", async (importOriginal) => {
46
+ const original = await importOriginal<typeof import("@temporalio/client")>();
47
+ return {
48
+ ...original,
49
+ Connection: {
50
+ connect: async (options: Record<string, unknown>) => {
51
+ sdkSpy.clientConnectCalls.push(options);
52
+ return { close: async () => {}, healthService: { check: async () => ({ status: 1 }) } };
53
+ },
54
+ },
55
+ Client: class {
56
+ constructor(readonly options: Record<string, unknown>) {}
57
+ },
58
+ };
59
+ });
60
+
39
61
  vi.mock("@temporalio/worker", async (importOriginal) => {
40
62
  const original = await importOriginal<typeof import("@temporalio/worker")>();
41
63
  return {
42
64
  ...original,
43
65
  NativeConnection: {
44
- connect: async () => {
66
+ connect: async (options: Record<string, unknown>) => {
67
+ sdkSpy.nativeConnectCalls.push(options);
45
68
  const connection = { close: async () => {} };
46
69
  sdkSpy.lastConnection = connection;
47
70
  return connection;
@@ -74,6 +97,8 @@ const managers: TemporalManager[] = [];
74
97
  afterEach(async () => {
75
98
  sdkSpy.createCalls.length = 0;
76
99
  sdkSpy.lastConnection = undefined;
100
+ sdkSpy.nativeConnectCalls.length = 0;
101
+ sdkSpy.clientConnectCalls.length = 0;
77
102
  for (const manager of managers.splice(0)) {
78
103
  await manager.close();
79
104
  }
@@ -108,11 +133,13 @@ function newManager(
108
133
  payloadCodecs?: ConstructorParameters<
109
134
  typeof TemporalManager
110
135
  >[0]["payloadCodecs"];
136
+ connection?: ConstructorParameters<typeof TemporalManager>[0]["connection"];
111
137
  },
112
138
  ): TemporalManager {
113
139
  const manager = new TemporalManager({
114
140
  hostPort: "127.0.0.1:1",
115
141
  namespace: "default",
142
+ connection: options?.connection ?? {},
116
143
  logger: options?.logger ?? silentLogger,
117
144
  payloadCodecs: options?.payloadCodecs ?? [],
118
145
  workerFactories: factories,
@@ -283,6 +310,37 @@ describe("TemporalManager createWorker capability (the finding-16 seam)", () =>
283
310
  });
284
311
  });
285
312
 
313
+ describe("TemporalManager connection security", () => {
314
+ it("spreads TLS and the API key into both the client's and the workers' connections", async () => {
315
+ const security = {
316
+ tls: { serverNameOverride: "temporal.internal" },
317
+ apiKey: "api-key-1",
318
+ };
319
+ const manager = newManager([fakeWorkerFactory({ shutdowns: 0 })], {
320
+ connection: security,
321
+ });
322
+
323
+ await manager.initialConnect();
324
+ await manager.startWorkers();
325
+
326
+ expect(sdkSpy.clientConnectCalls).toHaveLength(1);
327
+ expect(sdkSpy.clientConnectCalls[0]).toMatchObject({ address: "127.0.0.1:1", ...security });
328
+ expect(sdkSpy.nativeConnectCalls).toHaveLength(1);
329
+ expect(sdkSpy.nativeConnectCalls[0]).toEqual({ address: "127.0.0.1:1", ...security });
330
+ });
331
+
332
+ it("passes neither for a plaintext manager", async () => {
333
+ const manager = newManager([fakeWorkerFactory({ shutdowns: 0 })]);
334
+
335
+ await manager.initialConnect();
336
+ await manager.startWorkers();
337
+
338
+ expect(sdkSpy.clientConnectCalls[0]).not.toHaveProperty("tls");
339
+ expect(sdkSpy.clientConnectCalls[0]).not.toHaveProperty("apiKey");
340
+ expect(sdkSpy.nativeConnectCalls[0]).toEqual({ address: "127.0.0.1:1" });
341
+ });
342
+ });
343
+
286
344
  describe("TemporalManager worker-death observability", () => {
287
345
  it("logs a run() rejection at ERROR with the dead worker's queue identity", async () => {
288
346
  const lines: string[] = [];
@@ -32,6 +32,12 @@
32
32
  * connection errors exactly as Go's stale-client operations do, and the
33
33
  * health monitor swaps in a fresh client when the server returns.
34
34
  *
35
+ * One choke point for connection security: `connection` (TLS, mutual TLS,
36
+ * an API key; `@stigmer/temporal-codecs`' `loadTemporalConnectionConfig`)
37
+ * is spread into BOTH connects, the client's and the workers' native one,
38
+ * so every worker, OSS and extension alike, authenticates the same way
39
+ * through createWorker. Absent settings keep the plaintext connection.
40
+ *
35
41
  * One choke point for payload decryption: the codecs are installed on the
36
42
  * client at dial AND wired into every worker by the deps' createWorker
37
43
  * capability (the TS SDK's Worker.create takes its own dataConverter; Go
@@ -40,6 +46,7 @@
40
46
  */
41
47
  import { Client, Connection } from "@temporalio/client";
42
48
  import type { PayloadCodec } from "@temporalio/common";
49
+ import type { TemporalConnectionConfig } from "@stigmer/temporal-codecs";
43
50
  import {
44
51
  NativeConnection,
45
52
  Worker,
@@ -150,6 +157,8 @@ export type WorkerFactory = (deps: WorkerFactoryDeps) => Promise<Worker>;
150
157
  export interface TemporalManagerOptions {
151
158
  readonly hostPort: string;
152
159
  readonly namespace: string;
160
+ /** TLS and API key for both connections; `{}` for plaintext. */
161
+ readonly connection: TemporalConnectionConfig;
153
162
  readonly logger: Logger;
154
163
  readonly payloadCodecs: PayloadCodec[];
155
164
  readonly workerFactories: readonly WorkerFactory[];
@@ -163,6 +172,7 @@ interface RunningWorker {
163
172
  export class TemporalManager {
164
173
  private readonly hostPort: string;
165
174
  private readonly namespace: string;
175
+ private readonly connectionSecurity: TemporalConnectionConfig;
166
176
  private readonly logger: Logger;
167
177
  private readonly payloadCodecs: PayloadCodec[];
168
178
  private readonly workerFactories: readonly WorkerFactory[];
@@ -184,6 +194,7 @@ export class TemporalManager {
184
194
  constructor(options: TemporalManagerOptions) {
185
195
  this.hostPort = options.hostPort;
186
196
  this.namespace = options.namespace;
197
+ this.connectionSecurity = options.connection;
187
198
  this.logger = options.logger;
188
199
  this.payloadCodecs = options.payloadCodecs;
189
200
  this.workerFactories = options.workerFactories;
@@ -328,6 +339,7 @@ export class TemporalManager {
328
339
  const connection = await Connection.connect({
329
340
  address: this.hostPort,
330
341
  connectTimeout: DIAL_CONNECT_TIMEOUT_MS,
342
+ ...this.connectionSecurity,
331
343
  });
332
344
  const client = new Client({
333
345
  connection,
@@ -494,6 +506,7 @@ export class TemporalManager {
494
506
  private async createAndRunWorkers(): Promise<void> {
495
507
  const nativeConnection = await NativeConnection.connect({
496
508
  address: this.hostPort,
509
+ ...this.connectionSecurity,
497
510
  });
498
511
  this.nativeConnection = nativeConnection;
499
512