@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.
- package/README.md +13 -5
- package/dist/db-tool/service.d.ts +2 -1
- package/dist/db-tool/service.d.ts.map +1 -1
- package/dist/db-tool/sql-client.d.ts +1 -0
- package/dist/db-tool/sql-client.d.ts.map +1 -1
- package/dist/gh-tool/issue/triage.d.ts +3 -3
- package/dist/gh-tool/issue/triage.d.ts.map +1 -1
- package/dist/gh-tool/pr/commands.d.ts +7 -5
- package/dist/gh-tool/pr/commands.d.ts.map +1 -1
- package/dist/gh-tool/pr/core.d.ts +18 -0
- package/dist/gh-tool/pr/core.d.ts.map +1 -1
- package/dist/gh-tool/pr/review.d.ts.map +1 -1
- package/dist/gh-tool/pr/stack.d.ts.map +1 -1
- package/dist/gh-tool/read/api.d.ts.map +1 -1
- package/dist/gh-tool/read/pulls.d.ts.map +1 -1
- package/dist/gh-tool/read/review-model.d.ts +11 -8
- package/dist/gh-tool/read/review-model.d.ts.map +1 -1
- package/dist/gh-tool/read/reviews.d.ts.map +1 -1
- package/dist/gh-tool/read/waits.d.ts +1 -1
- package/dist/gh-tool/read/waits.d.ts.map +1 -1
- package/dist/gh-tool/types.d.ts +9 -3
- package/dist/gh-tool/types.d.ts.map +1 -1
- package/dist/observability-tool/logs.d.ts +1 -1
- package/dist/observability-tool/logs.d.ts.map +1 -1
- package/dist/observability-tool/metrics.d.ts +1 -1
- package/dist/observability-tool/metrics.d.ts.map +1 -1
- package/dist/observability-tool/shared.d.ts +2 -1
- package/dist/observability-tool/shared.d.ts.map +1 -1
- package/dist/observability-tool/trace.d.ts +1 -1
- package/dist/observability-tool/trace.d.ts.map +1 -1
- package/dist/shared/content-security.d.ts +3 -1
- package/dist/shared/content-security.d.ts.map +1 -1
- package/dist/shared/secret-env.d.ts +22 -0
- package/dist/shared/secret-env.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/db-tool/main.ts +2 -0
- package/src/db-tool/service.ts +122 -140
- package/src/db-tool/sql-client.ts +7 -4
- package/src/gh-tool/pr/commands.ts +12 -7
- package/src/gh-tool/pr/core.ts +41 -4
- package/src/gh-tool/pr/review.ts +18 -1
- package/src/gh-tool/pr/stack.ts +12 -2
- package/src/gh-tool/read/api.ts +6 -0
- package/src/gh-tool/read/pulls.ts +4 -1
- package/src/gh-tool/read/review-model.ts +11 -8
- package/src/gh-tool/read/reviews.ts +48 -2
- package/src/gh-tool/read/waits.ts +12 -2
- package/src/gh-tool/types.ts +15 -3
- package/src/observability-tool/main.ts +7 -1
- package/src/observability-tool/shared.ts +20 -14
- package/src/shared/content-security.ts +7 -2
- 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:
|
|
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
|
|
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)
|
|
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(
|
|
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 &&
|
package/src/gh-tool/types.ts
CHANGED
|
@@ -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:
|
|
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(
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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 = (
|
|
16
|
-
|
|
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
|
+
}
|