@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
@@ -53,6 +53,40 @@ describe("renderConfig", () => {
53
53
  expect(out).toEqual({ ts: "const x = {{ trigger.payload.title }};" });
54
54
  });
55
55
 
56
+ it("leaves x-secret-env fields verbatim so ${{ secrets.NAME }} survives", () => {
57
+ // The secret syntax embeds `{{ secrets.NAME }}`; without the skip the
58
+ // engine would evaluate it (no `secrets` in scope) and collapse the value
59
+ // to "$", failing config validation. See the secretEnv dispatch bug.
60
+ const out = renderConfig({
61
+ config: {
62
+ secretEnv: { secret: "${{ secrets.SECRET }}" },
63
+ message: "hi {{ trigger.payload.title }}",
64
+ },
65
+ jsonSchema: {
66
+ properties: {
67
+ secretEnv: { "x-secret-env": true },
68
+ message: { type: "string" },
69
+ },
70
+ },
71
+ context,
72
+ filters,
73
+ });
74
+ expect(out).toEqual({
75
+ secretEnv: { secret: "${{ secrets.SECRET }}" },
76
+ message: "hi Outage",
77
+ });
78
+ });
79
+
80
+ it("leaves a single x-secret field verbatim", () => {
81
+ const out = renderConfig({
82
+ config: { token: "${{ secrets.API_TOKEN }}" },
83
+ jsonSchema: { properties: { token: { "x-secret": true } } },
84
+ context,
85
+ filters,
86
+ });
87
+ expect(out).toEqual({ token: "${{ secrets.API_TOKEN }}" });
88
+ });
89
+
56
90
  it("falls back to plain rendering when no schema is supplied", () => {
57
91
  const out = renderConfig({
58
92
  config: { a: "{{ trigger.payload.title }}" },
@@ -47,15 +47,33 @@ function hasCodeEditorType(propSchema: unknown): boolean {
47
47
  }
48
48
 
49
49
  /**
50
- * Render an action's top-level `config`, skipping native-code fields.
50
+ * True for a secret-bearing field: a single `${{ secrets.NAME }}` reference
51
+ * (`x-secret`) or a secret→env mapping whose values are such references
52
+ * (`x-secret-env`). These MUST NOT be template-rendered: the secret syntax
53
+ * `${{ secrets.NAME }}` embeds `{{ … }}`, so the `{{ secrets.NAME }}` inside
54
+ * collides with automation interpolation and the engine would evaluate it
55
+ * (against a scope with no `secrets`), collapsing the value to `$` before the
56
+ * secret resolver ever runs. Passing these fields through verbatim keeps the
57
+ * reference intact for resolution at run/test time.
58
+ */
59
+ function hasSecretType(propSchema: unknown): boolean {
60
+ const rec = asRecord(propSchema);
61
+ return rec["x-secret"] === true || rec["x-secret-env"] === true;
62
+ }
63
+
64
+ /**
65
+ * Render an action's top-level `config`, skipping native-code and secret
66
+ * fields.
51
67
  *
52
- * Identical to {@link renderValue} for every field EXCEPT those whose
53
- * JSON schema declares an `x-editor-types` of `shell` / `typescript` /
54
- * `javascript`: those are returned verbatim so any `{{ }}` stays literal.
55
- * This removes the deprecated "templates inside a code field" path so it
56
- * can't be used by accident — code fields get run data via `context` /
57
- * env vars instead. Falls back to {@link renderValue} when the config
58
- * isn't a plain object or no schema is available.
68
+ * Identical to {@link renderValue} for every field EXCEPT those whose JSON
69
+ * schema declares an `x-editor-types` of `shell` / `typescript` /
70
+ * `javascript` (code fields), or `x-secret` / `x-secret-env` (secret fields):
71
+ * those are returned verbatim. Code fields keep any `{{ }}` literal (they get
72
+ * run data via `context` / env vars). Secret fields keep their
73
+ * `${{ secrets.NAME }}` reference intact for the secret resolver — rendering
74
+ * them would evaluate the embedded `{{ secrets.NAME }}` and destroy the
75
+ * reference. Falls back to {@link renderValue} when the config isn't a plain
76
+ * object or no schema is available.
59
77
  */
60
78
  export function renderConfig(args: {
61
79
  config: unknown;
@@ -70,9 +88,11 @@ export function renderConfig(args: {
70
88
  const properties = asRecord(jsonSchema?.["properties"]);
71
89
  const out: Record<string, unknown> = {};
72
90
  for (const [key, value] of Object.entries(asRecord(config))) {
73
- out[key] = hasCodeEditorType(properties[key])
74
- ? value
75
- : renderValue(value, context, filters);
91
+ const propSchema = properties[key];
92
+ out[key] =
93
+ hasCodeEditorType(propSchema) || hasSecretType(propSchema)
94
+ ? value
95
+ : renderValue(value, context, filters);
76
96
  }
77
97
  return out;
78
98
  }
@@ -0,0 +1,230 @@
1
+ import { createServiceRef, type ServiceRef } from "@checkstack/backend-api";
2
+ import { extractErrorMessage } from "@checkstack/common";
3
+ import type { AutomationDefinition, Action } from "@checkstack/automation-common";
4
+
5
+ import type { RunSecretRegistry } from "./run-secret-registry";
6
+
7
+ /**
8
+ * Cross-pod mask re-seeding for resume / stalled-recovery.
9
+ *
10
+ * The run-wide masking registry (see {@link RunSecretRegistry}) is
11
+ * IN-MEMORY and per-process: it only holds the secret values a run resolved
12
+ * on THIS pod. Capture happens lazily, as actions resolve secrets through
13
+ * the wrapped `getService`.
14
+ *
15
+ * That breaks across pods. When pod A resolves a connection credential and
16
+ * the run SUSPENDS (`wait_for_trigger` / `delay` / `wait_until`), and pod B
17
+ * later resumes that run with a FRESH, empty registry, every masking choke
18
+ * point on pod B (step output, run error, scope snapshot, artifact data)
19
+ * runs against an EMPTY mask set. Any persisted value that still contains
20
+ * pod-A's resolved secret — e.g. an error string, a scope variable carried
21
+ * over the suspension, or an artifact echoing a credential — would be
22
+ * written UNMASKED, leaking it to `getRunScopeForReplay` and the run-detail
23
+ * UI. This is the deferred "L2 cross-pod masking" gap.
24
+ *
25
+ * The fix: BEFORE pod B walks/persists anything, RE-SEED its registry by
26
+ * re-resolving the automation's DECLARED secret refs — the `secretEnv`
27
+ * mappings and the `connectionId` references the definition uses — through
28
+ * the run's already-wrapped `getService`. The wrapper auto-registers every
29
+ * resolved value, so pod B re-populates exactly the least-privilege mask
30
+ * set the run is allowed to see (Jenkins-style, by-value). Re-resolving is
31
+ * the SAME set the run resolves during normal execution, so it grants no
32
+ * extra access.
33
+ *
34
+ * Best-effort by construction: a declared secret that no longer resolves
35
+ * (rotated / deleted) simply isn't added to the mask set — the run would
36
+ * have failed re-resolving it during the walk anyway. A resolution failure
37
+ * here must never abort the resume, so each ref is resolved independently
38
+ * and failures are swallowed (the engine surfaces a genuinely-missing
39
+ * secret when the action re-runs).
40
+ */
41
+
42
+ /** Shapes of the two value-returning services we re-resolve through. */
43
+ interface ResolverLike {
44
+ resolveForRun(input: { secretEnv: Record<string, string> }): Promise<{
45
+ env: Record<string, string>;
46
+ masking: unknown;
47
+ }>;
48
+ }
49
+ interface ConnectionStoreLike {
50
+ getConnectionWithCredentials(
51
+ connectionId: string,
52
+ ): Promise<{ config: Record<string, unknown> } | undefined>;
53
+ }
54
+
55
+ function hasResolveForRun(s: unknown): s is ResolverLike {
56
+ return (
57
+ typeof s === "object" &&
58
+ s !== null &&
59
+ typeof (s as ResolverLike).resolveForRun === "function"
60
+ );
61
+ }
62
+ function hasConnectionCredentials(s: unknown): s is ConnectionStoreLike {
63
+ return (
64
+ typeof s === "object" &&
65
+ s !== null &&
66
+ typeof (s as ConnectionStoreLike).getConnectionWithCredentials ===
67
+ "function"
68
+ );
69
+ }
70
+
71
+ /** The declared secret references found by walking a definition's actions. */
72
+ interface DeclaredSecretRefs {
73
+ /** Distinct `secretEnv` mappings (`{ ENV: "${{ secrets.NAME }}" }`). */
74
+ secretEnvMaps: Record<string, string>[];
75
+ /** Distinct, literal `connectionId` values referenced by action configs. */
76
+ connectionIds: Set<string>;
77
+ }
78
+
79
+ /**
80
+ * Collect every `secretEnv` map and literal `connectionId` declared in an
81
+ * automation's action configs, walking the full nested action tree
82
+ * (choose / parallel / repeat / sequence). Templated connection ids (those
83
+ * containing a `${{ … }}` expression) are skipped — they can only be
84
+ * resolved against live scope, and the action that uses them re-registers
85
+ * its credential when it re-runs.
86
+ */
87
+ export function collectDeclaredSecretRefs(
88
+ definition: AutomationDefinition,
89
+ ): DeclaredSecretRefs {
90
+ const secretEnvMaps: Record<string, string>[] = [];
91
+ const connectionIds = new Set<string>();
92
+
93
+ const visitConfig = (config: Record<string, unknown>): void => {
94
+ const secretEnv = config.secretEnv;
95
+ if (isStringRecord(secretEnv) && Object.keys(secretEnv).length > 0) {
96
+ secretEnvMaps.push(secretEnv);
97
+ }
98
+ const connectionId = config.connectionId;
99
+ if (
100
+ typeof connectionId === "string" &&
101
+ connectionId.length > 0 &&
102
+ !connectionId.includes("${{")
103
+ ) {
104
+ connectionIds.add(connectionId);
105
+ }
106
+ };
107
+
108
+ const visit = (action: Action): void => {
109
+ if ("action" in action && isPlainObject(action.config)) {
110
+ visitConfig(action.config);
111
+ }
112
+ if ("choose" in action) {
113
+ for (const branch of action.choose) visitAll(branch.sequence);
114
+ if (action.else) visitAll(action.else);
115
+ }
116
+ if ("parallel" in action) visitAll(action.parallel);
117
+ if ("repeat" in action) visitAll(action.repeat.sequence);
118
+ if ("sequence" in action) visitAll(action.sequence);
119
+ };
120
+
121
+ const visitAll = (actions: ReadonlyArray<Action>): void => {
122
+ for (const action of actions) visit(action);
123
+ };
124
+
125
+ visitAll(definition.actions);
126
+ return { secretEnvMaps, connectionIds };
127
+ }
128
+
129
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
130
+ return value !== null && typeof value === "object" && !Array.isArray(value);
131
+ }
132
+
133
+ function isStringRecord(value: unknown): value is Record<string, string> {
134
+ return (
135
+ isPlainObject(value) &&
136
+ Object.values(value).every((v) => typeof v === "string")
137
+ );
138
+ }
139
+
140
+ export interface ReseedRunSecretRegistryArgs {
141
+ /**
142
+ * The run's ALREADY-WRAPPED `getService` (the same one the engine threads
143
+ * through dispatch). Resolving the secret resolver / connection store
144
+ * through it auto-registers every resolved value into the run's mask set.
145
+ */
146
+ getService: <T>(ref: ServiceRef<T>) => Promise<T>;
147
+ registry: RunSecretRegistry;
148
+ runId: string;
149
+ definition: AutomationDefinition;
150
+ /** Service-ref id of the secret resolver (`secretResolverRef.id`). */
151
+ resolverRefId: string;
152
+ /** Service-ref id of the connection store (`connectionStoreRef.id`). */
153
+ connectionStoreRefId: string;
154
+ logger?: { debug(msg: string): void; warn(msg: string): void };
155
+ }
156
+
157
+ /**
158
+ * Re-resolve an automation's declared secret refs through the run's wrapped
159
+ * `getService`, re-populating the run's in-memory mask set on the resuming
160
+ * pod. Call this on `resumeRun` / `recoverStalledRun` BEFORE walking the
161
+ * tree, so the masking choke points have the full mask set on the new pod.
162
+ */
163
+ export async function reseedRunSecretRegistry(
164
+ args: ReseedRunSecretRegistryArgs,
165
+ ): Promise<void> {
166
+ const { secretEnvMaps, connectionIds } = collectDeclaredSecretRefs(
167
+ args.definition,
168
+ );
169
+ if (secretEnvMaps.length === 0 && connectionIds.size === 0) return;
170
+
171
+ // Resolve the two services once through the wrapped getService. The
172
+ // wrapper returns the value-registering proxies, so the calls below feed
173
+ // the run's mask set.
174
+ let resolver: ResolverLike | undefined;
175
+ let connectionStore: ConnectionStoreLike | undefined;
176
+
177
+ if (secretEnvMaps.length > 0) {
178
+ try {
179
+ const svc = await args.getService(
180
+ createServiceRef<unknown>(args.resolverRefId),
181
+ );
182
+ if (hasResolveForRun(svc)) resolver = svc;
183
+ } catch (error) {
184
+ args.logger?.warn(
185
+ `reseed: could not resolve secret resolver for run ${args.runId}: ${extractErrorMessage(error)}`,
186
+ );
187
+ }
188
+ }
189
+ if (connectionIds.size > 0) {
190
+ try {
191
+ const svc = await args.getService(
192
+ createServiceRef<unknown>(args.connectionStoreRefId),
193
+ );
194
+ if (hasConnectionCredentials(svc)) connectionStore = svc;
195
+ } catch (error) {
196
+ args.logger?.warn(
197
+ `reseed: could not resolve connection store for run ${args.runId}: ${extractErrorMessage(error)}`,
198
+ );
199
+ }
200
+ }
201
+
202
+ if (resolver) {
203
+ for (const secretEnv of secretEnvMaps) {
204
+ try {
205
+ // The wrapped resolver registers every resolved value as a side
206
+ // effect; we don't need the returned env.
207
+ await resolver.resolveForRun({ secretEnv });
208
+ } catch (error) {
209
+ // A rotated/missing secret would fail the action's own re-run; here
210
+ // we just skip adding it to the mask set.
211
+ args.logger?.debug(
212
+ `reseed: secretEnv re-resolve skipped for run ${args.runId}: ${extractErrorMessage(error)}`,
213
+ );
214
+ }
215
+ }
216
+ }
217
+
218
+ if (connectionStore) {
219
+ for (const connectionId of connectionIds) {
220
+ try {
221
+ await connectionStore.getConnectionWithCredentials(connectionId);
222
+ } catch (error) {
223
+ args.logger?.debug(
224
+ `reseed: connection re-resolve skipped for run ${args.runId} (${connectionId}): ${extractErrorMessage(error)}`,
225
+ );
226
+ }
227
+ }
228
+ }
229
+ }
230
+
@@ -0,0 +1,189 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import type { ServiceRef } from "@checkstack/backend-api";
3
+ import {
4
+ createRunSecretRegistry,
5
+ wrapGetServiceForRun,
6
+ } from "./run-secret-registry";
7
+
8
+ const RUN = "run-1";
9
+
10
+ describe("RunSecretRegistry", () => {
11
+ it("masks registered values out of text + deep payloads for a run", () => {
12
+ const reg = createRunSecretRegistry();
13
+ reg.register(RUN, ["sk-live-9999", "db-pass-7777"]);
14
+ expect(reg.maskText(RUN, "auth=sk-live-9999")).toBe("auth=****");
15
+ expect(reg.maskDeep(RUN, { token: "db-pass-7777", n: 1 })).toEqual({
16
+ token: "****",
17
+ n: 1,
18
+ });
19
+ });
20
+
21
+ it("is least-privilege: a value NOT registered for the run is not masked", () => {
22
+ const reg = createRunSecretRegistry();
23
+ reg.register(RUN, ["resolved-in-run"]);
24
+ // A different secret that this run never resolved must pass through.
25
+ expect(reg.maskText(RUN, "x=some-other-secret")).toBe("x=some-other-secret");
26
+ // And a value registered for a DIFFERENT run doesn't leak across.
27
+ reg.register("run-2", ["run-2-secret"]);
28
+ expect(reg.maskText(RUN, "y=run-2-secret")).toBe("y=run-2-secret");
29
+ });
30
+
31
+ it("masks step output via the stepId link", () => {
32
+ const reg = createRunSecretRegistry();
33
+ reg.register(RUN, ["step-secret-abc"]);
34
+ reg.linkStep("step-1", RUN);
35
+ expect(reg.maskTextForStep("step-1", "v=step-secret-abc")).toBe("v=****");
36
+ expect(reg.maskDeepForStep("step-1", { v: "step-secret-abc" })).toEqual({
37
+ v: "****",
38
+ });
39
+ // An unknown step is a no-op (no run link).
40
+ expect(reg.maskTextForStep("unknown", "v=step-secret-abc")).toBe(
41
+ "v=step-secret-abc",
42
+ );
43
+ });
44
+
45
+ it("drop() clears a run's values + step links (memory-only)", () => {
46
+ const reg = createRunSecretRegistry();
47
+ reg.register(RUN, ["gone-after-drop"]);
48
+ reg.linkStep("step-1", RUN);
49
+ reg.drop(RUN);
50
+ expect(reg.maskText(RUN, "x=gone-after-drop")).toBe("x=gone-after-drop");
51
+ expect(reg.maskTextForStep("step-1", "x=gone-after-drop")).toBe(
52
+ "x=gone-after-drop",
53
+ );
54
+ });
55
+ });
56
+
57
+ describe("wrapGetServiceForRun", () => {
58
+ function ref(id: string): ServiceRef<unknown> {
59
+ return { id, T: undefined, toString: () => id };
60
+ }
61
+
62
+ interface ResolverShape {
63
+ resolveSecret(input: { name: string }): Promise<string>;
64
+ resolveForRun(input: {
65
+ secretEnv: Record<string, string>;
66
+ }): Promise<{ env: Record<string, string>; masking: unknown }>;
67
+ resolveBySchema(input: {
68
+ value: unknown;
69
+ schema: unknown;
70
+ }): Promise<{ resolved: unknown; warnings: string[] }>;
71
+ }
72
+ interface StoreShape {
73
+ getConnectionWithCredentials(
74
+ id: string,
75
+ ): Promise<{ config: Record<string, unknown> } | undefined>;
76
+ listConnections(): Promise<unknown[]>;
77
+ }
78
+
79
+ /** A getService that returns `svc` for any ref. */
80
+ function constGetService<S>(svc: S): <T>(r: ServiceRef<T>) => Promise<T> {
81
+ return <T>(_r: ServiceRef<T>) => Promise.resolve(svc as unknown as T);
82
+ }
83
+
84
+ it("registers values resolved via the resolver service (resolveForRun + resolveSecret)", async () => {
85
+ const reg = createRunSecretRegistry();
86
+ const resolver: ResolverShape = {
87
+ resolveSecret: async () => "single-secret",
88
+ resolveForRun: async () => ({
89
+ env: { A: "env-secret-1", B: "env-secret-2" },
90
+ masking: {},
91
+ }),
92
+ resolveBySchema: async () => ({
93
+ resolved: { password: "schema-secret" },
94
+ warnings: [],
95
+ }),
96
+ };
97
+ const wrapped = wrapGetServiceForRun({
98
+ getService: constGetService(resolver),
99
+ runId: RUN,
100
+ registry: reg,
101
+ resolverRefId: "secrets.resolver",
102
+ connectionStoreRefId: "integration.connectionStore",
103
+ });
104
+ const svc = await wrapped(ref("secrets.resolver") as ServiceRef<ResolverShape>);
105
+ await svc.resolveForRun({ secretEnv: {} });
106
+ await svc.resolveSecret({ name: "x" });
107
+ await svc.resolveBySchema({ value: {}, schema: {} });
108
+ // Every resolved value is now in the run's mask set.
109
+ expect(reg.maskText(RUN, "env-secret-1 env-secret-2 single-secret schema-secret")).toBe(
110
+ "**** **** **** ****",
111
+ );
112
+ });
113
+
114
+ it("L3: forwards a non-intercepted resolver method untouched (Proxy + Reflect.get)", async () => {
115
+ const reg = createRunSecretRegistry();
116
+ // Resolver carries an extra method the wrapper does NOT intercept; a
117
+ // hand-built literal would have dropped it.
118
+ const resolver: ResolverShape & {
119
+ describeNamedSecret: (name: string) => string;
120
+ } = {
121
+ resolveSecret: async (_input: { name: string }) =>
122
+ "intercepted-secret-value",
123
+ resolveForRun: async (_input: { secretEnv: Record<string, string> }) => ({
124
+ env: {},
125
+ masking: {},
126
+ }),
127
+ resolveBySchema: async (_input: { value: unknown; schema: unknown }) => ({
128
+ resolved: {},
129
+ warnings: [],
130
+ }),
131
+ // Not one of the three value-returning methods — must survive.
132
+ describeNamedSecret: (name: string) => `meta:${name}`,
133
+ };
134
+ const wrapped = wrapGetServiceForRun({
135
+ getService: constGetService(resolver),
136
+ runId: RUN,
137
+ registry: reg,
138
+ resolverRefId: "secrets.resolver",
139
+ connectionStoreRefId: "integration.connectionStore",
140
+ });
141
+ const svc = (await wrapped(
142
+ ref("secrets.resolver") as ServiceRef<typeof resolver>,
143
+ )) as typeof resolver;
144
+ // The non-intercepted method is forwarded via Reflect.get, not dropped.
145
+ expect(typeof svc.describeNamedSecret).toBe("function");
146
+ expect(svc.describeNamedSecret("API")).toBe("meta:API");
147
+ // And the intercepted method still registers its resolved value.
148
+ await svc.resolveSecret({ name: "x" });
149
+ expect(reg.maskText(RUN, "v=intercepted-secret-value")).toBe("v=****");
150
+ });
151
+
152
+ it("registers values resolved via the connection store credentials", async () => {
153
+ const reg = createRunSecretRegistry();
154
+ const store: StoreShape = {
155
+ getConnectionWithCredentials: async () => ({
156
+ config: { baseUrl: "https://x", apiToken: "conn-cred-secret" },
157
+ }),
158
+ // an unrelated passthrough method
159
+ listConnections: async () => [],
160
+ };
161
+ const wrapped = wrapGetServiceForRun({
162
+ getService: constGetService(store),
163
+ runId: RUN,
164
+ registry: reg,
165
+ resolverRefId: "secrets.resolver",
166
+ connectionStoreRefId: "integration.connectionStore",
167
+ });
168
+ const svc = await wrapped(
169
+ ref("integration.connectionStore") as ServiceRef<StoreShape>,
170
+ );
171
+ await svc.getConnectionWithCredentials("c1");
172
+ expect(reg.maskText(RUN, "token=conn-cred-secret")).toBe("token=****");
173
+ // Passthrough methods still work.
174
+ expect(await svc.listConnections()).toEqual([]);
175
+ });
176
+
177
+ it("passes other services through untouched", async () => {
178
+ const reg = createRunSecretRegistry();
179
+ const other = { foo: () => "bar" };
180
+ const wrapped = wrapGetServiceForRun({
181
+ getService: constGetService(other),
182
+ runId: RUN,
183
+ registry: reg,
184
+ resolverRefId: "secrets.resolver",
185
+ connectionStoreRefId: "integration.connectionStore",
186
+ });
187
+ expect(await wrapped(ref("some.other.service"))).toBe(other);
188
+ });
189
+ });