alvin-bot 4.12.1 → 4.12.3
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/CHANGELOG.md +145 -0
- package/README.md +42 -5
- package/dist/handlers/async-agent-chunk-handler.js +17 -0
- package/dist/handlers/background-bypass.js +75 -0
- package/dist/handlers/message.js +127 -16
- package/dist/index.js +75 -3
- package/dist/providers/claude-sdk-provider.js +4 -1
- package/dist/services/allowed-users-gate.js +56 -0
- package/dist/services/async-agent-watcher.js +25 -0
- package/dist/services/cron.js +17 -0
- package/dist/services/exec-guard.js +26 -1
- package/dist/services/fallback-order.js +4 -1
- package/dist/services/file-permissions.js +93 -0
- package/dist/services/session-persistence.js +19 -2
- package/dist/services/session.js +2 -0
- package/dist/services/subagents.js +23 -5
- package/dist/services/timing-safe-bearer.js +51 -0
- package/dist/web/doctor-api.js +8 -2
- package/dist/web/server.js +7 -3
- package/dist/web/setup-api.js +5 -2
- package/docs/security.md +279 -0
- package/package.json +4 -1
- package/test/allowed-users-gate.test.ts +98 -0
- package/test/async-agent-chunk-flow.test.ts +113 -0
- package/test/background-bypass-integration.test.ts +443 -0
- package/test/background-bypass-stress.test.ts +417 -0
- package/test/background-bypass.test.ts +127 -0
- package/test/exec-guard-metachars.test.ts +110 -0
- package/test/file-permissions.test.ts +130 -0
- package/test/session-pending-background.test.ts +59 -0
- package/test/subagent-toolset-allowlist.test.ts +146 -0
- package/test/subagents-toolset.test.ts +22 -2
- package/test/timing-safe-bearer.test.ts +65 -0
- package/test/watcher-pending-count.test.ts +228 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v4.12.2 — File permissions hardening.
|
|
3
|
+
*
|
|
4
|
+
* Sensitive files (.env, sessions.json, memory files) must be chmod 0o600
|
|
5
|
+
* so that on multi-user Dev-Server installations, other users on the same
|
|
6
|
+
* machine can't read Alvin's secrets or conversation history.
|
|
7
|
+
*
|
|
8
|
+
* This module provides pure helpers for ensuring files get 0o600 on write,
|
|
9
|
+
* plus a startup repair routine that fixes permissions on existing files.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
12
|
+
import fs from "fs";
|
|
13
|
+
import os from "os";
|
|
14
|
+
import { resolve } from "path";
|
|
15
|
+
import { ensureSecureMode, writeSecure, auditSensitiveFiles } from "../src/services/file-permissions.js";
|
|
16
|
+
|
|
17
|
+
const TEST_DIR = resolve(os.tmpdir(), `alvin-fileperm-${process.pid}-${Date.now()}`);
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
|
21
|
+
fs.mkdirSync(TEST_DIR, { recursive: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
try { fs.rmSync(TEST_DIR, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("file-permissions (v4.12.2)", () => {
|
|
29
|
+
describe("writeSecure", () => {
|
|
30
|
+
it("creates a file with mode 0o600", () => {
|
|
31
|
+
const file = resolve(TEST_DIR, "secret.txt");
|
|
32
|
+
writeSecure(file, "sensitive content");
|
|
33
|
+
const mode = fs.statSync(file).mode & 0o777;
|
|
34
|
+
expect(mode).toBe(0o600);
|
|
35
|
+
expect(fs.readFileSync(file, "utf-8")).toBe("sensitive content");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("overwrites an existing file and enforces mode 0o600 even if it was 0o644", () => {
|
|
39
|
+
const file = resolve(TEST_DIR, "existing.txt");
|
|
40
|
+
fs.writeFileSync(file, "old content", "utf-8");
|
|
41
|
+
fs.chmodSync(file, 0o644);
|
|
42
|
+
|
|
43
|
+
writeSecure(file, "new content");
|
|
44
|
+
|
|
45
|
+
const mode = fs.statSync(file).mode & 0o777;
|
|
46
|
+
expect(mode).toBe(0o600);
|
|
47
|
+
expect(fs.readFileSync(file, "utf-8")).toBe("new content");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("accepts Buffer content", () => {
|
|
51
|
+
const file = resolve(TEST_DIR, "buf.bin");
|
|
52
|
+
writeSecure(file, Buffer.from([1, 2, 3]));
|
|
53
|
+
expect(fs.statSync(file).mode & 0o777).toBe(0o600);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("ensureSecureMode", () => {
|
|
58
|
+
it("returns 'already-secure' when file is already 0o600", () => {
|
|
59
|
+
const file = resolve(TEST_DIR, "f.txt");
|
|
60
|
+
fs.writeFileSync(file, "x");
|
|
61
|
+
fs.chmodSync(file, 0o600);
|
|
62
|
+
const result = ensureSecureMode(file);
|
|
63
|
+
expect(result.status).toBe("already-secure");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("repairs a file that is too permissive (0o644 → 0o600)", () => {
|
|
67
|
+
const file = resolve(TEST_DIR, "f.txt");
|
|
68
|
+
fs.writeFileSync(file, "x");
|
|
69
|
+
fs.chmodSync(file, 0o644);
|
|
70
|
+
const result = ensureSecureMode(file);
|
|
71
|
+
expect(result.status).toBe("repaired");
|
|
72
|
+
expect(result.previousMode).toBe("644");
|
|
73
|
+
expect(fs.statSync(file).mode & 0o777).toBe(0o600);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("returns 'missing' for a nonexistent file without erroring", () => {
|
|
77
|
+
const result = ensureSecureMode(resolve(TEST_DIR, "nope.txt"));
|
|
78
|
+
expect(result.status).toBe("missing");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("is idempotent: calling twice on a 0o644 file still ends at 0o600", () => {
|
|
82
|
+
const file = resolve(TEST_DIR, "f.txt");
|
|
83
|
+
fs.writeFileSync(file, "x");
|
|
84
|
+
fs.chmodSync(file, 0o644);
|
|
85
|
+
ensureSecureMode(file);
|
|
86
|
+
const second = ensureSecureMode(file);
|
|
87
|
+
expect(second.status).toBe("already-secure");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("does NOT try to loosen a stricter-than-needed mode (e.g. 0o400)", () => {
|
|
91
|
+
const file = resolve(TEST_DIR, "f.txt");
|
|
92
|
+
fs.writeFileSync(file, "x");
|
|
93
|
+
fs.chmodSync(file, 0o400);
|
|
94
|
+
const result = ensureSecureMode(file);
|
|
95
|
+
expect(result.status).toBe("already-secure");
|
|
96
|
+
expect(fs.statSync(file).mode & 0o777).toBe(0o400);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("auditSensitiveFiles", () => {
|
|
101
|
+
it("reports a list of files checked and their status", () => {
|
|
102
|
+
const envFile = resolve(TEST_DIR, ".env");
|
|
103
|
+
const stateFile = resolve(TEST_DIR, "sessions.json");
|
|
104
|
+
fs.writeFileSync(envFile, "SECRET=1");
|
|
105
|
+
fs.chmodSync(envFile, 0o644); // insecure
|
|
106
|
+
fs.writeFileSync(stateFile, "{}");
|
|
107
|
+
fs.chmodSync(stateFile, 0o600); // secure
|
|
108
|
+
|
|
109
|
+
const report = auditSensitiveFiles([envFile, stateFile]);
|
|
110
|
+
expect(report).toHaveLength(2);
|
|
111
|
+
|
|
112
|
+
const env = report.find(r => r.path === envFile);
|
|
113
|
+
const state = report.find(r => r.path === stateFile);
|
|
114
|
+
expect(env!.status).toBe("repaired");
|
|
115
|
+
expect(state!.status).toBe("already-secure");
|
|
116
|
+
|
|
117
|
+
expect(fs.statSync(envFile).mode & 0o777).toBe(0o600);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("skips nonexistent files gracefully", () => {
|
|
121
|
+
const report = auditSensitiveFiles([
|
|
122
|
+
resolve(TEST_DIR, "nope.env"),
|
|
123
|
+
resolve(TEST_DIR, "also-nope.json"),
|
|
124
|
+
]);
|
|
125
|
+
expect(report).toHaveLength(2);
|
|
126
|
+
expect(report[0].status).toBe("missing");
|
|
127
|
+
expect(report[1].status).toBe("missing");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v4.12.3 — UserSession.pendingBackgroundCount
|
|
3
|
+
*
|
|
4
|
+
* When Claude launches an Agent/Task tool with run_in_background: true,
|
|
5
|
+
* the SDK's CLI subprocess stays alive until the task-notification is
|
|
6
|
+
* ready to deliver. During that window the main Telegram session is
|
|
7
|
+
* effectively blocked — isProcessing=true, all new user messages get
|
|
8
|
+
* queued. For 5-minute+ background tasks that's unacceptable UX.
|
|
9
|
+
*
|
|
10
|
+
* v4.12.3 tracks the count of pending background agents on each session
|
|
11
|
+
* so the handler can detect the blocked state and bypass the SDK resume
|
|
12
|
+
* (start a fresh SDK session for the new user message while the old
|
|
13
|
+
* session drains in the background).
|
|
14
|
+
*
|
|
15
|
+
* The count is incremented by the message handler on async_launched
|
|
16
|
+
* tool_result and decremented by the async-agent-watcher when it
|
|
17
|
+
* delivers the sub-agent's result.
|
|
18
|
+
*/
|
|
19
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
20
|
+
|
|
21
|
+
beforeEach(() => vi.resetModules());
|
|
22
|
+
|
|
23
|
+
describe("UserSession.pendingBackgroundCount (v4.12.3)", () => {
|
|
24
|
+
it("new session starts with pendingBackgroundCount=0", async () => {
|
|
25
|
+
const { getSession } = await import("../src/services/session.js");
|
|
26
|
+
const s = getSession("test-user-new");
|
|
27
|
+
expect(s.pendingBackgroundCount).toBe(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("incrementing on the session persists across getSession calls", async () => {
|
|
31
|
+
const { getSession } = await import("../src/services/session.js");
|
|
32
|
+
const s1 = getSession("test-user-inc");
|
|
33
|
+
s1.pendingBackgroundCount = 2;
|
|
34
|
+
const s2 = getSession("test-user-inc");
|
|
35
|
+
expect(s2.pendingBackgroundCount).toBe(2);
|
|
36
|
+
expect(s1).toBe(s2);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("resetSession zeroes pendingBackgroundCount", async () => {
|
|
40
|
+
const { getSession, resetSession } = await import("../src/services/session.js");
|
|
41
|
+
const s = getSession("test-user-reset");
|
|
42
|
+
s.pendingBackgroundCount = 3;
|
|
43
|
+
resetSession("test-user-reset");
|
|
44
|
+
expect(s.pendingBackgroundCount).toBe(0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("count can be decremented without going negative via explicit guard", async () => {
|
|
48
|
+
// The handler/watcher code is responsible for not decrementing below
|
|
49
|
+
// zero. This test just documents that the field is a plain number
|
|
50
|
+
// with no built-in guard — decrement logic lives in the consumers.
|
|
51
|
+
const { getSession } = await import("../src/services/session.js");
|
|
52
|
+
const s = getSession("test-user-dec");
|
|
53
|
+
s.pendingBackgroundCount = 1;
|
|
54
|
+
s.pendingBackgroundCount = Math.max(0, s.pendingBackgroundCount - 1);
|
|
55
|
+
expect(s.pendingBackgroundCount).toBe(0);
|
|
56
|
+
s.pendingBackgroundCount = Math.max(0, s.pendingBackgroundCount - 1);
|
|
57
|
+
expect(s.pendingBackgroundCount).toBe(0);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v4.12.2 — Sub-agent toolset allowlist (Task G).
|
|
3
|
+
*
|
|
4
|
+
* Sub-agents can now be spawned with a toolset preset that restricts which
|
|
5
|
+
* tools Claude has access to:
|
|
6
|
+
* - "full" — all tools (default, matches pre-v4.12.2 behavior)
|
|
7
|
+
* - "readonly" — Read, Glob, Grep (analyze, no write, no shell, no net)
|
|
8
|
+
* - "research" — Read, Glob, Grep, WebSearch, WebFetch (no write, no shell)
|
|
9
|
+
*
|
|
10
|
+
* This test verifies that the preset → allowedTools mapping is correct
|
|
11
|
+
* and that the provider honors the override. The integration path
|
|
12
|
+
* (spawnSubAgent → registry.queryWithFallback → claude-sdk-provider) is
|
|
13
|
+
* exercised via mocked SDK.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
16
|
+
import type { StreamChunk } from "../src/providers/types.js";
|
|
17
|
+
|
|
18
|
+
beforeEach(() => vi.resetModules());
|
|
19
|
+
|
|
20
|
+
describe("claude-sdk-provider honors options.allowedTools (v4.12.2)", () => {
|
|
21
|
+
it("uses the default full toolset when options.allowedTools is undefined", async () => {
|
|
22
|
+
let capturedOpts: Record<string, unknown> | undefined;
|
|
23
|
+
vi.doMock("../src/find-claude-binary.js", () => ({
|
|
24
|
+
findClaudeBinary: () => "/usr/bin/false",
|
|
25
|
+
}));
|
|
26
|
+
vi.doMock("@anthropic-ai/claude-agent-sdk", () => ({
|
|
27
|
+
query: (opts: { options: Record<string, unknown> }) => {
|
|
28
|
+
capturedOpts = opts.options;
|
|
29
|
+
return (async function* () {
|
|
30
|
+
yield { type: "system", subtype: "init", session_id: "s1" };
|
|
31
|
+
yield { type: "result", session_id: "s1", total_cost_usd: 0, usage: null };
|
|
32
|
+
})();
|
|
33
|
+
},
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
const { ClaudeSDKProvider } = await import("../src/providers/claude-sdk-provider.js");
|
|
37
|
+
const provider = new ClaudeSDKProvider();
|
|
38
|
+
|
|
39
|
+
for await (const _c of provider.query({ prompt: "test", systemPrompt: "test" })) {
|
|
40
|
+
void _c;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
expect(capturedOpts).toBeDefined();
|
|
44
|
+
expect(capturedOpts!.allowedTools).toEqual([
|
|
45
|
+
"Read", "Write", "Edit", "Bash", "Glob", "Grep",
|
|
46
|
+
"WebSearch", "WebFetch", "Task",
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("overrides allowedTools when caller passes a restricted list (readonly preset)", async () => {
|
|
51
|
+
let capturedOpts: Record<string, unknown> | undefined;
|
|
52
|
+
vi.doMock("../src/find-claude-binary.js", () => ({
|
|
53
|
+
findClaudeBinary: () => "/usr/bin/false",
|
|
54
|
+
}));
|
|
55
|
+
vi.doMock("@anthropic-ai/claude-agent-sdk", () => ({
|
|
56
|
+
query: (opts: { options: Record<string, unknown> }) => {
|
|
57
|
+
capturedOpts = opts.options;
|
|
58
|
+
return (async function* () {
|
|
59
|
+
yield { type: "system", subtype: "init", session_id: "s1" };
|
|
60
|
+
yield { type: "result", session_id: "s1", total_cost_usd: 0, usage: null };
|
|
61
|
+
})();
|
|
62
|
+
},
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
const { ClaudeSDKProvider } = await import("../src/providers/claude-sdk-provider.js");
|
|
66
|
+
const provider = new ClaudeSDKProvider();
|
|
67
|
+
|
|
68
|
+
const readonlyTools = ["Read", "Glob", "Grep"];
|
|
69
|
+
for await (const _c of provider.query({
|
|
70
|
+
prompt: "test",
|
|
71
|
+
systemPrompt: "test",
|
|
72
|
+
allowedTools: readonlyTools,
|
|
73
|
+
})) {
|
|
74
|
+
void _c;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
expect(capturedOpts!.allowedTools).toEqual(readonlyTools);
|
|
78
|
+
// Critically: Bash, Write, Edit are NOT in the list
|
|
79
|
+
expect(capturedOpts!.allowedTools).not.toContain("Bash");
|
|
80
|
+
expect(capturedOpts!.allowedTools).not.toContain("Write");
|
|
81
|
+
expect(capturedOpts!.allowedTools).not.toContain("Edit");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("overrides allowedTools with research preset (adds web tools)", async () => {
|
|
85
|
+
let capturedOpts: Record<string, unknown> | undefined;
|
|
86
|
+
vi.doMock("../src/find-claude-binary.js", () => ({
|
|
87
|
+
findClaudeBinary: () => "/usr/bin/false",
|
|
88
|
+
}));
|
|
89
|
+
vi.doMock("@anthropic-ai/claude-agent-sdk", () => ({
|
|
90
|
+
query: (opts: { options: Record<string, unknown> }) => {
|
|
91
|
+
capturedOpts = opts.options;
|
|
92
|
+
return (async function* () {
|
|
93
|
+
yield { type: "system", subtype: "init", session_id: "s1" };
|
|
94
|
+
yield { type: "result", session_id: "s1", total_cost_usd: 0, usage: null };
|
|
95
|
+
})();
|
|
96
|
+
},
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
const { ClaudeSDKProvider } = await import("../src/providers/claude-sdk-provider.js");
|
|
100
|
+
const provider = new ClaudeSDKProvider();
|
|
101
|
+
|
|
102
|
+
const researchTools = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
|
|
103
|
+
for await (const _c of provider.query({
|
|
104
|
+
prompt: "test",
|
|
105
|
+
systemPrompt: "test",
|
|
106
|
+
allowedTools: researchTools,
|
|
107
|
+
})) {
|
|
108
|
+
void _c;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
expect(capturedOpts!.allowedTools).toEqual(researchTools);
|
|
112
|
+
expect(capturedOpts!.allowedTools).toContain("WebSearch");
|
|
113
|
+
expect(capturedOpts!.allowedTools).not.toContain("Bash");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("empty allowedTools array is honored as such (no tools at all)", async () => {
|
|
117
|
+
let capturedOpts: Record<string, unknown> | undefined;
|
|
118
|
+
vi.doMock("../src/find-claude-binary.js", () => ({
|
|
119
|
+
findClaudeBinary: () => "/usr/bin/false",
|
|
120
|
+
}));
|
|
121
|
+
vi.doMock("@anthropic-ai/claude-agent-sdk", () => ({
|
|
122
|
+
query: (opts: { options: Record<string, unknown> }) => {
|
|
123
|
+
capturedOpts = opts.options;
|
|
124
|
+
return (async function* () {
|
|
125
|
+
yield { type: "system", subtype: "init", session_id: "s1" };
|
|
126
|
+
yield { type: "result", session_id: "s1", total_cost_usd: 0, usage: null };
|
|
127
|
+
})();
|
|
128
|
+
},
|
|
129
|
+
}));
|
|
130
|
+
|
|
131
|
+
const { ClaudeSDKProvider } = await import("../src/providers/claude-sdk-provider.js");
|
|
132
|
+
const provider = new ClaudeSDKProvider();
|
|
133
|
+
|
|
134
|
+
for await (const _c of provider.query({
|
|
135
|
+
prompt: "test",
|
|
136
|
+
systemPrompt: "test",
|
|
137
|
+
allowedTools: [],
|
|
138
|
+
})) {
|
|
139
|
+
void _c;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Empty array → no tools. Note: JS ?? operator treats [] as truthy,
|
|
143
|
+
// so this IS honored as "empty allowlist" not "use default".
|
|
144
|
+
expect(capturedOpts!.allowedTools).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -21,7 +21,7 @@ vi.mock("../src/engine.js", () => ({
|
|
|
21
21
|
}),
|
|
22
22
|
}));
|
|
23
23
|
|
|
24
|
-
describe("sub-agents toolset (G1)", () => {
|
|
24
|
+
describe("sub-agents toolset (G1, extended v4.12.2)", () => {
|
|
25
25
|
it("accepts toolset='full'", async () => {
|
|
26
26
|
const mod = await import("../src/services/subagents.js");
|
|
27
27
|
const id = await mod.spawnSubAgent({
|
|
@@ -38,13 +38,33 @@ describe("sub-agents toolset (G1)", () => {
|
|
|
38
38
|
expect(typeof id).toBe("string");
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
+
it("accepts toolset='readonly' (v4.12.2 — read-only sub-agents)", async () => {
|
|
42
|
+
const mod = await import("../src/services/subagents.js");
|
|
43
|
+
const id = await mod.spawnSubAgent({
|
|
44
|
+
name: "tool-readonly",
|
|
45
|
+
prompt: "hi",
|
|
46
|
+
toolset: "readonly",
|
|
47
|
+
});
|
|
48
|
+
expect(typeof id).toBe("string");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("accepts toolset='research' (v4.12.2 — readonly + web)", async () => {
|
|
52
|
+
const mod = await import("../src/services/subagents.js");
|
|
53
|
+
const id = await mod.spawnSubAgent({
|
|
54
|
+
name: "tool-research",
|
|
55
|
+
prompt: "hi",
|
|
56
|
+
toolset: "research",
|
|
57
|
+
});
|
|
58
|
+
expect(typeof id).toBe("string");
|
|
59
|
+
});
|
|
60
|
+
|
|
41
61
|
it("rejects unknown toolset values at runtime", async () => {
|
|
42
62
|
const mod = await import("../src/services/subagents.js");
|
|
43
63
|
await expect(
|
|
44
64
|
mod.spawnSubAgent({
|
|
45
65
|
name: "tool-bogus",
|
|
46
66
|
prompt: "hi",
|
|
47
|
-
toolset: "
|
|
67
|
+
toolset: "nonsense-preset" as unknown as "full",
|
|
48
68
|
}),
|
|
49
69
|
).rejects.toThrow(/toolset/i);
|
|
50
70
|
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v4.12.2 — Timing-safe bearer token comparison.
|
|
3
|
+
*
|
|
4
|
+
* The webhook auth check at src/web/server.ts:127 previously used naive
|
|
5
|
+
* string equality on the Authorization header. That's vulnerable (in
|
|
6
|
+
* principle) to timing side-channel attacks where an attacker measures
|
|
7
|
+
* response times to leak the token character by character.
|
|
8
|
+
*
|
|
9
|
+
* Real-world exploitability over network is low due to jitter, but
|
|
10
|
+
* crypto.timingSafeEqual is the right tool regardless.
|
|
11
|
+
*
|
|
12
|
+
* This test covers the pure helper; the integration is in server.ts.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect } from "vitest";
|
|
15
|
+
import { timingSafeBearerMatch } from "../src/services/timing-safe-bearer.js";
|
|
16
|
+
|
|
17
|
+
describe("timing-safe bearer token comparison (v4.12.2)", () => {
|
|
18
|
+
it("matches a correct token", () => {
|
|
19
|
+
expect(timingSafeBearerMatch("Bearer abc123xyz", "abc123xyz")).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("rejects an incorrect token", () => {
|
|
23
|
+
expect(timingSafeBearerMatch("Bearer wrong", "abc123xyz")).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("rejects when Bearer prefix is missing", () => {
|
|
27
|
+
expect(timingSafeBearerMatch("abc123xyz", "abc123xyz")).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("rejects when auth header is empty", () => {
|
|
31
|
+
expect(timingSafeBearerMatch("", "abc123xyz")).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("rejects when auth header is undefined", () => {
|
|
35
|
+
expect(timingSafeBearerMatch(undefined, "abc123xyz")).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("rejects when expected token is empty (prevents accidental auth bypass)", () => {
|
|
39
|
+
expect(timingSafeBearerMatch("Bearer anything", "")).toBe(false);
|
|
40
|
+
expect(timingSafeBearerMatch("Bearer ", "")).toBe(false);
|
|
41
|
+
expect(timingSafeBearerMatch("", "")).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("rejects tokens of different lengths without revealing prefix match", () => {
|
|
45
|
+
expect(timingSafeBearerMatch("Bearer abc", "abcdefg")).toBe(false);
|
|
46
|
+
expect(timingSafeBearerMatch("Bearer abcdefg", "abc")).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("handles unicode tokens (not that we'd use them, but correctness)", () => {
|
|
50
|
+
expect(timingSafeBearerMatch("Bearer 🔒xyz", "🔒xyz")).toBe(true);
|
|
51
|
+
expect(timingSafeBearerMatch("Bearer 🔒xyz", "🔒xYz")).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("case-sensitive comparison (tokens are opaque)", () => {
|
|
55
|
+
expect(timingSafeBearerMatch("Bearer AbCdEf", "abcdef")).toBe(false);
|
|
56
|
+
expect(timingSafeBearerMatch("Bearer AbCdEf", "AbCdEf")).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("rejects Bearer with leading/trailing whitespace mismatches the expected format", () => {
|
|
60
|
+
// RFC 6750 says: Authorization: Bearer <token>
|
|
61
|
+
// Exactly one space between "Bearer" and the token.
|
|
62
|
+
expect(timingSafeBearerMatch("Bearer abc", "abc")).toBe(false); // double space
|
|
63
|
+
expect(timingSafeBearerMatch(" Bearer abc", "abc")).toBe(false); // leading space
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v4.12.3 — async-agent watcher ↔ session.pendingBackgroundCount wiring.
|
|
3
|
+
*
|
|
4
|
+
* Contract:
|
|
5
|
+
* - registerPendingAgent takes an optional `sessionKey` so the watcher
|
|
6
|
+
* can locate the right UserSession later.
|
|
7
|
+
* - When the watcher delivers a result (completed/failed/timeout), the
|
|
8
|
+
* session's pendingBackgroundCount MUST be decremented so the main
|
|
9
|
+
* handler knows it's safe to resume SDK-session-based queries.
|
|
10
|
+
* - Decrement is clamped at 0 — the counter never goes negative even
|
|
11
|
+
* if decoupled operations drift.
|
|
12
|
+
* - The handler is responsible for INCREMENTING when it registers.
|
|
13
|
+
* The watcher only decrements.
|
|
14
|
+
*
|
|
15
|
+
* These tests use the shared in-memory session Map from session.ts so
|
|
16
|
+
* they exercise the actual wiring, not a mock.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
19
|
+
import fs from "fs";
|
|
20
|
+
import os from "os";
|
|
21
|
+
import { resolve } from "path";
|
|
22
|
+
|
|
23
|
+
const TEST_DATA_DIR = resolve(
|
|
24
|
+
os.tmpdir(),
|
|
25
|
+
`alvin-watcher-pending-${process.pid}-${Date.now()}`,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
beforeEach(async () => {
|
|
29
|
+
if (fs.existsSync(TEST_DATA_DIR)) {
|
|
30
|
+
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
|
33
|
+
process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
|
|
34
|
+
vi.resetModules();
|
|
35
|
+
vi.doMock("../src/services/subagent-delivery.js", () => ({
|
|
36
|
+
deliverSubAgentResult: async () => {},
|
|
37
|
+
attachBotApi: () => {},
|
|
38
|
+
__setBotApiForTest: () => {},
|
|
39
|
+
}));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(async () => {
|
|
43
|
+
try {
|
|
44
|
+
const mod = await import("../src/services/async-agent-watcher.js");
|
|
45
|
+
mod.stopWatcher();
|
|
46
|
+
mod.__resetForTest();
|
|
47
|
+
} catch {
|
|
48
|
+
/* ignore */
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
function writeCompletedJsonl(path: string, finalText: string): void {
|
|
53
|
+
const lines =
|
|
54
|
+
[
|
|
55
|
+
JSON.stringify({
|
|
56
|
+
type: "user",
|
|
57
|
+
isSidechain: true,
|
|
58
|
+
agentId: "x",
|
|
59
|
+
message: { role: "user", content: "do it" },
|
|
60
|
+
}),
|
|
61
|
+
JSON.stringify({
|
|
62
|
+
type: "assistant",
|
|
63
|
+
isSidechain: true,
|
|
64
|
+
agentId: "x",
|
|
65
|
+
message: {
|
|
66
|
+
role: "assistant",
|
|
67
|
+
content: [{ type: "text", text: finalText }],
|
|
68
|
+
stop_reason: "end_turn",
|
|
69
|
+
usage: { input_tokens: 10, output_tokens: 5 },
|
|
70
|
+
},
|
|
71
|
+
}),
|
|
72
|
+
].join("\n") + "\n";
|
|
73
|
+
fs.mkdirSync(resolve(path, ".."), { recursive: true });
|
|
74
|
+
fs.writeFileSync(path, lines, "utf-8");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("watcher ↔ session.pendingBackgroundCount (v4.12.3)", () => {
|
|
78
|
+
it("completed delivery decrements pendingBackgroundCount on the right session", async () => {
|
|
79
|
+
const { getSession } = await import("../src/services/session.js");
|
|
80
|
+
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
81
|
+
|
|
82
|
+
const sessionKey = "v412-session-a";
|
|
83
|
+
const session = getSession(sessionKey);
|
|
84
|
+
session.pendingBackgroundCount = 1;
|
|
85
|
+
|
|
86
|
+
const outPath = `${TEST_DATA_DIR}/out-a.jsonl`;
|
|
87
|
+
watcher.registerPendingAgent({
|
|
88
|
+
agentId: "a",
|
|
89
|
+
outputFile: outPath,
|
|
90
|
+
description: "research",
|
|
91
|
+
prompt: "p",
|
|
92
|
+
chatId: 42,
|
|
93
|
+
userId: 42,
|
|
94
|
+
toolUseId: null,
|
|
95
|
+
sessionKey,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
writeCompletedJsonl(outPath, "result");
|
|
99
|
+
await watcher.pollOnce();
|
|
100
|
+
|
|
101
|
+
expect(session.pendingBackgroundCount).toBe(0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("timeout delivery also decrements the counter", async () => {
|
|
105
|
+
const { getSession } = await import("../src/services/session.js");
|
|
106
|
+
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
107
|
+
|
|
108
|
+
const sessionKey = "v412-session-timeout";
|
|
109
|
+
const session = getSession(sessionKey);
|
|
110
|
+
session.pendingBackgroundCount = 2;
|
|
111
|
+
|
|
112
|
+
watcher.registerPendingAgent({
|
|
113
|
+
agentId: "timed-out",
|
|
114
|
+
outputFile: `${TEST_DATA_DIR}/never-written.jsonl`,
|
|
115
|
+
description: "slow task",
|
|
116
|
+
prompt: "p",
|
|
117
|
+
chatId: 42,
|
|
118
|
+
userId: 42,
|
|
119
|
+
toolUseId: null,
|
|
120
|
+
sessionKey,
|
|
121
|
+
giveUpAt: Date.now() - 1000,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await watcher.pollOnce();
|
|
125
|
+
|
|
126
|
+
expect(session.pendingBackgroundCount).toBe(1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("failure delivery decrements the counter", async () => {
|
|
130
|
+
const { getSession } = await import("../src/services/session.js");
|
|
131
|
+
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
132
|
+
|
|
133
|
+
const sessionKey = "v412-session-fail";
|
|
134
|
+
const session = getSession(sessionKey);
|
|
135
|
+
session.pendingBackgroundCount = 3;
|
|
136
|
+
|
|
137
|
+
const outPath = `${TEST_DATA_DIR}/fail.jsonl`;
|
|
138
|
+
// Write a malformed "error" state — a single invalid line that will
|
|
139
|
+
// fall through the parser and stay in "running" state. Then mark
|
|
140
|
+
// the session as a timeout by moving giveUpAt into the past.
|
|
141
|
+
// Actually easier: use giveUpAt again as the trigger.
|
|
142
|
+
watcher.registerPendingAgent({
|
|
143
|
+
agentId: "fail-via-timeout",
|
|
144
|
+
outputFile: outPath,
|
|
145
|
+
description: "will fail",
|
|
146
|
+
prompt: "p",
|
|
147
|
+
chatId: 42,
|
|
148
|
+
userId: 42,
|
|
149
|
+
toolUseId: null,
|
|
150
|
+
sessionKey,
|
|
151
|
+
giveUpAt: Date.now() - 1000,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await watcher.pollOnce();
|
|
155
|
+
|
|
156
|
+
expect(session.pendingBackgroundCount).toBe(2);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("decrement is clamped at 0 — counter never goes negative", async () => {
|
|
160
|
+
const { getSession } = await import("../src/services/session.js");
|
|
161
|
+
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
162
|
+
|
|
163
|
+
const sessionKey = "v412-session-drift";
|
|
164
|
+
const session = getSession(sessionKey);
|
|
165
|
+
session.pendingBackgroundCount = 0; // drift scenario
|
|
166
|
+
|
|
167
|
+
const outPath = `${TEST_DATA_DIR}/drift.jsonl`;
|
|
168
|
+
watcher.registerPendingAgent({
|
|
169
|
+
agentId: "drift",
|
|
170
|
+
outputFile: outPath,
|
|
171
|
+
description: "drift",
|
|
172
|
+
prompt: "p",
|
|
173
|
+
chatId: 42,
|
|
174
|
+
userId: 42,
|
|
175
|
+
toolUseId: null,
|
|
176
|
+
sessionKey,
|
|
177
|
+
});
|
|
178
|
+
writeCompletedJsonl(outPath, "done");
|
|
179
|
+
await watcher.pollOnce();
|
|
180
|
+
|
|
181
|
+
expect(session.pendingBackgroundCount).toBe(0);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("missing sessionKey is handled gracefully — no throw, no crash", async () => {
|
|
185
|
+
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
186
|
+
const outPath = `${TEST_DATA_DIR}/orphan.jsonl`;
|
|
187
|
+
watcher.registerPendingAgent({
|
|
188
|
+
agentId: "orphan",
|
|
189
|
+
outputFile: outPath,
|
|
190
|
+
description: "orphan",
|
|
191
|
+
prompt: "p",
|
|
192
|
+
chatId: 42,
|
|
193
|
+
userId: 42,
|
|
194
|
+
toolUseId: null,
|
|
195
|
+
// sessionKey intentionally omitted
|
|
196
|
+
});
|
|
197
|
+
writeCompletedJsonl(outPath, "done");
|
|
198
|
+
await expect(watcher.pollOnce()).resolves.not.toThrow();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("multiple agents for the same session all decrement", async () => {
|
|
202
|
+
const { getSession } = await import("../src/services/session.js");
|
|
203
|
+
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
204
|
+
|
|
205
|
+
const sessionKey = "v412-session-multi";
|
|
206
|
+
const session = getSession(sessionKey);
|
|
207
|
+
session.pendingBackgroundCount = 3;
|
|
208
|
+
|
|
209
|
+
for (const id of ["m1", "m2", "m3"]) {
|
|
210
|
+
const outPath = `${TEST_DATA_DIR}/${id}.jsonl`;
|
|
211
|
+
watcher.registerPendingAgent({
|
|
212
|
+
agentId: id,
|
|
213
|
+
outputFile: outPath,
|
|
214
|
+
description: `task ${id}`,
|
|
215
|
+
prompt: "p",
|
|
216
|
+
chatId: 42,
|
|
217
|
+
userId: 42,
|
|
218
|
+
toolUseId: null,
|
|
219
|
+
sessionKey,
|
|
220
|
+
});
|
|
221
|
+
writeCompletedJsonl(outPath, `result ${id}`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
await watcher.pollOnce();
|
|
225
|
+
|
|
226
|
+
expect(session.pendingBackgroundCount).toBe(0);
|
|
227
|
+
});
|
|
228
|
+
});
|