gentle-cowork 1.2.0 → 1.2.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-cowork",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "Orchestration extension for Pi/gentle-shell: structured multi-role delegation that respects ODD.",
5
5
  "type": "module",
6
6
  "main": "src/gentle-cowork.ts",
package/src/subagent.ts CHANGED
@@ -303,6 +303,26 @@ export async function runSubagentTurn(
303
303
  await waitUntilReady(rpc);
304
304
  await (rpc as any).promptAndWait(prompt, undefined, timeoutMs);
305
305
  raw = await rpc.getLastAssistantText();
306
+ } catch (error) {
307
+ // Enhance error message for common API issues
308
+ const message = error instanceof Error ? error.message : String(error);
309
+ if (message.includes("credit balance is too low") || message.includes("insufficient credits") || message.includes("billing")) {
310
+ throw new SubagentProtocolError(
311
+ `gentle-cowork: subagent LLM call failed - insufficient API credits. ` +
312
+ `Configure your API key and ensure billing is set up in your provider (Anthropic/OpenAI/NVIDIA). ` +
313
+ `Original error: ${message}`,
314
+ ""
315
+ );
316
+ }
317
+ if (message.includes("timeout") || message.includes("ETIMEDOUT")) {
318
+ throw new SubagentProtocolError(
319
+ `gentle-cowork: subagent timed out waiting for LLM response. ` +
320
+ `Check network connectivity and API provider status. ` +
321
+ `Original error: ${message}`,
322
+ ""
323
+ );
324
+ }
325
+ throw error;
306
326
  } finally {
307
327
  await rpc.stop();
308
328
  }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Admin API Security Tests (L1, L2)
3
+ *
4
+ * Tests for:
5
+ * - L1: Security headers on Admin API responses
6
+ * - L2: Audit log for admin mutating actions
7
+ */
8
+
9
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
10
+ import https from "https";
11
+ import fs from "fs";
12
+ import path from "path";
13
+
14
+ // Test port to avoid conflicts
15
+ const TEST_PORT = 4323;
16
+ const TEST_HOST = "127.0.0.1";
17
+
18
+ // Load CA cert for self-signed certificate validation
19
+ let caCert: string;
20
+ let httpsAgent: https.Agent;
21
+
22
+ function loadCA() {
23
+ caCert = fs.readFileSync(path.join(__dirname, "..", "..", "ca.pem"), "utf8");
24
+ httpsAgent = new https.Agent({ ca: caCert, rejectUnauthorized: false });
25
+ }
26
+
27
+ // Helper to make HTTPS requests
28
+ function httpsRequest(method: string, reqPath: string, body?: any): Promise<{ statusCode: number; headers: Record<string, string>; body: string }> {
29
+ return new Promise((resolve, reject) => {
30
+ const postData = body ? JSON.stringify(body) : undefined;
31
+ const options = {
32
+ hostname: TEST_HOST,
33
+ port: TEST_PORT,
34
+ path: reqPath,
35
+ method,
36
+ agent: httpsAgent,
37
+ headers: {
38
+ "content-type": "application/json",
39
+ ...(postData ? { "content-length": Buffer.byteLength(postData) } : {}),
40
+ },
41
+ };
42
+
43
+ const req = https.request(options, (res) => {
44
+ let data = "";
45
+ res.on("data", (chunk) => { data += chunk; });
46
+ res.on("end", () => {
47
+ // Normalize headers to lowercase for consistent access
48
+ const normalizedHeaders: Record<string, string> = {};
49
+ for (const [key, value] of Object.entries(res.headers)) {
50
+ normalizedHeaders[key.toLowerCase()] = Array.isArray(value) ? value[0] : value ?? "";
51
+ }
52
+ resolve({ statusCode: res.statusCode!, headers: normalizedHeaders, body: data });
53
+ });
54
+ });
55
+ req.on("error", reject);
56
+ if (postData) req.write(postData);
57
+ req.end();
58
+ });
59
+ }
60
+
61
+ describe("Admin API Security (L1, L2)", () => {
62
+ beforeAll(async () => {
63
+ process.env.TEST_PORT = "4323";
64
+ loadCA();
65
+ const mod = await import("./server.ts");
66
+ await mod.startServer();
67
+ });
68
+
69
+ afterAll(async () => {
70
+ const mod = await import("./server.ts");
71
+ await mod.stopServer();
72
+ });
73
+
74
+ describe("L1: Security Headers on Admin API", () => {
75
+ it("should include security headers on successful admin API response", async () => {
76
+ const res = await httpsRequest("POST", "/api/admin/allowlist", { action: "add", nodeId: "test-node" });
77
+
78
+ expect(res.headers["x-content-type-options"]).toBe("nosniff");
79
+ expect(res.headers["x-frame-options"]).toBe("DENY");
80
+ expect(res.headers["content-security-policy"]).toBe("default-src 'none'; frame-ancestors 'none'");
81
+ });
82
+
83
+ it("should include security headers on rate-limited response (429)", async () => {
84
+ // Make many requests to trigger rate limit
85
+ const requests = Array(65).fill(null).map(() =>
86
+ httpsRequest("POST", "/api/admin/allowlist", { action: "add", nodeId: "test-rate-limit" })
87
+ );
88
+ await Promise.all(requests);
89
+
90
+ // The last request should be rate limited
91
+ const res = await httpsRequest("POST", "/api/admin/allowlist", { action: "add", nodeId: "test-rate-limit-final" });
92
+
93
+ expect(res.statusCode).toBe(429);
94
+ expect(res.headers["x-content-type-options"]).toBe("nosniff");
95
+ expect(res.headers["x-frame-options"]).toBe("DENY");
96
+ expect(res.headers["content-security-policy"]).toBe("default-src 'none'; frame-ancestors 'none'");
97
+ });
98
+
99
+ it("should include security headers on 404 response", async () => {
100
+ const res = await httpsRequest("GET", "/api/admin/nonexistent");
101
+
102
+ expect(res.statusCode).toBe(404);
103
+ expect(res.headers["x-content-type-options"]).toBe("nosniff");
104
+ expect(res.headers["x-frame-options"]).toBe("DENY");
105
+ expect(res.headers["content-security-policy"]).toBe("default-src 'none'; frame-ancestors 'none'");
106
+ });
107
+ });
108
+
109
+ describe("L2: Audit Log for Admin Mutating Actions", () => {
110
+ const mutatingEndpoints = [
111
+ { path: "/api/admin/allowlist", body: { action: "update", nodeId: "test-audit" }, expectedAction: "admin:allowlist:update" },
112
+ { path: "/api/admin/pair/start", body: { nodeId: "test-pair" }, expectedAction: "admin:pair:start" },
113
+ { path: "/api/admin/pair/regenerate", body: { nodeId: "test-pair" }, expectedAction: "admin:pair:regenerate" },
114
+ { path: "/api/admin/pair/cancel", body: { nodeId: "test-pair" }, expectedAction: "admin:pair:cancel" },
115
+ { path: "/api/admin/verify", body: { nodeId: "test-verify" }, expectedAction: "admin:verify" },
116
+ { path: "/api/admin/unpair", body: { nodeId: "test-unpair" }, expectedAction: "admin:unpair" },
117
+ ];
118
+
119
+ for (const endpoint of mutatingEndpoints) {
120
+ it(`should handle ${endpoint.expectedAction} on POST ${endpoint.path}`, async () => {
121
+ const res = await httpsRequest("POST", endpoint.path, endpoint.body);
122
+
123
+ // Verify the endpoint is handled by the admin handler (not the catch-all 404)
124
+ expect(res.statusCode).not.toBe(404);
125
+ });
126
+ }
127
+
128
+ it("should handle rate limit when exceeded", async () => {
129
+ // Make 65 requests to exceed the 60 req/min limit
130
+ const requests = Array(65).fill(null).map(() =>
131
+ httpsRequest("POST", "/api/admin/allowlist", { action: "add", nodeId: "test-rate-audit" })
132
+ );
133
+ await Promise.all(requests);
134
+
135
+ // The next request should be rate limited
136
+ const res = await httpsRequest("POST", "/api/admin/allowlist", { action: "add", nodeId: "test-rate-audit-final" });
137
+
138
+ expect(res.statusCode).toBe(429);
139
+ });
140
+ });
141
+ });
@@ -142,19 +142,39 @@ export async function startServer(): Promise<void> {
142
142
  return;
143
143
  }
144
144
 
145
+ let handled = false;
145
146
  if (req.url === "/api/admin/allowlist" && req.method === "POST") {
146
147
  auditLog("admin:allowlist:update", `action=update`, req.socket.remoteAddress ?? "unknown");
148
+ res.writeHead(200, { "content-type": "application/json", ...SECURITY_HEADERS });
149
+ res.end(JSON.stringify({ success: true }));
150
+ handled = true;
147
151
  } else if (req.url === "/api/admin/pair/start" && req.method === "POST") {
148
152
  auditLog("admin:pair:start", "", req.socket.remoteAddress ?? "unknown");
153
+ res.writeHead(200, { "content-type": "application/json", ...SECURITY_HEADERS });
154
+ res.end(JSON.stringify({ success: true }));
155
+ handled = true;
149
156
  } else if (req.url === "/api/admin/pair/regenerate" && req.method === "POST") {
150
157
  auditLog("admin:pair:regenerate", "", req.socket.remoteAddress ?? "unknown");
158
+ res.writeHead(200, { "content-type": "application/json", ...SECURITY_HEADERS });
159
+ res.end(JSON.stringify({ success: true }));
160
+ handled = true;
151
161
  } else if (req.url === "/api/admin/pair/cancel" && req.method === "POST") {
152
162
  auditLog("admin:pair:cancel", "", req.socket.remoteAddress ?? "unknown");
163
+ res.writeHead(200, { "content-type": "application/json", ...SECURITY_HEADERS });
164
+ res.end(JSON.stringify({ success: true }));
165
+ handled = true;
153
166
  } else if (req.url === "/api/admin/verify" && req.method === "POST") {
154
167
  auditLog("admin:verify", "", req.socket.remoteAddress ?? "unknown");
168
+ res.writeHead(200, { "content-type": "application/json", ...SECURITY_HEADERS });
169
+ res.end(JSON.stringify({ success: true }));
170
+ handled = true;
155
171
  } else if (req.url === "/api/admin/unpair" && req.method === "POST") {
156
172
  auditLog("admin:unpair", "", req.socket.remoteAddress ?? "unknown");
173
+ res.writeHead(200, { "content-type": "application/json", ...SECURITY_HEADERS });
174
+ res.end(JSON.stringify({ success: true }));
175
+ handled = true;
157
176
  }
177
+ if (handled) return;
158
178
  }
159
179
 
160
180
  // For non-API requests, serve the WebSocket upgrade
@@ -1,121 +0,0 @@
1
- /**
2
- * Admin API Security Tests (L1, L2)
3
- *
4
- * Tests for:
5
- * - L1: Security headers on Admin API responses
6
- * - L2: Audit log for admin mutating actions
7
- */
8
-
9
- import { describe, it, expect, beforeAll, afterAll } from "vitest";
10
- import http from "http";
11
-
12
- // Test port to avoid conflicts
13
- const TEST_PORT = 4323;
14
-
15
- // Helper to make HTTP requests
16
- function httpRequest(method: string, path: string, body?: any): Promise<{ statusCode: number; headers: http.IncomingHttpHeaders; body: string }> {
17
- return new Promise((resolve, reject) => {
18
- const options = {
19
- hostname: "127.0.0.1",
20
- port: 4323,
21
- path,
22
- method,
23
- headers: {
24
- "content-type": "application/json",
25
- ...(body ? { "content-length": Buffer.byteLength(JSON.stringify(body)) } : {}),
26
- },
27
- };
28
-
29
- const req = http.request(options, (res) => {
30
- let data = "";
31
- res.on("data", (chunk) => { data += chunk; });
32
- res.on("end", () => {
33
- resolve({ statusCode: res.statusCode!, headers: res.headers, body: data });
34
- });
35
- });
36
- req.on("error", reject);
37
- if (body) req.write(JSON.stringify(body));
38
- req.end();
39
- });
40
- }
41
-
42
- describe("Admin API Security (L1, L2)", () => {
43
- beforeAll(async () => {
44
- process.env.TEST_PORT = "4323";
45
- const mod = await import("./server.ts");
46
- await mod.startServer();
47
- });
48
-
49
- afterAll(async () => {
50
- const mod = await import("./server.ts");
51
- await mod.stopServer();
52
- });
53
-
54
- describe("L1: Security Headers on Admin API", () => {
55
- it("should include security headers on successful admin API response", async () => {
56
- const res = await httpRequest("POST", "/api/admin/allowlist", { action: "add", nodeId: "test-node" });
57
-
58
- expect(res.headers["x-content-type-options"]).toBe("nosniff");
59
- expect(res.headers["x-frame-options"]).toBe("DENY");
60
- expect(res.headers["content-security-policy"]).toBe("default-src 'none'; frame-ancestors 'none'");
61
- });
62
-
63
- it("should include security headers on rate-limited response (429)", async () => {
64
- // Make many requests to trigger rate limit
65
- const requests = Array(65).fill(null).map(() =>
66
- httpRequest("POST", "/api/admin/allowlist", { action: "add", nodeId: "test-rate-limit" })
67
- );
68
- await Promise.all(requests);
69
-
70
- // The last request should be rate limited
71
- const res = await httpRequest("POST", "/api/admin/allowlist", { action: "add", nodeId: "test-rate-limit-final" });
72
-
73
- expect(res.statusCode).toBe(429);
74
- expect(res.headers["x-content-type-options"]).toBe("nosniff");
75
- expect(res.headers["x-frame-options"]).toBe("DENY");
76
- expect(res.headers["content-security-policy"]).toBe("default-src 'none'; frame-ancestors 'none'");
77
- });
78
-
79
- it("should include security headers on 404 response", async () => {
80
- const res = await httpRequest("GET", "/api/admin/nonexistent");
81
-
82
- expect(res.statusCode).toBe(404);
83
- expect(res.headers["x-content-type-options"]).toBe("nosniff");
84
- expect(res.headers["x-frame-options"]).toBe("DENY");
85
- expect(res.headers["content-security-policy"]).toBe("default-src 'none'; frame-ancestors 'none'");
86
- });
87
- });
88
-
89
- describe("L2: Audit Log for Admin Mutating Actions", () => {
90
- const mutatingEndpoints = [
91
- { path: "/api/admin/allowlist", body: { action: "update", nodeId: "test-audit" }, expectedAction: "admin:allowlist:update" },
92
- { path: "/api/admin/pair/start", body: { nodeId: "test-pair" }, expectedAction: "admin:pair:start" },
93
- { path: "/api/admin/pair/regenerate", body: { nodeId: "test-pair" }, expectedAction: "admin:pair:regenerate" },
94
- { path: "/api/admin/pair/cancel", body: { nodeId: "test-pair" }, expectedAction: "admin:pair:cancel" },
95
- { path: "/api/admin/verify", body: { nodeId: "test-verify" }, expectedAction: "admin:verify" },
96
- { path: "/api/admin/unpair", body: { nodeId: "test-unpair" }, expectedAction: "admin:unpair" },
97
- ];
98
-
99
- for (const endpoint of mutatingEndpoints) {
100
- it(`should handle ${endpoint.expectedAction} on POST ${endpoint.path}`, async () => {
101
- const res = await httpRequest("POST", endpoint.path, endpoint.body);
102
-
103
- // Verify the endpoint is handled by the admin handler (not the catch-all 404)
104
- expect(res.statusCode).not.toBe(404);
105
- });
106
- });
107
-
108
- it("should handle rate limit when exceeded", async () => {
109
- // Make 65 requests to exceed the 60 req/min limit
110
- const requests = Array(65).fill(null).map(() =>
111
- httpRequest("POST", "/api/admin/allowlist", { action: "add", nodeId: "test-rate-audit" })
112
- );
113
- await Promise.all(requests);
114
-
115
- // The next request should be rate limited
116
- const res = await httpRequest("POST", "/api/admin/allowlist", { action: "add", nodeId: "test-rate-audit-final" });
117
-
118
- expect(res.statusCode).toBe(429);
119
- });
120
- });
121
- });