@stigmer/server 3.30.0 → 3.32.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 (111) hide show
  1. package/README.md +1 -1
  2. package/dist/authorization/model/data/authorization-model.json +16 -46
  3. package/dist/boot/compose.d.ts.map +1 -1
  4. package/dist/boot/compose.js +8 -0
  5. package/dist/boot/compose.js.map +1 -1
  6. package/dist/boot/config.d.ts +10 -0
  7. package/dist/boot/config.d.ts.map +1 -1
  8. package/dist/boot/config.js +2 -0
  9. package/dist/boot/config.js.map +1 -1
  10. package/dist/domain/agentexecution/create-execution-context-step.d.ts +7 -0
  11. package/dist/domain/agentexecution/create-execution-context-step.d.ts.map +1 -1
  12. package/dist/domain/agentexecution/create-execution-context-step.js +79 -4
  13. package/dist/domain/agentexecution/create-execution-context-step.js.map +1 -1
  14. package/dist/domain/iampolicy/constants.d.ts +11 -1
  15. package/dist/domain/iampolicy/constants.d.ts.map +1 -1
  16. package/dist/domain/iampolicy/constants.js +11 -1
  17. package/dist/domain/iampolicy/constants.js.map +1 -1
  18. package/dist/domain/iampolicy/controller.d.ts.map +1 -1
  19. package/dist/domain/iampolicy/controller.js +36 -12
  20. package/dist/domain/iampolicy/controller.js.map +1 -1
  21. package/dist/domain/iampolicy/steps.d.ts +98 -0
  22. package/dist/domain/iampolicy/steps.d.ts.map +1 -1
  23. package/dist/domain/iampolicy/steps.js +119 -4
  24. package/dist/domain/iampolicy/steps.js.map +1 -1
  25. package/dist/domain/oauthapp/controller.d.ts +16 -8
  26. package/dist/domain/oauthapp/controller.d.ts.map +1 -1
  27. package/dist/domain/oauthapp/controller.js +28 -11
  28. package/dist/domain/oauthapp/controller.js.map +1 -1
  29. package/dist/domain/oauthapp/refresolution.d.ts.map +1 -1
  30. package/dist/domain/oauthapp/refresolution.js +3 -2
  31. package/dist/domain/oauthapp/refresolution.js.map +1 -1
  32. package/dist/domain/oauthapp/steps.d.ts +5 -5
  33. package/dist/domain/oauthapp/steps.js +6 -6
  34. package/dist/domain/oauthapp/steps.js.map +1 -1
  35. package/dist/domain/platformclient/constants.d.ts +11 -1
  36. package/dist/domain/platformclient/constants.d.ts.map +1 -1
  37. package/dist/domain/platformclient/constants.js +11 -1
  38. package/dist/domain/platformclient/constants.js.map +1 -1
  39. package/dist/domain/platformclient/verifier.d.ts.map +1 -1
  40. package/dist/domain/platformclient/verifier.js +7 -2
  41. package/dist/domain/platformclient/verifier.js.map +1 -1
  42. package/dist/extensions/identity.d.ts +14 -0
  43. package/dist/extensions/identity.d.ts.map +1 -1
  44. package/dist/extensions/identity.js.map +1 -1
  45. package/dist/index.d.ts +1 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +5 -0
  48. package/dist/index.js.map +1 -1
  49. package/dist/pipeline/steps/defaults.d.ts +5 -1
  50. package/dist/pipeline/steps/defaults.d.ts.map +1 -1
  51. package/dist/pipeline/steps/defaults.js +6 -1
  52. package/dist/pipeline/steps/defaults.js.map +1 -1
  53. package/dist/sandbox/docker.d.ts.map +1 -1
  54. package/dist/sandbox/docker.js +13 -5
  55. package/dist/sandbox/docker.js.map +1 -1
  56. package/dist/sandbox/kubernetes.d.ts +1 -1
  57. package/dist/sandbox/kubernetes.d.ts.map +1 -1
  58. package/dist/sandbox/kubernetes.js +18 -4
  59. package/dist/sandbox/kubernetes.js.map +1 -1
  60. package/dist/sandbox/local-process.d.ts.map +1 -1
  61. package/dist/sandbox/local-process.js +8 -1
  62. package/dist/sandbox/local-process.js.map +1 -1
  63. package/dist/sandbox/provisioner.d.ts +12 -0
  64. package/dist/sandbox/provisioner.d.ts.map +1 -1
  65. package/dist/sandbox/provisioner.js.map +1 -1
  66. package/dist/temporal/manager.d.ts +10 -0
  67. package/dist/temporal/manager.d.ts.map +1 -1
  68. package/dist/temporal/manager.js +10 -0
  69. package/dist/temporal/manager.js.map +1 -1
  70. package/package.json +6 -6
  71. package/src/authorization/__tests__/store-tests.test.ts +1 -0
  72. package/src/authorization/model/data/authorization-model.json +16 -46
  73. package/src/boot/__tests__/config.test.ts +23 -1
  74. package/src/boot/compose.ts +8 -0
  75. package/src/boot/config.ts +13 -0
  76. package/src/domain/agentexecution/__tests__/create-execution-context-step.test.ts +329 -0
  77. package/src/domain/agentexecution/__tests__/lifecycle.test.ts +7 -0
  78. package/src/domain/agentexecution/create-execution-context-step.ts +110 -4
  79. package/src/domain/iampolicy/__tests__/controller.test.ts +99 -3
  80. package/src/domain/iampolicy/__tests__/steps.test.ts +179 -0
  81. package/src/domain/iampolicy/constants.ts +15 -1
  82. package/src/domain/iampolicy/controller.ts +43 -9
  83. package/src/domain/iampolicy/steps.ts +164 -4
  84. package/src/domain/oauthapp/__tests__/oauthapp.test.ts +17 -10
  85. package/src/domain/oauthapp/controller.ts +51 -18
  86. package/src/domain/oauthapp/refresolution.ts +3 -2
  87. package/src/domain/oauthapp/steps.ts +6 -6
  88. package/src/domain/platformclient/__tests__/mint.test.ts +7 -1
  89. package/src/domain/platformclient/__tests__/verifier-and-guard.test.ts +4 -2
  90. package/src/domain/platformclient/constants.ts +11 -1
  91. package/src/domain/platformclient/verifier.ts +7 -2
  92. package/src/extensions/__tests__/role-reconciliation-composed.test.ts +18 -10
  93. package/src/extensions/identity.ts +14 -0
  94. package/src/index.ts +5 -0
  95. package/src/pipeline/__tests__/steps.test.ts +72 -1
  96. package/src/pipeline/steps/defaults.ts +6 -1
  97. package/src/sandbox/__tests__/docker.integration.test.ts +2 -0
  98. package/src/sandbox/__tests__/kubernetes.test.ts +27 -0
  99. package/src/sandbox/__tests__/local-process.integration.test.ts +10 -0
  100. package/src/sandbox/__tests__/provisioner.test.ts +2 -0
  101. package/src/sandbox/docker.ts +11 -5
  102. package/src/sandbox/kubernetes.ts +18 -3
  103. package/src/sandbox/local-process.ts +11 -1
  104. package/src/sandbox/provisioner.ts +12 -0
  105. package/src/temporal/__tests__/manager.test.ts +59 -1
  106. package/src/temporal/manager.ts +13 -0
  107. package/dist/domain/oauthapp/refresolution/refresolution.d.ts +0 -12
  108. package/dist/domain/oauthapp/refresolution/refresolution.d.ts.map +0 -1
  109. package/dist/domain/oauthapp/refresolution/refresolution.js +0 -79
  110. package/dist/domain/oauthapp/refresolution/refresolution.js.map +0 -1
  111. package/src/domain/oauthapp/refresolution/refresolution.ts +0 -94
@@ -11,6 +11,15 @@
11
11
  * agent's env united with the session servers' env, and a session
12
12
  * server's saved key reaching the run from the personal environment — for
13
13
  * an agent-bound run and for the built-in assistant alike.
14
+ *
15
+ * And the minting PlatformClient's layer (#1256): an execution whose audit
16
+ * names the client a minted user came through receives the client's
17
+ * environments BELOW the instance layer and runtime_env; a creator with no
18
+ * client reads no client at all; a deleted client or one of another
19
+ * organization contributes nothing; an unresolvable ref fails the create;
20
+ * and the layer survives the runner's status writes, so recovery — which
21
+ * rebuilds from the persisted execution with no minted caller — delivers
22
+ * it again.
14
23
  */
15
24
  import { mkdtempSync, rmSync } from "node:fs";
16
25
  import { tmpdir } from "node:os";
@@ -22,7 +31,10 @@ import { afterAll, beforeAll, expect, it } from "vitest";
22
31
 
23
32
  import { AgentSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
24
33
  import { AgentInstanceSchema } from "@stigmer/protos/ai/stigmer/agentic/agentinstance/v1/api_pb";
34
+ import type { AgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
25
35
  import { AgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
36
+ import { ExecutionPhase } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
37
+ import { AgentExecutionUpdateStatusInputSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/io_pb";
26
38
  import { EnvironmentSchema } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/api_pb";
27
39
  import type { EnvironmentValue } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/spec_pb";
28
40
  import { EnvironmentValueSchema } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/spec_pb";
@@ -30,6 +42,9 @@ import type { ExecutionContext } from "@stigmer/protos/ai/stigmer/agentic/execut
30
42
  import { McpServerSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
31
43
  import { SessionSchema } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
32
44
  import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
45
+ import type { ApiResourceReference } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
46
+ import type { PlatformClient } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/api_pb";
47
+ import { PlatformClientSchema } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/api_pb";
33
48
 
34
49
  import { createLogger } from "../../../boot/logger.js";
35
50
  import { SqliteStore } from "../../../store/sqlite/store.js";
@@ -38,6 +53,7 @@ import type { ManagedEnvironmentService } from "../../mcpserver/oauth/managed-en
38
53
 
39
54
  import type { ExecutionContextBuilderDeps } from "../create-execution-context-step.js";
40
55
  import { buildAndPersistExecutionContext } from "../create-execution-context-step.js";
56
+ import { applyUpdateStatusMerge } from "../update-status.js";
41
57
 
42
58
  const silentLogger = createLogger({
43
59
  level: "error",
@@ -45,6 +61,16 @@ const silentLogger = createLogger({
45
61
  write: () => {},
46
62
  });
47
63
 
64
+ /**
65
+ * The client port for every execution no minted user created: the layer
66
+ * answers from the execution's own audit, so reaching this is a failure.
67
+ */
68
+ const unreadPlatformClients: ExecutionContextBuilderDeps["platformClients"] = {
69
+ findById: async () => {
70
+ throw new Error("no minting client: the client must not be read");
71
+ },
72
+ };
73
+
48
74
  let dir: string;
49
75
  let store: Store;
50
76
 
@@ -113,6 +139,7 @@ it("an unresolvable environment ref surfaces the inner status code with Go's wra
113
139
  readSecretValue: async () => "",
114
140
  updateSecrets: async () => {},
115
141
  } as unknown as ManagedEnvironmentService,
142
+ platformClients: unreadPlatformClients,
116
143
  };
117
144
 
118
145
  const execution = create(AgentExecutionSchema, {
@@ -251,6 +278,7 @@ it("assembles merge → filter → OAuth injection → EC persist over a non-emp
251
278
  },
252
279
  }),
253
280
  managedEnvService,
281
+ platformClients: unreadPlatformClients,
254
282
  // The vendor's token endpoint: rotates the refresh token and issues
255
283
  // a fresh access token.
256
284
  fetchImpl: async () =>
@@ -394,6 +422,7 @@ it("a session-level server's declared key saved in the personal environment reac
394
422
  readSecretValue: async () => "",
395
423
  updateSecrets: async () => {},
396
424
  } as unknown as ManagedEnvironmentService,
425
+ platformClients: unreadPlatformClients,
397
426
  };
398
427
  const execution = create(AgentExecutionSchema, {
399
428
  metadata: { id: "aexec_rule", org: ORG },
@@ -480,6 +509,7 @@ it("the built-in assistant: no instance, no agent, the session's servers are the
480
509
  readSecretValue: async () => "",
481
510
  updateSecrets: async () => {},
482
511
  } as unknown as ManagedEnvironmentService,
512
+ platformClients: unreadPlatformClients,
483
513
  };
484
514
  const execution = create(AgentExecutionSchema, {
485
515
  metadata: { id: "aexec_assistant", org: ORG },
@@ -500,3 +530,302 @@ it("the built-in assistant: no instance, no agent, the session's servers are the
500
530
  expect(data["STRAY"]).toBeUndefined();
501
531
  expect(reads).toEqual(["NOTES_TOKEN"]);
502
532
  });
533
+
534
+ // ---------------------------------------------------------------------------
535
+ // The minting PlatformClient's layer (#1256): keyed on the execution's
536
+ // audit created_by.platform_client_id, which the server stamped from the
537
+ // verified token, below every other layer.
538
+ // ---------------------------------------------------------------------------
539
+
540
+ const PC_ORG = "acme";
541
+
542
+ function environmentOf(
543
+ slug: string,
544
+ data: Record<string, string>,
545
+ org = PC_ORG,
546
+ ): ReturnType<typeof create<typeof EnvironmentSchema>> {
547
+ return create(EnvironmentSchema, {
548
+ metadata: { id: `env_${slug}`, org, slug },
549
+ spec: {
550
+ data: Object.fromEntries(
551
+ Object.entries(data).map(([key, value]) => [key, { value, isSecret: true }]),
552
+ ),
553
+ },
554
+ });
555
+ }
556
+
557
+ /** A PlatformClient of `org` whose environment_refs name `slugs`. */
558
+ function mintingClient(org: string, ...slugs: string[]): PlatformClient {
559
+ return create(PlatformClientSchema, {
560
+ metadata: { id: "pcl_dashboard", org, slug: "dashboard" },
561
+ spec: {
562
+ environmentRefs: slugs.map((slug) => ({
563
+ kind: ApiResourceKind.environment,
564
+ org,
565
+ slug,
566
+ })),
567
+ },
568
+ });
569
+ }
570
+
571
+ /** An execution in PC_ORG created by an actor who came through `platformClientId`. */
572
+ function executionCreatedThrough(
573
+ id: string,
574
+ platformClientId: string,
575
+ runtimeEnv: Record<string, string> = {},
576
+ ): AgentExecution {
577
+ return create(AgentExecutionSchema, {
578
+ metadata: { id, org: PC_ORG },
579
+ spec: {
580
+ sessionId: `ses_${id}`,
581
+ message: "hi",
582
+ runtimeEnv: Object.fromEntries(
583
+ Object.entries(runtimeEnv).map(([key, value]) => [key, { value, isSecret: true }]),
584
+ ),
585
+ },
586
+ status: {
587
+ audit: {
588
+ specAudit: { createdBy: { id: "ida_pat", platformClientId } },
589
+ },
590
+ },
591
+ });
592
+ }
593
+
594
+ /**
595
+ * Builder deps for an agent that declares nothing (every merged key passes
596
+ * the filter) over an instance whose one ref is `instance-secrets`. Every
597
+ * resolved reference and every client read is recorded.
598
+ */
599
+ function platformClientLayerDeps(opts: {
600
+ readonly client: PlatformClient | undefined;
601
+ readonly environments: ReadonlyArray<ReturnType<typeof environmentOf>>;
602
+ readonly resolved: ApiResourceReference[];
603
+ readonly clientReads: string[];
604
+ readonly createdEcs: ExecutionContext[];
605
+ }): ExecutionContextBuilderDeps {
606
+ return {
607
+ store,
608
+ logger: silentLogger,
609
+ agentLoader: () => ({
610
+ get: async () =>
611
+ create(AgentSchema, { metadata: { id: "agt_pc", org: PC_ORG } }),
612
+ }),
613
+ agentInstanceLoader: () => ({
614
+ get: async (instanceId) =>
615
+ create(AgentInstanceSchema, {
616
+ metadata: { id: instanceId, org: PC_ORG },
617
+ spec: {
618
+ agentId: "agt_pc",
619
+ environmentRefs: [
620
+ {
621
+ kind: ApiResourceKind.environment,
622
+ org: PC_ORG,
623
+ slug: "instance-secrets",
624
+ },
625
+ ],
626
+ },
627
+ }),
628
+ }),
629
+ sessionLoader: () => ({
630
+ get: async (sessionId) =>
631
+ create(SessionSchema, {
632
+ metadata: { id: sessionId, org: PC_ORG },
633
+ spec: { agentInstanceId: "agi_pc" },
634
+ }),
635
+ }),
636
+ environmentReader: () => ({
637
+ list: async () => {
638
+ throw new Error("personal-env lookup not needed in this test");
639
+ },
640
+ getSecretValue: async () => {
641
+ throw new Error("personal-env lookup not needed in this test");
642
+ },
643
+ }),
644
+ environmentResolution: {
645
+ resolveByReference: async (ref: ApiResourceReference) => {
646
+ opts.resolved.push(ref);
647
+ const found = opts.environments.find(
648
+ (env) => env.metadata?.slug === ref.slug && env.metadata?.org === ref.org,
649
+ );
650
+ if (found === undefined) {
651
+ throw new ConnectError(`environment not found: ${ref.slug}`, Code.NotFound);
652
+ }
653
+ return found;
654
+ },
655
+ } as unknown as ExecutionContextBuilderDeps["environmentResolution"],
656
+ executionContextCreator: () => ({
657
+ create: async (ec) => {
658
+ opts.createdEcs.push(ec);
659
+ return ec;
660
+ },
661
+ }),
662
+ managedEnvService: {
663
+ readSecretValue: async () => "",
664
+ updateSecrets: async () => {},
665
+ } as unknown as ManagedEnvironmentService,
666
+ platformClients: {
667
+ findById: async (id) => {
668
+ opts.clientReads.push(id);
669
+ return opts.client;
670
+ },
671
+ },
672
+ };
673
+ }
674
+
675
+ const instanceSecrets = environmentOf("instance-secrets", {
676
+ INSTANCE_WINS: "instance",
677
+ });
678
+ const clientSecrets = environmentOf("embed-secrets", {
679
+ SHARED_API_SECRET: "client-secret",
680
+ INSTANCE_WINS: "client",
681
+ RUNTIME_WINS: "client",
682
+ });
683
+
684
+ it("a minted user's execution receives its client's environments below the instance layer and runtime_env", async () => {
685
+ const resolved: ApiResourceReference[] = [];
686
+ const clientReads: string[] = [];
687
+ const createdEcs: ExecutionContext[] = [];
688
+ const deps = platformClientLayerDeps({
689
+ client: mintingClient(PC_ORG, "embed-secrets"),
690
+ environments: [instanceSecrets, clientSecrets],
691
+ resolved,
692
+ clientReads,
693
+ createdEcs,
694
+ });
695
+
696
+ await buildAndPersistExecutionContext(
697
+ deps,
698
+ executionCreatedThrough("aexec_pc_layer", "pcl_dashboard", {
699
+ RUNTIME_WINS: "runtime",
700
+ }),
701
+ "",
702
+ );
703
+
704
+ const data = createdEcs[0]?.spec?.data ?? {};
705
+ // The client's own key reaches the run.
706
+ expect(data["SHARED_API_SECRET"]?.value).toBe("client-secret");
707
+ // The precedence #1256 asks to pin: instance refs, then runtime_env,
708
+ // override the client on a key conflict.
709
+ expect(data["INSTANCE_WINS"]?.value).toBe("instance");
710
+ expect(data["RUNTIME_WINS"]?.value).toBe("runtime");
711
+ expect(clientReads).toEqual(["pcl_dashboard"]);
712
+ expect(resolved.map((ref) => `${ref.org}/${ref.slug}`)).toEqual([
713
+ "acme/instance-secrets",
714
+ "acme/embed-secrets",
715
+ ]);
716
+ });
717
+
718
+ it("no client, a deleted client and a client of another organization contribute nothing", async () => {
719
+ // A creator who came through no client: no client is read at all.
720
+ const noClient: ExecutionContext[] = [];
721
+ await buildAndPersistExecutionContext(
722
+ {
723
+ ...platformClientLayerDeps({
724
+ client: undefined,
725
+ environments: [instanceSecrets],
726
+ resolved: [],
727
+ clientReads: [],
728
+ createdEcs: noClient,
729
+ }),
730
+ platformClients: unreadPlatformClients,
731
+ },
732
+ executionCreatedThrough("aexec_pc_none", ""),
733
+ "",
734
+ );
735
+ expect(noClient[0]?.spec?.data["INSTANCE_WINS"]?.value).toBe("instance");
736
+
737
+ // The client was deleted since: the run proceeds without its layer.
738
+ const deleted: ExecutionContext[] = [];
739
+ const deletedReads: string[] = [];
740
+ await buildAndPersistExecutionContext(
741
+ platformClientLayerDeps({
742
+ client: undefined,
743
+ environments: [instanceSecrets, clientSecrets],
744
+ resolved: [],
745
+ clientReads: deletedReads,
746
+ createdEcs: deleted,
747
+ }),
748
+ executionCreatedThrough("aexec_pc_deleted", "pcl_dashboard"),
749
+ "",
750
+ );
751
+ expect(deletedReads).toEqual(["pcl_dashboard"]);
752
+ expect(deleted[0]?.spec?.data["SHARED_API_SECRET"]).toBeUndefined();
753
+ expect(deleted[0]?.spec?.data["INSTANCE_WINS"]?.value).toBe("instance");
754
+
755
+ // A client of another organization: no environment value crosses an
756
+ // organization, so its refs are never even resolved.
757
+ const foreign: ExecutionContext[] = [];
758
+ const foreignResolved: ApiResourceReference[] = [];
759
+ await buildAndPersistExecutionContext(
760
+ platformClientLayerDeps({
761
+ client: mintingClient("globex", "embed-secrets"),
762
+ environments: [instanceSecrets, environmentOf("embed-secrets", { SHARED_API_SECRET: "x" }, "globex")],
763
+ resolved: foreignResolved,
764
+ clientReads: [],
765
+ createdEcs: foreign,
766
+ }),
767
+ executionCreatedThrough("aexec_pc_foreign", "pcl_dashboard"),
768
+ "",
769
+ );
770
+ expect(foreign[0]?.spec?.data["SHARED_API_SECRET"]).toBeUndefined();
771
+ expect(foreignResolved.map((ref) => ref.org)).toEqual(["acme"]);
772
+ });
773
+
774
+ it("a client environment that no longer exists fails the create with the client named in the chain", async () => {
775
+ try {
776
+ await buildAndPersistExecutionContext(
777
+ platformClientLayerDeps({
778
+ client: mintingClient(PC_ORG, "gone-secrets"),
779
+ environments: [instanceSecrets],
780
+ resolved: [],
781
+ clientReads: [],
782
+ createdEcs: [],
783
+ }),
784
+ executionCreatedThrough("aexec_pc_gone", "pcl_dashboard"),
785
+ "",
786
+ );
787
+ expect.unreachable("expected NotFound");
788
+ } catch (error) {
789
+ const connectError = ConnectError.from(error);
790
+ expect(connectError.code).toBe(Code.NotFound);
791
+ expect(connectError.rawMessage).toBe(
792
+ "resolve platform client pcl_dashboard environment_refs: " +
793
+ "resolve environment ref (org=acme, slug=gone-secrets): " +
794
+ "rpc error: code = NotFound desc = environment not found: gone-secrets",
795
+ );
796
+ }
797
+ });
798
+
799
+ it("the layer survives the runner's status writes, so recovery delivers it again", async () => {
800
+ const execution = executionCreatedThrough("aexec_pc_recover", "pcl_dashboard");
801
+ // The runner's progressive status write, as UpdateStatus applies it.
802
+ applyUpdateStatusMerge(
803
+ execution,
804
+ create(AgentExecutionUpdateStatusInputSchema, {
805
+ executionId: "aexec_pc_recover",
806
+ status: { phase: ExecutionPhase.EXECUTION_FAILED, error: "boom" },
807
+ }),
808
+ silentLogger,
809
+ );
810
+ expect(
811
+ execution.status?.audit?.specAudit?.createdBy?.platformClientId,
812
+ ).toBe("pcl_dashboard");
813
+
814
+ // Recover rebuilds from the persisted execution with no pre-resolved
815
+ // instance and no minted caller (lifecycle.ts's recreate step).
816
+ const createdEcs: ExecutionContext[] = [];
817
+ await buildAndPersistExecutionContext(
818
+ platformClientLayerDeps({
819
+ client: mintingClient(PC_ORG, "embed-secrets"),
820
+ environments: [instanceSecrets, clientSecrets],
821
+ resolved: [],
822
+ clientReads: [],
823
+ createdEcs,
824
+ }),
825
+ execution,
826
+ "",
827
+ );
828
+ expect(createdEcs[0]?.spec?.data["SHARED_API_SECRET"]?.value).toBe(
829
+ "client-secret",
830
+ );
831
+ });
@@ -434,6 +434,13 @@ function stubBuilderDeps(overrides?: {
434
434
  readSecretValue: async () => "",
435
435
  updateSecrets: async () => {},
436
436
  } as unknown as ManagedEnvironmentService,
437
+ // No execution here was created by a minted user, so the minting
438
+ // client's layer answers from the audit without reading a client.
439
+ platformClients: {
440
+ findById: async () => {
441
+ throw new Error("no minting client in this test");
442
+ },
443
+ },
437
444
  };
438
445
  }
439
446
 
@@ -16,10 +16,14 @@
16
16
  * no instance layers, and the session's own MCP servers are the whole
17
17
  * tool set.
18
18
  *
19
- * Merge priority (lowest to highest): schedule/workflow-task
20
- * environment_refs (the share/channel/schedule/agent_call layering) →
21
- * instance environment_refs (via the environment RuntimeResolutionService
22
- * — decrypted, the RPC surface redacts, oss#405) → spec.runtime_env.
19
+ * Merge priority (lowest to highest): the minting PlatformClient's
20
+ * environment_refs, for an execution a PlatformClient-minted user created
21
+ * (#1256) → schedule/workflow-task environment_refs (the
22
+ * share/channel/schedule/agent_call layering) → instance environment_refs
23
+ * (via the environment RuntimeResolutionService — decrypted, the RPC
24
+ * surface redacts, oss#405) → spec.runtime_env. Every layer is keyed on
25
+ * the persisted execution alone (its labels, its audit), never on the
26
+ * request's caller, so recovery rebuilds exactly what create built.
23
27
  *
24
28
  * Then ONE declaration rule for every shape: the run declares what its
25
29
  * agent declares and what its session's MCP servers declare, and the
@@ -96,6 +100,7 @@ import type { PersonalEnvironmentResolution } from "../environment/personal.js";
96
100
  import type { RuntimeResolutionService } from "../environment/resolution/resolution.js";
97
101
  import type { ManagedEnvironmentService } from "../mcpserver/oauth/managed-env.js";
98
102
  import { refreshTokenIfExpired } from "../mcpserver/oauth/refresh.js";
103
+ import type { PlatformClientStore } from "../platformclient/store.js";
99
104
  import { unmarshalTaskConfig } from "../workflow/converter/unmarshal.js";
100
105
 
101
106
  import { DEFAULT_INSTANCE_ID_KEY } from "./create-steps.js";
@@ -160,6 +165,12 @@ export interface ExecutionContextBuilderDeps {
160
165
  readonly environmentResolution: RuntimeResolutionService;
161
166
  readonly executionContextCreator: () => ExecutionContextCreator;
162
167
  readonly managedEnvService: ManagedEnvironmentService;
168
+ /**
169
+ * The PlatformClient port the minting client's environment layer reads
170
+ * (4.7). The port, not the Store: a composition may keep client rows in
171
+ * a table of its own (domain/platformclient/store.ts).
172
+ */
173
+ readonly platformClients: Pick<PlatformClientStore, "findById">;
163
174
  /** Test seam forwarded to the OAuth token-endpoint refresh. */
164
175
  readonly fetchImpl?: typeof fetch;
165
176
  }
@@ -323,6 +334,22 @@ export async function buildAndPersistExecutionContext(
323
334
  environments = [...workflowEnvironments, ...environments];
324
335
  }
325
336
 
337
+ // 4.7 PlatformClient-minted executions: the minting client's own
338
+ // environment_refs, BELOW every other layer — the fifth application of
339
+ // the connection-resource mechanism (#381, restored by #1256). The key
340
+ // is the execution's audit created_by, which the server stamped from
341
+ // the verified token, so it holds on recover, where no minted caller
342
+ // exists. Independent of 4.5/4.6: a minted user's own turn carries no
343
+ // schedule or workflow provenance, and a fire or a workflow task stamps
344
+ // no minted actor.
345
+ const platformClientEnvironments = await resolvePlatformClientEnvironments(
346
+ deps,
347
+ execution,
348
+ );
349
+ if (platformClientEnvironments.length > 0) {
350
+ environments = [...platformClientEnvironments, ...environments];
351
+ }
352
+
326
353
  // 5. Merge all layers.
327
354
  const merged = mergeEnvironmentLayers(
328
355
  environments,
@@ -745,6 +772,85 @@ async function resolveScheduleEnvironments(
745
772
  }
746
773
  }
747
774
 
775
+ /**
776
+ * Resolves the environment_refs of the PlatformClient whose minted user
777
+ * created this execution (its audit created_by.platform_client_id, stamped
778
+ * by the server from the verified token). No minting client — every
779
+ * execution not created by a minted user — answers empty without a read.
780
+ * A DELETED client degrades to no client environments (the schedule
781
+ * layer's posture; its tokens are already refused). A client of another
782
+ * organization than the execution's contributes nothing: no environment
783
+ * value crosses an organization, the run-time twin of the reference
784
+ * rule's cross-organization clause (pipeline/steps/references.ts). An
785
+ * expired client still contributes: expiry stops minting, not the tokens
786
+ * already minted. Environment visibility is not consulted, as for every
787
+ * layer (the reference rule's reasoning: the server resolves the
788
+ * environment on the run's behalf). An unresolvable REF fails the create.
789
+ */
790
+ async function resolvePlatformClientEnvironments(
791
+ deps: ExecutionContextBuilderDeps,
792
+ execution: AgentExecution,
793
+ ): Promise<Environment[]> {
794
+ const platformClientId =
795
+ execution.status?.audit?.specAudit?.createdBy?.platformClientId ?? "";
796
+ if (platformClientId === "") {
797
+ return [];
798
+ }
799
+ const executionId = execution.metadata?.id ?? "";
800
+
801
+ let client;
802
+ try {
803
+ client = await deps.platformClients.findById(platformClientId);
804
+ } catch (error) {
805
+ throw new Error(
806
+ `load platform client ${platformClientId} for environment resolution: ${error instanceof Error ? error.message : String(error)}`,
807
+ );
808
+ }
809
+ if (client === undefined) {
810
+ deps.logger.warn(
811
+ "Minting platform client is gone — running without its environments",
812
+ { platformClientId, executionId },
813
+ );
814
+ return [];
815
+ }
816
+
817
+ const clientOrg = client.metadata?.org ?? "";
818
+ const executionOrg = execution.metadata?.org ?? "";
819
+ if (clientOrg !== executionOrg) {
820
+ deps.logger.warn(
821
+ "Minting platform client belongs to another organization — its environments are not delivered",
822
+ { platformClientId, clientOrg, executionOrg, executionId },
823
+ );
824
+ return [];
825
+ }
826
+
827
+ const refs = client.spec?.environmentRefs ?? [];
828
+ if (refs.length === 0) {
829
+ return [];
830
+ }
831
+ // Stored refs are absolute (NormalizeReferences on the client's writes);
832
+ // an empty org is filled from the client's own, as the schedule layer
833
+ // does for its manifest refs.
834
+ const resolved = refs.map((ref) =>
835
+ ref.org === ""
836
+ ? create(ApiResourceReferenceSchema, {
837
+ kind: ref.kind,
838
+ org: clientOrg,
839
+ slug: ref.slug,
840
+ })
841
+ : ref,
842
+ );
843
+
844
+ try {
845
+ return await resolveEnvironments(deps, resolved);
846
+ } catch (error) {
847
+ chainError(
848
+ `resolve platform client ${platformClientId} environment_refs`,
849
+ error,
850
+ );
851
+ }
852
+ }
853
+
748
854
  /**
749
855
  * Resolves the environment_refs of the agent_call task that created this
750
856
  * execution (the workflow-provenance labels). Missing labels — every
@@ -25,7 +25,11 @@
25
25
  * the row's kind and id as the target); delete authorizes before it
26
26
  * loads; create validates the role before it writes, then runs the
27
27
  * `iam-policy-create:pre-side-effect-gate` slot, whose refusal leaves
28
- * no row; bootstrapPolicy never runs that slot.
28
+ * no row; bootstrapPolicy never runs that slot;
29
+ * - owner is assigned by owners: an admin is refused granting owner,
30
+ * revoking it and removing an owner, after position 1 and before any
31
+ * write; an owner is allowed each; the last owner is kept; the
32
+ * bootstrap twins run neither owner step.
29
33
  */
30
34
  import { create } from "@bufbuild/protobuf";
31
35
  import type { DescMessage } from "@bufbuild/protobuf";
@@ -76,6 +80,8 @@ import {
76
80
  import {
77
81
  AUTHENTICATION_REQUIRED_MESSAGE,
78
82
  AUTHORIZATION_QUERIES_UNIMPLEMENTED_MESSAGE,
83
+ LAST_OWNER_MESSAGE,
84
+ OWNER_ASSIGNMENT_DENIED_MESSAGE,
79
85
  policyIdFor,
80
86
  policyNotFoundMessage,
81
87
  principalNotGrantableMessage,
@@ -129,7 +135,8 @@ function stampCaller(identity: CallerIdentity): Interceptor {
129
135
 
130
136
  interface RecordingAuthorizer extends Authorizer {
131
137
  readonly checks: AuthzCheck[];
132
- decision: AuthzDecision | (() => never);
138
+ /** One answer for every check, or one per check (a throw is an outage). */
139
+ decision: AuthzDecision | ((check: AuthzCheck) => AuthzDecision);
133
140
  }
134
141
 
135
142
  function recordingAuthorizer(): RecordingAuthorizer {
@@ -140,7 +147,7 @@ function recordingAuthorizer(): RecordingAuthorizer {
140
147
  authorize(_caller, check) {
141
148
  checks.push(check);
142
149
  if (typeof authorizer.decision === "function") {
143
- return authorizer.decision();
150
+ return Promise.resolve(authorizer.decision(check));
144
151
  }
145
152
  return Promise.resolve(authorizer.decision);
146
153
  },
@@ -933,6 +940,95 @@ describe("the write lanes' order", () => {
933
940
  });
934
941
  });
935
942
 
943
+ describe("owner is assigned by owners, on the three caller lanes", () => {
944
+ /** alice is an admin of acme: she may grant access there, and may not assign owner. */
945
+ const adminOfAcme = (check: AuthzCheck): AuthzDecision =>
946
+ check.permission === IamPermission.can_assign_roles
947
+ ? { kind: "deny", reason: "" }
948
+ : { kind: "allow" };
949
+ const assignRolesOnAcme: AuthzCheck = {
950
+ permission: IamPermission.can_assign_roles,
951
+ resourceKind: ApiResourceKind.organization,
952
+ resourceId: "acme",
953
+ };
954
+
955
+ /** acme's roles, written straight through the grant path under the harness's store. */
956
+ async function acmeWith(
957
+ h: Harness,
958
+ roles: ReadonlyArray<readonly [string, string]>,
959
+ ): Promise<void> {
960
+ const path = newIamPolicyGrantPath({ policies: h.policies, lifecycle: undefined, logger: silent });
961
+ for (const [account, role] of roles) {
962
+ await path.grant(orgRole(account, role, "acme"), internal);
963
+ }
964
+ }
965
+
966
+ it("an admin is refused granting owner, after the role check and before the gate and the write", async () => {
967
+ const seen: string[] = [];
968
+ const h = await harness({
969
+ caller: alice,
970
+ createGate: { name: "RecordForTest", execute: () => void seen.push("gate") },
971
+ });
972
+ h.authorizer.decision = adminOfAcme;
973
+ const error = await refusal(() => h.command.create(orgRole("ida_bob", "owner", "acme")));
974
+ expect(error.code).toBe(Code.PermissionDenied);
975
+ expect(error.rawMessage).toBe(OWNER_ASSIGNMENT_DENIED_MESSAGE);
976
+ expect(h.authorizer.checks.at(-1)).toEqual(assignRolesOnAcme);
977
+ expect(seen).toEqual([]);
978
+ expect(h.policies.rows.size).toBe(0);
979
+
980
+ // Every role up to admin is still hers to grant, and asks nothing more.
981
+ await h.command.create(orgRole("ida_bob", "admin", "acme"));
982
+ expect(h.authorizer.checks.at(-1)?.permission).toBe(IamPermission.can_grant_access);
983
+ });
984
+
985
+ it("an admin is refused revoking owner and removing an owner, and neither row moves", async () => {
986
+ const asAlice = await harness({ caller: alice });
987
+ await acmeWith(asAlice, [["ida_root", "owner"], ["ida_rhea", "owner"]]);
988
+ asAlice.authorizer.decision = adminOfAcme;
989
+
990
+ const revoke = await refusal(() => asAlice.command.delete(orgRole("ida_rhea", "owner", "acme")));
991
+ expect(revoke.code).toBe(Code.PermissionDenied);
992
+ expect(revoke.rawMessage).toBe(OWNER_ASSIGNMENT_DENIED_MESSAGE);
993
+ const remove = await refusal(() =>
994
+ asAlice.command.revokeOrgAccess({ identityAccountId: "ida_rhea", organizationId: "acme" }),
995
+ );
996
+ expect(remove.code).toBe(Code.PermissionDenied);
997
+ expect(remove.rawMessage).toBe(OWNER_ASSIGNMENT_DENIED_MESSAGE);
998
+ expect(asAlice.policies.rows.size).toBe(2);
999
+ });
1000
+
1001
+ it("an owner grants, revokes and removes owner, but never the last one", async () => {
1002
+ const h = await harness({ caller: alice });
1003
+ await acmeWith(h, [["ida_alice", "owner"]]);
1004
+
1005
+ await h.command.create(orgRole("ida_bob", "owner", "acme"));
1006
+ await h.command.delete(orgRole("ida_bob", "owner", "acme"));
1007
+ await h.command.create(orgRole("ida_bob", "owner", "acme"));
1008
+ await h.command.revokeOrgAccess({ identityAccountId: "ida_bob", organizationId: "acme" });
1009
+ expect([...h.policies.rows.values()].map((row) => row.spec?.principal?.id)).toEqual(["ida_alice"]);
1010
+
1011
+ const revoke = await refusal(() => h.command.delete(orgRole("ida_alice", "owner", "acme")));
1012
+ expect(revoke.code).toBe(Code.FailedPrecondition);
1013
+ expect(revoke.rawMessage).toBe(LAST_OWNER_MESSAGE);
1014
+ const remove = await refusal(() =>
1015
+ h.command.revokeOrgAccess({ identityAccountId: "ida_alice", organizationId: "acme" }),
1016
+ );
1017
+ expect(remove.code).toBe(Code.FailedPrecondition);
1018
+ expect(remove.rawMessage).toBe(LAST_OWNER_MESSAGE);
1019
+ expect(h.policies.rows.size).toBe(1);
1020
+ });
1021
+
1022
+ it("the bootstrap twins run neither owner step: the platform removes the last owner", async () => {
1023
+ const h = await harness({ caller: machine });
1024
+ await acmeWith(h, [["ida_root", "owner"]]);
1025
+ h.authorizer.decision = adminOfAcme;
1026
+ await h.command.bootstrapRevokeOrgAccess({ identityAccountId: "ida_root", organizationId: "acme" });
1027
+ expect(h.policies.rows.size).toBe(0);
1028
+ expect(h.authorizer.checks.map((check) => check.permission)).not.toContain(IamPermission.can_assign_roles);
1029
+ });
1030
+ });
1031
+
936
1032
  describe("a wire kind string is refused BEFORE position 1 on every annotated lane (Q-S6-1)", () => {
937
1033
  // A kind string that names no kind names no authorization target, so
938
1034
  // there is nothing to ask the Authorizer — the same reason