@checkstack/automation-backend 0.2.0 → 0.3.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 (125) hide show
  1. package/CHANGELOG.md +544 -0
  2. package/drizzle/0003_sparkling_xorn.sql +17 -0
  3. package/drizzle/0004_cultured_spyke.sql +2 -0
  4. package/drizzle/0005_classy_the_hand.sql +19 -0
  5. package/drizzle/0006_burly_wallop.sql +10 -0
  6. package/drizzle/0007_nappy_jackal.sql +1 -0
  7. package/drizzle/0008_remove_seeded_auto_incident_automations.sql +13 -0
  8. package/drizzle/0009_steady_liz_osborn.sql +12 -0
  9. package/drizzle/0010_chunky_changeling.sql +2 -0
  10. package/drizzle/meta/0003_snapshot.json +1007 -0
  11. package/drizzle/meta/0004_snapshot.json +1028 -0
  12. package/drizzle/meta/0005_snapshot.json +1164 -0
  13. package/drizzle/meta/0006_snapshot.json +1261 -0
  14. package/drizzle/meta/0007_snapshot.json +1215 -0
  15. package/drizzle/meta/0008_snapshot.json +1215 -0
  16. package/drizzle/meta/0009_snapshot.json +1328 -0
  17. package/drizzle/meta/0010_snapshot.json +1349 -0
  18. package/drizzle/meta/_journal.json +56 -0
  19. package/package.json +23 -12
  20. package/src/action-types.ts +23 -0
  21. package/src/artifact-store.ts +16 -1
  22. package/src/automation-store.test.ts +143 -0
  23. package/src/automation-store.ts +30 -8
  24. package/src/builtin-triggers.test.ts +77 -74
  25. package/src/builtin-triggers.ts +105 -108
  26. package/src/dispatch/action-kind.ts +2 -0
  27. package/src/dispatch/assemble-get-service.ts +31 -0
  28. package/src/dispatch/cancel-resurrect.test.ts +147 -0
  29. package/src/dispatch/concurrency-race.test.ts +255 -0
  30. package/src/dispatch/concurrency-scope.test.ts +166 -0
  31. package/src/dispatch/condition.ts +24 -5
  32. package/src/dispatch/dwell-queue.ts +65 -0
  33. package/src/dispatch/dwell-store.ts +154 -0
  34. package/src/dispatch/dwell.it.test.ts +142 -0
  35. package/src/dispatch/dwell.test.ts +799 -0
  36. package/src/dispatch/dwell.ts +257 -0
  37. package/src/dispatch/engine.test.ts +189 -2
  38. package/src/dispatch/engine.ts +555 -9
  39. package/src/dispatch/entity-scope.test.ts +176 -0
  40. package/src/dispatch/get-service-wiring.test.ts +318 -0
  41. package/src/dispatch/numeric.test.ts +71 -0
  42. package/src/dispatch/numeric.ts +96 -0
  43. package/src/dispatch/render.test.ts +34 -0
  44. package/src/dispatch/render.ts +31 -11
  45. package/src/dispatch/reseed-run-secrets.ts +230 -0
  46. package/src/dispatch/run-secret-registry.test.ts +189 -0
  47. package/src/dispatch/run-secret-registry.ts +247 -0
  48. package/src/dispatch/run-state-masking.test.ts +376 -0
  49. package/src/dispatch/run-state-store.ts +95 -38
  50. package/src/dispatch/run-state.ts +226 -59
  51. package/src/dispatch/scope-artifact-masking.test.ts +138 -0
  52. package/src/dispatch/secret-ref-ids.test.ts +19 -0
  53. package/src/dispatch/secret-ref-ids.ts +17 -0
  54. package/src/dispatch/snapshots.test.ts +86 -0
  55. package/src/dispatch/snapshots.ts +79 -0
  56. package/src/dispatch/stage1-router.test.ts +324 -0
  57. package/src/dispatch/stage1-router.ts +152 -0
  58. package/src/dispatch/stage1.it.test.ts +84 -0
  59. package/src/dispatch/stage2-dispatch.test.ts +285 -0
  60. package/src/dispatch/stage2-dispatch.ts +207 -0
  61. package/src/dispatch/stage2-stalled.it.test.ts +132 -0
  62. package/src/dispatch/stalled-sweeper.test.ts +197 -0
  63. package/src/dispatch/stalled-sweeper.ts +112 -5
  64. package/src/dispatch/state-scope.test.ts +234 -0
  65. package/src/dispatch/state-scope.ts +322 -0
  66. package/src/dispatch/structured-conditions.test.ts +246 -0
  67. package/src/dispatch/structured-conditions.ts +146 -0
  68. package/src/dispatch/test-fixtures.ts +306 -38
  69. package/src/dispatch/trigger-fanin.test.ts +111 -0
  70. package/src/dispatch/trigger-subscriber.ts +316 -14
  71. package/src/dispatch/types.ts +263 -8
  72. package/src/dispatch/wait-timeout-queue.ts +89 -0
  73. package/src/dispatch/wait-until-entity-wake.test.ts +544 -0
  74. package/src/dispatch/wait-until.test.ts +540 -0
  75. package/src/dispatch/wake-refs.test.ts +158 -0
  76. package/src/dispatch/wake-refs.ts +348 -0
  77. package/src/dispatch/window-gate.test.ts +513 -0
  78. package/src/dispatch/window-store.test.ts +162 -0
  79. package/src/dispatch/window-store.ts +102 -0
  80. package/src/entity/change-derivers.test.ts +148 -0
  81. package/src/entity/change-derivers.ts +143 -0
  82. package/src/entity/change-emitter.test.ts +66 -0
  83. package/src/entity/change-emitter.ts +76 -0
  84. package/src/entity/create-handle.ts +344 -0
  85. package/src/entity/cross-pod-read-consistency.it.test.ts +281 -0
  86. package/src/entity/define-entity.ts +157 -0
  87. package/src/entity/diff.test.ts +57 -0
  88. package/src/entity/diff.ts +54 -0
  89. package/src/entity/entity-store.test.ts +30 -0
  90. package/src/entity/entity-store.ts +171 -0
  91. package/src/entity/extension-point.ts +56 -0
  92. package/src/entity/fake-entity-store.ts +130 -0
  93. package/src/entity/hook.ts +19 -0
  94. package/src/entity/index.ts +50 -0
  95. package/src/entity/mutate-handle.test.ts +517 -0
  96. package/src/entity/on-entity-changed.test.ts +189 -0
  97. package/src/entity/on-entity-changed.ts +214 -0
  98. package/src/entity/registry.test.ts +181 -0
  99. package/src/entity/registry.ts +200 -0
  100. package/src/entity/stable-stringify.test.ts +55 -0
  101. package/src/entity/stable-stringify.ts +49 -0
  102. package/src/entity/wake-index.it.test.ts +251 -0
  103. package/src/entity/with-entity-write.test.ts +100 -0
  104. package/src/entity/with-entity-write.ts +69 -0
  105. package/src/entity-driven-trigger.ts +46 -0
  106. package/src/extension-points.ts +35 -0
  107. package/src/gitops-docs.test.ts +215 -0
  108. package/src/gitops-docs.ts +151 -0
  109. package/src/gitops-kinds.test.ts +174 -0
  110. package/src/gitops-kinds.ts +137 -0
  111. package/src/index.ts +355 -11
  112. package/src/migration/flapping-to-window.test.ts +123 -0
  113. package/src/migration/flapping-to-window.ts +205 -0
  114. package/src/router.test.ts +182 -1
  115. package/src/router.ts +73 -2
  116. package/src/schema.ts +236 -3
  117. package/src/script-test-replay.test.ts +88 -0
  118. package/src/script-test-replay.ts +100 -0
  119. package/src/script-test-shell-env.test.ts +41 -0
  120. package/src/script-test-shell-env.ts +89 -0
  121. package/src/script-test.test.ts +386 -0
  122. package/src/script-test.ts +258 -0
  123. package/src/trigger-registry.ts +2 -0
  124. package/src/validate-definition.test.ts +1 -0
  125. package/tsconfig.json +24 -0
@@ -0,0 +1,247 @@
1
+ import type { ServiceRef } from "@checkstack/backend-api";
2
+ import {
3
+ maskSecrets,
4
+ maskSecretsDeep,
5
+ } from "@checkstack/secrets-common";
6
+
7
+ /**
8
+ * Run-scoped accumulation of every secret VALUE resolved during a dispatch
9
+ * run, so the run-state persistence layer can mask it out of step / run
10
+ * output before it is written (and therefore before any DTO / run-detail
11
+ * page can read it). Jenkins-style, by-value, least-privilege: a run's set
12
+ * holds ONLY the values that run actually resolved.
13
+ *
14
+ * Capture happens by wrapping the run's `getService` (see
15
+ * {@link wrapGetServiceForRun}) so that resolving the secret resolver or
16
+ * connection store during the run registers the resolved values here. The
17
+ * resolved values themselves stay in memory only — this registry is never
18
+ * persisted, and a run's entry is dropped when the run reaches a terminal
19
+ * state.
20
+ */
21
+ export interface RunSecretRegistry {
22
+ /** Register resolved secret values for a run (deduped, in memory). */
23
+ register(runId: string, values: Iterable<string>): void;
24
+ /**
25
+ * Associate a step with its run, so step writes (which carry only a
26
+ * stepId) can find the run's mask set without threading runId through
27
+ * every `updateStep` caller.
28
+ */
29
+ linkStep(stepId: string, runId: string): void;
30
+ /** Mask every registered value out of `text` for a run. No-op if none. */
31
+ maskText(runId: string, text: string): string;
32
+ /** Mask every registered value out of a JSON-like payload for a run. */
33
+ maskDeep(runId: string, value: unknown): unknown;
34
+ /** Mask `text` using the run that owns `stepId`. */
35
+ maskTextForStep(stepId: string, text: string): string;
36
+ /** Mask a payload using the run that owns `stepId`. */
37
+ maskDeepForStep(stepId: string, value: unknown): unknown;
38
+ /** Drop a run's accumulated values + its step links (terminal status). */
39
+ drop(runId: string): void;
40
+ }
41
+
42
+ export function createRunSecretRegistry(): RunSecretRegistry {
43
+ const byRun = new Map<string, Set<string>>();
44
+ const stepToRun = new Map<string, string>();
45
+
46
+ const maskTextForRun = (runId: string, text: string): string => {
47
+ const set = byRun.get(runId);
48
+ if (!set || set.size === 0) return text;
49
+ return maskSecrets({ text, values: set });
50
+ };
51
+ const maskDeepForRun = (runId: string, value: unknown): unknown => {
52
+ const set = byRun.get(runId);
53
+ if (!set || set.size === 0) return value;
54
+ return maskSecretsDeep({ value, values: set });
55
+ };
56
+
57
+ return {
58
+ register(runId, values) {
59
+ let set = byRun.get(runId);
60
+ if (!set) {
61
+ set = new Set<string>();
62
+ byRun.set(runId, set);
63
+ }
64
+ for (const v of values) {
65
+ if (typeof v === "string" && v.length > 0) set.add(v);
66
+ }
67
+ },
68
+
69
+ linkStep(stepId, runId) {
70
+ stepToRun.set(stepId, runId);
71
+ },
72
+
73
+ maskText: maskTextForRun,
74
+ maskDeep: maskDeepForRun,
75
+
76
+ maskTextForStep(stepId, text) {
77
+ const runId = stepToRun.get(stepId);
78
+ return runId ? maskTextForRun(runId, text) : text;
79
+ },
80
+ maskDeepForStep(stepId, value) {
81
+ const runId = stepToRun.get(stepId);
82
+ return runId ? maskDeepForRun(runId, value) : value;
83
+ },
84
+
85
+ drop(runId) {
86
+ byRun.delete(runId);
87
+ for (const [stepId, rid] of stepToRun) {
88
+ if (rid === runId) stepToRun.delete(stepId);
89
+ }
90
+ },
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Wrap a run's `getService` so resolving the secret resolver or the
96
+ * connection store registers every resolved value into the
97
+ * {@link RunSecretRegistry} for this run. This is the single capture point
98
+ * for run-wide masking: any action that resolves secrets (script
99
+ * `secretEnv`, a `${{ secrets.NAME }}` template, or a provider connection
100
+ * credential) contributes its resolved values to the run's mask set,
101
+ * least-privilege.
102
+ *
103
+ * `refs` carries the ids to intercept (the resolver + connection-store
104
+ * refs); other services pass through untouched. Ids are passed in (rather
105
+ * than imported) to avoid a hard dependency cycle on the secrets / integration
106
+ * packages from the dispatch core.
107
+ */
108
+ export function wrapGetServiceForRun(input: {
109
+ getService: <T>(ref: ServiceRef<T>) => Promise<T>;
110
+ runId: string;
111
+ registry: RunSecretRegistry;
112
+ /** Service-ref id of the secret resolver (`secretResolverRef`). */
113
+ resolverRefId: string;
114
+ /** Service-ref id of the connection store (`connectionStoreRef`). */
115
+ connectionStoreRefId: string;
116
+ }): <T>(ref: ServiceRef<T>) => Promise<T> {
117
+ const { getService, runId, registry, resolverRefId, connectionStoreRefId } =
118
+ input;
119
+
120
+ return async <T>(ref: ServiceRef<T>): Promise<T> => {
121
+ const service = await getService(ref);
122
+ if (ref.id === resolverRefId) {
123
+ return wrapResolver(service, runId, registry);
124
+ }
125
+ if (ref.id === connectionStoreRefId) {
126
+ return wrapConnectionStore(service, runId, registry);
127
+ }
128
+ return service;
129
+ };
130
+ }
131
+
132
+ // ─── Service proxies that register resolved values ─────────────────────────
133
+
134
+ // Minimal structural shapes of the methods that return secret values. We
135
+ // avoid importing the concrete service types to keep the dispatch core free
136
+ // of a dependency on the secrets / integration packages.
137
+
138
+ interface ResolverLike {
139
+ resolveSecret(input: { name: string }): Promise<string>;
140
+ resolveForRun(input: { secretEnv: Record<string, string> }): Promise<{
141
+ env: Record<string, string>;
142
+ masking: unknown;
143
+ }>;
144
+ resolveBySchema(input: {
145
+ value: unknown;
146
+ schema: unknown;
147
+ }): Promise<{ resolved: unknown; warnings: string[] }>;
148
+ }
149
+
150
+ interface ConnectionStoreLike {
151
+ getConnectionWithCredentials(
152
+ connectionId: string,
153
+ ): Promise<{ config: Record<string, unknown> } | undefined>;
154
+ }
155
+
156
+ function hasResolverShape(s: unknown): s is ResolverLike {
157
+ return (
158
+ typeof s === "object" &&
159
+ s !== null &&
160
+ typeof (s as ResolverLike).resolveForRun === "function" &&
161
+ typeof (s as ResolverLike).resolveSecret === "function"
162
+ );
163
+ }
164
+
165
+ function hasConnectionStoreShape(s: unknown): s is ConnectionStoreLike {
166
+ return (
167
+ typeof s === "object" &&
168
+ s !== null &&
169
+ typeof (s as ConnectionStoreLike).getConnectionWithCredentials ===
170
+ "function"
171
+ );
172
+ }
173
+
174
+ /** Collect every string leaf in a JSON-like value (resolved cred values). */
175
+ function collectStrings(value: unknown, out: string[]): void {
176
+ if (typeof value === "string") {
177
+ out.push(value);
178
+ } else if (Array.isArray(value)) {
179
+ for (const v of value) collectStrings(v, out);
180
+ } else if (value !== null && typeof value === "object") {
181
+ for (const v of Object.values(value)) collectStrings(v, out);
182
+ }
183
+ }
184
+
185
+ function wrapResolver<T>(service: T, runId: string, registry: RunSecretRegistry): T {
186
+ if (!hasResolverShape(service)) return service;
187
+ const inner = service as ResolverLike & Record<string, unknown>;
188
+ // Proxy that intercepts ONLY the three value-returning methods (to
189
+ // register resolved secrets) and forwards every other method / property
190
+ // untouched via Reflect.get. Mirrors `wrapConnectionStore` — a hand-built
191
+ // literal would silently drop any resolver method we didn't re-declare.
192
+ return new Proxy(inner, {
193
+ get(target, prop, receiver) {
194
+ if (prop === "resolveSecret") {
195
+ return async (input: { name: string }) => {
196
+ const value = await inner.resolveSecret(input);
197
+ registry.register(runId, [value]);
198
+ return value;
199
+ };
200
+ }
201
+ if (prop === "resolveForRun") {
202
+ return async (input: { secretEnv: Record<string, string> }) => {
203
+ const result = await inner.resolveForRun(input);
204
+ registry.register(runId, Object.values(result.env));
205
+ return result;
206
+ };
207
+ }
208
+ if (prop === "resolveBySchema") {
209
+ return async (input: { value: unknown; schema: unknown }) => {
210
+ const result = await inner.resolveBySchema(input);
211
+ const strings: string[] = [];
212
+ collectStrings(result.resolved, strings);
213
+ registry.register(runId, strings);
214
+ return result;
215
+ };
216
+ }
217
+ return Reflect.get(target, prop, receiver);
218
+ },
219
+ }) as unknown as T;
220
+ }
221
+
222
+ function wrapConnectionStore<T>(
223
+ service: T,
224
+ runId: string,
225
+ registry: RunSecretRegistry,
226
+ ): T {
227
+ if (!hasConnectionStoreShape(service)) return service;
228
+ const inner = service as ConnectionStoreLike & Record<string, unknown>;
229
+ // Preserve all the store's other methods; only the credential resolver
230
+ // registers values.
231
+ return new Proxy(inner, {
232
+ get(target, prop, receiver) {
233
+ if (prop === "getConnectionWithCredentials") {
234
+ return async (connectionId: string) => {
235
+ const conn = await inner.getConnectionWithCredentials(connectionId);
236
+ if (conn) {
237
+ const strings: string[] = [];
238
+ collectStrings(conn.config, strings);
239
+ registry.register(runId, strings);
240
+ }
241
+ return conn;
242
+ };
243
+ }
244
+ return Reflect.get(target, prop, receiver);
245
+ },
246
+ }) as unknown as T;
247
+ }
@@ -0,0 +1,376 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import type { ServiceRef } from "@checkstack/backend-api";
3
+ import { AutomationDefinitionSchema } from "@checkstack/automation-common";
4
+ import { createRunStore } from "./run-state";
5
+ import { createRunStateStore } from "./run-state-store";
6
+ import { createArtifactStore } from "../artifact-store";
7
+ import {
8
+ createRunSecretRegistry,
9
+ wrapGetServiceForRun,
10
+ } from "./run-secret-registry";
11
+ import { reseedRunSecretRegistry } from "./reseed-run-secrets";
12
+ import type { AdvisoryLockService } from "@checkstack/backend-api";
13
+
14
+ /**
15
+ * Asserts the run-state persistence CHOKE POINT masks resolved secret
16
+ * values out of step / run output before they are written — so any
17
+ * downstream read / DTO / run-detail page is safe by construction.
18
+ *
19
+ * Uses a capturing fake `db` (the established boundary avoids real-DB
20
+ * tests); we assert on the values the store hands to the DB layer.
21
+ */
22
+
23
+ interface Captured {
24
+ inserts: Array<{ table: string; values: Record<string, unknown> }>;
25
+ updates: Array<Record<string, unknown>>;
26
+ }
27
+
28
+ function capturingDb(captured: Captured) {
29
+ // The store calls: insert(table).values(v).returning() and
30
+ // update(table).set(v).where(). We capture v and synthesize ids.
31
+ let lastInsertTable = "";
32
+ const db = {
33
+ insert(table: { [k: string]: unknown }) {
34
+ lastInsertTable = String(
35
+ (table as { _?: { name?: string } })._?.name ?? "table",
36
+ );
37
+ return {
38
+ values(v: Record<string, unknown>) {
39
+ captured.inserts.push({ table: lastInsertTable, values: v });
40
+ return {
41
+ returning() {
42
+ return Promise.resolve([{ id: "generated-id", ...v }]);
43
+ },
44
+ onConflictDoUpdate() {
45
+ return Promise.resolve();
46
+ },
47
+ };
48
+ },
49
+ };
50
+ },
51
+ update() {
52
+ return {
53
+ set(v: Record<string, unknown>) {
54
+ captured.updates.push(v);
55
+ return {
56
+ where() {
57
+ return Promise.resolve();
58
+ },
59
+ };
60
+ },
61
+ };
62
+ },
63
+ };
64
+ // The store's typed signature wants a SafeDatabase; this fake implements
65
+ // only the chains exercised here.
66
+ return db as unknown as Parameters<typeof createRunStore>[0];
67
+ }
68
+
69
+ describe("run-state masking choke point", () => {
70
+ it("masks a step resultPayload + errorMessage that contain a resolved secret before persist", async () => {
71
+ const captured: Captured = { inserts: [], updates: [] };
72
+ const registry = createRunSecretRegistry();
73
+ const store = createRunStore(capturingDb(captured), undefined, registry);
74
+
75
+ // A run resolved this credential during execution.
76
+ registry.register("run-1", ["resolved-cred-XYZ"]);
77
+
78
+ const stepId = await store.createStep({
79
+ runId: "run-1",
80
+ actionPath: "actions[0]",
81
+ actionId: "a1",
82
+ actionKind: "action",
83
+ providerActionId: "integration-jira.create_issue",
84
+ });
85
+
86
+ // A provider HTTP error embedding the credential + a payload echoing it.
87
+ await store.updateStep(stepId, {
88
+ status: "failed",
89
+ errorMessage: "401 from Jira using token resolved-cred-XYZ",
90
+ resultPayload: { detail: { auth: "Bearer resolved-cred-XYZ" } },
91
+ });
92
+
93
+ const stepUpdate = captured.updates.at(-1)!;
94
+ expect(stepUpdate.errorMessage).toBe("401 from Jira using token ****");
95
+ expect(stepUpdate.resultPayload).toEqual({
96
+ detail: { auth: "Bearer ****" },
97
+ });
98
+ expect(JSON.stringify(stepUpdate)).not.toContain("resolved-cred-XYZ");
99
+ });
100
+
101
+ it("masks the run-level errorMessage before persist", async () => {
102
+ const captured: Captured = { inserts: [], updates: [] };
103
+ const registry = createRunSecretRegistry();
104
+ const store = createRunStore(capturingDb(captured), undefined, registry);
105
+ registry.register("run-2", ["run-cred-999"]);
106
+
107
+ await store.updateRunStatus(
108
+ "run-2",
109
+ "failed",
110
+ "run failed: leaked run-cred-999 in error",
111
+ );
112
+
113
+ const runUpdate = captured.updates.at(-1)!;
114
+ expect(runUpdate.errorMessage).toBe("run failed: leaked **** in error");
115
+ expect(JSON.stringify(runUpdate)).not.toContain("run-cred-999");
116
+ });
117
+
118
+ it("least-privilege: a value not resolved in the run is left intact", async () => {
119
+ const captured: Captured = { inserts: [], updates: [] };
120
+ const registry = createRunSecretRegistry();
121
+ const store = createRunStore(capturingDb(captured), undefined, registry);
122
+ // run-3 resolved nothing.
123
+ const stepId = await store.createStep({
124
+ runId: "run-3",
125
+ actionPath: "actions[0]",
126
+ actionId: "a1",
127
+ actionKind: "log",
128
+ providerActionId: null,
129
+ });
130
+ await store.updateStep(stepId, {
131
+ status: "success",
132
+ resultPayload: { message: "not-a-secret-value" },
133
+ });
134
+ const stepUpdate = captured.updates.at(-1)!;
135
+ expect(stepUpdate.resultPayload).toEqual({ message: "not-a-secret-value" });
136
+ });
137
+
138
+ it("drops the run's mask set once the run reaches a terminal status", async () => {
139
+ const captured: Captured = { inserts: [], updates: [] };
140
+ const registry = createRunSecretRegistry();
141
+ const store = createRunStore(capturingDb(captured), undefined, registry);
142
+ registry.register("run-4", ["transient-cred"]);
143
+ await store.updateRunStatus("run-4", "success");
144
+ // After terminal, the value is no longer in the registry (memory-only).
145
+ expect(registry.maskText("run-4", "x=transient-cred")).toBe(
146
+ "x=transient-cred",
147
+ );
148
+ });
149
+ });
150
+
151
+ // ─── L2 cross-pod masking ───────────────────────────────────────────────
152
+
153
+ const RESOLVER_REF_ID = "secrets.resolver";
154
+ const CONNECTION_REF_ID = "integration.connectionStore";
155
+ const RUN_ID = "run-suspended";
156
+ const POD_A_CRED = "podA-resolved-cred-XYZ";
157
+
158
+ /**
159
+ * A SHARED secret backend both pods read through — the value lives in the
160
+ * cluster's secret/connection store, not on any one pod. Pod A and pod B get
161
+ * their own (per-process) mask registry, but resolve against this same store.
162
+ */
163
+ function sharedSecretBackend() {
164
+ return {
165
+ resolver: {
166
+ resolveSecret: async () => POD_A_CRED,
167
+ resolveForRun: async () => ({
168
+ env: { API_TOKEN: POD_A_CRED },
169
+ masking: {},
170
+ }),
171
+ resolveBySchema: async () => ({ resolved: {}, warnings: [] }),
172
+ },
173
+ connectionStore: {
174
+ getConnectionWithCredentials: async () => ({
175
+ config: { baseUrl: "https://jira.example", apiToken: POD_A_CRED },
176
+ }),
177
+ },
178
+ };
179
+ }
180
+
181
+ /** A getService over the shared backend, keyed by the two intercepted refs. */
182
+ function sharedGetService(backend: ReturnType<typeof sharedSecretBackend>) {
183
+ return async <T>(refArg: ServiceRef<T>): Promise<T> => {
184
+ if (refArg.id === RESOLVER_REF_ID) {
185
+ return backend.resolver as unknown as T;
186
+ }
187
+ if (refArg.id === CONNECTION_REF_ID) {
188
+ return backend.connectionStore as unknown as T;
189
+ }
190
+ throw new Error(`unexpected ref ${refArg.id}`);
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Definition whose action uses BOTH a `secretEnv` mapping and a connection
196
+ * — the declared secret refs the resume path must re-resolve.
197
+ */
198
+ function definitionUsingSecrets() {
199
+ return AutomationDefinitionSchema.parse({
200
+ name: "Cross-pod masking",
201
+ triggers: [{ event: "incident.created" }],
202
+ conditions: [],
203
+ actions: [
204
+ {
205
+ action: "integration-jira.create_issue",
206
+ config: {
207
+ connectionId: "conn-1",
208
+ secretEnv: { API_TOKEN: "${{ secrets.jira_token }}" },
209
+ },
210
+ },
211
+ { wait_for_trigger: { event: "incident.resolved" } },
212
+ {
213
+ action: "integration-script.run",
214
+ config: { script: "echo done" },
215
+ },
216
+ ],
217
+ mode: "single",
218
+ max_runs: 1,
219
+ });
220
+ }
221
+
222
+ describe("L2 cross-pod masking — resume on a fresh pod re-seeds the mask set", () => {
223
+ it("WITHOUT re-seed: a fresh pod-B registry persists pod-A's secret UNMASKED (the leak)", async () => {
224
+ // Pod B comes up cold: it never resolved this run's secrets, so its mask
225
+ // set is empty.
226
+ const podBRegistry = createRunSecretRegistry();
227
+ const captured: Captured = { inserts: [], updates: [] };
228
+ const store = createRunStore(capturingDb(captured), undefined, podBRegistry);
229
+
230
+ // Pod B persists a step whose output still carries pod-A's credential
231
+ // (e.g. a carried-over value surfaced post-resume).
232
+ const stepId = await store.createStep({
233
+ runId: RUN_ID,
234
+ actionPath: "actions[2]",
235
+ actionId: "a3",
236
+ actionKind: "action",
237
+ providerActionId: "integration-script.run",
238
+ });
239
+ await store.updateStep(stepId, {
240
+ status: "success",
241
+ resultPayload: { echoed: `token=${POD_A_CRED}` },
242
+ });
243
+
244
+ const stepUpdate = captured.updates.at(-1)!;
245
+ // The leak: an empty mask set leaves the value intact in the persisted row.
246
+ expect(JSON.stringify(stepUpdate)).toContain(POD_A_CRED);
247
+ });
248
+
249
+ it("WITH re-seed: pod B re-resolves declared refs, so the same persist is masked", async () => {
250
+ const backend = sharedSecretBackend();
251
+ const podBRegistry = createRunSecretRegistry();
252
+ const wrappedGetService = wrapGetServiceForRun({
253
+ getService: sharedGetService(backend),
254
+ runId: RUN_ID,
255
+ registry: podBRegistry,
256
+ resolverRefId: RESOLVER_REF_ID,
257
+ connectionStoreRefId: CONNECTION_REF_ID,
258
+ });
259
+
260
+ // The fix: before walking/persisting, the resuming pod re-seeds its mask
261
+ // set from the automation's declared secret refs.
262
+ await reseedRunSecretRegistry({
263
+ getService: wrappedGetService,
264
+ registry: podBRegistry,
265
+ runId: RUN_ID,
266
+ definition: definitionUsingSecrets(),
267
+ resolverRefId: RESOLVER_REF_ID,
268
+ connectionStoreRefId: CONNECTION_REF_ID,
269
+ });
270
+
271
+ // Now every choke point on pod B masks pod-A's value.
272
+ const captured: Captured = { inserts: [], updates: [] };
273
+ const runStore = createRunStore(
274
+ capturingDb(captured),
275
+ undefined,
276
+ podBRegistry,
277
+ );
278
+ const stepId = await runStore.createStep({
279
+ runId: RUN_ID,
280
+ actionPath: "actions[2]",
281
+ actionId: "a3",
282
+ actionKind: "action",
283
+ providerActionId: "integration-script.run",
284
+ });
285
+ await runStore.updateStep(stepId, {
286
+ status: "success",
287
+ resultPayload: { echoed: `token=${POD_A_CRED}` },
288
+ errorMessage: undefined,
289
+ });
290
+ const stepUpdate = captured.updates.at(-1)!;
291
+ expect(stepUpdate.resultPayload).toEqual({ echoed: "token=****" });
292
+ expect(JSON.stringify(stepUpdate)).not.toContain(POD_A_CRED);
293
+
294
+ // The scope-snapshot choke point (run-state) is masked too.
295
+ const noopAdvisoryLock: AdvisoryLockService = {
296
+ tryAcquire: async () => ({ release: async () => {} }),
297
+ };
298
+ const stateCaptured: Captured = { inserts: [], updates: [] };
299
+ const stateStore = createRunStateStore(
300
+ capturingDb(stateCaptured) as unknown as Parameters<
301
+ typeof createRunStateStore
302
+ >[0],
303
+ noopAdvisoryLock,
304
+ podBRegistry,
305
+ );
306
+ await stateStore.upsert({
307
+ runId: RUN_ID,
308
+ scopeSnapshot: { variables: { carried: POD_A_CRED }, artifacts: {} },
309
+ lastActionPath: "actions[2]",
310
+ });
311
+ const persistedScope = stateCaptured.inserts.at(-1)!.values;
312
+ expect(JSON.stringify(persistedScope.scopeSnapshot)).not.toContain(
313
+ POD_A_CRED,
314
+ );
315
+ expect(persistedScope.scopeSnapshot).toEqual({
316
+ variables: { carried: "****" },
317
+ artifacts: {},
318
+ });
319
+
320
+ // And a produced artifact echoing the credential is masked.
321
+ const artCaptured: Captured = { inserts: [], updates: [] };
322
+ const artifactStore = createArtifactStore(
323
+ capturingDb(artCaptured) as unknown as Parameters<
324
+ typeof createArtifactStore
325
+ >[0],
326
+ podBRegistry,
327
+ );
328
+ await artifactStore.record({
329
+ automationId: "auto-1",
330
+ runId: RUN_ID,
331
+ stepId,
332
+ actionId: "a3",
333
+ artifactType: "integration-jira.issue",
334
+ data: { url: "https://x", auth: `Bearer ${POD_A_CRED}` },
335
+ contextKey: "incident-1",
336
+ });
337
+ const persistedArtifact = artCaptured.inserts.at(-1)!.values;
338
+ expect(JSON.stringify(persistedArtifact.data)).not.toContain(POD_A_CRED);
339
+ expect(persistedArtifact.data).toEqual({
340
+ url: "https://x",
341
+ auth: "Bearer ****",
342
+ });
343
+ });
344
+
345
+ it("re-seed is a no-op for a definition that declares no secret refs", async () => {
346
+ const backend = sharedSecretBackend();
347
+ const registry = createRunSecretRegistry();
348
+ const wrapped = wrapGetServiceForRun({
349
+ getService: sharedGetService(backend),
350
+ runId: "run-clean",
351
+ registry,
352
+ resolverRefId: RESOLVER_REF_ID,
353
+ connectionStoreRefId: CONNECTION_REF_ID,
354
+ });
355
+ const definition = AutomationDefinitionSchema.parse({
356
+ name: "No secrets",
357
+ triggers: [{ event: "x.y" }],
358
+ conditions: [],
359
+ actions: [{ variables: { greeting: "hi" } }],
360
+ mode: "single",
361
+ max_runs: 1,
362
+ });
363
+ await reseedRunSecretRegistry({
364
+ getService: wrapped,
365
+ registry,
366
+ runId: "run-clean",
367
+ definition,
368
+ resolverRefId: RESOLVER_REF_ID,
369
+ connectionStoreRefId: CONNECTION_REF_ID,
370
+ });
371
+ // Nothing registered, so an unrelated value is left intact.
372
+ expect(registry.maskText("run-clean", `x=${POD_A_CRED}`)).toBe(
373
+ `x=${POD_A_CRED}`,
374
+ );
375
+ });
376
+ });