@blogic-cz/agent-tools 1.10.0 → 1.11.1

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 (52) hide show
  1. package/README.md +13 -5
  2. package/dist/db-tool/service.d.ts +2 -1
  3. package/dist/db-tool/service.d.ts.map +1 -1
  4. package/dist/db-tool/sql-client.d.ts +1 -0
  5. package/dist/db-tool/sql-client.d.ts.map +1 -1
  6. package/dist/gh-tool/issue/triage.d.ts +3 -3
  7. package/dist/gh-tool/issue/triage.d.ts.map +1 -1
  8. package/dist/gh-tool/pr/commands.d.ts +7 -5
  9. package/dist/gh-tool/pr/commands.d.ts.map +1 -1
  10. package/dist/gh-tool/pr/core.d.ts +18 -0
  11. package/dist/gh-tool/pr/core.d.ts.map +1 -1
  12. package/dist/gh-tool/pr/review.d.ts.map +1 -1
  13. package/dist/gh-tool/pr/stack.d.ts.map +1 -1
  14. package/dist/gh-tool/read/api.d.ts.map +1 -1
  15. package/dist/gh-tool/read/pulls.d.ts.map +1 -1
  16. package/dist/gh-tool/read/review-model.d.ts +11 -8
  17. package/dist/gh-tool/read/review-model.d.ts.map +1 -1
  18. package/dist/gh-tool/read/reviews.d.ts.map +1 -1
  19. package/dist/gh-tool/read/waits.d.ts +1 -1
  20. package/dist/gh-tool/read/waits.d.ts.map +1 -1
  21. package/dist/gh-tool/types.d.ts +9 -3
  22. package/dist/gh-tool/types.d.ts.map +1 -1
  23. package/dist/observability-tool/logs.d.ts +1 -1
  24. package/dist/observability-tool/logs.d.ts.map +1 -1
  25. package/dist/observability-tool/metrics.d.ts +1 -1
  26. package/dist/observability-tool/metrics.d.ts.map +1 -1
  27. package/dist/observability-tool/shared.d.ts +2 -1
  28. package/dist/observability-tool/shared.d.ts.map +1 -1
  29. package/dist/observability-tool/trace.d.ts +1 -1
  30. package/dist/observability-tool/trace.d.ts.map +1 -1
  31. package/dist/shared/content-security.d.ts +3 -1
  32. package/dist/shared/content-security.d.ts.map +1 -1
  33. package/dist/shared/secret-env.d.ts +22 -0
  34. package/dist/shared/secret-env.d.ts.map +1 -0
  35. package/package.json +1 -1
  36. package/src/db-tool/main.ts +2 -0
  37. package/src/db-tool/service.ts +122 -140
  38. package/src/db-tool/sql-client.ts +7 -4
  39. package/src/gh-tool/pr/commands.ts +12 -7
  40. package/src/gh-tool/pr/core.ts +41 -4
  41. package/src/gh-tool/pr/review.ts +18 -1
  42. package/src/gh-tool/pr/stack.ts +12 -2
  43. package/src/gh-tool/read/api.ts +6 -0
  44. package/src/gh-tool/read/pulls.ts +4 -1
  45. package/src/gh-tool/read/review-model.ts +11 -8
  46. package/src/gh-tool/read/reviews.ts +48 -2
  47. package/src/gh-tool/read/waits.ts +12 -2
  48. package/src/gh-tool/types.ts +15 -3
  49. package/src/observability-tool/main.ts +7 -1
  50. package/src/observability-tool/shared.ts +20 -14
  51. package/src/shared/content-security.ts +7 -2
  52. package/src/shared/secret-env.ts +230 -0
@@ -9,7 +9,7 @@ export const REVIEW_THREADS_QUERY = `
9
9
  comments(first: 100) {
10
10
  nodes {
11
11
  id
12
- databaseId
12
+ databaseId: fullDatabaseId
13
13
  path
14
14
  line
15
15
  body
@@ -36,7 +36,7 @@ export const REVIEW_THREAD_COMMENTS_QUERY = `
36
36
  comments(first: 100, after: $after) {
37
37
  nodes {
38
38
  id
39
- databaseId
39
+ databaseId: fullDatabaseId
40
40
  path
41
41
  line
42
42
  body
@@ -136,15 +136,15 @@ export const LAST_HUMAN_REVIEWER_QUERY = `
136
136
  // Internal types
137
137
  // ---------------------------------------------------------------------------
138
138
 
139
- export type ThreadNode = {
139
+ export type ThreadNode<DatabaseId = number> = {
140
140
  id: string;
141
141
  isResolved: boolean;
142
142
  comments: {
143
143
  nodes: Array<{
144
144
  id: string;
145
- databaseId: number;
145
+ databaseId: DatabaseId;
146
146
  path: string;
147
- line: number;
147
+ line: number | null;
148
148
  body: string;
149
149
  author: { login: string };
150
150
  commit: { oid: string } | null;
@@ -154,14 +154,14 @@ export type ThreadNode = {
154
154
  };
155
155
 
156
156
  export type ThreadCommentsQueryResult = {
157
- node: { comments: ThreadNode["comments"] } | null;
157
+ node: { comments: ThreadNode<string | null>["comments"] } | null;
158
158
  };
159
159
 
160
160
  export type ThreadsQueryResult = {
161
161
  repository: {
162
162
  pullRequest: {
163
163
  reviewThreads: {
164
- nodes: ThreadNode[];
164
+ nodes: ThreadNode<string | null>[];
165
165
  pageInfo: {
166
166
  hasNextPage: boolean;
167
167
  endCursor: string | null;
@@ -243,7 +243,10 @@ export type RawReviewComment = {
243
243
  user: { login: string };
244
244
  body: string;
245
245
  path: string;
246
- line: number;
246
+ line: number | null;
247
+ original_line?: number | null;
248
+ original_commit_id?: string | null;
249
+ diff_hunk?: string | null;
247
250
  created_at: string;
248
251
  updated_at: string;
249
252
  pull_request_review_id?: number | null;
@@ -1,5 +1,6 @@
1
1
  import { Effect, Schema } from "effect";
2
2
  import { GitHubService } from "#gh/read/transport";
3
+ import { GitHubCommandError } from "#gh/errors";
3
4
  import { cachedRead } from "./cache";
4
5
  import { gatedRead } from "./gated";
5
6
  import { restSnapshot, snapshotDigest } from "./rest";
@@ -20,6 +21,28 @@ import type {
20
21
  RawPullRequestReview,
21
22
  } from "./review-model";
22
23
 
24
+ const CachedThreads = Schema.Array(
25
+ Schema.Struct({
26
+ id: Schema.String,
27
+ isResolved: Schema.Boolean,
28
+ comments: Schema.Struct({
29
+ nodes: Schema.Array(
30
+ Schema.Struct({
31
+ id: Schema.String,
32
+ databaseId: Schema.Number.check(
33
+ Schema.makeFilter((id) => Number.isSafeInteger(id) && id > 0),
34
+ ),
35
+ path: Schema.String,
36
+ line: Schema.NullOr(Schema.Number),
37
+ body: Schema.String,
38
+ author: Schema.Struct({ login: Schema.String }),
39
+ commit: Schema.NullOr(Schema.Struct({ oid: Schema.String })),
40
+ }),
41
+ ),
42
+ }),
43
+ }),
44
+ );
45
+
23
46
  const originThreads = Effect.fn("pr.fetchAllThreadNodes")(function* (
24
47
  repoInfo: RepoRef,
25
48
  pr: number,
@@ -50,7 +73,27 @@ const originThreads = Effect.fn("pr.fetchAllThreadNodes")(function* (
50
73
  node.comments.pageInfo = commentsResponse.node.comments.pageInfo;
51
74
  commentsAfter = commentsResponse.node.comments.pageInfo?.endCursor ?? null;
52
75
  }
53
- nodes.push(node);
76
+ const comments: ThreadNode["comments"]["nodes"] = [];
77
+ for (const comment of node.comments.nodes) {
78
+ // fullDatabaseId is a GraphQL BigInt string, aliased to the existing internal key.
79
+ const rawId: unknown = comment.databaseId;
80
+ const databaseId = typeof rawId === "string" ? Number(rawId) : rawId;
81
+ if (
82
+ typeof databaseId !== "number" ||
83
+ !Number.isSafeInteger(databaseId) ||
84
+ databaseId < 1 ||
85
+ (typeof rawId === "string" && String(databaseId) !== rawId)
86
+ ) {
87
+ return yield* new GitHubCommandError({
88
+ command: "gh api graphql",
89
+ exitCode: 1,
90
+ message: "GitHub returned a missing or unsafe review comment ID",
91
+ stderr: "Cannot represent fullDatabaseId as a positive safe integer",
92
+ });
93
+ }
94
+ comments.push({ ...comment, databaseId });
95
+ }
96
+ nodes.push({ ...node, comments: { ...node.comments, nodes: comments } });
54
97
  }
55
98
 
56
99
  if (!page.pageInfo.hasNextPage || page.pageInfo.endCursor === null) {
@@ -86,7 +129,10 @@ export const reviewThreads = Effect.fn("reads.reviewThreads")(function* (
86
129
  return yield* gatedRead({
87
130
  key: { op: "reviewThreads", repo, pr },
88
131
  consistency,
89
- decode: (raw) => Schema.decodeUnknownSync(Schema.Array(Schema.Unknown))(raw) as ThreadNode[],
132
+ decode: (raw): ThreadNode[] => {
133
+ Schema.decodeUnknownSync(CachedThreads)(raw);
134
+ return raw as ThreadNode[];
135
+ },
90
136
  fetch: originThreads(repo, pr),
91
137
  signal: reviewPages(repo, pr, "prComments").pipe(Effect.map(snapshotDigest)),
92
138
  maxAgeMs: () => OPS.reviewThreads.maxAgeMs,
@@ -7,7 +7,7 @@ const until = <A, E, R>(
7
7
  read: Effect.Effect<A, E, R>,
8
8
  done: (value: A) => boolean,
9
9
  opts: {
10
- readonly intervalMs: number;
10
+ readonly intervalMs: number | ((value: A) => number);
11
11
  readonly timeoutMs: number;
12
12
  readonly initial?: { readonly value: A };
13
13
  readonly readAtDeadline?: boolean;
@@ -31,7 +31,17 @@ const until = <A, E, R>(
31
31
  return;
32
32
  }
33
33
  yield* Effect.sleep(
34
- Duration.millis(Math.min(Math.max(5000, opts.intervalMs), remaining)),
34
+ Duration.millis(
35
+ Math.min(
36
+ Math.max(
37
+ 5000,
38
+ typeof opts.intervalMs === "number"
39
+ ? opts.intervalMs
40
+ : opts.intervalMs(state.value),
41
+ ),
42
+ remaining,
43
+ ),
44
+ ),
35
45
  );
36
46
  if (
37
47
  Number(yield* Clock.currentTimeMillis) >= deadline &&
@@ -35,7 +35,10 @@ export type ReviewThread = {
35
35
  feedbackOrigin: FeedbackOrigin;
36
36
  commentId: number;
37
37
  path: string;
38
- line: number;
38
+ line: number | null;
39
+ originalLine: number | null;
40
+ originalCommitSha: string | null;
41
+ diffHunk: string | null;
39
42
  body: string;
40
43
  isResolved: boolean;
41
44
  hasReply: boolean;
@@ -58,7 +61,10 @@ export type ReviewComment = {
58
61
  author: string;
59
62
  body: string;
60
63
  path: string;
61
- line: number;
64
+ line: number | null;
65
+ originalLine: number | null;
66
+ originalCommitSha: string | null;
67
+ diffHunk: string | null;
62
68
  createdAt: string;
63
69
  reviewId: number | null;
64
70
  /**
@@ -340,7 +346,13 @@ export type StackView = {
340
346
 
341
347
  export type StackMergeBlocker = {
342
348
  number: number;
343
- reason: "draft" | "not_mergeable" | "mergeability_unknown" | "checks_failing" | "checks_pending";
349
+ reason:
350
+ | "draft"
351
+ | "not_mergeable"
352
+ | "mergeability_unknown"
353
+ | "checks_failing"
354
+ | "checks_pending"
355
+ | "checks_none_reported";
344
356
  detail: string;
345
357
  };
346
358
 
@@ -7,6 +7,7 @@ import { Command } from "effect/cli";
7
7
  import { ConfigServiceLayer } from "#config";
8
8
  import { AuditServiceLayer, withAudit } from "#shared/audit";
9
9
  import { withRedactedOutput } from "#shared/output-boundary";
10
+ import { SecretEnvResolver } from "#shared/secret-env";
10
11
  import { makeSchemaCommand, renderCauseToStderr, VERSION } from "#shared";
11
12
 
12
13
  import { metricsCommand } from "./metrics";
@@ -24,7 +25,12 @@ const mainCommand = Command.make("observability-tool", {}).pipe(
24
25
 
25
26
  const cli = Command.run(mainCommand, { version: VERSION, renderErrors: false });
26
27
 
27
- const MainLayer = Layer.mergeAll(BunServices.layer, ConfigServiceLayer, AuditServiceLayer);
28
+ const MainLayer = Layer.mergeAll(
29
+ BunServices.layer,
30
+ ConfigServiceLayer,
31
+ AuditServiceLayer,
32
+ SecretEnvResolver.liveLayer,
33
+ );
28
34
 
29
35
  const program = withRedactedOutput(withAudit("observability", cli)).pipe(
30
36
  Effect.provide(MainLayer),
@@ -5,7 +5,8 @@ import { ConfigService, getToolConfig } from "#config";
5
5
  import type { ObservabilityConfig } from "#config";
6
6
 
7
7
  import { ObservabilityToolError } from "./errors";
8
- import { registerResolvedSecret, unsafeOutboundTextReason } from "#shared/content-security";
8
+ import { redactSensitiveText, unsafeOutboundTextReason } from "#shared/content-security";
9
+ import { SecretEnvResolver } from "#shared/secret-env";
9
10
  import type {
10
11
  DsQueryOpts,
11
12
  DsQueryResponse,
@@ -48,18 +49,15 @@ export const profileOption = Flag.optional(
48
49
  ),
49
50
  );
50
51
 
51
- function resolveToken(tokenEnvVar?: string): string | undefined {
52
+ function resolveToken(
53
+ resolver: SecretEnvResolver["Service"],
54
+ tokenEnvVar?: string,
55
+ ): Promise<string | undefined> {
52
56
  if (!tokenEnvVar) {
53
- return undefined;
54
- }
55
-
56
- const token = process.env[tokenEnvVar];
57
- if (!token) {
58
- throw new Error(`${tokenEnvVar} environment variable is not set`);
57
+ return Promise.resolve(undefined);
59
58
  }
60
59
 
61
- registerResolvedSecret(token);
62
- return token;
60
+ return resolver.resolveSecretEnv(tokenEnvVar);
63
61
  }
64
62
 
65
63
  async function discoverDatasources(url: string, token?: string): Promise<GrafanaDatasource[]> {
@@ -68,7 +66,9 @@ async function discoverDatasources(url: string, token?: string): Promise<Grafana
68
66
 
69
67
  if (!response.ok) {
70
68
  const body = await response.text().catch(() => "");
71
- throw new Error(`Grafana API ${response.status}: /api/datasources — ${body}`);
69
+ throw new Error(
70
+ redactSensitiveText(`Grafana API ${response.status}: /api/datasources — ${body}`),
71
+ );
72
72
  }
73
73
 
74
74
  return (await response.json()) as GrafanaDatasource[];
@@ -102,13 +102,14 @@ export function requireTempoUid(
102
102
  async function resolveFromProfile(
103
103
  profile: ObservabilityConfig | undefined,
104
104
  env: string,
105
+ resolver: SecretEnvResolver["Service"],
105
106
  ): Promise<ObservabilityEnvConfig | undefined> {
106
107
  const environment = profile?.environments[env];
107
108
  if (!environment) {
108
109
  return undefined;
109
110
  }
110
111
 
111
- const token = resolveToken(environment.tokenEnvVar);
112
+ const token = await resolveToken(resolver, environment.tokenEnvVar);
112
113
  const datasources = await discoverDatasources(environment.url, token);
113
114
 
114
115
  // Tempo is optional: metrics/logs work on Grafana instances without it (e.g. Percona PMM).
@@ -157,6 +158,7 @@ function resolveFromEnv(
157
158
  export const resolveConfig = (env: string, profile: Option.Option<string>) =>
158
159
  Effect.gen(function* () {
159
160
  const config = yield* ConfigService;
161
+ const secretEnvResolver = yield* SecretEnvResolver;
160
162
  const profileName = Option.getOrUndefined(profile);
161
163
  const observabilityConfig = getToolConfig<ObservabilityConfig>(
162
164
  config,
@@ -166,7 +168,11 @@ export const resolveConfig = (env: string, profile: Option.Option<string>) =>
166
168
 
167
169
  return yield* Effect.tryPromise({
168
170
  try: async () => {
169
- const profileResolved = await resolveFromProfile(observabilityConfig, env);
171
+ const profileResolved = await resolveFromProfile(
172
+ observabilityConfig,
173
+ env,
174
+ secretEnvResolver,
175
+ );
170
176
  if (profileResolved) {
171
177
  return profileResolved;
172
178
  }
@@ -258,7 +264,7 @@ export function observabilityFetch<T>(
258
264
 
259
265
  if (!response.ok) {
260
266
  const body = await response.text().catch(() => "");
261
- throw new Error(`Grafana API ${response.status}: ${path} — ${body}`);
267
+ throw new Error(redactSensitiveText(`Grafana API ${response.status}: ${path} — ${body}`));
262
268
  }
263
269
 
264
270
  return (await response.json()) as T;
@@ -12,8 +12,13 @@ const MIN_SECRET_LENGTH = 8;
12
12
  const resolvedSecrets = new Set<string>();
13
13
 
14
14
  /** Redact a credential a tool resolved outside a sensitively named environment variable. */
15
- export const registerResolvedSecret = (value: string) => {
16
- if (value.length >= MIN_SECRET_LENGTH) resolvedSecrets.add(value);
15
+ export const registerResolvedSecret = (
16
+ value: string,
17
+ options: { readonly allowShort?: boolean } = {},
18
+ ) => {
19
+ if (value.length > 0 && (options.allowShort === true || value.length >= MIN_SECRET_LENGTH)) {
20
+ resolvedSecrets.add(value);
21
+ }
17
22
  };
18
23
 
19
24
  const sensitiveEnvironmentValues = (environment: Record<string, string | undefined>) =>
@@ -0,0 +1,230 @@
1
+ import { spawn } from "node:child_process";
2
+ import { tmpdir } from "node:os";
3
+ import { relative, resolve as resolvePath } from "node:path";
4
+ import { Context, Layer } from "effect";
5
+
6
+ import { registerResolvedSecret } from "./content-security";
7
+
8
+ const SECRET_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
9
+ const CONFIG_NAME_PATTERN = /^[A-Za-z0-9_]+$/;
10
+ const DEFAULT_TIMEOUT_MS = 10_000;
11
+ const STARTUP_ENV_NAMES = [
12
+ "PATH",
13
+ "HOME",
14
+ "USER",
15
+ "LOGNAME",
16
+ "SHELL",
17
+ "TERM",
18
+ "LANG",
19
+ "LC_ALL",
20
+ "TMPDIR",
21
+ "XDG_CONFIG_HOME",
22
+ "ZDOTDIR",
23
+ ] as const;
24
+
25
+ export type LookupResult =
26
+ | { readonly _tag: "found"; readonly value: string }
27
+ | { readonly _tag: "absent" }
28
+ | { readonly _tag: "failed"; readonly reason: string };
29
+
30
+ export type ZshRunner = (name: string, timeoutMs: number) => Promise<LookupResult>;
31
+
32
+ const failed = (name: string): LookupResult => ({ _tag: "failed", reason: name });
33
+
34
+ type FrameInspection =
35
+ | { readonly _tag: "incomplete" }
36
+ | { readonly _tag: "malformed" }
37
+ | { readonly _tag: "complete"; readonly result: LookupResult };
38
+
39
+ const inspectFrame = (frame: Buffer): FrameInspection => {
40
+ const firstNewline = frame.indexOf(10);
41
+ if (firstNewline < 0) {
42
+ const prefix = frame.toString("ascii");
43
+ return "set".startsWith(prefix) || "unset".startsWith(prefix)
44
+ ? { _tag: "incomplete" }
45
+ : { _tag: "malformed" };
46
+ }
47
+
48
+ const state = frame.subarray(0, firstNewline).toString("ascii");
49
+ if (state === "unset") {
50
+ return firstNewline === frame.length - 1
51
+ ? { _tag: "complete", result: { _tag: "absent" } }
52
+ : { _tag: "malformed" };
53
+ }
54
+ if (state !== "set") return { _tag: "malformed" };
55
+
56
+ const lengthEnd = frame.indexOf(10, firstNewline + 1);
57
+ if (lengthEnd < 0) {
58
+ const lengthPrefix = frame.subarray(firstNewline + 1).toString("ascii");
59
+ if (/^(0|[1-9][0-9]*)$/.test(lengthPrefix) || lengthPrefix === "") {
60
+ return { _tag: "incomplete" };
61
+ }
62
+ return { _tag: "malformed" };
63
+ }
64
+ const lengthText = frame.subarray(firstNewline + 1, lengthEnd).toString("ascii");
65
+ if (!/^(0|[1-9][0-9]*)$/.test(lengthText)) return { _tag: "malformed" };
66
+
67
+ const length = Number(lengthText);
68
+ const start = lengthEnd + 1;
69
+ if (!Number.isSafeInteger(length)) return { _tag: "malformed" };
70
+ if (start + length < frame.length) return { _tag: "malformed" };
71
+ if (start + length > frame.length) return { _tag: "incomplete" };
72
+ return {
73
+ _tag: "complete",
74
+ result: { _tag: "found", value: frame.subarray(start).toString("utf8") },
75
+ };
76
+ };
77
+
78
+ const runInteractiveZsh: ZshRunner = (name, timeoutMs) =>
79
+ new Promise((resolve) => {
80
+ if (process.env.VITEST !== undefined) {
81
+ const home = process.env.HOME;
82
+ const zdotdir = process.env.ZDOTDIR;
83
+ const relativeZdotdir = zdotdir ? relative(tmpdir(), resolvePath(zdotdir)) : "..";
84
+ if (
85
+ !home ||
86
+ !zdotdir ||
87
+ resolvePath(home) !== resolvePath(zdotdir) ||
88
+ relativeZdotdir === ".." ||
89
+ relativeZdotdir.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)
90
+ ) {
91
+ resolve(failed(name));
92
+ return;
93
+ }
94
+ }
95
+
96
+ const childEnv = Object.fromEntries(
97
+ STARTUP_ENV_NAMES.flatMap((key) => {
98
+ const value = process.env[key];
99
+ return value === undefined ? [] : [[key, value]];
100
+ }),
101
+ );
102
+ const script = `export LC_ALL=C; if (( \${+parameters[$1]} )); then value=\${(P)1}; print -ru3 -- "set"; print -ru3 -- "\${#value}"; print -rn -- "$value" >&3; else print -ru3 -- "unset"; fi`;
103
+
104
+ let child: ReturnType<typeof spawn>;
105
+ try {
106
+ child = spawn("zsh", ["-i", "-c", script, "agent-tools-secret-env", name], {
107
+ detached: true,
108
+ env: childEnv,
109
+ stdio: ["ignore", "ignore", "ignore", "pipe"],
110
+ });
111
+ } catch {
112
+ resolve(failed(name));
113
+ return;
114
+ }
115
+
116
+ const stream = child.stdio[3];
117
+ if (!stream || typeof stream === "number") {
118
+ child.kill("SIGKILL");
119
+ resolve(failed(name));
120
+ return;
121
+ }
122
+
123
+ const chunks: Buffer[] = [];
124
+ let exited = false;
125
+ let ended = false;
126
+ let exitCode: number | null = null;
127
+ let settled = false;
128
+ const killProcessGroup = () => {
129
+ if (child.pid === undefined) return;
130
+ try {
131
+ process.kill(-child.pid, "SIGKILL");
132
+ } catch {
133
+ // The process group may already have exited.
134
+ }
135
+ };
136
+ const finish = (result: LookupResult) => {
137
+ if (settled) return;
138
+ settled = true;
139
+ clearTimeout(timer);
140
+ stream.destroy();
141
+ killProcessGroup();
142
+ resolve(result);
143
+ };
144
+ const checkFrame = () => {
145
+ if (settled) return;
146
+ const inspection = inspectFrame(Buffer.concat(chunks));
147
+ if (inspection._tag === "malformed") return finish(failed(name));
148
+ if (!exited) return;
149
+ if (exitCode !== 0) return finish(failed(name));
150
+ if (inspection._tag === "complete") finish(inspection.result);
151
+ else if (ended) finish(failed(name));
152
+ };
153
+ const timer = setTimeout(() => {
154
+ finish(failed(name));
155
+ }, timeoutMs);
156
+
157
+ stream.on("data", (chunk: Buffer | string) => {
158
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
159
+ checkFrame();
160
+ });
161
+ stream.on("end", () => {
162
+ ended = true;
163
+ checkFrame();
164
+ });
165
+ stream.on("error", () => finish(failed(name)));
166
+ child.on("error", () => finish(failed(name)));
167
+ child.on("exit", (code) => {
168
+ exited = true;
169
+ exitCode = code;
170
+ checkFrame();
171
+ });
172
+ });
173
+
174
+ export class SecretEnvResolver extends Context.Service<
175
+ SecretEnvResolver,
176
+ {
177
+ readonly resolveSecretEnv: (name: string) => Promise<string>;
178
+ readonly lookupConfigEnv: (name: string) => Promise<LookupResult>;
179
+ }
180
+ >()("@agent-tools/SecretEnvResolver") {
181
+ static get liveLayer() {
182
+ return makeSecretEnvResolverLayer();
183
+ }
184
+ static readonly testLayer = (runner: ZshRunner = () => Promise.resolve({ _tag: "absent" })) =>
185
+ makeSecretEnvResolverLayer(runner);
186
+ }
187
+
188
+ export function makeSecretEnvResolverLayer(
189
+ runner: ZshRunner = runInteractiveZsh,
190
+ timeoutMs = DEFAULT_TIMEOUT_MS,
191
+ ) {
192
+ return Layer.sync(SecretEnvResolver, () => {
193
+ const cache = new Map<string, Promise<LookupResult>>();
194
+ const lookup = (name: string, emptyProcessValueWins: boolean): Promise<LookupResult> => {
195
+ const fromProcess = process.env[name];
196
+ if (fromProcess !== undefined && (emptyProcessValueWins || fromProcess !== "")) {
197
+ return Promise.resolve({ _tag: "found", value: fromProcess });
198
+ }
199
+
200
+ let result = cache.get(name);
201
+ if (!result) {
202
+ result = Promise.resolve()
203
+ .then(() => runner(name, timeoutMs))
204
+ .catch(() => failed(name));
205
+ cache.set(name, result);
206
+ }
207
+ return result;
208
+ };
209
+ const lookupConfigEnv = (name: string): Promise<LookupResult> => {
210
+ if (!CONFIG_NAME_PATTERN.test(name)) return Promise.resolve(failed(name));
211
+ return lookup(name, true);
212
+ };
213
+ const resolveSecretEnv = async (name: string): Promise<string> => {
214
+ if (!SECRET_NAME_PATTERN.test(name)) {
215
+ throw new Error(`Invalid environment variable name: ${name}`);
216
+ }
217
+ const result = await lookup(name, false);
218
+ if (result._tag !== "found") {
219
+ throw new Error(`${name} is not in the environment or in the interactive zsh`);
220
+ }
221
+ if (result.value.length < 4) {
222
+ throw new Error(`${name} is too short to redact safely`);
223
+ }
224
+ registerResolvedSecret(result.value, { allowShort: true });
225
+ return result.value;
226
+ };
227
+
228
+ return SecretEnvResolver.of({ resolveSecretEnv, lookupConfigEnv });
229
+ });
230
+ }