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
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
|
+
});
|
package/src/zero-trust/server.ts
CHANGED
|
@@ -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
|
-
});
|