@app-vitals/agent-lib 0.0.1 → 0.1.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/admin-types.ts +4097 -0
- package/chat-notify.ts +41 -0
- package/chat-notify.unit.test.ts +88 -0
- package/claim-ttl.ts +12 -0
- package/claim-ttl.unit.test.ts +26 -0
- package/claude-timeout.ts +10 -0
- package/claude-timeout.unit.test.ts +8 -0
- package/cli-flags.ts +83 -0
- package/cli-flags.unit.test.ts +137 -0
- package/clone-plan.ts +73 -0
- package/clone-plan.unit.test.ts +69 -0
- package/default-account.ts +5 -0
- package/default-account.unit.test.ts +8 -0
- package/default-agent-env.ts +15 -0
- package/default-agent-env.unit.test.ts +19 -0
- package/github-login.ts +20 -0
- package/github-login.unit.test.ts +78 -0
- package/graceful-shutdown.ts +143 -0
- package/graceful-shutdown.unit.test.ts +236 -0
- package/http.ts +14 -0
- package/http.unit.test.ts +42 -0
- package/org-repo.ts +9 -0
- package/org-repo.unit.test.ts +42 -0
- package/package-exports.unit.test.ts +21 -0
- package/package.json +36 -4
- package/pg-sslmode.ts +66 -0
- package/pg-sslmode.unit.test.ts +74 -0
- package/pool-observability.ts +209 -0
- package/pool-observability.unit.test.ts +233 -0
- package/pricing.ts +98 -0
- package/pricing.unit.test.ts +214 -0
- package/progress-phases.ts +58 -0
- package/progress-phases.unit.test.ts +50 -0
- package/request-context.ts +23 -0
- package/request-context.unit.test.ts +51 -0
- package/secret-env-vars.ts +41 -0
- package/secret-env-vars.unit.test.ts +62 -0
- package/sentry.ts +201 -0
- package/sentry.unit.test.ts +294 -0
- package/task-store-types.ts +3256 -0
- package/task-store-types.unit.test.ts +24 -0
- package/web/toolbar.ts +244 -0
- package/web/toolbar.unit.test.ts +243 -0
- package/README.md +0 -3
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
CONTEXT_WINDOW,
|
|
4
|
+
OPUS_MODEL,
|
|
5
|
+
RATES,
|
|
6
|
+
calculateCost,
|
|
7
|
+
normalizeModelToRateKey,
|
|
8
|
+
} from "./pricing.ts";
|
|
9
|
+
|
|
10
|
+
const SAMPLE_USAGE = {
|
|
11
|
+
input_tokens: 100,
|
|
12
|
+
output_tokens: 200,
|
|
13
|
+
cache_read_input_tokens: 50,
|
|
14
|
+
cache_creation_input_tokens: 10,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const ZERO_USAGE = {
|
|
18
|
+
input_tokens: 0,
|
|
19
|
+
output_tokens: 0,
|
|
20
|
+
cache_read_input_tokens: 0,
|
|
21
|
+
cache_creation_input_tokens: 0,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe("OPUS_MODEL", () => {
|
|
25
|
+
test("is the canonical opus key present in RATES", () => {
|
|
26
|
+
expect(OPUS_MODEL).toBe("claude-opus-4-8");
|
|
27
|
+
expect(RATES[OPUS_MODEL]).toBeDefined();
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("calculateCost — non-zero for known keys", () => {
|
|
32
|
+
test("returns non-zero for claude-sonnet-4-6 with sample usage", () => {
|
|
33
|
+
expect(calculateCost(SAMPLE_USAGE, "claude-sonnet-4-6")).toBeGreaterThan(0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("returns non-zero for claude-haiku-4-5 with sample usage", () => {
|
|
37
|
+
expect(calculateCost(SAMPLE_USAGE, "claude-haiku-4-5")).toBeGreaterThan(0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("returns non-zero for claude-opus-4-8 with sample usage", () => {
|
|
41
|
+
expect(calculateCost(SAMPLE_USAGE, "claude-opus-4-8")).toBeGreaterThan(0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("returns 0 for unknown model", () => {
|
|
45
|
+
expect(calculateCost(SAMPLE_USAGE, "gpt-4")).toBe(0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("returns 0 for zero usage", () => {
|
|
49
|
+
expect(calculateCost(ZERO_USAGE, "claude-sonnet-4-6")).toBe(0);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("normalizeModelToRateKey — haiku family", () => {
|
|
54
|
+
test('"haiku" maps to haiku canonical key', () => {
|
|
55
|
+
expect(normalizeModelToRateKey("haiku")).toBe("claude-haiku-5-5");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('"claude-haiku-4-5" maps to haiku canonical key', () => {
|
|
59
|
+
expect(normalizeModelToRateKey("claude-haiku-4-5")).toBe("claude-haiku-4-5");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('"claude-haiku-4-6" maps to haiku canonical key', () => {
|
|
63
|
+
expect(normalizeModelToRateKey("claude-haiku-4-6")).toBe("claude-haiku-4-5");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("normalizeModelToRateKey — sonnet family", () => {
|
|
68
|
+
test('"sonnet" maps to sonnet canonical key', () => {
|
|
69
|
+
expect(normalizeModelToRateKey("sonnet")).toBe("claude-sonnet-5-5");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('"claude-sonnet-4-6" maps to sonnet canonical key', () => {
|
|
73
|
+
expect(normalizeModelToRateKey("claude-sonnet-4-6")).toBe("claude-sonnet-4-6");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("normalizeModelToRateKey — opus family", () => {
|
|
78
|
+
test('"opus" maps to opus canonical key', () => {
|
|
79
|
+
expect(normalizeModelToRateKey("opus")).toBe("claude-opus-5-5");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('"claude-opus-4-8" maps to opus canonical key', () => {
|
|
83
|
+
expect(normalizeModelToRateKey("claude-opus-4-8")).toBe("claude-opus-4-8");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('"claude-opus-4-7" maps to opus canonical key', () => {
|
|
87
|
+
expect(normalizeModelToRateKey("claude-opus-4-7")).toBe("claude-opus-4-8");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('"claude-opus-4-6" maps to opus canonical key', () => {
|
|
91
|
+
expect(normalizeModelToRateKey("claude-opus-4-6")).toBe("claude-opus-4-8");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("normalizeModelToRateKey — fable family", () => {
|
|
96
|
+
test('"fable" maps to fable canonical key', () => {
|
|
97
|
+
expect(normalizeModelToRateKey("fable")).toBe("claude-fable-5");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('"claude-fable-5" maps to fable canonical key', () => {
|
|
101
|
+
expect(normalizeModelToRateKey("claude-fable-5")).toBe("claude-fable-5");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("normalizeModelToRateKey — dated CLI snapshot IDs", () => {
|
|
106
|
+
test('"claude-haiku-4-5-20251001" strips snapshot suffix and maps to haiku canonical key', () => {
|
|
107
|
+
expect(normalizeModelToRateKey("claude-haiku-4-5-20251001")).toBe(
|
|
108
|
+
"claude-haiku-4-5",
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('"claude-haiku-4-6-20251001" strips snapshot suffix and maps to haiku canonical key', () => {
|
|
113
|
+
expect(normalizeModelToRateKey("claude-haiku-4-6-20251001")).toBe(
|
|
114
|
+
"claude-haiku-4-5",
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('"claude-sonnet-4-6-20250929" strips snapshot suffix and maps to sonnet canonical key', () => {
|
|
119
|
+
expect(normalizeModelToRateKey("claude-sonnet-4-6-20250929")).toBe(
|
|
120
|
+
"claude-sonnet-4-6",
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('"claude-opus-4-8-20250915" strips snapshot suffix and maps to opus canonical key', () => {
|
|
125
|
+
expect(normalizeModelToRateKey("claude-opus-4-8-20250915")).toBe(
|
|
126
|
+
"claude-opus-4-8",
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('"claude-opus-4-7-20250601" strips snapshot suffix and maps to opus canonical key', () => {
|
|
131
|
+
expect(normalizeModelToRateKey("claude-opus-4-7-20250601")).toBe(
|
|
132
|
+
"claude-opus-4-8",
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('"claude-fable-5-20251101" strips snapshot suffix and maps to fable canonical key', () => {
|
|
137
|
+
expect(normalizeModelToRateKey("claude-fable-5-20251101")).toBe(
|
|
138
|
+
"claude-fable-5",
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('unknown dated snapshot still returns null (no crash)', () => {
|
|
143
|
+
expect(normalizeModelToRateKey("claude-nope-1-0-20251001")).toBeNull();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("normalizeModelToRateKey — unknown inputs", () => {
|
|
148
|
+
test('"unknown-model" returns null', () => {
|
|
149
|
+
expect(normalizeModelToRateKey("unknown-model")).toBeNull();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('empty string returns null', () => {
|
|
153
|
+
expect(normalizeModelToRateKey("")).toBeNull();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('"gpt-4" returns null', () => {
|
|
157
|
+
expect(normalizeModelToRateKey("gpt-4")).toBeNull();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("normalizeModelToRateKey — 5.x ids price as themselves", () => {
|
|
162
|
+
for (const id of [
|
|
163
|
+
"claude-fable-5-1",
|
|
164
|
+
"claude-opus-5-5",
|
|
165
|
+
"claude-sonnet-5-5",
|
|
166
|
+
"claude-haiku-5-5",
|
|
167
|
+
]) {
|
|
168
|
+
test(`${id} resolves to itself`, () => {
|
|
169
|
+
expect(normalizeModelToRateKey(id)).toBe(id);
|
|
170
|
+
expect(RATES[id]).toBeDefined();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test(`${id} dated snapshot resolves to itself`, () => {
|
|
174
|
+
expect(normalizeModelToRateKey(`${id}-20260101`)).toBe(id);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("5.x rates match the published rate card", () => {
|
|
180
|
+
test.each([
|
|
181
|
+
["claude-fable-5-1", 10, 50],
|
|
182
|
+
["claude-opus-5-5", 4, 20],
|
|
183
|
+
["claude-sonnet-5-5", 2, 10],
|
|
184
|
+
["claude-haiku-5-5", 0.1, 0.5],
|
|
185
|
+
])("%s is $%d in / $%d out per MTok", (key, input, output) => {
|
|
186
|
+
expect(RATES[key]).toEqual({ input, output });
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("normalizeModelToRateKey — bare aliases resolve to 5.5 keys", () => {
|
|
191
|
+
test("haiku/sonnet/opus aliases map to the 5.5 generation", () => {
|
|
192
|
+
expect(normalizeModelToRateKey("haiku")).toBe("claude-haiku-5-5");
|
|
193
|
+
expect(normalizeModelToRateKey("sonnet")).toBe("claude-sonnet-5-5");
|
|
194
|
+
expect(normalizeModelToRateKey("opus")).toBe("claude-opus-5-5");
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("CONTEXT_WINDOW", () => {
|
|
199
|
+
test("has exactly the same keys as RATES", () => {
|
|
200
|
+
expect(Object.keys(CONTEXT_WINDOW).sort()).toEqual(Object.keys(RATES).sort());
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("every window is a positive integer", () => {
|
|
204
|
+
for (const size of Object.values(CONTEXT_WINDOW)) {
|
|
205
|
+
expect(Number.isInteger(size) && size > 0).toBe(true);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("haiku 4.x is 200k; 5.x models are 1M", () => {
|
|
210
|
+
expect(CONTEXT_WINDOW["claude-haiku-4-5"]).toBe(200_000);
|
|
211
|
+
expect(CONTEXT_WINDOW["claude-opus-5-5"]).toBe(1_000_000);
|
|
212
|
+
expect(CONTEXT_WINDOW["claude-haiku-5-5"]).toBe(1_000_000);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/progress-phases.ts
|
|
3
|
+
* The closed set of generic progress phases derived from a Claude Code
|
|
4
|
+
* `stream-json` session (see `agent/src/runtime/progress-milestones.ts`), plus their
|
|
5
|
+
* human-readable labels. Consumed by `agent/src/runtime/claude.ts`'s `ProgressCallback`
|
|
6
|
+
* and, eventually, by UI surfaces that want to show "what is the agent doing
|
|
7
|
+
* right now" without exposing raw tool inputs (which may contain secrets or
|
|
8
|
+
* file paths).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** The closed set of generic progress phases a Claude stream can report. */
|
|
12
|
+
export type ProgressPhase =
|
|
13
|
+
| "starting"
|
|
14
|
+
| "thinking"
|
|
15
|
+
| "reading"
|
|
16
|
+
| "searching"
|
|
17
|
+
| "web"
|
|
18
|
+
| "editing"
|
|
19
|
+
| "running"
|
|
20
|
+
| "delegating"
|
|
21
|
+
| "planning"
|
|
22
|
+
| "tool"
|
|
23
|
+
| "writing";
|
|
24
|
+
|
|
25
|
+
/** Runtime-checkable list of every valid ProgressPhase, in a stable order. */
|
|
26
|
+
export const PROGRESS_PHASES: readonly ProgressPhase[] = [
|
|
27
|
+
"starting",
|
|
28
|
+
"thinking",
|
|
29
|
+
"reading",
|
|
30
|
+
"searching",
|
|
31
|
+
"web",
|
|
32
|
+
"editing",
|
|
33
|
+
"running",
|
|
34
|
+
"delegating",
|
|
35
|
+
"planning",
|
|
36
|
+
"tool",
|
|
37
|
+
"writing",
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Short human-readable label per phase, for eventual UI use. Every label
|
|
42
|
+
* ends in an ellipsis ("…") — they describe an *in-progress* action (e.g.
|
|
43
|
+
* a Slack Thinking Steps card mid-run), and a bare label like "Reading
|
|
44
|
+
* files" reads as already-completed rather than ongoing.
|
|
45
|
+
*/
|
|
46
|
+
export const PROGRESS_LABELS: Record<ProgressPhase, string> = {
|
|
47
|
+
starting: "Starting up…",
|
|
48
|
+
thinking: "Thinking…",
|
|
49
|
+
reading: "Reading files…",
|
|
50
|
+
searching: "Searching the codebase…",
|
|
51
|
+
web: "Searching the web…",
|
|
52
|
+
editing: "Editing files…",
|
|
53
|
+
running: "Running commands…",
|
|
54
|
+
delegating: "Delegating to a subagent…",
|
|
55
|
+
planning: "Planning…",
|
|
56
|
+
tool: "Using a tool…",
|
|
57
|
+
writing: "Writing a response…",
|
|
58
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { PROGRESS_LABELS, PROGRESS_PHASES } from "./progress-phases.ts";
|
|
3
|
+
|
|
4
|
+
describe("PROGRESS_PHASES", () => {
|
|
5
|
+
test("is the closed set of exactly eleven phases", () => {
|
|
6
|
+
expect(PROGRESS_PHASES).toHaveLength(11);
|
|
7
|
+
expect(new Set(PROGRESS_PHASES).size).toBe(11);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("contains exactly the documented phase names", () => {
|
|
11
|
+
expect([...(PROGRESS_PHASES as readonly string[])].sort()).toEqual(
|
|
12
|
+
[
|
|
13
|
+
"starting",
|
|
14
|
+
"thinking",
|
|
15
|
+
"reading",
|
|
16
|
+
"searching",
|
|
17
|
+
"web",
|
|
18
|
+
"editing",
|
|
19
|
+
"running",
|
|
20
|
+
"delegating",
|
|
21
|
+
"planning",
|
|
22
|
+
"tool",
|
|
23
|
+
"writing",
|
|
24
|
+
].sort(),
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("PROGRESS_LABELS", () => {
|
|
30
|
+
test("has exactly one label per phase in PROGRESS_PHASES", () => {
|
|
31
|
+
const labelKeys = Object.keys(PROGRESS_LABELS).sort();
|
|
32
|
+
expect(labelKeys).toEqual([...PROGRESS_PHASES].sort());
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("every label is a non-empty human-readable string", () => {
|
|
36
|
+
for (const phase of PROGRESS_PHASES) {
|
|
37
|
+
expect(typeof PROGRESS_LABELS[phase]).toBe("string");
|
|
38
|
+
expect(PROGRESS_LABELS[phase].length).toBeGreaterThan(0);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// STS2-1.1: every in-progress label must end in "…" for consistent
|
|
43
|
+
// Thinking Steps stream rendering (a bare, un-ellipsized label like
|
|
44
|
+
// "Reading files" reads as a completed action, not an in-progress one).
|
|
45
|
+
test("every label ends in an ellipsis (…), consistently", () => {
|
|
46
|
+
for (const phase of PROGRESS_PHASES) {
|
|
47
|
+
expect(PROGRESS_LABELS[phase].endsWith("…")).toBe(true);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Represents an authenticated request caller, shared across services that
|
|
3
|
+
* resolve caller identity in auth middleware (admin, task-store, metrics).
|
|
4
|
+
*
|
|
5
|
+
* scope === "*" → unrestricted/admin (all clients/agents)
|
|
6
|
+
* scope === "<id>" → scoped to a single identity (clientId, agentId, etc.)
|
|
7
|
+
*/
|
|
8
|
+
export interface Caller {
|
|
9
|
+
name: string;
|
|
10
|
+
scope: string; // "*" or a scoped identity such as a clientId/agentId
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Produce a stable, readable label for a caller, suitable for log lines.
|
|
15
|
+
* Returns "anonymous" when no caller is present (e.g. unauthenticated or
|
|
16
|
+
* not-yet-resolved requests). When the auth middleware's scope kind is
|
|
17
|
+
* known, it is appended in brackets (e.g. "svc (platform.read) [platform-read]").
|
|
18
|
+
*/
|
|
19
|
+
export function callerLabel(caller?: Caller, scopeKind?: string): string {
|
|
20
|
+
if (!caller) return "anonymous";
|
|
21
|
+
const label = `${caller.name} (${caller.scope})`;
|
|
22
|
+
return scopeKind ? `${label} [${scopeKind}]` : label;
|
|
23
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { callerLabel } from "./request-context.ts";
|
|
3
|
+
|
|
4
|
+
describe("callerLabel — defined caller", () => {
|
|
5
|
+
test("formats an admin caller (scope '*')", () => {
|
|
6
|
+
expect(callerLabel({ name: "bodhi", scope: "*" })).toBe("bodhi (*)");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("formats a caller scoped to a clientId", () => {
|
|
10
|
+
expect(callerLabel({ name: "sully", scope: "client-xyz" })).toBe(
|
|
11
|
+
"sully (client-xyz)",
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("formats a caller scoped to an agentId", () => {
|
|
16
|
+
expect(callerLabel({ name: "agent-123", scope: "agent-456" })).toBe(
|
|
17
|
+
"agent-123 (agent-456)",
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("is stable across repeated calls with the same input", () => {
|
|
22
|
+
const caller = { name: "bodhi", scope: "*" };
|
|
23
|
+
expect(callerLabel(caller)).toBe(callerLabel(caller));
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("callerLabel — undefined caller", () => {
|
|
28
|
+
test("returns a distinct, readable label for undefined", () => {
|
|
29
|
+
const label = callerLabel(undefined);
|
|
30
|
+
expect(label).toBe("anonymous");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("returns the same label when called with no argument", () => {
|
|
34
|
+
expect(callerLabel()).toBe("anonymous");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("undefined label never collides with a defined caller's label", () => {
|
|
38
|
+
const anonymousLabel = callerLabel(undefined);
|
|
39
|
+
expect(callerLabel({ name: "anonymous", scope: "*" })).not.toBe(
|
|
40
|
+
anonymousLabel,
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("callerLabel — scope kind", () => {
|
|
46
|
+
test("appends the scope kind when given", () => {
|
|
47
|
+
expect(
|
|
48
|
+
callerLabel({ name: "svc", scope: "platform.read" }, "platform-read"),
|
|
49
|
+
).toBe("svc (platform.read) [platform-read]");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/secret-env-vars.ts
|
|
3
|
+
* Canonical list of well-known secret-shaped env var names used by the
|
|
4
|
+
* shipwright-agent framework. Shared by Sentry log/event scrubbing
|
|
5
|
+
* (lib/sentry.ts) and by admin's agent env storage (marks these keys as
|
|
6
|
+
* `secret` so they're masked in GET /agents/:id/envs responses).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Secret-shaped env var names, redacted/masked wherever their value is currently set. */
|
|
10
|
+
export const SECRET_ENV_VARS = [
|
|
11
|
+
"ANTHROPIC_API_KEY",
|
|
12
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
13
|
+
"GH_APP_PRIVATE_KEY",
|
|
14
|
+
"GH_APP_CLIENT_SECRET",
|
|
15
|
+
"GH_TOKEN",
|
|
16
|
+
"SLACK_BOT_TOKEN",
|
|
17
|
+
"SLACK_APP_TOKEN",
|
|
18
|
+
"SLACK_SIGNING_SECRET",
|
|
19
|
+
"SLACK_CLIENT_SECRET",
|
|
20
|
+
"SLACK_ADMIN_TOKEN",
|
|
21
|
+
"SHIPWRIGHT_AGENT_API_KEY",
|
|
22
|
+
"SHIPWRIGHT_TASK_STORE_TOKEN",
|
|
23
|
+
"SHIPWRIGHT_TASK_STORE_ADMIN_TOKEN",
|
|
24
|
+
"SHIPWRIGHT_CHAT_SERVICE_TOKEN",
|
|
25
|
+
"SHIPWRIGHT_CHAT_SERVICE_ADMIN_TOKEN",
|
|
26
|
+
"SHIPWRIGHT_ADMIN_API_KEYS",
|
|
27
|
+
"SHIPWRIGHT_SESSION_SECRET",
|
|
28
|
+
"SHIPWRIGHT_ENCRYPTION_KEY",
|
|
29
|
+
// VAPID private key for Web Push (RFC 8292). The PUBLIC key is NOT secret —
|
|
30
|
+
// it's sent to the browser — so only the private key is listed here.
|
|
31
|
+
"SHIPWRIGHT_ADMIN_VAPID_PRIVATE_KEY",
|
|
32
|
+
// Shared token the chat service presents to admin's push-notify webhook so
|
|
33
|
+
// an unauthenticated caller can't trigger notifications.
|
|
34
|
+
"SHIPWRIGHT_ADMIN_PUSH_WEBHOOK_TOKEN",
|
|
35
|
+
// Bearer token the chat service presents to admin's push-notify webhook
|
|
36
|
+
// when firing outbound reply notifications. Same shared-secret value as
|
|
37
|
+
// SHIPWRIGHT_ADMIN_PUSH_WEBHOOK_TOKEN in deployment, but a distinct env
|
|
38
|
+
// var name (chat-side vs. admin-side).
|
|
39
|
+
"SHIPWRIGHT_CHAT_PUSH_WEBHOOK_TOKEN",
|
|
40
|
+
"GOOGLE_CLIENT_SECRET",
|
|
41
|
+
] as const;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { SECRET_ENV_VARS } from "./secret-env-vars.ts";
|
|
3
|
+
|
|
4
|
+
describe("SECRET_ENV_VARS", () => {
|
|
5
|
+
test("is a non-empty array", () => {
|
|
6
|
+
expect(Array.isArray(SECRET_ENV_VARS)).toBe(true);
|
|
7
|
+
expect(SECRET_ENV_VARS.length).toBeGreaterThan(0);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("contains expected well-known secret env var names", () => {
|
|
11
|
+
const expectedEntries = [
|
|
12
|
+
"ANTHROPIC_API_KEY",
|
|
13
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
14
|
+
"GH_APP_PRIVATE_KEY",
|
|
15
|
+
"GH_APP_CLIENT_SECRET",
|
|
16
|
+
"GH_TOKEN",
|
|
17
|
+
"SLACK_BOT_TOKEN",
|
|
18
|
+
"SLACK_APP_TOKEN",
|
|
19
|
+
"SLACK_SIGNING_SECRET",
|
|
20
|
+
"SLACK_CLIENT_SECRET",
|
|
21
|
+
"SLACK_ADMIN_TOKEN",
|
|
22
|
+
"SHIPWRIGHT_AGENT_API_KEY",
|
|
23
|
+
"SHIPWRIGHT_TASK_STORE_TOKEN",
|
|
24
|
+
"SHIPWRIGHT_TASK_STORE_ADMIN_TOKEN",
|
|
25
|
+
"SHIPWRIGHT_CHAT_SERVICE_TOKEN",
|
|
26
|
+
"SHIPWRIGHT_CHAT_SERVICE_ADMIN_TOKEN",
|
|
27
|
+
"SHIPWRIGHT_ADMIN_API_KEYS",
|
|
28
|
+
"SHIPWRIGHT_SESSION_SECRET",
|
|
29
|
+
"SHIPWRIGHT_ENCRYPTION_KEY",
|
|
30
|
+
"SHIPWRIGHT_ADMIN_VAPID_PRIVATE_KEY",
|
|
31
|
+
"SHIPWRIGHT_ADMIN_PUSH_WEBHOOK_TOKEN",
|
|
32
|
+
"SHIPWRIGHT_CHAT_PUSH_WEBHOOK_TOKEN",
|
|
33
|
+
"GOOGLE_CLIENT_SECRET",
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
for (const entry of expectedEntries) {
|
|
37
|
+
expect(SECRET_ENV_VARS as readonly string[]).toContain(entry);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("has exactly the expected number of entries (catches accidental additions/removals)", () => {
|
|
42
|
+
expect(SECRET_ENV_VARS.length).toBe(22);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("every entry is a non-empty string", () => {
|
|
46
|
+
for (const entry of SECRET_ENV_VARS) {
|
|
47
|
+
expect(typeof entry).toBe("string");
|
|
48
|
+
expect(entry.length).toBeGreaterThan(0);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("every entry looks like an env var name (uppercase snake case)", () => {
|
|
53
|
+
for (const entry of SECRET_ENV_VARS) {
|
|
54
|
+
expect(entry).toMatch(/^[A-Z][A-Z0-9_]*$/);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("has no duplicate entries", () => {
|
|
59
|
+
const uniqueEntries = new Set(SECRET_ENV_VARS);
|
|
60
|
+
expect(uniqueEntries.size).toBe(SECRET_ENV_VARS.length);
|
|
61
|
+
});
|
|
62
|
+
});
|