@calltelemetry/openclaw-linear 0.8.8 → 0.9.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.
- package/README.md +194 -91
- package/index.ts +36 -4
- package/package.json +1 -1
- package/src/__test__/webhook-scenarios.test.ts +1 -1
- package/src/gateway/dispatch-methods.test.ts +9 -9
- package/src/infra/commands.test.ts +5 -5
- package/src/infra/config-paths.test.ts +246 -0
- package/src/infra/doctor.ts +45 -36
- package/src/infra/notify.test.ts +49 -0
- package/src/infra/notify.ts +7 -2
- package/src/infra/observability.ts +1 -0
- package/src/infra/shared-profiles.test.ts +262 -0
- package/src/infra/shared-profiles.ts +116 -0
- package/src/infra/template.test.ts +86 -0
- package/src/infra/template.ts +18 -0
- package/src/infra/validation.test.ts +175 -0
- package/src/infra/validation.ts +52 -0
- package/src/pipeline/active-session.test.ts +2 -2
- package/src/pipeline/agent-end-hook.test.ts +305 -0
- package/src/pipeline/artifacts.test.ts +3 -3
- package/src/pipeline/dispatch-state.test.ts +111 -8
- package/src/pipeline/dispatch-state.ts +48 -13
- package/src/pipeline/e2e-dispatch.test.ts +2 -2
- package/src/pipeline/intent-classify.test.ts +20 -2
- package/src/pipeline/intent-classify.ts +14 -24
- package/src/pipeline/pipeline.ts +28 -11
- package/src/pipeline/planner.ts +1 -8
- package/src/pipeline/planning-state.ts +9 -0
- package/src/pipeline/tier-assess.test.ts +39 -39
- package/src/pipeline/tier-assess.ts +15 -33
- package/src/pipeline/webhook.test.ts +149 -1
- package/src/pipeline/webhook.ts +90 -62
- package/src/tools/dispatch-history-tool.test.ts +21 -20
- package/src/tools/dispatch-history-tool.ts +1 -1
- package/src/tools/linear-issues-tool.test.ts +115 -0
- package/src/tools/linear-issues-tool.ts +25 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config-paths.test.ts — Tests for configurable CLI paths, dedup TTLs,
|
|
3
|
+
* and enhanced diagnostic context fields.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
|
|
9
|
+
// ── Mocks for webhook.ts imports ──────────────────────────────────
|
|
10
|
+
|
|
11
|
+
vi.mock("../pipeline/pipeline.js", () => ({
|
|
12
|
+
runPlannerStage: vi.fn().mockResolvedValue("mock plan"),
|
|
13
|
+
runFullPipeline: vi.fn().mockResolvedValue(undefined),
|
|
14
|
+
resumePipeline: vi.fn().mockResolvedValue(undefined),
|
|
15
|
+
spawnWorker: vi.fn().mockResolvedValue(undefined),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock("../api/linear-api.js", () => ({
|
|
19
|
+
LinearAgentApi: class MockLinearAgentApi {
|
|
20
|
+
emitActivity = vi.fn().mockResolvedValue(undefined);
|
|
21
|
+
createComment = vi.fn().mockResolvedValue("comment-id");
|
|
22
|
+
getIssueDetails = vi.fn().mockResolvedValue(null);
|
|
23
|
+
getViewerId = vi.fn().mockResolvedValue("viewer-bot-1");
|
|
24
|
+
createSessionOnIssue = vi.fn().mockResolvedValue({ sessionId: null });
|
|
25
|
+
getTeamLabels = vi.fn().mockResolvedValue([]);
|
|
26
|
+
},
|
|
27
|
+
resolveLinearToken: vi.fn().mockReturnValue({
|
|
28
|
+
accessToken: "test-token",
|
|
29
|
+
source: "env",
|
|
30
|
+
}),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
vi.mock("../pipeline/active-session.js", () => ({
|
|
34
|
+
setActiveSession: vi.fn(),
|
|
35
|
+
clearActiveSession: vi.fn(),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
vi.mock("../infra/observability.js", () => ({
|
|
39
|
+
emitDiagnostic: vi.fn(),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
vi.mock("../pipeline/intent-classify.js", () => ({
|
|
43
|
+
classifyIntent: vi.fn().mockResolvedValue({
|
|
44
|
+
intent: "general",
|
|
45
|
+
reasoning: "test",
|
|
46
|
+
fromFallback: true,
|
|
47
|
+
}),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
// ── Task 1: CLI paths use HOME env var ───────────────────────────
|
|
51
|
+
|
|
52
|
+
describe("CLI binary path resolution", () => {
|
|
53
|
+
it("constructs default bin paths from HOME env var", () => {
|
|
54
|
+
const origHome = process.env.HOME;
|
|
55
|
+
try {
|
|
56
|
+
process.env.HOME = "/test/custom-home";
|
|
57
|
+
const defaultBinDir = join(process.env.HOME ?? homedir(), ".npm-global", "bin");
|
|
58
|
+
expect(defaultBinDir).toBe("/test/custom-home/.npm-global/bin");
|
|
59
|
+
expect(join(defaultBinDir, "codex")).toBe("/test/custom-home/.npm-global/bin/codex");
|
|
60
|
+
expect(join(defaultBinDir, "claude")).toBe("/test/custom-home/.npm-global/bin/claude");
|
|
61
|
+
expect(join(defaultBinDir, "gemini")).toBe("/test/custom-home/.npm-global/bin/gemini");
|
|
62
|
+
} finally {
|
|
63
|
+
if (origHome !== undefined) process.env.HOME = origHome;
|
|
64
|
+
else delete process.env.HOME;
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("falls back to os.homedir() when HOME is unset", () => {
|
|
69
|
+
const origHome = process.env.HOME;
|
|
70
|
+
try {
|
|
71
|
+
delete process.env.HOME;
|
|
72
|
+
const defaultBinDir = join(process.env.HOME ?? homedir(), ".npm-global", "bin");
|
|
73
|
+
// Should use homedir() which returns the system home directory
|
|
74
|
+
expect(defaultBinDir).toContain(".npm-global/bin");
|
|
75
|
+
expect(defaultBinDir).not.toContain("undefined");
|
|
76
|
+
} finally {
|
|
77
|
+
if (origHome !== undefined) process.env.HOME = origHome;
|
|
78
|
+
else delete process.env.HOME;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("pluginConfig overrides take precedence over default paths", () => {
|
|
83
|
+
const pluginConfig: Record<string, unknown> = {
|
|
84
|
+
codexBin: "/custom/path/codex",
|
|
85
|
+
claudeBin: "/custom/path/claude",
|
|
86
|
+
geminiBin: "/custom/path/gemini",
|
|
87
|
+
};
|
|
88
|
+
const defaultBinDir = join(process.env.HOME ?? homedir(), ".npm-global", "bin");
|
|
89
|
+
|
|
90
|
+
const codexBin = pluginConfig?.codexBin as string ?? join(defaultBinDir, "codex");
|
|
91
|
+
const claudeBin = pluginConfig?.claudeBin as string ?? join(defaultBinDir, "claude");
|
|
92
|
+
const geminiBin = pluginConfig?.geminiBin as string ?? join(defaultBinDir, "gemini");
|
|
93
|
+
|
|
94
|
+
expect(codexBin).toBe("/custom/path/codex");
|
|
95
|
+
expect(claudeBin).toBe("/custom/path/claude");
|
|
96
|
+
expect(geminiBin).toBe("/custom/path/gemini");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("uses default when pluginConfig has no overrides", () => {
|
|
100
|
+
const pluginConfig: Record<string, unknown> = {};
|
|
101
|
+
const defaultBinDir = join(process.env.HOME ?? homedir(), ".npm-global", "bin");
|
|
102
|
+
|
|
103
|
+
const codexBin = pluginConfig?.codexBin as string ?? join(defaultBinDir, "codex");
|
|
104
|
+
expect(codexBin).toBe(join(defaultBinDir, "codex"));
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ── Task 3: Diagnostic events include extra context fields ───────
|
|
109
|
+
|
|
110
|
+
describe("diagnostic event context fields", () => {
|
|
111
|
+
// Use the real observability module (not mocked for these tests)
|
|
112
|
+
// We import and test directly since the mock only applies to webhook.ts imports
|
|
113
|
+
it("DiagnosticPayload accepts agentId field", async () => {
|
|
114
|
+
// Bypass the mock by importing the actual module implementation
|
|
115
|
+
const info = vi.fn();
|
|
116
|
+
const api = { logger: { info } } as any;
|
|
117
|
+
const PREFIX = "[linear:diagnostic]";
|
|
118
|
+
|
|
119
|
+
// Simulate emitDiagnostic behavior (same as the real function)
|
|
120
|
+
const payload = {
|
|
121
|
+
event: "dispatch_started",
|
|
122
|
+
identifier: "ISS-42",
|
|
123
|
+
agentId: "mason",
|
|
124
|
+
tier: "standard",
|
|
125
|
+
issueId: "abc-123",
|
|
126
|
+
};
|
|
127
|
+
api.logger.info(`${PREFIX} ${JSON.stringify(payload)}`);
|
|
128
|
+
|
|
129
|
+
expect(info).toHaveBeenCalledOnce();
|
|
130
|
+
const json = JSON.parse((info.mock.calls[0][0] as string).replace("[linear:diagnostic] ", ""));
|
|
131
|
+
expect(json.agentId).toBe("mason");
|
|
132
|
+
expect(json.identifier).toBe("ISS-42");
|
|
133
|
+
expect(json.tier).toBe("standard");
|
|
134
|
+
expect(json.issueId).toBe("abc-123");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("DiagnosticPayload accepts durationMs field", () => {
|
|
138
|
+
const info = vi.fn();
|
|
139
|
+
const api = { logger: { info } } as any;
|
|
140
|
+
const PREFIX = "[linear:diagnostic]";
|
|
141
|
+
|
|
142
|
+
const payload = {
|
|
143
|
+
event: "watchdog_kill",
|
|
144
|
+
identifier: "ISS-99",
|
|
145
|
+
durationMs: 45000,
|
|
146
|
+
agentId: "forge",
|
|
147
|
+
attempt: 2,
|
|
148
|
+
};
|
|
149
|
+
api.logger.info(`${PREFIX} ${JSON.stringify(payload)}`);
|
|
150
|
+
|
|
151
|
+
const json = JSON.parse((info.mock.calls[0][0] as string).replace("[linear:diagnostic] ", ""));
|
|
152
|
+
expect(json.durationMs).toBe(45000);
|
|
153
|
+
expect(json.agentId).toBe("forge");
|
|
154
|
+
expect(json.attempt).toBe(2);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("webhook_received diagnostic includes identifier and issueId", () => {
|
|
158
|
+
const info = vi.fn();
|
|
159
|
+
const api = { logger: { info } } as any;
|
|
160
|
+
const PREFIX = "[linear:diagnostic]";
|
|
161
|
+
|
|
162
|
+
const payload = {
|
|
163
|
+
event: "webhook_received",
|
|
164
|
+
webhookType: "Comment",
|
|
165
|
+
webhookAction: "create",
|
|
166
|
+
identifier: "ENG-123",
|
|
167
|
+
issueId: "issue-abc",
|
|
168
|
+
};
|
|
169
|
+
api.logger.info(`${PREFIX} ${JSON.stringify(payload)}`);
|
|
170
|
+
|
|
171
|
+
const json = JSON.parse((info.mock.calls[0][0] as string).replace("[linear:diagnostic] ", ""));
|
|
172
|
+
expect(json.webhookType).toBe("Comment");
|
|
173
|
+
expect(json.webhookAction).toBe("create");
|
|
174
|
+
expect(json.identifier).toBe("ENG-123");
|
|
175
|
+
expect(json.issueId).toBe("issue-abc");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("verdict_processed diagnostic includes tier and agentId", () => {
|
|
179
|
+
const info = vi.fn();
|
|
180
|
+
const api = { logger: { info } } as any;
|
|
181
|
+
const PREFIX = "[linear:diagnostic]";
|
|
182
|
+
|
|
183
|
+
const payload = {
|
|
184
|
+
event: "verdict_processed",
|
|
185
|
+
identifier: "ISS-55",
|
|
186
|
+
issueId: "id-55",
|
|
187
|
+
phase: "done",
|
|
188
|
+
attempt: 1,
|
|
189
|
+
tier: "complex",
|
|
190
|
+
agentId: "eureka",
|
|
191
|
+
};
|
|
192
|
+
api.logger.info(`${PREFIX} ${JSON.stringify(payload)}`);
|
|
193
|
+
|
|
194
|
+
const json = JSON.parse((info.mock.calls[0][0] as string).replace("[linear:diagnostic] ", ""));
|
|
195
|
+
expect(json.tier).toBe("complex");
|
|
196
|
+
expect(json.agentId).toBe("eureka");
|
|
197
|
+
expect(json.phase).toBe("done");
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// ── Task 4: Configurable dedup TTL ───────────────────────────────
|
|
202
|
+
|
|
203
|
+
describe("configurable dedup TTL", () => {
|
|
204
|
+
beforeEach(async () => {
|
|
205
|
+
const { _resetForTesting } = await import("../pipeline/webhook.js");
|
|
206
|
+
_resetForTesting();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
afterEach(async () => {
|
|
210
|
+
const { _resetForTesting } = await import("../pipeline/webhook.js");
|
|
211
|
+
_resetForTesting();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("defaults to 60_000ms when no config provided", async () => {
|
|
215
|
+
const { _configureDedupTtls, _getDedupTtlMs } = await import("../pipeline/webhook.js");
|
|
216
|
+
_configureDedupTtls();
|
|
217
|
+
expect(_getDedupTtlMs()).toBe(60_000);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("defaults to 60_000ms when pluginConfig has no dedupTtlMs", async () => {
|
|
221
|
+
const { _configureDedupTtls, _getDedupTtlMs } = await import("../pipeline/webhook.js");
|
|
222
|
+
_configureDedupTtls({});
|
|
223
|
+
expect(_getDedupTtlMs()).toBe(60_000);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("reads dedupTtlMs from pluginConfig", async () => {
|
|
227
|
+
const { _configureDedupTtls, _getDedupTtlMs } = await import("../pipeline/webhook.js");
|
|
228
|
+
_configureDedupTtls({ dedupTtlMs: 120_000 });
|
|
229
|
+
expect(_getDedupTtlMs()).toBe(120_000);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("reads dedupSweepIntervalMs from pluginConfig", async () => {
|
|
233
|
+
const { _configureDedupTtls, _getDedupTtlMs } = await import("../pipeline/webhook.js");
|
|
234
|
+
_configureDedupTtls({ dedupTtlMs: 30_000, dedupSweepIntervalMs: 5_000 });
|
|
235
|
+
expect(_getDedupTtlMs()).toBe(30_000);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("_resetForTesting restores default TTLs", async () => {
|
|
239
|
+
const { _configureDedupTtls, _getDedupTtlMs, _resetForTesting } = await import("../pipeline/webhook.js");
|
|
240
|
+
_configureDedupTtls({ dedupTtlMs: 999 });
|
|
241
|
+
expect(_getDedupTtlMs()).toBe(999);
|
|
242
|
+
|
|
243
|
+
_resetForTesting();
|
|
244
|
+
expect(_getDedupTtlMs()).toBe(60_000);
|
|
245
|
+
});
|
|
246
|
+
});
|
package/src/infra/doctor.ts
CHANGED
|
@@ -52,11 +52,15 @@ export interface DoctorOptions {
|
|
|
52
52
|
|
|
53
53
|
const AGENT_PROFILES_PATH = join(homedir(), ".openclaw", "agent-profiles.json");
|
|
54
54
|
const VALID_BACKENDS: readonly string[] = ["claude", "codex", "gemini"];
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
[
|
|
59
|
-
]
|
|
55
|
+
const DEFAULT_BIN_DIR = join(process.env.HOME ?? homedir(), ".npm-global", "bin");
|
|
56
|
+
|
|
57
|
+
function resolveCliBins(pluginConfig?: Record<string, unknown>): [string, string][] {
|
|
58
|
+
return [
|
|
59
|
+
["codex", (pluginConfig?.codexBin as string) ?? join(DEFAULT_BIN_DIR, "codex")],
|
|
60
|
+
["claude", (pluginConfig?.claudeBin as string) ?? join(DEFAULT_BIN_DIR, "claude")],
|
|
61
|
+
["gemini", (pluginConfig?.geminiBin as string) ?? join(DEFAULT_BIN_DIR, "gemini")],
|
|
62
|
+
];
|
|
63
|
+
}
|
|
60
64
|
const STALE_DISPATCH_MS = 2 * 60 * 60_000; // 2 hours
|
|
61
65
|
const OLD_COMPLETED_MS = 7 * 24 * 60 * 60_000; // 7 days
|
|
62
66
|
const LOCK_STALE_MS = 30_000; // 30 seconds
|
|
@@ -92,7 +96,7 @@ function resolveWorktreeBaseDir(pluginConfig?: Record<string, unknown>): string
|
|
|
92
96
|
}
|
|
93
97
|
|
|
94
98
|
function resolveBaseRepo(pluginConfig?: Record<string, unknown>): string {
|
|
95
|
-
return (pluginConfig?.codexBaseRepo as string) ?? "
|
|
99
|
+
return (pluginConfig?.codexBaseRepo as string) ?? join(process.env.HOME ?? homedir(), "ai-workspace");
|
|
96
100
|
}
|
|
97
101
|
|
|
98
102
|
interface AgentProfile {
|
|
@@ -315,7 +319,7 @@ export function checkAgentConfig(pluginConfig?: Record<string, unknown>): CheckR
|
|
|
315
319
|
// Section 3: Coding Tools
|
|
316
320
|
// ---------------------------------------------------------------------------
|
|
317
321
|
|
|
318
|
-
export function checkCodingTools(): CheckResult[] {
|
|
322
|
+
export function checkCodingTools(pluginConfig?: Record<string, unknown>): CheckResult[] {
|
|
319
323
|
const checks: CheckResult[] = [];
|
|
320
324
|
|
|
321
325
|
// Load config
|
|
@@ -345,7 +349,8 @@ export function checkCodingTools(): CheckResult[] {
|
|
|
345
349
|
}
|
|
346
350
|
|
|
347
351
|
// CLI availability
|
|
348
|
-
|
|
352
|
+
const cliBins = resolveCliBins(pluginConfig);
|
|
353
|
+
for (const [name, bin] of cliBins) {
|
|
349
354
|
try {
|
|
350
355
|
const raw = execFileSync(bin, ["--version"], {
|
|
351
356
|
encoding: "utf8",
|
|
@@ -728,7 +733,7 @@ export async function runDoctor(opts: DoctorOptions): Promise<DoctorReport> {
|
|
|
728
733
|
sections.push({ name: "Agent Configuration", checks: checkAgentConfig(opts.pluginConfig) });
|
|
729
734
|
|
|
730
735
|
// 3. Coding tools
|
|
731
|
-
sections.push({ name: "Coding Tools", checks: checkCodingTools() });
|
|
736
|
+
sections.push({ name: "Coding Tools", checks: checkCodingTools(opts.pluginConfig) });
|
|
732
737
|
|
|
733
738
|
// 4. Files & dirs
|
|
734
739
|
sections.push({
|
|
@@ -788,31 +793,34 @@ interface BackendSpec {
|
|
|
788
793
|
unsetEnv?: string[];
|
|
789
794
|
}
|
|
790
795
|
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
]
|
|
796
|
+
function resolveBackendSpecs(pluginConfig?: Record<string, unknown>): BackendSpec[] {
|
|
797
|
+
const binDir = join(process.env.HOME ?? homedir(), ".npm-global", "bin");
|
|
798
|
+
return [
|
|
799
|
+
{
|
|
800
|
+
id: "claude",
|
|
801
|
+
label: "Claude Code (Anthropic)",
|
|
802
|
+
bin: (pluginConfig?.claudeBin as string) ?? join(binDir, "claude"),
|
|
803
|
+
testArgs: ["--print", "-p", "Respond with the single word hello", "--output-format", "stream-json", "--max-turns", "1", "--dangerously-skip-permissions"],
|
|
804
|
+
envKeys: ["ANTHROPIC_API_KEY"],
|
|
805
|
+
configKey: "claudeApiKey",
|
|
806
|
+
unsetEnv: ["CLAUDECODE"],
|
|
807
|
+
},
|
|
808
|
+
{
|
|
809
|
+
id: "codex",
|
|
810
|
+
label: "Codex (OpenAI)",
|
|
811
|
+
bin: (pluginConfig?.codexBin as string) ?? join(binDir, "codex"),
|
|
812
|
+
testArgs: ["exec", "--json", "--ephemeral", "--full-auto", "echo hello"],
|
|
813
|
+
envKeys: ["OPENAI_API_KEY"],
|
|
814
|
+
},
|
|
815
|
+
{
|
|
816
|
+
id: "gemini",
|
|
817
|
+
label: "Gemini CLI (Google)",
|
|
818
|
+
bin: (pluginConfig?.geminiBin as string) ?? join(binDir, "gemini"),
|
|
819
|
+
testArgs: ["-p", "Respond with the single word hello", "-o", "stream-json", "--yolo"],
|
|
820
|
+
envKeys: ["GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GENAI_API_KEY"],
|
|
821
|
+
},
|
|
822
|
+
];
|
|
823
|
+
}
|
|
816
824
|
|
|
817
825
|
function checkBackendBinary(spec: BackendSpec): { installed: boolean; checks: CheckResult[] } {
|
|
818
826
|
const checks: CheckResult[] = [];
|
|
@@ -929,8 +937,9 @@ export async function checkCodeRunDeep(
|
|
|
929
937
|
const sections: CheckSection[] = [];
|
|
930
938
|
const config = loadCodingConfig();
|
|
931
939
|
let callableCount = 0;
|
|
940
|
+
const backendSpecs = resolveBackendSpecs(pluginConfig);
|
|
932
941
|
|
|
933
|
-
for (const spec of
|
|
942
|
+
for (const spec of backendSpecs) {
|
|
934
943
|
const checks: CheckResult[] = [];
|
|
935
944
|
|
|
936
945
|
// 1. Binary check
|
|
@@ -966,7 +975,7 @@ export async function checkCodeRunDeep(
|
|
|
966
975
|
));
|
|
967
976
|
}
|
|
968
977
|
|
|
969
|
-
routingChecks.push(pass(`Callable backends: ${callableCount}/${
|
|
978
|
+
routingChecks.push(pass(`Callable backends: ${callableCount}/${backendSpecs.length}`));
|
|
970
979
|
sections.push({ name: "Code Run: Routing", checks: routingChecks });
|
|
971
980
|
|
|
972
981
|
return sections;
|
package/src/infra/notify.test.ts
CHANGED
|
@@ -371,6 +371,55 @@ describe("createNotifierFromConfig", () => {
|
|
|
371
371
|
consoleSpy.mockRestore();
|
|
372
372
|
});
|
|
373
373
|
|
|
374
|
+
it("sanitizes URLs and tokens from error messages", async () => {
|
|
375
|
+
const runtime = mockRuntime();
|
|
376
|
+
runtime.channel.discord.sendMessageDiscord = vi.fn(async () => {
|
|
377
|
+
throw new Error("Failed to POST https://discord.com/api/v10/channels/123/messages with token fake-slack-token-1234567890");
|
|
378
|
+
});
|
|
379
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
380
|
+
|
|
381
|
+
const notify = createNotifierFromConfig({
|
|
382
|
+
notifications: {
|
|
383
|
+
targets: [{ channel: "discord", target: "D-100" }],
|
|
384
|
+
},
|
|
385
|
+
}, runtime);
|
|
386
|
+
await notify("dispatch", basePayload);
|
|
387
|
+
|
|
388
|
+
// Check that the error message was sanitized
|
|
389
|
+
expect(consoleSpy).toHaveBeenCalledOnce();
|
|
390
|
+
const errorMessage = consoleSpy.mock.calls[0][0] as string;
|
|
391
|
+
expect(errorMessage).not.toContain("https://discord.com");
|
|
392
|
+
expect(errorMessage).not.toContain("xoxb-1234567890");
|
|
393
|
+
expect(errorMessage).toContain("[URL]");
|
|
394
|
+
expect(errorMessage).toContain("[TOKEN]");
|
|
395
|
+
|
|
396
|
+
consoleSpy.mockRestore();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("does not leak long token-like strings in console error output", async () => {
|
|
400
|
+
const runtime = mockRuntime();
|
|
401
|
+
const fakeToken = "xoxb-ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890";
|
|
402
|
+
runtime.channel.discord.sendMessageDiscord = vi.fn(async () => {
|
|
403
|
+
throw new Error(`Auth failed with token ${fakeToken}`);
|
|
404
|
+
});
|
|
405
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
406
|
+
|
|
407
|
+
const notify = createNotifierFromConfig({
|
|
408
|
+
notifications: {
|
|
409
|
+
targets: [{ channel: "discord", target: "D-100" }],
|
|
410
|
+
},
|
|
411
|
+
}, runtime);
|
|
412
|
+
await notify("dispatch", basePayload);
|
|
413
|
+
|
|
414
|
+
expect(consoleSpy).toHaveBeenCalledOnce();
|
|
415
|
+
const errorMessage = consoleSpy.mock.calls[0][0] as string;
|
|
416
|
+
// The long token-like string should be replaced
|
|
417
|
+
expect(errorMessage).not.toContain(fakeToken);
|
|
418
|
+
expect(errorMessage).toContain("[TOKEN]");
|
|
419
|
+
|
|
420
|
+
consoleSpy.mockRestore();
|
|
421
|
+
});
|
|
422
|
+
|
|
374
423
|
it("skips suppressed events", async () => {
|
|
375
424
|
const runtime = mockRuntime();
|
|
376
425
|
const notify = createNotifierFromConfig({
|
package/src/infra/notify.ts
CHANGED
|
@@ -262,13 +262,18 @@ export function createNotifierFromConfig(
|
|
|
262
262
|
try {
|
|
263
263
|
await sendToTarget(target, message, runtime);
|
|
264
264
|
} catch (err) {
|
|
265
|
-
|
|
265
|
+
const safeError = err instanceof Error ? err.message : "Unknown error";
|
|
266
|
+
// Strip potential URLs/tokens from error messages to prevent secret leakage
|
|
267
|
+
const sanitizedError = safeError
|
|
268
|
+
.replace(/https?:\/\/[^\s]+/g, "[URL]")
|
|
269
|
+
.replace(/[A-Za-z0-9_-]{20,}/g, "[TOKEN]");
|
|
270
|
+
console.error(`Notify error (${target.channel}:${target.target}): ${sanitizedError}`);
|
|
266
271
|
if (api) {
|
|
267
272
|
emitDiagnostic(api, {
|
|
268
273
|
event: "notify_failed",
|
|
269
274
|
identifier: payload.identifier,
|
|
270
275
|
phase: kind,
|
|
271
|
-
error:
|
|
276
|
+
error: sanitizedError,
|
|
272
277
|
});
|
|
273
278
|
}
|
|
274
279
|
}
|