claude-octopus 1.0.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.
@@ -0,0 +1,374 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ envStr,
4
+ envList,
5
+ envNum,
6
+ envBool,
7
+ envJson,
8
+ sanitizeToolName,
9
+ MAX_TOOL_NAME_LEN,
10
+ isDescendantPath,
11
+ mergeAllowedTools,
12
+ mergeDisallowedTools,
13
+ validatePermissionMode,
14
+ VALID_PERM_MODES,
15
+ deriveServerName,
16
+ deriveToolName,
17
+ serializeArrayEnv,
18
+ buildResultPayload,
19
+ formatErrorMessage,
20
+ } from "./lib.js";
21
+
22
+ // ── envStr ─────────────────────────────────────────────────────────
23
+
24
+ describe("envStr", () => {
25
+ it("returns value when set", () => {
26
+ expect(envStr("FOO", { FOO: "bar" })).toBe("bar");
27
+ });
28
+
29
+ it("returns undefined when missing", () => {
30
+ expect(envStr("FOO", {})).toBeUndefined();
31
+ });
32
+
33
+ it("returns undefined for empty string", () => {
34
+ expect(envStr("FOO", { FOO: "" })).toBeUndefined();
35
+ });
36
+ });
37
+
38
+ // ── envList ────────────────────────────────────────────────────────
39
+
40
+ describe("envList", () => {
41
+ it("splits comma-separated values", () => {
42
+ expect(envList("X", { X: "a,b,c" })).toEqual(["a", "b", "c"]);
43
+ });
44
+
45
+ it("trims whitespace", () => {
46
+ expect(envList("X", { X: " a , b , c " })).toEqual(["a", "b", "c"]);
47
+ });
48
+
49
+ it("filters empty segments", () => {
50
+ expect(envList("X", { X: "a,,b," })).toEqual(["a", "b"]);
51
+ });
52
+
53
+ it("returns undefined when missing", () => {
54
+ expect(envList("X", {})).toBeUndefined();
55
+ });
56
+
57
+ it("parses JSON array", () => {
58
+ expect(envList("X", { X: '["a","b","c"]' })).toEqual(["a", "b", "c"]);
59
+ });
60
+
61
+ it("handles JSON array with commas in values", () => {
62
+ expect(envList("X", { X: '["/path,with,commas","/normal"]' })).toEqual([
63
+ "/path,with,commas",
64
+ "/normal",
65
+ ]);
66
+ });
67
+
68
+ it("falls back to comma-split on invalid JSON starting with [", () => {
69
+ expect(envList("X", { X: "[not-json" })).toEqual(["[not-json"]);
70
+ });
71
+ });
72
+
73
+ // ── envNum ─────────────────────────────────────────────────────────
74
+
75
+ describe("envNum", () => {
76
+ it("parses integers", () => {
77
+ expect(envNum("X", { X: "42" })).toBe(42);
78
+ });
79
+
80
+ it("parses floats", () => {
81
+ expect(envNum("X", { X: "1.5" })).toBe(1.5);
82
+ });
83
+
84
+ it("returns undefined for NaN", () => {
85
+ expect(envNum("X", { X: "abc" })).toBeUndefined();
86
+ });
87
+
88
+ it("returns undefined when missing", () => {
89
+ expect(envNum("X", {})).toBeUndefined();
90
+ });
91
+
92
+ it("parses zero", () => {
93
+ expect(envNum("X", { X: "0" })).toBe(0);
94
+ });
95
+
96
+ it("parses negative numbers", () => {
97
+ expect(envNum("X", { X: "-5" })).toBe(-5);
98
+ });
99
+ });
100
+
101
+ // ── envBool ────────────────────────────────────────────────────────
102
+
103
+ describe("envBool", () => {
104
+ it('returns true for "true"', () => {
105
+ expect(envBool("X", false, { X: "true" })).toBe(true);
106
+ });
107
+
108
+ it('returns true for "1"', () => {
109
+ expect(envBool("X", false, { X: "1" })).toBe(true);
110
+ });
111
+
112
+ it("returns false for other values", () => {
113
+ expect(envBool("X", true, { X: "false" })).toBe(false);
114
+ expect(envBool("X", true, { X: "0" })).toBe(false);
115
+ expect(envBool("X", true, { X: "no" })).toBe(false);
116
+ });
117
+
118
+ it("returns fallback when missing", () => {
119
+ expect(envBool("X", true, {})).toBe(true);
120
+ expect(envBool("X", false, {})).toBe(false);
121
+ });
122
+ });
123
+
124
+ // ── envJson ────────────────────────────────────────────────────────
125
+
126
+ describe("envJson", () => {
127
+ it("parses valid JSON", () => {
128
+ expect(envJson("X", { X: '{"a":1}' })).toEqual({ a: 1 });
129
+ });
130
+
131
+ it("returns undefined for invalid JSON", () => {
132
+ expect(envJson("X", { X: "{bad" })).toBeUndefined();
133
+ });
134
+
135
+ it("returns undefined when missing", () => {
136
+ expect(envJson("X", {})).toBeUndefined();
137
+ });
138
+ });
139
+
140
+ // ── sanitizeToolName ───────────────────────────────────────────────
141
+
142
+ describe("sanitizeToolName", () => {
143
+ it("passes through valid names", () => {
144
+ expect(sanitizeToolName("code_reviewer")).toBe("code_reviewer");
145
+ });
146
+
147
+ it("replaces invalid characters with underscore", () => {
148
+ expect(sanitizeToolName("my-tool.name")).toBe("my_tool_name");
149
+ });
150
+
151
+ it("truncates to MAX_TOOL_NAME_LEN", () => {
152
+ const long = "a".repeat(100);
153
+ expect(sanitizeToolName(long).length).toBe(MAX_TOOL_NAME_LEN);
154
+ });
155
+
156
+ it("falls back to claude_code when sanitization empties the string", () => {
157
+ expect(sanitizeToolName("---")).toBe("___");
158
+ expect(sanitizeToolName("")).toBe("claude_code");
159
+ });
160
+
161
+ it("reserves space for _reply suffix", () => {
162
+ expect(MAX_TOOL_NAME_LEN).toBe(58);
163
+ const name = sanitizeToolName("a".repeat(58));
164
+ expect(`${name}_reply`.length).toBeLessThanOrEqual(64);
165
+ });
166
+ });
167
+
168
+ // ── isDescendantPath ───────────────────────────────────────────────
169
+
170
+ describe("isDescendantPath", () => {
171
+ it("allows exact base path", () => {
172
+ expect(isDescendantPath("/srv/app", "/srv/app")).toBe(true);
173
+ });
174
+
175
+ it("allows subdirectory", () => {
176
+ expect(isDescendantPath("subdir", "/srv/app")).toBe(true);
177
+ });
178
+
179
+ it("allows nested subdirectory", () => {
180
+ expect(isDescendantPath("a/b/c", "/srv/app")).toBe(true);
181
+ });
182
+
183
+ it("rejects parent traversal", () => {
184
+ expect(isDescendantPath("../escape", "/srv/app")).toBe(false);
185
+ });
186
+
187
+ it("rejects prefix attack (/srv/app-escape)", () => {
188
+ expect(isDescendantPath("/srv/app-escape", "/srv/app")).toBe(false);
189
+ });
190
+
191
+ it("rejects absolute path outside base", () => {
192
+ expect(isDescendantPath("/etc/passwd", "/srv/app")).toBe(false);
193
+ });
194
+
195
+ it("allows absolute path inside base", () => {
196
+ expect(isDescendantPath("/srv/app/sub", "/srv/app")).toBe(true);
197
+ });
198
+
199
+ it("handles base path with trailing slash", () => {
200
+ expect(isDescendantPath("subdir", "/srv/app/")).toBe(true);
201
+ expect(isDescendantPath("/srv/app-escape", "/srv/app/")).toBe(false);
202
+ });
203
+ });
204
+
205
+ // ── mergeAllowedTools ──────────────────────────────────────────────
206
+
207
+ describe("mergeAllowedTools", () => {
208
+ it("intersects when server has a list", () => {
209
+ expect(
210
+ mergeAllowedTools(["Read", "Grep", "Glob"], ["Read", "Write", "Glob"])
211
+ ).toEqual(["Read", "Glob"]);
212
+ });
213
+
214
+ it("passes through when server has no list", () => {
215
+ expect(
216
+ mergeAllowedTools(undefined, ["Read", "Write"])
217
+ ).toEqual(["Read", "Write"]);
218
+ });
219
+
220
+ it("returns empty when no overlap", () => {
221
+ expect(
222
+ mergeAllowedTools(["Read"], ["Write"])
223
+ ).toEqual([]);
224
+ });
225
+ });
226
+
227
+ // ── mergeDisallowedTools ───────────────────────────────────────────
228
+
229
+ describe("mergeDisallowedTools", () => {
230
+ it("unions server and call lists", () => {
231
+ const result = mergeDisallowedTools(["WebFetch"], ["WebSearch"]);
232
+ expect(result).toContain("WebFetch");
233
+ expect(result).toContain("WebSearch");
234
+ expect(result).toHaveLength(2);
235
+ });
236
+
237
+ it("deduplicates", () => {
238
+ const result = mergeDisallowedTools(["WebFetch"], ["WebFetch", "WebSearch"]);
239
+ expect(result).toHaveLength(2);
240
+ });
241
+
242
+ it("handles undefined server list", () => {
243
+ expect(mergeDisallowedTools(undefined, ["WebFetch"])).toEqual(["WebFetch"]);
244
+ });
245
+ });
246
+
247
+ // ── validatePermissionMode ─────────────────────────────────────────
248
+
249
+ describe("validatePermissionMode", () => {
250
+ it("passes valid modes through", () => {
251
+ for (const mode of VALID_PERM_MODES) {
252
+ expect(validatePermissionMode(mode)).toBe(mode);
253
+ }
254
+ });
255
+
256
+ it("falls back to default for invalid modes", () => {
257
+ expect(validatePermissionMode("allowEdits")).toBe("default");
258
+ expect(validatePermissionMode("garbage")).toBe("default");
259
+ expect(validatePermissionMode("")).toBe("default");
260
+ });
261
+ });
262
+
263
+ // ── deriveServerName ───────────────────────────────────────────────
264
+
265
+ describe("deriveServerName", () => {
266
+ it("slugifies ASCII description", () => {
267
+ expect(deriveServerName("a strict code reviewer")).toBe(
268
+ "a-strict-code-reviewer"
269
+ );
270
+ });
271
+
272
+ it("truncates to 30 chars", () => {
273
+ const long = "a very long description that exceeds the thirty character limit";
274
+ expect(deriveServerName(long).length).toBeLessThanOrEqual(30);
275
+ });
276
+
277
+ it("falls back for non-ASCII-only description", () => {
278
+ const name = deriveServerName("严谨代码审计员");
279
+ expect(name).toMatch(/^agent-\d+$/);
280
+ });
281
+
282
+ it("strips special characters", () => {
283
+ expect(deriveServerName("code!!reviewer##v2")).toBe("code-reviewer-v2");
284
+ });
285
+ });
286
+
287
+ // ── deriveToolName ─────────────────────────────────────────────────
288
+
289
+ describe("deriveToolName", () => {
290
+ it("converts hyphens to underscores", () => {
291
+ expect(deriveToolName("code-reviewer")).toBe("code_reviewer");
292
+ });
293
+
294
+ it("strips leading/trailing underscores", () => {
295
+ expect(deriveToolName("-code-")).toBe("code");
296
+ });
297
+
298
+ it("falls back to agent for empty result", () => {
299
+ expect(deriveToolName("---")).toBe("agent");
300
+ });
301
+
302
+ it("respects MAX_TOOL_NAME_LEN", () => {
303
+ expect(deriveToolName("a".repeat(100)).length).toBeLessThanOrEqual(
304
+ MAX_TOOL_NAME_LEN
305
+ );
306
+ });
307
+ });
308
+
309
+ // ── serializeArrayEnv ──────────────────────────────────────────────
310
+
311
+ describe("serializeArrayEnv", () => {
312
+ it("comma-joins simple values", () => {
313
+ expect(serializeArrayEnv(["a", "b", "c"])).toBe("a,b,c");
314
+ });
315
+
316
+ it("uses JSON when values contain commas", () => {
317
+ const result = serializeArrayEnv(["/path,with,commas", "/normal"]);
318
+ expect(result).toBe('["/path,with,commas","/normal"]');
319
+ expect(JSON.parse(result)).toEqual(["/path,with,commas", "/normal"]);
320
+ });
321
+ });
322
+
323
+ // ── buildResultPayload ─────────────────────────────────────────────
324
+
325
+ describe("buildResultPayload", () => {
326
+ it("builds success payload", () => {
327
+ const payload = buildResultPayload({
328
+ session_id: "abc-123",
329
+ total_cost_usd: 0.05,
330
+ duration_ms: 1234,
331
+ num_turns: 3,
332
+ is_error: false,
333
+ subtype: "success",
334
+ result: "Hello world",
335
+ });
336
+ expect(payload).toEqual({
337
+ session_id: "abc-123",
338
+ cost_usd: 0.05,
339
+ duration_ms: 1234,
340
+ num_turns: 3,
341
+ is_error: false,
342
+ result: "Hello world",
343
+ });
344
+ });
345
+
346
+ it("builds error payload", () => {
347
+ const payload = buildResultPayload({
348
+ session_id: "abc-123",
349
+ total_cost_usd: 0,
350
+ duration_ms: 500,
351
+ num_turns: 1,
352
+ is_error: true,
353
+ subtype: "error_during_execution",
354
+ errors: ["Something went wrong"],
355
+ });
356
+ expect(payload.is_error).toBe(true);
357
+ expect(payload.errors).toEqual(["Something went wrong"]);
358
+ expect(payload.result).toBeUndefined();
359
+ });
360
+ });
361
+
362
+ // ── formatErrorMessage ─────────────────────────────────────────────
363
+
364
+ describe("formatErrorMessage", () => {
365
+ it("extracts Error message", () => {
366
+ expect(formatErrorMessage(new Error("boom"))).toBe("boom");
367
+ });
368
+
369
+ it("stringifies non-Error values", () => {
370
+ expect(formatErrorMessage("oops")).toBe("oops");
371
+ expect(formatErrorMessage(42)).toBe("42");
372
+ expect(formatErrorMessage(null)).toBe("null");
373
+ });
374
+ });
package/src/lib.ts ADDED
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Pure, testable logic extracted from index.ts.
3
+ */
4
+
5
+ import { normalize, resolve, sep } from "node:path";
6
+
7
+ // ── Env helpers ────────────────────────────────────────────────────
8
+
9
+ export function envStr(
10
+ key: string,
11
+ env: Record<string, string | undefined> = process.env
12
+ ): string | undefined {
13
+ return env[key] || undefined;
14
+ }
15
+
16
+ export function envList(
17
+ key: string,
18
+ env: Record<string, string | undefined> = process.env
19
+ ): string[] | undefined {
20
+ const val = env[key];
21
+ if (!val) return undefined;
22
+ if (val.startsWith("[")) {
23
+ try {
24
+ const parsed = JSON.parse(val);
25
+ if (Array.isArray(parsed)) return parsed.map(String).filter(Boolean);
26
+ } catch {
27
+ // fall through to comma-split
28
+ }
29
+ }
30
+ return val
31
+ .split(",")
32
+ .map((s) => s.trim())
33
+ .filter(Boolean);
34
+ }
35
+
36
+ export function envNum(
37
+ key: string,
38
+ env: Record<string, string | undefined> = process.env
39
+ ): number | undefined {
40
+ const val = env[key];
41
+ if (!val) return undefined;
42
+ const n = Number(val);
43
+ return Number.isNaN(n) ? undefined : n;
44
+ }
45
+
46
+ export function envBool(
47
+ key: string,
48
+ fallback: boolean,
49
+ env: Record<string, string | undefined> = process.env
50
+ ): boolean {
51
+ const val = env[key];
52
+ if (val === undefined) return fallback;
53
+ return val === "true" || val === "1";
54
+ }
55
+
56
+ export function envJson<T>(
57
+ key: string,
58
+ env: Record<string, string | undefined> = process.env
59
+ ): T | undefined {
60
+ const val = env[key];
61
+ if (!val) return undefined;
62
+ try {
63
+ return JSON.parse(val) as T;
64
+ } catch {
65
+ return undefined;
66
+ }
67
+ }
68
+
69
+ // ── Tool name sanitization ─────────────────────────────────────────
70
+
71
+ export const MAX_TOOL_NAME_LEN = 64 - "_reply".length;
72
+
73
+ export function sanitizeToolName(raw: string): string {
74
+ const sanitized = raw
75
+ .replace(/[^a-zA-Z0-9_]/g, "_")
76
+ .slice(0, MAX_TOOL_NAME_LEN);
77
+ return sanitized || "claude_code";
78
+ }
79
+
80
+ // ── cwd security check ────────────────────────────────────────────
81
+
82
+ export function isDescendantPath(
83
+ requested: string,
84
+ baseCwd: string
85
+ ): boolean {
86
+ const normalBase = normalize(baseCwd);
87
+ const normalReq = normalize(resolve(normalBase, requested));
88
+ if (normalReq === normalBase) return true;
89
+ const baseWithSep = normalBase.endsWith(sep)
90
+ ? normalBase
91
+ : normalBase + sep;
92
+ return normalReq.startsWith(baseWithSep);
93
+ }
94
+
95
+ // ── Tool restriction merging ───────────────────────────────────────
96
+
97
+ export function mergeAllowedTools(
98
+ serverList: string[] | undefined,
99
+ callList: string[]
100
+ ): string[] {
101
+ if (serverList?.length) {
102
+ const serverSet = new Set(serverList);
103
+ return callList.filter((t) => serverSet.has(t));
104
+ }
105
+ return callList;
106
+ }
107
+
108
+ export function mergeDisallowedTools(
109
+ serverList: string[] | undefined,
110
+ callList: string[]
111
+ ): string[] {
112
+ const merged = new Set([...(serverList || []), ...callList]);
113
+ return [...merged];
114
+ }
115
+
116
+ // ── Permission mode validation ─────────────────────────────────────
117
+
118
+ export const VALID_PERM_MODES = new Set([
119
+ "default",
120
+ "acceptEdits",
121
+ "bypassPermissions",
122
+ "plan",
123
+ "dontAsk",
124
+ "auto",
125
+ ]);
126
+
127
+ export function validatePermissionMode(mode: string): string {
128
+ return VALID_PERM_MODES.has(mode) ? mode : "default";
129
+ }
130
+
131
+ // ── Factory name derivation ────────────────────────────────────────
132
+
133
+ export function deriveServerName(description: string): string {
134
+ const slug = description
135
+ .toLowerCase()
136
+ .replace(/[^a-z0-9]+/g, "-")
137
+ .replace(/^-|-$/g, "")
138
+ .slice(0, 30);
139
+ return slug || `agent-${Date.now()}`;
140
+ }
141
+
142
+ export function deriveToolName(name: string): string {
143
+ const slug = name
144
+ .replace(/[^a-zA-Z0-9]+/g, "_")
145
+ .replace(/^_|_$/g, "")
146
+ .slice(0, MAX_TOOL_NAME_LEN);
147
+ return slug || "agent";
148
+ }
149
+
150
+ // ── Factory env serialization ──────────────────────────────────────
151
+
152
+ export function serializeArrayEnv(val: unknown[]): string {
153
+ const hasComma = val.some((v) => String(v).includes(","));
154
+ return hasComma ? JSON.stringify(val) : val.join(",");
155
+ }
156
+
157
+ // ── Formatters ─────────────────────────────────────────────────────
158
+
159
+ export interface ResultPayload {
160
+ session_id: string;
161
+ cost_usd: number;
162
+ duration_ms: number;
163
+ num_turns: number;
164
+ is_error: boolean;
165
+ result?: string;
166
+ errors?: string[];
167
+ }
168
+
169
+ export function buildResultPayload(result: {
170
+ session_id: string;
171
+ total_cost_usd: number;
172
+ duration_ms: number;
173
+ num_turns: number;
174
+ is_error: boolean;
175
+ subtype: string;
176
+ result?: string;
177
+ errors?: string[];
178
+ }): ResultPayload {
179
+ const payload: ResultPayload = {
180
+ session_id: result.session_id,
181
+ cost_usd: result.total_cost_usd,
182
+ duration_ms: result.duration_ms,
183
+ num_turns: result.num_turns,
184
+ is_error: result.is_error,
185
+ };
186
+ if (result.subtype === "success") {
187
+ payload.result = result.result;
188
+ } else {
189
+ payload.errors = result.errors;
190
+ }
191
+ return payload;
192
+ }
193
+
194
+ export function formatErrorMessage(error: unknown): string {
195
+ return error instanceof Error ? error.message : String(error);
196
+ }