@cloudagentfleet/hub 0.1.1
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/dist/cloudagentfleet-hub.mjs +2675 -0
- package/package.json +32 -0
|
@@ -0,0 +1,2675 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
// apps/control-plane/src/server.ts
|
|
5
|
+
import { createServer } from "node:http";
|
|
6
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
7
|
+
import { readFile } from "node:fs/promises";
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { resolve, dirname as dirname2, join as join2, sep } from "node:path";
|
|
11
|
+
|
|
12
|
+
// packages/protocol/src/index.ts
|
|
13
|
+
var DEFAULT_HEARTBEAT_TIMEOUT_MS = 45e3;
|
|
14
|
+
var DEFAULT_PREVIEW_LEASE_MS = 15 * 6e4;
|
|
15
|
+
function isCodemanSession(value) {
|
|
16
|
+
return isRecord(value) && isNonEmptyString(value.id) && isNonEmptyString(value.instanceId) && isNonEmptyString(value.workspaceId) && isCodemanAgentProfile(value.agent) && isNonEmptyString(value.title) && (value.status === "starting" || value.status === "working" || value.status === "waiting" || value.status === "idle" || value.status === "completed" || value.status === "failed" || value.status === "stopped") && typeof value.terminalBuffer === "string" && typeof value.needsInput === "boolean" && typeof value.previewAvailable === "boolean" && typeof value.browserAvailable === "boolean" && typeof value.createdAt === "string" && typeof value.lastEventAt === "string";
|
|
17
|
+
}
|
|
18
|
+
function validateCodemanSession(value) {
|
|
19
|
+
if (!isCodemanSession(value)) {
|
|
20
|
+
throw new TypeError("Invalid namespaced Codeman session");
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
function isCodemanSessionEvent(value) {
|
|
25
|
+
return isRecord(value) && isNonEmptyString(value.instanceId) && isNonEmptyString(value.sessionId) && (value.event === "created" || value.event === "started" || value.event === "output" || value.event === "waiting" || value.event === "idle" || value.event === "completed" || value.event === "failed" || value.event === "stopped") && (value.data === void 0 || typeof value.data === "string") && isNonEmptyString(value.createdAt);
|
|
26
|
+
}
|
|
27
|
+
function isCodemanInstance(value) {
|
|
28
|
+
return isRecord(value) && isNonEmptyString(value.id) && isNonEmptyString(value.label) && (value.connectionMode === "connector" || value.connectionMode === "tailscale-url") && (value.nodeId === void 0 || isNonEmptyString(value.nodeId)) && (value.endpoint === null || typeof value.endpoint === "string" && isSafeCodemanEndpoint(value.endpoint)) && (value.connectionMode !== "tailscale-url" || typeof value.endpoint === "string") && (value.workspacePolicy === void 0 || isWorkspacePolicy(value.workspacePolicy)) && (value.status === "online" || value.status === "offline" || value.status === "unhealthy" || value.status === "revoked") && Array.isArray(value.capabilities) && value.capabilities.every((capability) => typeof capability === "string") && Array.isArray(value.agents) && value.agents.every(isCodemanAgentProfile) && Array.isArray(value.workspaces) && value.workspaces.every(isWorkspaceDescriptor) && (value.lastSeenAt === null || typeof value.lastSeenAt === "string") && typeof value.createdAt === "string";
|
|
29
|
+
}
|
|
30
|
+
function validateCodemanInstance(value) {
|
|
31
|
+
if (!isCodemanInstance(value)) {
|
|
32
|
+
throw new TypeError("Invalid Codeman instance");
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
function validateCodemanInstanceRegistration(value) {
|
|
37
|
+
if (!isRecord(value) || !isNonEmptyString(value.id) || !isNonEmptyString(value.label) || value.connectionMode !== "connector" && value.connectionMode !== "tailscale-url" || value.connectionMode === "connector" && !isNonEmptyString(value.nodeId) || value.endpoint !== void 0 && (typeof value.endpoint !== "string" || !isSafeCodemanEndpoint(value.endpoint)) || value.workspacePolicy !== void 0 && !isWorkspacePolicy(value.workspacePolicy) || value.connectionMode === "tailscale-url" && typeof value.endpoint !== "string") {
|
|
38
|
+
throw new TypeError("Invalid Codeman instance registration");
|
|
39
|
+
}
|
|
40
|
+
if (value.capabilities !== void 0 && (!Array.isArray(value.capabilities) || !value.capabilities.every((capability) => typeof capability === "string")) || value.agents !== void 0 && (!Array.isArray(value.agents) || !value.agents.every(isCodemanAgentProfile)) || value.workspaces !== void 0 && (!Array.isArray(value.workspaces) || !value.workspaces.every(isWorkspaceDescriptor))) {
|
|
41
|
+
throw new TypeError("Invalid Codeman instance registration");
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
function isCodemanConnectorResponse(value) {
|
|
46
|
+
return isRecord(value) && value.type === "codeman.response" && isNonEmptyString(value.requestId) && isNonEmptyString(value.instanceId) && typeof value.success === "boolean" && (value.success || isNonEmptyString(value.error)) && (value.error === void 0 || isNonEmptyString(value.error)) && (value.errorCode === void 0 || isNonEmptyString(value.errorCode));
|
|
47
|
+
}
|
|
48
|
+
function isCodemanConnectorSessionEvent(value) {
|
|
49
|
+
return isRecord(value) && value.type === "codeman.session.event" && isNonEmptyString(value.instanceId) && isCodemanSessionEvent(value.event) && value.event.instanceId === value.instanceId;
|
|
50
|
+
}
|
|
51
|
+
function validateCodemanConnectorResponse(value) {
|
|
52
|
+
if (!isCodemanConnectorResponse(value)) {
|
|
53
|
+
throw new TypeError("Invalid Codeman connector response");
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
function validateCodemanConnectorSessionEvent(value) {
|
|
58
|
+
if (!isCodemanConnectorSessionEvent(value)) {
|
|
59
|
+
throw new TypeError("Invalid Codeman connector session event");
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
function isRecord(value) {
|
|
64
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
65
|
+
}
|
|
66
|
+
function isNonEmptyString(value) {
|
|
67
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
68
|
+
}
|
|
69
|
+
function isSafeCodemanEndpoint(value) {
|
|
70
|
+
try {
|
|
71
|
+
const endpoint = new URL(value);
|
|
72
|
+
const hasNoEmbeddedCredentials = endpoint.username === "" && endpoint.password === "";
|
|
73
|
+
return hasNoEmbeddedCredentials && (endpoint.protocol === "https:" || endpoint.protocol === "http:" && (endpoint.hostname === "localhost" || endpoint.hostname === "127.0.0.1" || endpoint.hostname === "::1" || endpoint.hostname === "[::1]"));
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function isCodemanAgentProfile(value) {
|
|
79
|
+
return isRecord(value) && isNonEmptyString(value.id) && isNonEmptyString(value.name) && isCodemanAgentMode(value.mode) && typeof value.ready === "boolean";
|
|
80
|
+
}
|
|
81
|
+
function isCodemanAgentMode(value) {
|
|
82
|
+
return value === "claude" || value === "shell" || value === "opencode" || value === "codex" || value === "gemini" || value === "antigravity" || value === "pi" || value === "grok" || value === "deepseek";
|
|
83
|
+
}
|
|
84
|
+
function isWorkspaceDescriptor(value) {
|
|
85
|
+
return isRecord(value) && isNonEmptyString(value.id) && isNonEmptyString(value.name) && isNonEmptyString(value.canonicalPath) && (value.health === "ready" || value.health === "missing" || value.health === "unreadable") && isRecord(value.providers) && Object.values(value.providers).every(
|
|
86
|
+
(provider) => provider === "ready" || provider === "missing" || provider === "unauthenticated" || provider === "unavailable"
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
function isWorkspacePolicy(value) {
|
|
90
|
+
return isRecord(value) && (value.mode === "folders" || value.mode === "system") && Array.isArray(value.roots) && value.roots.every((root) => isNonEmptyString(root));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// packages/protocol/src/lifecycle.ts
|
|
94
|
+
var defaultClock = () => /* @__PURE__ */ new Date();
|
|
95
|
+
function id(prefix) {
|
|
96
|
+
return `${prefix}_${crypto.randomUUID()}`;
|
|
97
|
+
}
|
|
98
|
+
function providerCapability(provider) {
|
|
99
|
+
if (provider === "codeman") return "codeman";
|
|
100
|
+
if (provider === "cursor") return "cursor-worker";
|
|
101
|
+
return "claude-runner";
|
|
102
|
+
}
|
|
103
|
+
var NodeRegistry = class {
|
|
104
|
+
nodes = /* @__PURE__ */ new Map();
|
|
105
|
+
clock;
|
|
106
|
+
heartbeatTimeoutMs;
|
|
107
|
+
constructor(clock = defaultClock, heartbeatTimeoutMs = DEFAULT_HEARTBEAT_TIMEOUT_MS) {
|
|
108
|
+
this.clock = clock;
|
|
109
|
+
this.heartbeatTimeoutMs = heartbeatTimeoutMs;
|
|
110
|
+
}
|
|
111
|
+
enroll(registration) {
|
|
112
|
+
const now = this.clock().toISOString();
|
|
113
|
+
const existing = this.nodes.get(registration.nodeId);
|
|
114
|
+
if (existing?.status === "revoked") {
|
|
115
|
+
throw new Error(`Node is revoked: ${registration.nodeId}`);
|
|
116
|
+
}
|
|
117
|
+
const node = {
|
|
118
|
+
id: registration.nodeId,
|
|
119
|
+
name: registration.name,
|
|
120
|
+
platform: registration.platform,
|
|
121
|
+
status: "online",
|
|
122
|
+
capabilities: [...new Set(registration.capabilities)],
|
|
123
|
+
labels: { ...registration.labels },
|
|
124
|
+
workspacePolicy: registration.workspacePolicy ?? { mode: "folders", roots: [] },
|
|
125
|
+
workspaces: [...registration.workspaces ?? []],
|
|
126
|
+
lastSeenAt: now
|
|
127
|
+
};
|
|
128
|
+
this.nodes.set(node.id, node);
|
|
129
|
+
return node;
|
|
130
|
+
}
|
|
131
|
+
heartbeat(heartbeat) {
|
|
132
|
+
const node = this.nodes.get(heartbeat.nodeId);
|
|
133
|
+
if (!node) throw new Error(`Unknown node: ${heartbeat.nodeId}`);
|
|
134
|
+
if (node.status === "revoked") throw new Error(`Node is revoked: ${node.id}`);
|
|
135
|
+
const updated = {
|
|
136
|
+
...node,
|
|
137
|
+
status: "online",
|
|
138
|
+
capabilities: [...new Set(heartbeat.capabilities)],
|
|
139
|
+
lastSeenAt: heartbeat.sentAt
|
|
140
|
+
};
|
|
141
|
+
this.nodes.set(updated.id, updated);
|
|
142
|
+
return updated;
|
|
143
|
+
}
|
|
144
|
+
markOffline(now = this.clock()) {
|
|
145
|
+
for (const node of this.nodes.values()) {
|
|
146
|
+
if (node.status !== "online" || !node.lastSeenAt) continue;
|
|
147
|
+
const age = now.getTime() - Date.parse(node.lastSeenAt);
|
|
148
|
+
if (age > this.heartbeatTimeoutMs) {
|
|
149
|
+
this.nodes.set(node.id, { ...node, status: "offline" });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
revoke(nodeId) {
|
|
154
|
+
const node = this.require(nodeId);
|
|
155
|
+
const revoked = { ...node, status: "revoked" };
|
|
156
|
+
this.nodes.set(nodeId, revoked);
|
|
157
|
+
return revoked;
|
|
158
|
+
}
|
|
159
|
+
select(provider, requestedNodeId) {
|
|
160
|
+
if (requestedNodeId) {
|
|
161
|
+
const node2 = this.require(requestedNodeId);
|
|
162
|
+
this.assertSchedulable(node2, provider);
|
|
163
|
+
return node2;
|
|
164
|
+
}
|
|
165
|
+
const capability = providerCapability(provider);
|
|
166
|
+
const node = [...this.nodes.values()].find(
|
|
167
|
+
(candidate) => candidate.status === "online" && candidate.capabilities.includes(capability)
|
|
168
|
+
);
|
|
169
|
+
if (!node) throw new Error(`No online node supports provider: ${provider}`);
|
|
170
|
+
return node;
|
|
171
|
+
}
|
|
172
|
+
selectWorkspace(nodeId, workspaceId, provider) {
|
|
173
|
+
const node = this.require(nodeId);
|
|
174
|
+
this.assertSchedulable(node, provider);
|
|
175
|
+
const workspace = node.workspaces.find((candidate) => candidate.id === workspaceId);
|
|
176
|
+
if (!workspace) throw new Error(`Unknown workspace: ${workspaceId}`);
|
|
177
|
+
if (workspace.health !== "ready") {
|
|
178
|
+
throw new Error(`Workspace is not ready: ${workspaceId}`);
|
|
179
|
+
}
|
|
180
|
+
if (workspace.providers[provider] !== "ready") {
|
|
181
|
+
throw new Error(`Provider is not ready for workspace: ${provider}`);
|
|
182
|
+
}
|
|
183
|
+
return workspace;
|
|
184
|
+
}
|
|
185
|
+
list() {
|
|
186
|
+
return [...this.nodes.values()].map((node) => ({
|
|
187
|
+
...node,
|
|
188
|
+
capabilities: [...node.capabilities],
|
|
189
|
+
labels: { ...node.labels },
|
|
190
|
+
workspacePolicy: { ...node.workspacePolicy, roots: [...node.workspacePolicy.roots] },
|
|
191
|
+
workspaces: node.workspaces.map((workspace) => ({
|
|
192
|
+
...workspace,
|
|
193
|
+
providers: { ...workspace.providers }
|
|
194
|
+
}))
|
|
195
|
+
}));
|
|
196
|
+
}
|
|
197
|
+
require(nodeId) {
|
|
198
|
+
const node = this.nodes.get(nodeId);
|
|
199
|
+
if (!node) throw new Error(`Unknown node: ${nodeId}`);
|
|
200
|
+
return node;
|
|
201
|
+
}
|
|
202
|
+
assertSchedulable(node, provider) {
|
|
203
|
+
if (node.status !== "online") throw new Error(`Node is not online: ${node.id}`);
|
|
204
|
+
if (!node.capabilities.includes(providerCapability(provider))) {
|
|
205
|
+
throw new Error(`Node does not support provider: ${provider}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
var JobStore = class {
|
|
210
|
+
jobs = /* @__PURE__ */ new Map();
|
|
211
|
+
byIdempotencyKey = /* @__PURE__ */ new Map();
|
|
212
|
+
clock;
|
|
213
|
+
constructor(clock = defaultClock) {
|
|
214
|
+
this.clock = clock;
|
|
215
|
+
}
|
|
216
|
+
create(request, nodeId) {
|
|
217
|
+
const existingId = this.byIdempotencyKey.get(request.idempotencyKey);
|
|
218
|
+
if (existingId) return this.require(existingId);
|
|
219
|
+
const job = {
|
|
220
|
+
id: id("job"),
|
|
221
|
+
provider: request.provider,
|
|
222
|
+
nodeId,
|
|
223
|
+
workspaceId: request.workspaceId,
|
|
224
|
+
prompt: request.prompt,
|
|
225
|
+
status: "queued",
|
|
226
|
+
idempotencyKey: request.idempotencyKey,
|
|
227
|
+
createdAt: this.clock().toISOString()
|
|
228
|
+
};
|
|
229
|
+
this.jobs.set(job.id, job);
|
|
230
|
+
this.byIdempotencyKey.set(job.idempotencyKey, job.id);
|
|
231
|
+
return job;
|
|
232
|
+
}
|
|
233
|
+
start(jobId) {
|
|
234
|
+
return this.update(jobId, { status: "running" });
|
|
235
|
+
}
|
|
236
|
+
complete(jobId) {
|
|
237
|
+
return this.update(jobId, { status: "completed" });
|
|
238
|
+
}
|
|
239
|
+
fail(jobId) {
|
|
240
|
+
return this.update(jobId, { status: "failed" });
|
|
241
|
+
}
|
|
242
|
+
cancel(jobId) {
|
|
243
|
+
const job = this.require(jobId);
|
|
244
|
+
if (job.status === "completed" || job.status === "failed") {
|
|
245
|
+
throw new Error(`Cannot cancel terminal job: ${job.id}`);
|
|
246
|
+
}
|
|
247
|
+
return this.update(jobId, { status: "cancelled" });
|
|
248
|
+
}
|
|
249
|
+
get(jobId) {
|
|
250
|
+
return this.jobs.get(jobId);
|
|
251
|
+
}
|
|
252
|
+
getByIdempotencyKey(idempotencyKey) {
|
|
253
|
+
const jobId = this.byIdempotencyKey.get(idempotencyKey);
|
|
254
|
+
return jobId ? this.jobs.get(jobId) : void 0;
|
|
255
|
+
}
|
|
256
|
+
update(jobId, patch) {
|
|
257
|
+
const job = this.require(jobId);
|
|
258
|
+
const updated = { ...job, ...patch };
|
|
259
|
+
this.jobs.set(jobId, updated);
|
|
260
|
+
return updated;
|
|
261
|
+
}
|
|
262
|
+
require(jobId) {
|
|
263
|
+
const job = this.jobs.get(jobId);
|
|
264
|
+
if (!job) throw new Error(`Unknown job: ${jobId}`);
|
|
265
|
+
return job;
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
var AuditLog = class {
|
|
269
|
+
events = [];
|
|
270
|
+
clock;
|
|
271
|
+
constructor(clock = defaultClock) {
|
|
272
|
+
this.clock = clock;
|
|
273
|
+
}
|
|
274
|
+
append(type, actor, resourceId, metadata = {}) {
|
|
275
|
+
const event = {
|
|
276
|
+
id: id("audit"),
|
|
277
|
+
type,
|
|
278
|
+
actor,
|
|
279
|
+
resourceId,
|
|
280
|
+
createdAt: this.clock().toISOString(),
|
|
281
|
+
metadata: { ...metadata }
|
|
282
|
+
};
|
|
283
|
+
this.events.push(event);
|
|
284
|
+
return event;
|
|
285
|
+
}
|
|
286
|
+
list() {
|
|
287
|
+
return this.events.map((event) => ({
|
|
288
|
+
...event,
|
|
289
|
+
metadata: { ...event.metadata }
|
|
290
|
+
}));
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
// apps/control-plane/src/index.ts
|
|
295
|
+
var ControlPlane = class {
|
|
296
|
+
nodes;
|
|
297
|
+
jobs;
|
|
298
|
+
audit;
|
|
299
|
+
connections = /* @__PURE__ */ new Map();
|
|
300
|
+
connectorRequests = /* @__PURE__ */ new Map();
|
|
301
|
+
connectorEventListeners = /* @__PURE__ */ new Map();
|
|
302
|
+
constructor() {
|
|
303
|
+
this.nodes = new NodeRegistry();
|
|
304
|
+
this.jobs = new JobStore();
|
|
305
|
+
this.audit = new AuditLog();
|
|
306
|
+
}
|
|
307
|
+
enrollNode(registration) {
|
|
308
|
+
const node = this.nodes.enroll(registration);
|
|
309
|
+
this.audit.append("node.enrolled", "node", node.id, { name: node.name });
|
|
310
|
+
return node;
|
|
311
|
+
}
|
|
312
|
+
heartbeatNode(heartbeat) {
|
|
313
|
+
const node = this.nodes.heartbeat(heartbeat);
|
|
314
|
+
this.audit.append("node.heartbeat", "node", node.id);
|
|
315
|
+
return node;
|
|
316
|
+
}
|
|
317
|
+
registerConnection(connection) {
|
|
318
|
+
if (connection.node.status === "revoked") {
|
|
319
|
+
throw new Error(`Cannot register revoked node: ${connection.node.id}`);
|
|
320
|
+
}
|
|
321
|
+
const existing = this.connections.get(connection.node.id);
|
|
322
|
+
void existing?.close();
|
|
323
|
+
if (existing) this.rejectConnectorRequests(connection.node.id, "Worker connection replaced");
|
|
324
|
+
this.connections.set(connection.node.id, connection);
|
|
325
|
+
}
|
|
326
|
+
removeConnection(nodeId, connection) {
|
|
327
|
+
if (connection && this.connections.get(nodeId) !== connection) return;
|
|
328
|
+
this.connections.delete(nodeId);
|
|
329
|
+
this.rejectConnectorRequests(nodeId, `Worker connection closed: ${nodeId}`);
|
|
330
|
+
}
|
|
331
|
+
receiveNodeMessage(nodeId, message) {
|
|
332
|
+
if (message.type === "codeman.response") {
|
|
333
|
+
this.receiveConnectorResponse(nodeId, message);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (message.type === "codeman.session.event") {
|
|
337
|
+
this.receiveConnectorEvent(nodeId, message);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
if (message.type === "heartbeat") {
|
|
341
|
+
this.heartbeatNode({ ...message, nodeId });
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (message.type !== "job.accept" && message.type !== "job.event") return;
|
|
345
|
+
const jobId = message.jobId;
|
|
346
|
+
const job = this.jobs.get(jobId);
|
|
347
|
+
if (!job) throw new Error(`Unknown job: ${jobId}`);
|
|
348
|
+
if (job.nodeId !== nodeId) {
|
|
349
|
+
throw new Error(`Job ${jobId} is assigned to a different node`);
|
|
350
|
+
}
|
|
351
|
+
if (message.type === "job.accept") {
|
|
352
|
+
this.jobs.start(jobId);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (message.event === "started") {
|
|
356
|
+
this.jobs.start(jobId);
|
|
357
|
+
this.audit.append("job.started", "node", jobId, { nodeId });
|
|
358
|
+
} else if (message.event === "completed") {
|
|
359
|
+
this.jobs.complete(jobId);
|
|
360
|
+
this.audit.append("job.completed", "node", jobId, { nodeId });
|
|
361
|
+
} else if (message.event === "failed") {
|
|
362
|
+
this.jobs.fail(jobId);
|
|
363
|
+
this.audit.append("job.failed", "node", jobId, { nodeId });
|
|
364
|
+
} else if (message.event === "cancelled") {
|
|
365
|
+
if (job.status !== "cancelled") this.jobs.cancel(jobId);
|
|
366
|
+
this.audit.append("job.cancelled", "node", jobId, { nodeId });
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
async requestConnector(nodeId, instanceId, operation, payload, timeoutMs = 15e3) {
|
|
370
|
+
const connection = this.connections.get(nodeId);
|
|
371
|
+
if (!connection) throw new Error(`Node has no active connection: ${nodeId}`);
|
|
372
|
+
if (connection.node.status === "revoked") {
|
|
373
|
+
throw new Error(`Node is revoked: ${nodeId}`);
|
|
374
|
+
}
|
|
375
|
+
const requestId = crypto.randomUUID();
|
|
376
|
+
const request = {
|
|
377
|
+
type: "codeman.request",
|
|
378
|
+
requestId,
|
|
379
|
+
instanceId,
|
|
380
|
+
operation,
|
|
381
|
+
...payload === void 0 ? {} : { payload }
|
|
382
|
+
};
|
|
383
|
+
const result = new Promise((resolve2, reject) => {
|
|
384
|
+
const timer = setTimeout(() => {
|
|
385
|
+
this.connectorRequests.delete(requestId);
|
|
386
|
+
reject(new Error(`Connector request timed out: ${operation}`));
|
|
387
|
+
}, timeoutMs);
|
|
388
|
+
this.connectorRequests.set(requestId, {
|
|
389
|
+
nodeId,
|
|
390
|
+
instanceId,
|
|
391
|
+
timer,
|
|
392
|
+
resolve: resolve2,
|
|
393
|
+
reject
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
try {
|
|
397
|
+
await connection.send(request);
|
|
398
|
+
} catch (error) {
|
|
399
|
+
const pending = this.connectorRequests.get(requestId);
|
|
400
|
+
if (pending) {
|
|
401
|
+
clearTimeout(pending.timer);
|
|
402
|
+
this.connectorRequests.delete(requestId);
|
|
403
|
+
pending.reject(error instanceof Error ? error : new Error("Connector request failed"));
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return result;
|
|
407
|
+
}
|
|
408
|
+
subscribeConnectorEvents(nodeId, instanceId, listener) {
|
|
409
|
+
const key = connectorKey(nodeId, instanceId);
|
|
410
|
+
const listeners = this.connectorEventListeners.get(key) ?? /* @__PURE__ */ new Set();
|
|
411
|
+
listeners.add(listener);
|
|
412
|
+
this.connectorEventListeners.set(key, listeners);
|
|
413
|
+
return () => {
|
|
414
|
+
listeners.delete(listener);
|
|
415
|
+
if (listeners.size === 0) this.connectorEventListeners.delete(key);
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
receiveConnectorResponse(nodeId, message) {
|
|
419
|
+
validateCodemanConnectorResponse(message);
|
|
420
|
+
const pending = this.connectorRequests.get(message.requestId);
|
|
421
|
+
if (!pending) throw new Error(`Unknown connector request: ${message.requestId}`);
|
|
422
|
+
if (pending.nodeId !== nodeId || pending.instanceId !== message.instanceId) {
|
|
423
|
+
throw new Error("Connector response authorization failed");
|
|
424
|
+
}
|
|
425
|
+
clearTimeout(pending.timer);
|
|
426
|
+
this.connectorRequests.delete(message.requestId);
|
|
427
|
+
if (message.success) {
|
|
428
|
+
pending.resolve(message.data);
|
|
429
|
+
} else {
|
|
430
|
+
pending.reject(new Error(message.error ?? "Connector request failed"));
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
receiveConnectorEvent(nodeId, message) {
|
|
434
|
+
validateCodemanConnectorSessionEvent(message);
|
|
435
|
+
const listeners = this.connectorEventListeners.get(connectorKey(nodeId, message.instanceId));
|
|
436
|
+
if (!listeners) return;
|
|
437
|
+
for (const listener of listeners) listener(message.event);
|
|
438
|
+
}
|
|
439
|
+
rejectConnectorRequests(nodeId, reason) {
|
|
440
|
+
for (const [requestId, pending] of this.connectorRequests) {
|
|
441
|
+
if (pending.nodeId !== nodeId) continue;
|
|
442
|
+
clearTimeout(pending.timer);
|
|
443
|
+
pending.reject(new Error(reason));
|
|
444
|
+
this.connectorRequests.delete(requestId);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
async createJob(request) {
|
|
448
|
+
const existing = this.jobs.getByIdempotencyKey(request.idempotencyKey);
|
|
449
|
+
if (existing) return existing;
|
|
450
|
+
const node = this.nodes.select(request.provider, request.nodeId);
|
|
451
|
+
this.nodes.selectWorkspace(node.id, request.workspaceId, request.provider);
|
|
452
|
+
const connection = this.connections.get(node.id);
|
|
453
|
+
if (!connection) throw new Error(`Node has no active connection: ${node.id}`);
|
|
454
|
+
const job = this.jobs.create(request, node.id);
|
|
455
|
+
this.audit.append("job.created", "operator", job.id, {
|
|
456
|
+
nodeId: node.id,
|
|
457
|
+
provider: job.provider
|
|
458
|
+
});
|
|
459
|
+
await connection.send({ type: "job.assign", job });
|
|
460
|
+
return job;
|
|
461
|
+
}
|
|
462
|
+
async cancelJob(jobId) {
|
|
463
|
+
const job = this.jobs.cancel(jobId);
|
|
464
|
+
const connection = job.nodeId ? this.connections.get(job.nodeId) : void 0;
|
|
465
|
+
if (connection) await connection.send({ type: "job.cancel", jobId });
|
|
466
|
+
this.audit.append("job.cancelled", "operator", jobId);
|
|
467
|
+
}
|
|
468
|
+
revokeNode(nodeId) {
|
|
469
|
+
const node = this.nodes.revoke(nodeId);
|
|
470
|
+
this.audit.append("node.revoked", "operator", nodeId);
|
|
471
|
+
void this.connections.get(nodeId)?.close();
|
|
472
|
+
this.connections.delete(nodeId);
|
|
473
|
+
return node;
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
function connectorKey(nodeId, instanceId) {
|
|
477
|
+
return `${nodeId}\0${instanceId}`;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// packages/codeman-bridge/src/index.ts
|
|
481
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
482
|
+
import { request as httpRequest } from "node:http";
|
|
483
|
+
import { request as httpsRequest } from "node:https";
|
|
484
|
+
var CodemanHttpAdapter = class {
|
|
485
|
+
instance;
|
|
486
|
+
fetchImpl;
|
|
487
|
+
headers;
|
|
488
|
+
listeners = /* @__PURE__ */ new Set();
|
|
489
|
+
eventAbort;
|
|
490
|
+
inputSequence = Date.now();
|
|
491
|
+
constructor(options) {
|
|
492
|
+
this.instance = validateCodemanInstance(options.instance);
|
|
493
|
+
if (!this.instance.endpoint) throw new Error("Codeman HTTP adapter requires an endpoint");
|
|
494
|
+
const endpoint = new URL(this.instance.endpoint);
|
|
495
|
+
if (endpoint.protocol !== "https:" && endpoint.hostname !== "localhost" && endpoint.hostname !== "127.0.0.1" && endpoint.hostname !== "::1" && endpoint.hostname !== "[::1]") {
|
|
496
|
+
throw new Error("Codeman endpoint must use HTTPS or loopback HTTP");
|
|
497
|
+
}
|
|
498
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
499
|
+
this.headers = { accept: "application/json" };
|
|
500
|
+
if (options.username !== void 0 || options.password !== void 0) {
|
|
501
|
+
if (!options.username || options.password === void 0) {
|
|
502
|
+
throw new Error("Codeman username and password must be provided together");
|
|
503
|
+
}
|
|
504
|
+
this.headers.authorization = `Basic ${Buffer.from(`${options.username}:${options.password}`).toString("base64")}`;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
async listWorkspaces() {
|
|
508
|
+
return this.instance.workspaces;
|
|
509
|
+
}
|
|
510
|
+
async listWorkspacePathSuggestions(prefix) {
|
|
511
|
+
const trimmed = prefix.trim();
|
|
512
|
+
const normalizedPrefix = trimmed.toLowerCase();
|
|
513
|
+
const suggestions = /* @__PURE__ */ new Map();
|
|
514
|
+
const add = (path, name) => {
|
|
515
|
+
const key = path.replaceAll("\\", "/").toLowerCase();
|
|
516
|
+
if (!suggestions.has(key)) suggestions.set(key, { path, name });
|
|
517
|
+
};
|
|
518
|
+
for (const workspace of this.instance.workspaces) {
|
|
519
|
+
if (workspace.health !== "ready") continue;
|
|
520
|
+
add(workspace.canonicalPath, workspace.name);
|
|
521
|
+
}
|
|
522
|
+
const roots = this.instance.workspacePolicy?.roots ?? [];
|
|
523
|
+
for (const root of roots) {
|
|
524
|
+
const segments = root.replaceAll("\\", "/").split("/").filter(Boolean);
|
|
525
|
+
add(root, segments.at(-1) ?? root);
|
|
526
|
+
}
|
|
527
|
+
return [...suggestions.values()].filter((suggestion) => {
|
|
528
|
+
if (!normalizedPrefix) return true;
|
|
529
|
+
const path = suggestion.path.toLowerCase();
|
|
530
|
+
return path.startsWith(normalizedPrefix) || suggestion.name.toLowerCase().startsWith(normalizedPrefix) || normalizedPrefix.startsWith(path);
|
|
531
|
+
}).slice(0, 50);
|
|
532
|
+
}
|
|
533
|
+
async getStatus() {
|
|
534
|
+
const data = await this.requestData("/api/v1/status");
|
|
535
|
+
const status = {};
|
|
536
|
+
if (typeof data.version === "string") status.version = data.version;
|
|
537
|
+
if (typeof data.sessions === "number") status.sessions = data.sessions;
|
|
538
|
+
if (Array.isArray(data.capabilities)) {
|
|
539
|
+
status.capabilities = data.capabilities.filter(
|
|
540
|
+
(capability) => typeof capability === "string"
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
if (Array.isArray(data.agents)) {
|
|
544
|
+
status.agents = data.agents.map((agent) => this.toAgent(agent));
|
|
545
|
+
}
|
|
546
|
+
return status;
|
|
547
|
+
}
|
|
548
|
+
async listCapabilities() {
|
|
549
|
+
const status = await this.getStatus();
|
|
550
|
+
if (status.agents && status.agents.length > 0) return status.agents;
|
|
551
|
+
if (status.capabilities && status.capabilities.length > 0) {
|
|
552
|
+
return status.capabilities.map((capability) => this.toAgent(capability));
|
|
553
|
+
}
|
|
554
|
+
return this.instance.agents.map((agent) => ({ ...agent }));
|
|
555
|
+
}
|
|
556
|
+
async listSessions() {
|
|
557
|
+
const data = await this.requestData(
|
|
558
|
+
"/api/v1/sessions"
|
|
559
|
+
);
|
|
560
|
+
const sessions = Array.isArray(data) ? data : data.sessions ?? [];
|
|
561
|
+
return Promise.all(sessions.map(async (session) => {
|
|
562
|
+
const normalized = this.toSession(session);
|
|
563
|
+
const terminal = await this.readTerminal(normalized.id);
|
|
564
|
+
return terminal ? {
|
|
565
|
+
...normalized,
|
|
566
|
+
status: this.statusFromCodeman(terminal.status ?? String(session.status ?? "")),
|
|
567
|
+
terminalBuffer: terminal.terminalBuffer === void 0 ? normalized.terminalBuffer : terminal.terminalBuffer
|
|
568
|
+
} : normalized;
|
|
569
|
+
}));
|
|
570
|
+
}
|
|
571
|
+
async createSession(workspace, agent, title = `${agent.name} \xB7 ${workspace.name}`) {
|
|
572
|
+
const data = await this.requestData("/api/v1/sessions", {
|
|
573
|
+
method: "POST",
|
|
574
|
+
body: {
|
|
575
|
+
workingDir: workspace.canonicalPath,
|
|
576
|
+
mode: agent.mode,
|
|
577
|
+
name: title
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
const session = this.toSession(
|
|
581
|
+
data.session ?? data,
|
|
582
|
+
workspace.id,
|
|
583
|
+
agent
|
|
584
|
+
);
|
|
585
|
+
await this.request(`/api/v1/sessions/${encodeURIComponent(session.id)}/interactive`, {
|
|
586
|
+
method: "POST"
|
|
587
|
+
});
|
|
588
|
+
this.emit(this.event(session.id, "started", void 0, session.lastEventAt));
|
|
589
|
+
return { ...session, status: "working" };
|
|
590
|
+
}
|
|
591
|
+
async sendInput(sessionId, input, raw = false) {
|
|
592
|
+
await this.request(`/api/v1/sessions/${encodeURIComponent(sessionId)}/input`, {
|
|
593
|
+
method: "POST",
|
|
594
|
+
body: {
|
|
595
|
+
input: raw ? input : `${input.replaceAll(/\r?\n/g, " ")}${input.endsWith("\r") ? "" : "\r"}`,
|
|
596
|
+
useMux: !raw,
|
|
597
|
+
clientId: `hosted-agents-${this.instance.id}`,
|
|
598
|
+
seq: ++this.inputSequence
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
async resize(sessionId, cols, rows) {
|
|
603
|
+
await this.request(`/api/v1/sessions/${encodeURIComponent(sessionId)}/resize`, {
|
|
604
|
+
method: "POST",
|
|
605
|
+
body: { cols, rows, viewportType: "desktop" }
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
async stopSession(sessionId) {
|
|
609
|
+
await this.request(`/api/v1/sessions/${encodeURIComponent(sessionId)}`, {
|
|
610
|
+
method: "DELETE"
|
|
611
|
+
});
|
|
612
|
+
this.emit(this.event(sessionId, "stopped", void 0, (/* @__PURE__ */ new Date()).toISOString()));
|
|
613
|
+
}
|
|
614
|
+
async proxyTerminal(sessionId, request, clientSocket, head) {
|
|
615
|
+
const endpoint = new URL(this.instance.endpoint);
|
|
616
|
+
const websocketPath = `/ws/sessions/${encodeURIComponent(sessionId)}/terminal`;
|
|
617
|
+
const query = typeof request.url === "string" ? new URL(request.url, "http://controller.local").search : "";
|
|
618
|
+
const transport = endpoint.protocol === "https:" ? httpsRequest : httpRequest;
|
|
619
|
+
const upstreamKey = randomBytes(16).toString("base64");
|
|
620
|
+
const browserKey = request.headers["sec-websocket-key"];
|
|
621
|
+
if (typeof browserKey !== "string") {
|
|
622
|
+
rejectSocket(clientSocket, 400, "Missing WebSocket key");
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
const headers = {
|
|
626
|
+
connection: "Upgrade",
|
|
627
|
+
upgrade: "websocket",
|
|
628
|
+
"sec-websocket-version": "13",
|
|
629
|
+
"sec-websocket-key": upstreamKey
|
|
630
|
+
};
|
|
631
|
+
if (this.basicAuthorization) headers.authorization = this.basicAuthorization;
|
|
632
|
+
const upstreamRequest = transport({
|
|
633
|
+
hostname: endpoint.hostname,
|
|
634
|
+
port: endpoint.port || void 0,
|
|
635
|
+
path: `${websocketPath}${query}`,
|
|
636
|
+
method: "GET",
|
|
637
|
+
headers,
|
|
638
|
+
rejectUnauthorized: endpoint.protocol !== "https:" || endpoint.hostname === "localhost"
|
|
639
|
+
});
|
|
640
|
+
upstreamRequest.once("upgrade", (response, upstreamSocket, upstreamHead) => {
|
|
641
|
+
if (response.statusCode !== 101) {
|
|
642
|
+
upstreamSocket.destroy();
|
|
643
|
+
rejectSocket(clientSocket, response.statusCode ?? 502, "Codeman WebSocket rejected");
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
const accept = createHash("sha1").update(`${browserKey}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`).digest("base64");
|
|
647
|
+
clientSocket.write(
|
|
648
|
+
`HTTP/1.1 101 Switching Protocols\r
|
|
649
|
+
Upgrade: websocket\r
|
|
650
|
+
Connection: Upgrade\r
|
|
651
|
+
Sec-WebSocket-Accept: ${accept}\r
|
|
652
|
+
\r
|
|
653
|
+
`
|
|
654
|
+
);
|
|
655
|
+
if (head.length > 0) upstreamSocket.write(head);
|
|
656
|
+
if (upstreamHead.length > 0) clientSocket.write(upstreamHead);
|
|
657
|
+
relaySockets(clientSocket, upstreamSocket);
|
|
658
|
+
});
|
|
659
|
+
upstreamRequest.once("response", (response) => {
|
|
660
|
+
response.resume();
|
|
661
|
+
rejectSocket(clientSocket, response.statusCode ?? 502, "Codeman WebSocket unavailable");
|
|
662
|
+
});
|
|
663
|
+
upstreamRequest.once("error", () => rejectSocket(clientSocket, 502, "Codeman WebSocket unavailable"));
|
|
664
|
+
upstreamRequest.end();
|
|
665
|
+
}
|
|
666
|
+
async ensureNativeCases() {
|
|
667
|
+
const workspaces = this.instance.workspaces.filter(
|
|
668
|
+
(workspace) => typeof workspace.canonicalPath === "string" && workspace.canonicalPath.length > 0
|
|
669
|
+
);
|
|
670
|
+
if (workspaces.length === 0) return;
|
|
671
|
+
let existingNames = /* @__PURE__ */ new Set();
|
|
672
|
+
try {
|
|
673
|
+
const data = await this.requestData("/api/cases");
|
|
674
|
+
const cases = Array.isArray(data) ? data : isRecord2(data) && Array.isArray(data.cases) ? data.cases : [];
|
|
675
|
+
existingNames = new Set(
|
|
676
|
+
cases.map((entry) => isRecord2(entry) ? String(entry.name ?? "") : "").filter(Boolean)
|
|
677
|
+
);
|
|
678
|
+
} catch {
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
for (const workspace of workspaces) {
|
|
682
|
+
const candidates = uniqueCaseNames(workspace.name, workspace.id);
|
|
683
|
+
if (candidates.some((name2) => existingNames.has(name2))) continue;
|
|
684
|
+
const name = candidates[0];
|
|
685
|
+
try {
|
|
686
|
+
await this.request("/api/cases/link", {
|
|
687
|
+
method: "POST",
|
|
688
|
+
body: { name, path: workspace.canonicalPath }
|
|
689
|
+
});
|
|
690
|
+
existingNames.add(name);
|
|
691
|
+
} catch (error) {
|
|
692
|
+
const message = error instanceof Error ? error.message : "";
|
|
693
|
+
if (/already exists/i.test(message)) {
|
|
694
|
+
existingNames.add(name);
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
async proxyHttp(request, response) {
|
|
701
|
+
const endpoint = new URL(this.instance.endpoint);
|
|
702
|
+
const requestUrl = new URL(request.url ?? "/", "http://controller.local");
|
|
703
|
+
const transport = endpoint.protocol === "https:" ? httpsRequest : httpRequest;
|
|
704
|
+
const headers = {};
|
|
705
|
+
for (const [name, value] of Object.entries(request.headers)) {
|
|
706
|
+
if (name === "host" || name === "authorization" || name === "cookie" || name === "connection" || name === "upgrade" || name === "origin" || name === "referer" || name.startsWith("x-forwarded-")) continue;
|
|
707
|
+
if (typeof value === "string") headers[name] = value;
|
|
708
|
+
else if (Array.isArray(value)) headers[name] = value.join(", ");
|
|
709
|
+
}
|
|
710
|
+
headers["accept-encoding"] = "identity";
|
|
711
|
+
if (this.basicAuthorization) headers.authorization = this.basicAuthorization;
|
|
712
|
+
const upstreamRequest = transport({
|
|
713
|
+
hostname: endpoint.hostname,
|
|
714
|
+
port: endpoint.port || void 0,
|
|
715
|
+
path: `${requestUrl.pathname}${requestUrl.search}`,
|
|
716
|
+
method: request.method ?? "GET",
|
|
717
|
+
headers,
|
|
718
|
+
rejectUnauthorized: endpoint.protocol !== "https:" || endpoint.hostname === "localhost"
|
|
719
|
+
}, (upstreamResponse) => {
|
|
720
|
+
const isHtml = String(upstreamResponse.headers["content-type"] ?? "").includes("text/html");
|
|
721
|
+
const responseHeaders = {};
|
|
722
|
+
for (const [name, value] of Object.entries(upstreamResponse.headers)) {
|
|
723
|
+
if (value === void 0 || name === "set-cookie" || name === "connection" || isHtml && (name === "content-length" || name === "content-encoding")) continue;
|
|
724
|
+
responseHeaders[name] = value;
|
|
725
|
+
}
|
|
726
|
+
const location = responseHeaders.location;
|
|
727
|
+
if (typeof location === "string") {
|
|
728
|
+
try {
|
|
729
|
+
const redirected = new URL(location, endpoint);
|
|
730
|
+
responseHeaders.location = `${redirected.pathname}${redirected.search}${redirected.hash}`;
|
|
731
|
+
} catch {
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
if (isHtml) {
|
|
735
|
+
const chunks = [];
|
|
736
|
+
upstreamResponse.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
|
737
|
+
upstreamResponse.on("end", () => {
|
|
738
|
+
const html = Buffer.concat(chunks).toString("utf8");
|
|
739
|
+
const injected = html.includes("</body>") ? html.replace("</body>", '<script src="/fleet-host-switcher.js"></script></body>') : `${html}<script src="/fleet-host-switcher.js"></script>`;
|
|
740
|
+
responseHeaders["content-type"] = "text/html; charset=utf-8";
|
|
741
|
+
response.writeHead(upstreamResponse.statusCode ?? 502, responseHeaders);
|
|
742
|
+
response.end(injected);
|
|
743
|
+
});
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
response.writeHead(upstreamResponse.statusCode ?? 502, responseHeaders);
|
|
747
|
+
upstreamResponse.pipe(response);
|
|
748
|
+
});
|
|
749
|
+
upstreamRequest.once("error", () => {
|
|
750
|
+
if (!response.headersSent) {
|
|
751
|
+
response.writeHead(502, { "content-type": "application/json; charset=utf-8" });
|
|
752
|
+
response.end(JSON.stringify({ error: "Codeman request unavailable" }));
|
|
753
|
+
} else {
|
|
754
|
+
response.destroy();
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
request.pipe(upstreamRequest);
|
|
758
|
+
}
|
|
759
|
+
subscribeEvents(onEvent) {
|
|
760
|
+
this.listeners.add(onEvent);
|
|
761
|
+
if (!this.eventAbort) void this.startEventStream();
|
|
762
|
+
return () => {
|
|
763
|
+
this.listeners.delete(onEvent);
|
|
764
|
+
if (this.listeners.size === 0) {
|
|
765
|
+
this.eventAbort?.abort();
|
|
766
|
+
this.eventAbort = void 0;
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
async startEventStream() {
|
|
771
|
+
const abort = new AbortController();
|
|
772
|
+
this.eventAbort = abort;
|
|
773
|
+
const response = await this.fetchImpl(`${this.baseUrl()}/api/v1/events`, {
|
|
774
|
+
headers: { ...this.headers, accept: "text/event-stream" },
|
|
775
|
+
signal: abort.signal
|
|
776
|
+
}).catch(() => void 0);
|
|
777
|
+
if (!response?.ok || !response.body) {
|
|
778
|
+
if (this.eventAbort === abort) this.eventAbort = void 0;
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
const reader = response.body.getReader();
|
|
782
|
+
const decoder = new TextDecoder();
|
|
783
|
+
let buffer = "";
|
|
784
|
+
try {
|
|
785
|
+
while (!abort.signal.aborted) {
|
|
786
|
+
const result = await reader.read();
|
|
787
|
+
if (result.done) break;
|
|
788
|
+
buffer += decoder.decode(result.value, { stream: true });
|
|
789
|
+
let boundary = sseBoundary(buffer);
|
|
790
|
+
while (boundary) {
|
|
791
|
+
const raw = buffer.slice(0, boundary.index);
|
|
792
|
+
buffer = buffer.slice(boundary.index + boundary.length);
|
|
793
|
+
boundary = sseBoundary(buffer);
|
|
794
|
+
const data = raw.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n");
|
|
795
|
+
if (!data) continue;
|
|
796
|
+
const eventName = raw.split(/\r?\n/).find((line) => line.startsWith("event:"))?.slice("event:".length).trim();
|
|
797
|
+
let event;
|
|
798
|
+
try {
|
|
799
|
+
const parsed = JSON.parse(data);
|
|
800
|
+
const payload = isRecord2(parsed) ? parsed : { data: parsed };
|
|
801
|
+
if (eventName && payload.type === void 0 && payload.event === void 0) {
|
|
802
|
+
payload.type = eventName;
|
|
803
|
+
}
|
|
804
|
+
event = this.toEvent(payload);
|
|
805
|
+
} catch {
|
|
806
|
+
event = void 0;
|
|
807
|
+
}
|
|
808
|
+
if (event) this.emit(event);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
} catch {
|
|
812
|
+
if (!abort.signal.aborted) {
|
|
813
|
+
}
|
|
814
|
+
} finally {
|
|
815
|
+
reader.releaseLock();
|
|
816
|
+
if (this.eventAbort === abort) this.eventAbort = void 0;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
async readTerminal(sessionId) {
|
|
820
|
+
try {
|
|
821
|
+
return await this.requestData(
|
|
822
|
+
`/api/v1/sessions/${encodeURIComponent(sessionId)}/terminal?tail=20000`
|
|
823
|
+
);
|
|
824
|
+
} catch {
|
|
825
|
+
return void 0;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
toSession(raw, workspaceId, agent) {
|
|
829
|
+
const id2 = String(raw.id ?? "");
|
|
830
|
+
if (!id2) throw new Error("Codeman returned a session without an ID");
|
|
831
|
+
const resolvedWorkspaceId = workspaceId ?? this.workspaceIdForPath(String(raw.workspaceId ?? raw.workingDir ?? ""));
|
|
832
|
+
const resolvedAgent = agent ?? this.agentFromMode(String(raw.mode ?? "shell"));
|
|
833
|
+
const createdAt = this.timestamp(raw.createdAt ?? raw.created_at);
|
|
834
|
+
return {
|
|
835
|
+
id: id2,
|
|
836
|
+
instanceId: this.instance.id,
|
|
837
|
+
workspaceId: resolvedWorkspaceId,
|
|
838
|
+
agent: resolvedAgent,
|
|
839
|
+
title: String(raw.name ?? id2),
|
|
840
|
+
status: this.statusFromCodeman(String(raw.status ?? "idle")),
|
|
841
|
+
terminalBuffer: String(raw.terminalBuffer ?? ""),
|
|
842
|
+
needsInput: false,
|
|
843
|
+
previewAvailable: false,
|
|
844
|
+
browserAvailable: false,
|
|
845
|
+
createdAt,
|
|
846
|
+
lastEventAt: this.timestamp(raw.lastEventAt ?? raw.last_event_at ?? createdAt)
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
toEvent(raw) {
|
|
850
|
+
const type = String(raw.type ?? raw.event ?? "").toLowerCase();
|
|
851
|
+
const nestedSession = isRecord2(raw.session) ? raw.session : void 0;
|
|
852
|
+
const nestedData = isRecord2(raw.data) ? raw.data : void 0;
|
|
853
|
+
const sessionId = String(
|
|
854
|
+
raw.sessionId ?? raw.session_id ?? nestedSession?.id ?? nestedData?.sessionId ?? nestedData?.session_id ?? ""
|
|
855
|
+
);
|
|
856
|
+
if (!sessionId) return void 0;
|
|
857
|
+
const createdAt = this.timestamp(raw.createdAt ?? raw.created_at);
|
|
858
|
+
const data = raw.data ?? raw.output ?? raw.text;
|
|
859
|
+
if (type === "session:terminal" || type.includes("terminal") || type.includes("output")) {
|
|
860
|
+
return this.event(sessionId, "output", data, createdAt);
|
|
861
|
+
}
|
|
862
|
+
if (type === "hook:idle_prompt" || type.includes("permission") || type.includes("wait")) {
|
|
863
|
+
return this.event(sessionId, "waiting", data, createdAt);
|
|
864
|
+
}
|
|
865
|
+
if (type === "session:idle") return this.event(sessionId, "idle", data, createdAt);
|
|
866
|
+
if (type.includes("fail") || type.includes("error")) {
|
|
867
|
+
return this.event(sessionId, "failed", data, createdAt);
|
|
868
|
+
}
|
|
869
|
+
if (type.includes("complete") || type.includes("completion")) {
|
|
870
|
+
return this.event(sessionId, "completed", data, createdAt);
|
|
871
|
+
}
|
|
872
|
+
if (type.includes("exit") || type.includes("delet")) {
|
|
873
|
+
return this.event(sessionId, "stopped", data, createdAt);
|
|
874
|
+
}
|
|
875
|
+
if (type.includes("start") || type.includes("run")) {
|
|
876
|
+
return this.event(sessionId, "started", data, createdAt);
|
|
877
|
+
}
|
|
878
|
+
if (type.includes("stop")) {
|
|
879
|
+
return this.event(sessionId, "stopped", data, createdAt);
|
|
880
|
+
}
|
|
881
|
+
if (type.includes("create")) {
|
|
882
|
+
return this.event(sessionId, "created", data, createdAt);
|
|
883
|
+
}
|
|
884
|
+
return void 0;
|
|
885
|
+
}
|
|
886
|
+
agentFromMode(mode) {
|
|
887
|
+
const known = ["claude", "shell", "opencode", "codex", "gemini", "antigravity", "pi", "grok", "deepseek"];
|
|
888
|
+
const selected = known.includes(mode) ? mode : "shell";
|
|
889
|
+
return {
|
|
890
|
+
id: selected,
|
|
891
|
+
name: selected === "claude" ? "Claude Code" : selected,
|
|
892
|
+
mode: selected,
|
|
893
|
+
ready: true
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
toAgent(raw) {
|
|
897
|
+
if (typeof raw === "string") return this.agentFromMode(raw);
|
|
898
|
+
const value = isRecord2(raw) ? raw : {};
|
|
899
|
+
const mode = String(value.mode ?? value.id ?? "shell");
|
|
900
|
+
const agent = this.agentFromMode(mode);
|
|
901
|
+
return {
|
|
902
|
+
...agent,
|
|
903
|
+
id: String(value.id ?? agent.id),
|
|
904
|
+
name: String(value.name ?? agent.name),
|
|
905
|
+
ready: value.ready !== false && value.available !== false
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
workspaceIdForPath(path) {
|
|
909
|
+
const workspace = this.instance.workspaces.find(
|
|
910
|
+
(candidate) => candidate.id === path || candidate.canonicalPath === path
|
|
911
|
+
);
|
|
912
|
+
return workspace?.id ?? (path || "unknown");
|
|
913
|
+
}
|
|
914
|
+
timestamp(value) {
|
|
915
|
+
if (typeof value === "string" && !Number.isNaN(Date.parse(value))) return value;
|
|
916
|
+
if (typeof value === "number" && Number.isFinite(value)) return new Date(value).toISOString();
|
|
917
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
918
|
+
}
|
|
919
|
+
event(sessionId, event, data, createdAt) {
|
|
920
|
+
return {
|
|
921
|
+
instanceId: this.instance.id,
|
|
922
|
+
sessionId,
|
|
923
|
+
event,
|
|
924
|
+
...data === void 0 ? {} : { data: typeof data === "string" ? data : JSON.stringify(data) },
|
|
925
|
+
createdAt
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
statusFromCodeman(status) {
|
|
929
|
+
if (status === "running" || status === "working" || status === "busy") return "working";
|
|
930
|
+
if (status === "starting" || status === "created") return "starting";
|
|
931
|
+
if (status === "completed") return "completed";
|
|
932
|
+
if (status === "failed") return "failed";
|
|
933
|
+
if (status === "stopped" || status === "deleted" || status === "exited") return "stopped";
|
|
934
|
+
if (status === "waiting" || status === "blocked") return "waiting";
|
|
935
|
+
return "idle";
|
|
936
|
+
}
|
|
937
|
+
emit(event) {
|
|
938
|
+
for (const listener of this.listeners) listener(event);
|
|
939
|
+
}
|
|
940
|
+
baseUrl() {
|
|
941
|
+
return this.instance.endpoint.replace(/\/+$/, "");
|
|
942
|
+
}
|
|
943
|
+
get basicAuthorization() {
|
|
944
|
+
return this.headers.authorization;
|
|
945
|
+
}
|
|
946
|
+
async requestData(path, options = {}) {
|
|
947
|
+
const envelope = await this.request(path, options);
|
|
948
|
+
if (envelope.data === void 0) throw new Error("Codeman response did not include data");
|
|
949
|
+
return envelope.data;
|
|
950
|
+
}
|
|
951
|
+
async request(path, options = {}) {
|
|
952
|
+
const response = await this.fetchImpl(`${this.baseUrl()}${path}`, {
|
|
953
|
+
method: options.method ?? "GET",
|
|
954
|
+
headers: {
|
|
955
|
+
...this.headers,
|
|
956
|
+
...options.body === void 0 ? {} : { "content-type": "application/json" }
|
|
957
|
+
},
|
|
958
|
+
body: options.body === void 0 ? void 0 : JSON.stringify(options.body),
|
|
959
|
+
signal: options.signal
|
|
960
|
+
});
|
|
961
|
+
const text = await response.text();
|
|
962
|
+
if (response.status === 401) throw new Error("Codeman request unauthorized");
|
|
963
|
+
if (!text) {
|
|
964
|
+
if (response.ok) return { success: true };
|
|
965
|
+
throw new Error(`Codeman request failed with HTTP ${response.status}`);
|
|
966
|
+
}
|
|
967
|
+
let envelope;
|
|
968
|
+
try {
|
|
969
|
+
envelope = JSON.parse(text);
|
|
970
|
+
} catch {
|
|
971
|
+
throw new Error(`Codeman returned invalid JSON with HTTP ${response.status}`);
|
|
972
|
+
}
|
|
973
|
+
if (!response.ok || envelope.success === false) {
|
|
974
|
+
throw new Error(envelope.error ?? `Codeman request failed with HTTP ${response.status}`);
|
|
975
|
+
}
|
|
976
|
+
return envelope;
|
|
977
|
+
}
|
|
978
|
+
};
|
|
979
|
+
var CodemanConnectorAdapter = class {
|
|
980
|
+
instance;
|
|
981
|
+
request;
|
|
982
|
+
subscribe;
|
|
983
|
+
constructor(options) {
|
|
984
|
+
this.instance = validateCodemanInstance(options.instance);
|
|
985
|
+
if (this.instance.connectionMode !== "connector") {
|
|
986
|
+
throw new Error("Codeman connector adapter requires connector mode");
|
|
987
|
+
}
|
|
988
|
+
this.request = options.request;
|
|
989
|
+
this.subscribe = options.subscribeEvents;
|
|
990
|
+
}
|
|
991
|
+
async getStatus() {
|
|
992
|
+
return this.request("status");
|
|
993
|
+
}
|
|
994
|
+
async listCapabilities() {
|
|
995
|
+
const data = await this.request("capabilities");
|
|
996
|
+
const agents = isRecord2(data) && Array.isArray(data.agents) ? data.agents : data;
|
|
997
|
+
if (!Array.isArray(agents)) return this.instance.agents.map((agent) => ({ ...agent }));
|
|
998
|
+
return agents.map((agent) => toConnectorAgent(agent));
|
|
999
|
+
}
|
|
1000
|
+
async listWorkspaces() {
|
|
1001
|
+
const data = await this.request("workspaces");
|
|
1002
|
+
return Array.isArray(data) ? data.map((workspace) => workspace) : [];
|
|
1003
|
+
}
|
|
1004
|
+
async listWorkspacePathSuggestions(prefix) {
|
|
1005
|
+
const data = await this.request("workspaces.suggest", { prefix });
|
|
1006
|
+
return Array.isArray(data) ? data.map((suggestion) => suggestion) : [];
|
|
1007
|
+
}
|
|
1008
|
+
async listSessions() {
|
|
1009
|
+
const data = await this.request("sessions.list");
|
|
1010
|
+
const sessions = isRecord2(data) && Array.isArray(data.sessions) ? data.sessions : data;
|
|
1011
|
+
if (!Array.isArray(sessions)) return [];
|
|
1012
|
+
return sessions.map((session) => this.toSession(session));
|
|
1013
|
+
}
|
|
1014
|
+
async createSession(workspace, agent, title = `${agent.name} \xB7 ${workspace.name}`) {
|
|
1015
|
+
const knownWorkspace = this.instance.workspaces.some(
|
|
1016
|
+
(candidate) => candidate.id === workspace.id || candidate.canonicalPath === workspace.canonicalPath
|
|
1017
|
+
);
|
|
1018
|
+
const data = await this.request("sessions.create", {
|
|
1019
|
+
...knownWorkspace ? { workspaceId: workspace.id } : { workspacePath: workspace.canonicalPath },
|
|
1020
|
+
agent,
|
|
1021
|
+
title
|
|
1022
|
+
});
|
|
1023
|
+
const raw = isRecord2(data) && isRecord2(data.session) ? data.session : data;
|
|
1024
|
+
return this.toSession(raw, workspace.id, agent);
|
|
1025
|
+
}
|
|
1026
|
+
async sendInput(sessionId, input, raw = false) {
|
|
1027
|
+
await this.request("sessions.input", {
|
|
1028
|
+
sessionId,
|
|
1029
|
+
input,
|
|
1030
|
+
raw
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
async resize(sessionId, cols, rows) {
|
|
1034
|
+
await this.request("sessions.resize", { sessionId, cols, rows });
|
|
1035
|
+
}
|
|
1036
|
+
async stopSession(sessionId) {
|
|
1037
|
+
await this.request("sessions.stop", { sessionId });
|
|
1038
|
+
}
|
|
1039
|
+
subscribeEvents(onEvent) {
|
|
1040
|
+
return this.subscribe(onEvent);
|
|
1041
|
+
}
|
|
1042
|
+
toSession(raw, workspaceId, agent) {
|
|
1043
|
+
if (!isRecord2(raw)) throw new Error("Connector returned an invalid session");
|
|
1044
|
+
const id2 = String(raw.id ?? "");
|
|
1045
|
+
if (!id2) throw new Error("Connector returned a session without an ID");
|
|
1046
|
+
const resolvedWorkspaceId = workspaceId ?? String(raw.workspaceId ?? raw.workingDir ?? "unknown");
|
|
1047
|
+
const resolvedAgent = agent ?? toConnectorAgent(raw.mode);
|
|
1048
|
+
const createdAt = connectorTimestamp(raw.createdAt ?? raw.created_at);
|
|
1049
|
+
return {
|
|
1050
|
+
id: id2,
|
|
1051
|
+
instanceId: this.instance.id,
|
|
1052
|
+
workspaceId: resolvedWorkspaceId,
|
|
1053
|
+
agent: resolvedAgent,
|
|
1054
|
+
title: String(raw.name ?? id2),
|
|
1055
|
+
status: connectorStatus(String(raw.status ?? "idle")),
|
|
1056
|
+
terminalBuffer: String(raw.terminalBuffer ?? ""),
|
|
1057
|
+
needsInput: false,
|
|
1058
|
+
previewAvailable: false,
|
|
1059
|
+
browserAvailable: false,
|
|
1060
|
+
createdAt,
|
|
1061
|
+
lastEventAt: connectorTimestamp(raw.lastEventAt ?? raw.last_event_at ?? createdAt)
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
};
|
|
1065
|
+
function toConnectorAgent(raw) {
|
|
1066
|
+
const value = isRecord2(raw) ? raw : {};
|
|
1067
|
+
const mode = String(value.mode ?? value.id ?? (typeof raw === "string" ? raw : "shell"));
|
|
1068
|
+
const known = ["claude", "shell", "opencode", "codex", "gemini", "antigravity", "pi", "grok", "deepseek"];
|
|
1069
|
+
const selected = known.includes(mode) ? mode : "shell";
|
|
1070
|
+
return {
|
|
1071
|
+
id: String(value.id ?? selected),
|
|
1072
|
+
name: String(value.name ?? (selected === "claude" ? "Claude Code" : selected)),
|
|
1073
|
+
mode: selected,
|
|
1074
|
+
ready: value.ready !== false && value.available !== false
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
function connectorTimestamp(value) {
|
|
1078
|
+
if (typeof value === "string" && !Number.isNaN(Date.parse(value))) return value;
|
|
1079
|
+
if (typeof value === "number" && Number.isFinite(value)) return new Date(value).toISOString();
|
|
1080
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
1081
|
+
}
|
|
1082
|
+
function connectorStatus(status) {
|
|
1083
|
+
if (status === "running" || status === "working") return "working";
|
|
1084
|
+
if (status === "completed") return "completed";
|
|
1085
|
+
if (status === "failed") return "failed";
|
|
1086
|
+
if (status === "stopped" || status === "deleted" || status === "exited") return "stopped";
|
|
1087
|
+
if (status === "waiting" || status === "blocked") return "waiting";
|
|
1088
|
+
return "idle";
|
|
1089
|
+
}
|
|
1090
|
+
function isRecord2(value) {
|
|
1091
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1092
|
+
}
|
|
1093
|
+
function uniqueCaseNames(...values) {
|
|
1094
|
+
const names = [];
|
|
1095
|
+
for (const value of values) {
|
|
1096
|
+
const name = String(value ?? "").trim().replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
|
|
1097
|
+
if (name && !names.includes(name)) names.push(name);
|
|
1098
|
+
}
|
|
1099
|
+
return names;
|
|
1100
|
+
}
|
|
1101
|
+
function sseBoundary(value) {
|
|
1102
|
+
const lineFeed = value.indexOf("\n\n");
|
|
1103
|
+
const carriageReturn = value.indexOf("\r\n\r\n");
|
|
1104
|
+
if (lineFeed < 0 && carriageReturn < 0) return void 0;
|
|
1105
|
+
if (lineFeed < 0) return { index: carriageReturn, length: 4 };
|
|
1106
|
+
if (carriageReturn < 0 || lineFeed < carriageReturn) {
|
|
1107
|
+
return { index: lineFeed, length: 2 };
|
|
1108
|
+
}
|
|
1109
|
+
return { index: carriageReturn, length: 4 };
|
|
1110
|
+
}
|
|
1111
|
+
function relaySockets(clientSocket, upstreamSocket) {
|
|
1112
|
+
let closed = false;
|
|
1113
|
+
const close = () => {
|
|
1114
|
+
if (closed) return;
|
|
1115
|
+
closed = true;
|
|
1116
|
+
clientSocket.destroy();
|
|
1117
|
+
upstreamSocket.destroy();
|
|
1118
|
+
};
|
|
1119
|
+
clientSocket.on("data", (chunk) => {
|
|
1120
|
+
if (!upstreamSocket.destroyed) upstreamSocket.write(chunk);
|
|
1121
|
+
});
|
|
1122
|
+
upstreamSocket.on("data", (chunk) => {
|
|
1123
|
+
if (!clientSocket.destroyed) clientSocket.write(chunk);
|
|
1124
|
+
});
|
|
1125
|
+
clientSocket.on("error", close);
|
|
1126
|
+
clientSocket.on("close", close);
|
|
1127
|
+
upstreamSocket.on("error", close);
|
|
1128
|
+
upstreamSocket.on("close", close);
|
|
1129
|
+
}
|
|
1130
|
+
function rejectSocket(socket, status, message) {
|
|
1131
|
+
if (socket.destroyed) return;
|
|
1132
|
+
socket.write(
|
|
1133
|
+
`HTTP/1.1 ${status} ${message}\r
|
|
1134
|
+
Connection: close\r
|
|
1135
|
+
Content-Type: text/plain; charset=utf-8\r
|
|
1136
|
+
Content-Length: ${Buffer.byteLength(message)}\r
|
|
1137
|
+
\r
|
|
1138
|
+
` + message
|
|
1139
|
+
);
|
|
1140
|
+
socket.destroy();
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// packages/protocol/src/persistence.ts
|
|
1144
|
+
import {
|
|
1145
|
+
chmodSync,
|
|
1146
|
+
mkdirSync,
|
|
1147
|
+
readFileSync,
|
|
1148
|
+
renameSync,
|
|
1149
|
+
unlinkSync,
|
|
1150
|
+
writeFileSync
|
|
1151
|
+
} from "node:fs";
|
|
1152
|
+
import { dirname } from "node:path";
|
|
1153
|
+
var emptyState = () => ({ instances: [], sessions: [], audit: [] });
|
|
1154
|
+
var HubStateStore = class {
|
|
1155
|
+
state;
|
|
1156
|
+
path;
|
|
1157
|
+
constructor(path) {
|
|
1158
|
+
this.path = path;
|
|
1159
|
+
this.state = path ? load(path) : emptyState();
|
|
1160
|
+
}
|
|
1161
|
+
snapshot() {
|
|
1162
|
+
return structuredClone(this.state);
|
|
1163
|
+
}
|
|
1164
|
+
upsertInstance(instance) {
|
|
1165
|
+
validateCodemanInstance(instance);
|
|
1166
|
+
const index = this.state.instances.findIndex((candidate) => candidate.id === instance.id);
|
|
1167
|
+
if (index < 0) this.state.instances.push(structuredClone(instance));
|
|
1168
|
+
else this.state.instances[index] = structuredClone(instance);
|
|
1169
|
+
this.persist();
|
|
1170
|
+
}
|
|
1171
|
+
removeInstance(instanceId) {
|
|
1172
|
+
this.state.instances = this.state.instances.filter((instance) => instance.id !== instanceId);
|
|
1173
|
+
this.state.sessions = this.state.sessions.filter((session) => session.instanceId !== instanceId);
|
|
1174
|
+
this.persist();
|
|
1175
|
+
}
|
|
1176
|
+
upsertSession(session) {
|
|
1177
|
+
validateCodemanSession(session);
|
|
1178
|
+
const index = this.state.sessions.findIndex(
|
|
1179
|
+
(candidate) => candidate.instanceId === session.instanceId && candidate.id === session.id
|
|
1180
|
+
);
|
|
1181
|
+
if (index < 0) this.state.sessions.push(structuredClone(session));
|
|
1182
|
+
else this.state.sessions[index] = structuredClone(session);
|
|
1183
|
+
this.persist();
|
|
1184
|
+
}
|
|
1185
|
+
removeSession(instanceId, sessionId) {
|
|
1186
|
+
this.state.sessions = this.state.sessions.filter(
|
|
1187
|
+
(session) => session.instanceId !== instanceId || session.id !== sessionId
|
|
1188
|
+
);
|
|
1189
|
+
this.persist();
|
|
1190
|
+
}
|
|
1191
|
+
appendAudit(event) {
|
|
1192
|
+
this.state.audit.push(structuredClone(event));
|
|
1193
|
+
this.persist();
|
|
1194
|
+
}
|
|
1195
|
+
persist() {
|
|
1196
|
+
if (!this.path) return;
|
|
1197
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
1198
|
+
const temp = `${this.path}.tmp`;
|
|
1199
|
+
const serialized = `${JSON.stringify(this.state, null, 2)}
|
|
1200
|
+
`;
|
|
1201
|
+
writeFileSync(temp, serialized, { mode: 384 });
|
|
1202
|
+
try {
|
|
1203
|
+
renameSync(temp, this.path);
|
|
1204
|
+
chmodSync(this.path, 384);
|
|
1205
|
+
} catch {
|
|
1206
|
+
writeFileSync(this.path, serialized, { mode: 384 });
|
|
1207
|
+
chmodSync(this.path, 384);
|
|
1208
|
+
try {
|
|
1209
|
+
unlinkSync(temp);
|
|
1210
|
+
} catch {
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
};
|
|
1215
|
+
function load(path) {
|
|
1216
|
+
try {
|
|
1217
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
1218
|
+
return {
|
|
1219
|
+
instances: Array.isArray(parsed.instances) ? parsed.instances.filter(isCodemanInstance) : [],
|
|
1220
|
+
sessions: Array.isArray(parsed.sessions) ? parsed.sessions.filter(isCodemanSession) : [],
|
|
1221
|
+
audit: Array.isArray(parsed.audit) ? parsed.audit.filter(isAuditEvent) : []
|
|
1222
|
+
};
|
|
1223
|
+
} catch {
|
|
1224
|
+
return emptyState();
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
function isAuditEvent(value) {
|
|
1228
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
1229
|
+
const event = value;
|
|
1230
|
+
return typeof event.id === "string" && isAuditType(event.type) && (event.actor === "system" || event.actor === "operator" || event.actor === "node") && typeof event.resourceId === "string" && typeof event.createdAt === "string" && typeof event.metadata === "object" && event.metadata !== null && !Array.isArray(event.metadata) && Object.values(event.metadata).every((entry) => typeof entry === "string");
|
|
1231
|
+
}
|
|
1232
|
+
function isAuditType(value) {
|
|
1233
|
+
return value === "node.enrolled" || value === "node.heartbeat" || value === "node.revoked" || value === "job.created" || value === "job.started" || value === "job.completed" || value === "job.failed" || value === "job.cancelled" || value === "instance.registered" || value === "instance.removed" || value === "instance.health" || value === "session.created" || value === "session.stopped" || value === "session.removed" || value === "preview.created" || value === "preview.revoked" || value === "browser.started" || value === "browser.stopped";
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// apps/control-plane/src/hub.ts
|
|
1237
|
+
var CodemanHub = class {
|
|
1238
|
+
state;
|
|
1239
|
+
credentials;
|
|
1240
|
+
adapterFactory;
|
|
1241
|
+
hasCustomAdapterFactory;
|
|
1242
|
+
connectorRequest;
|
|
1243
|
+
connectorSubscribeEvents;
|
|
1244
|
+
runtimeCredentials = /* @__PURE__ */ new Map();
|
|
1245
|
+
adapters = /* @__PURE__ */ new Map();
|
|
1246
|
+
adapterUnsubscribers = /* @__PURE__ */ new Map();
|
|
1247
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1248
|
+
constructor(options = {}) {
|
|
1249
|
+
this.state = new HubStateStore(options.statePath);
|
|
1250
|
+
this.hasCustomAdapterFactory = options.adapterFactory !== void 0;
|
|
1251
|
+
this.credentials = options.credentials;
|
|
1252
|
+
this.connectorRequest = options.connectorRequest;
|
|
1253
|
+
this.connectorSubscribeEvents = options.connectorSubscribeEvents;
|
|
1254
|
+
this.adapterFactory = options.adapterFactory ?? ((instance, credentials) => {
|
|
1255
|
+
if (instance.connectionMode === "connector") {
|
|
1256
|
+
if (!this.connectorRequest || !this.connectorSubscribeEvents || !instance.nodeId) {
|
|
1257
|
+
throw new Error(`Connector is not configured: ${instance.id}`);
|
|
1258
|
+
}
|
|
1259
|
+
return new CodemanConnectorAdapter({
|
|
1260
|
+
instance,
|
|
1261
|
+
request: (operation, payload) => this.connectorRequest(
|
|
1262
|
+
instance.nodeId,
|
|
1263
|
+
instance.id,
|
|
1264
|
+
operation,
|
|
1265
|
+
payload
|
|
1266
|
+
),
|
|
1267
|
+
subscribeEvents: (listener) => this.connectorSubscribeEvents(
|
|
1268
|
+
instance.nodeId,
|
|
1269
|
+
instance.id,
|
|
1270
|
+
listener
|
|
1271
|
+
)
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
return new CodemanHttpAdapter({ instance, ...credentials ?? {} });
|
|
1275
|
+
});
|
|
1276
|
+
for (const instance of this.state.snapshot().instances) this.attachAdapter(instance);
|
|
1277
|
+
}
|
|
1278
|
+
listInstances() {
|
|
1279
|
+
return this.state.snapshot().instances;
|
|
1280
|
+
}
|
|
1281
|
+
listSessions() {
|
|
1282
|
+
return this.state.snapshot().sessions;
|
|
1283
|
+
}
|
|
1284
|
+
async suggestWorkspacePaths(instanceId, prefix) {
|
|
1285
|
+
this.getInstance(instanceId);
|
|
1286
|
+
return this.getAdapter(instanceId).listWorkspacePathSuggestions(prefix);
|
|
1287
|
+
}
|
|
1288
|
+
listAudit() {
|
|
1289
|
+
return this.state.snapshot().audit;
|
|
1290
|
+
}
|
|
1291
|
+
getInstance(instanceId) {
|
|
1292
|
+
const instance = this.listInstances().find((candidate) => candidate.id === instanceId);
|
|
1293
|
+
if (!instance) throw new Error(`Unknown Codeman instance: ${instanceId}`);
|
|
1294
|
+
return instance;
|
|
1295
|
+
}
|
|
1296
|
+
getAdapter(instanceId) {
|
|
1297
|
+
const adapter = this.adapters.get(instanceId);
|
|
1298
|
+
if (!adapter) throw new Error(`Codeman instance is not connected: ${instanceId}`);
|
|
1299
|
+
return adapter;
|
|
1300
|
+
}
|
|
1301
|
+
getSession(instanceId, sessionId) {
|
|
1302
|
+
const session = this.listSessions().find(
|
|
1303
|
+
(candidate) => candidate.instanceId === instanceId && candidate.id === sessionId
|
|
1304
|
+
);
|
|
1305
|
+
if (!session) throw new Error(`Unknown session: ${instanceId}/${sessionId}`);
|
|
1306
|
+
return session;
|
|
1307
|
+
}
|
|
1308
|
+
registerInstance(registration, credentials) {
|
|
1309
|
+
validateCodemanInstanceRegistration(registration);
|
|
1310
|
+
const existing = this.listInstances().find((instance2) => instance2.id === registration.id);
|
|
1311
|
+
const instance = {
|
|
1312
|
+
id: registration.id,
|
|
1313
|
+
label: registration.label,
|
|
1314
|
+
connectionMode: registration.connectionMode,
|
|
1315
|
+
...registration.nodeId ? { nodeId: registration.nodeId } : {},
|
|
1316
|
+
endpoint: registration.endpoint ?? null,
|
|
1317
|
+
status: existing?.status ?? "offline",
|
|
1318
|
+
capabilities: [...new Set(registration.capabilities ?? ["codeman"])],
|
|
1319
|
+
...registration.workspacePolicy ? {
|
|
1320
|
+
workspacePolicy: {
|
|
1321
|
+
mode: registration.workspacePolicy.mode,
|
|
1322
|
+
roots: [...registration.workspacePolicy.roots]
|
|
1323
|
+
}
|
|
1324
|
+
} : existing?.workspacePolicy ? {
|
|
1325
|
+
workspacePolicy: {
|
|
1326
|
+
mode: existing.workspacePolicy.mode,
|
|
1327
|
+
roots: [...existing.workspacePolicy.roots]
|
|
1328
|
+
}
|
|
1329
|
+
} : {},
|
|
1330
|
+
agents: (registration.agents ?? defaultAgents()).map((agent) => ({ ...agent })),
|
|
1331
|
+
workspaces: (registration.workspaces ?? []).map((workspace) => ({
|
|
1332
|
+
...workspace,
|
|
1333
|
+
providers: { ...workspace.providers }
|
|
1334
|
+
})),
|
|
1335
|
+
lastSeenAt: existing?.lastSeenAt ?? null,
|
|
1336
|
+
createdAt: existing?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
1337
|
+
};
|
|
1338
|
+
validateCodemanInstance(instance);
|
|
1339
|
+
if (credentials) this.runtimeCredentials.set(instance.id, credentials);
|
|
1340
|
+
this.attachAdapter(instance, credentials ?? this.runtimeCredentials.get(instance.id));
|
|
1341
|
+
this.state.upsertInstance(instance);
|
|
1342
|
+
this.appendAudit("instance.registered", instance.id, { connectionMode: instance.connectionMode });
|
|
1343
|
+
return instance;
|
|
1344
|
+
}
|
|
1345
|
+
removeInstance(instanceId) {
|
|
1346
|
+
this.getInstance(instanceId);
|
|
1347
|
+
this.adapterUnsubscribers.get(instanceId)?.();
|
|
1348
|
+
this.adapterUnsubscribers.delete(instanceId);
|
|
1349
|
+
this.adapters.delete(instanceId);
|
|
1350
|
+
this.runtimeCredentials.delete(instanceId);
|
|
1351
|
+
this.appendAudit("instance.removed", instanceId);
|
|
1352
|
+
this.state.removeInstance(instanceId);
|
|
1353
|
+
}
|
|
1354
|
+
async checkInstance(instanceId) {
|
|
1355
|
+
const instance = this.getInstance(instanceId);
|
|
1356
|
+
const adapter = this.getAdapter(instanceId);
|
|
1357
|
+
try {
|
|
1358
|
+
const [workspaces, agents] = await Promise.all([
|
|
1359
|
+
adapter.listWorkspaces(),
|
|
1360
|
+
adapter.listCapabilities()
|
|
1361
|
+
]);
|
|
1362
|
+
const updated = {
|
|
1363
|
+
...instance,
|
|
1364
|
+
status: "online",
|
|
1365
|
+
workspaces,
|
|
1366
|
+
agents,
|
|
1367
|
+
lastSeenAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1368
|
+
};
|
|
1369
|
+
this.state.upsertInstance(updated);
|
|
1370
|
+
this.appendAudit("instance.health", instanceId, { status: updated.status });
|
|
1371
|
+
return updated;
|
|
1372
|
+
} catch {
|
|
1373
|
+
const updated = { ...instance, status: "unhealthy" };
|
|
1374
|
+
this.state.upsertInstance(updated);
|
|
1375
|
+
this.appendAudit("instance.health", instanceId, { status: updated.status });
|
|
1376
|
+
return updated;
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
async listCapabilities(instanceId) {
|
|
1380
|
+
const instance = this.getInstance(instanceId);
|
|
1381
|
+
const agents = await this.getAdapter(instanceId).listCapabilities();
|
|
1382
|
+
this.state.upsertInstance({
|
|
1383
|
+
...instance,
|
|
1384
|
+
agents,
|
|
1385
|
+
status: "online",
|
|
1386
|
+
lastSeenAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1387
|
+
});
|
|
1388
|
+
return agents;
|
|
1389
|
+
}
|
|
1390
|
+
async syncSessions(instanceId) {
|
|
1391
|
+
const adapter = this.getAdapter(instanceId);
|
|
1392
|
+
const sessions = await adapter.listSessions();
|
|
1393
|
+
for (const session of sessions) this.state.upsertSession(session);
|
|
1394
|
+
return sessions;
|
|
1395
|
+
}
|
|
1396
|
+
async createSession(instanceId, workspaceId, agentId, title, workspacePath) {
|
|
1397
|
+
const instance = this.getInstance(instanceId);
|
|
1398
|
+
const workspace = workspaceId ? requireWorkspace(instance.workspaces, workspaceId) : this.resolveWorkspacePath(instance, workspacePath);
|
|
1399
|
+
const agent = instance.agents.find((candidate) => candidate.id === agentId);
|
|
1400
|
+
if (!agent || !agent.ready) throw new Error(`Agent is not ready: ${agentId}`);
|
|
1401
|
+
let session;
|
|
1402
|
+
try {
|
|
1403
|
+
session = await this.getAdapter(instanceId).createSession(workspace, agent, title);
|
|
1404
|
+
} catch (error) {
|
|
1405
|
+
const message = error instanceof Error ? error.message : "Unable to create session";
|
|
1406
|
+
if (/workingDir does not exist|does not exist|ENOENT/i.test(message)) {
|
|
1407
|
+
throw new Error(
|
|
1408
|
+
`Workspace path does not exist on that host: ${workspace.canonicalPath}. Use a path that exists on the Codeman machine (for WSL, prefer /home/... or /mnt/...).`
|
|
1409
|
+
);
|
|
1410
|
+
}
|
|
1411
|
+
throw error;
|
|
1412
|
+
}
|
|
1413
|
+
this.state.upsertSession(session);
|
|
1414
|
+
this.appendAudit("session.created", `${instanceId}/${session.id}`, {
|
|
1415
|
+
workspaceId: workspace.id,
|
|
1416
|
+
agentId
|
|
1417
|
+
});
|
|
1418
|
+
return session;
|
|
1419
|
+
}
|
|
1420
|
+
async sendInput(instanceId, sessionId, input, raw = false) {
|
|
1421
|
+
this.assertSession(instanceId, sessionId);
|
|
1422
|
+
await this.getAdapter(instanceId).sendInput(sessionId, input, raw);
|
|
1423
|
+
}
|
|
1424
|
+
async resizeSession(instanceId, sessionId, cols, rows) {
|
|
1425
|
+
this.assertSession(instanceId, sessionId);
|
|
1426
|
+
await this.getAdapter(instanceId).resize(sessionId, cols, rows);
|
|
1427
|
+
}
|
|
1428
|
+
async proxyTerminal(instanceId, sessionId, request, clientSocket, head) {
|
|
1429
|
+
const adapter = this.getAdapter(instanceId);
|
|
1430
|
+
if (!this.listSessions().some(
|
|
1431
|
+
(session) => session.instanceId === instanceId && session.id === sessionId
|
|
1432
|
+
)) {
|
|
1433
|
+
const remoteSession = (await adapter.listSessions()).find((session) => session.id === sessionId);
|
|
1434
|
+
if (!remoteSession) throw new Error(`Unknown session: ${instanceId}/${sessionId}`);
|
|
1435
|
+
this.state.upsertSession(remoteSession);
|
|
1436
|
+
}
|
|
1437
|
+
const proxyTerminal = adapter.proxyTerminal;
|
|
1438
|
+
if (!proxyTerminal) {
|
|
1439
|
+
throw new Error(`Codeman WebSocket proxy is unavailable: ${instanceId}`);
|
|
1440
|
+
}
|
|
1441
|
+
await proxyTerminal.call(
|
|
1442
|
+
adapter,
|
|
1443
|
+
sessionId,
|
|
1444
|
+
request,
|
|
1445
|
+
clientSocket,
|
|
1446
|
+
head
|
|
1447
|
+
);
|
|
1448
|
+
}
|
|
1449
|
+
async proxyCodemanRequest(instanceId, request, response) {
|
|
1450
|
+
const adapter = this.getAdapter(instanceId);
|
|
1451
|
+
const proxyHttp = adapter.proxyHttp;
|
|
1452
|
+
if (!proxyHttp) {
|
|
1453
|
+
throw new Error(`Codeman HTTP proxy is unavailable: ${instanceId}`);
|
|
1454
|
+
}
|
|
1455
|
+
await proxyHttp.call(adapter, request, response);
|
|
1456
|
+
}
|
|
1457
|
+
/**
|
|
1458
|
+
* Prepare a Tailscale Codeman host for the native UI: seed Cases from Hub
|
|
1459
|
+
* workspaces so Codeman Quick Start can create sessions.
|
|
1460
|
+
*/
|
|
1461
|
+
async prepareNativeUi(instanceId) {
|
|
1462
|
+
const instance = this.getInstance(instanceId);
|
|
1463
|
+
if (instance.connectionMode !== "tailscale-url") {
|
|
1464
|
+
throw new Error(`Native Codeman UI requires a Tailscale host: ${instanceId}`);
|
|
1465
|
+
}
|
|
1466
|
+
const adapter = this.getAdapter(instanceId);
|
|
1467
|
+
await adapter.ensureNativeCases?.();
|
|
1468
|
+
}
|
|
1469
|
+
async stopSession(instanceId, sessionId) {
|
|
1470
|
+
this.assertSession(instanceId, sessionId);
|
|
1471
|
+
await this.getAdapter(instanceId).stopSession(sessionId);
|
|
1472
|
+
const existing = this.state.snapshot().sessions.find(
|
|
1473
|
+
(session) => session.instanceId === instanceId && session.id === sessionId
|
|
1474
|
+
);
|
|
1475
|
+
if (existing) this.state.upsertSession({ ...existing, status: "stopped", lastEventAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
1476
|
+
this.appendAudit("session.stopped", `${instanceId}/${sessionId}`);
|
|
1477
|
+
}
|
|
1478
|
+
removeSession(instanceId, sessionId) {
|
|
1479
|
+
const session = this.getSession(instanceId, sessionId);
|
|
1480
|
+
if (!["completed", "failed", "stopped"].includes(session.status)) {
|
|
1481
|
+
throw new Error("Only stopped sessions can be removed");
|
|
1482
|
+
}
|
|
1483
|
+
this.state.removeSession(instanceId, sessionId);
|
|
1484
|
+
this.appendAudit("session.removed", `${instanceId}/${sessionId}`);
|
|
1485
|
+
}
|
|
1486
|
+
subscribeEvents(listener) {
|
|
1487
|
+
this.listeners.add(listener);
|
|
1488
|
+
return () => this.listeners.delete(listener);
|
|
1489
|
+
}
|
|
1490
|
+
attachAdapter(instance, credentials) {
|
|
1491
|
+
this.adapterUnsubscribers.get(instance.id)?.();
|
|
1492
|
+
this.adapterUnsubscribers.delete(instance.id);
|
|
1493
|
+
this.adapters.delete(instance.id);
|
|
1494
|
+
if (instance.connectionMode === "connector") {
|
|
1495
|
+
if (!instance.nodeId || !this.hasCustomAdapterFactory && (!this.connectorRequest || !this.connectorSubscribeEvents)) return;
|
|
1496
|
+
} else if (!instance.endpoint) {
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
const adapter = this.adapterFactory(
|
|
1500
|
+
instance,
|
|
1501
|
+
credentials ?? this.credentials?.[instance.id]
|
|
1502
|
+
);
|
|
1503
|
+
this.adapters.set(instance.id, adapter);
|
|
1504
|
+
const unsubscribe = adapter.subscribeEvents((event) => {
|
|
1505
|
+
this.updateSessionFromEvent(instance.id, event);
|
|
1506
|
+
for (const listener of this.listeners) listener(event);
|
|
1507
|
+
});
|
|
1508
|
+
this.adapterUnsubscribers.set(instance.id, unsubscribe);
|
|
1509
|
+
}
|
|
1510
|
+
updateSessionFromEvent(instanceId, event) {
|
|
1511
|
+
const existing = this.state.snapshot().sessions.find(
|
|
1512
|
+
(session) => session.instanceId === instanceId && session.id === event.sessionId
|
|
1513
|
+
);
|
|
1514
|
+
if (!existing) return;
|
|
1515
|
+
const status = sessionStatusForEvent(event.event, existing.status);
|
|
1516
|
+
this.state.upsertSession({
|
|
1517
|
+
...existing,
|
|
1518
|
+
status,
|
|
1519
|
+
needsInput: event.event === "waiting" ? true : event.event === "output" || event.event === "started" ? false : existing.needsInput,
|
|
1520
|
+
terminalBuffer: event.data ? `${existing.terminalBuffer}${event.data}` : existing.terminalBuffer,
|
|
1521
|
+
lastEventAt: event.createdAt
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
assertSession(instanceId, sessionId) {
|
|
1525
|
+
const exists = this.state.snapshot().sessions.some(
|
|
1526
|
+
(session) => session.instanceId === instanceId && session.id === sessionId
|
|
1527
|
+
);
|
|
1528
|
+
if (!exists) throw new Error(`Unknown session: ${instanceId}/${sessionId}`);
|
|
1529
|
+
}
|
|
1530
|
+
resolveWorkspacePath(instance, workspacePath) {
|
|
1531
|
+
if (!workspacePath?.trim()) throw new Error("workspaceId or workspacePath is required");
|
|
1532
|
+
const path = workspacePath.trim();
|
|
1533
|
+
const known = instance.workspaces.find((workspace) => workspace.canonicalPath === path);
|
|
1534
|
+
if (known) return requireWorkspace(instance.workspaces, known.id);
|
|
1535
|
+
const policy = instance.workspacePolicy;
|
|
1536
|
+
if (!policy) throw new Error("This instance does not allow arbitrary workspace paths");
|
|
1537
|
+
if (policy.mode !== "system" && !policy.roots.some((root) => isWithinPath(path, root))) {
|
|
1538
|
+
throw new Error("Workspace path is outside the instance policy");
|
|
1539
|
+
}
|
|
1540
|
+
return {
|
|
1541
|
+
id: path,
|
|
1542
|
+
name: path.split(/[\\/]/).filter(Boolean).at(-1) ?? path,
|
|
1543
|
+
canonicalPath: path,
|
|
1544
|
+
health: "ready",
|
|
1545
|
+
providers: { codeman: "ready" }
|
|
1546
|
+
};
|
|
1547
|
+
}
|
|
1548
|
+
appendAudit(type, resourceId, metadata = {}) {
|
|
1549
|
+
this.state.appendAudit({
|
|
1550
|
+
id: `audit_${crypto.randomUUID()}`,
|
|
1551
|
+
type,
|
|
1552
|
+
actor: "operator",
|
|
1553
|
+
resourceId,
|
|
1554
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1555
|
+
metadata
|
|
1556
|
+
});
|
|
1557
|
+
}
|
|
1558
|
+
};
|
|
1559
|
+
function sessionStatusForEvent(event, current) {
|
|
1560
|
+
switch (event) {
|
|
1561
|
+
case "output":
|
|
1562
|
+
return current;
|
|
1563
|
+
case "created":
|
|
1564
|
+
return "starting";
|
|
1565
|
+
case "started":
|
|
1566
|
+
return "working";
|
|
1567
|
+
case "waiting":
|
|
1568
|
+
return "waiting";
|
|
1569
|
+
case "idle":
|
|
1570
|
+
return "idle";
|
|
1571
|
+
case "completed":
|
|
1572
|
+
return "completed";
|
|
1573
|
+
case "failed":
|
|
1574
|
+
return "failed";
|
|
1575
|
+
case "stopped":
|
|
1576
|
+
return "stopped";
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
function requireWorkspace(workspaces, workspaceId) {
|
|
1580
|
+
const workspace = workspaces.find((candidate) => candidate.id === workspaceId);
|
|
1581
|
+
if (!workspace) throw new Error(`Unknown workspace: ${workspaceId}`);
|
|
1582
|
+
if (workspace.health !== "ready") throw new Error(`Workspace is not ready: ${workspaceId}`);
|
|
1583
|
+
return workspace;
|
|
1584
|
+
}
|
|
1585
|
+
function isWithinPath(path, root) {
|
|
1586
|
+
const normalizedPath = normalizePath(path);
|
|
1587
|
+
const normalizedRoot = normalizePath(root);
|
|
1588
|
+
return !normalizedRoot || normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}/`);
|
|
1589
|
+
}
|
|
1590
|
+
function normalizePath(value) {
|
|
1591
|
+
const segments = [];
|
|
1592
|
+
for (const segment of value.replaceAll("\\", "/").split("/")) {
|
|
1593
|
+
if (!segment || segment === ".") continue;
|
|
1594
|
+
if (segment === "..") {
|
|
1595
|
+
segments.pop();
|
|
1596
|
+
} else {
|
|
1597
|
+
segments.push(segment);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
return segments.join("/").toLowerCase();
|
|
1601
|
+
}
|
|
1602
|
+
function defaultAgents() {
|
|
1603
|
+
const modes = [
|
|
1604
|
+
"claude",
|
|
1605
|
+
"shell",
|
|
1606
|
+
"opencode",
|
|
1607
|
+
"codex",
|
|
1608
|
+
"gemini",
|
|
1609
|
+
"antigravity",
|
|
1610
|
+
"pi",
|
|
1611
|
+
"grok",
|
|
1612
|
+
"deepseek"
|
|
1613
|
+
];
|
|
1614
|
+
return modes.map((mode) => ({
|
|
1615
|
+
id: mode,
|
|
1616
|
+
name: mode === "claude" ? "Claude Code" : mode,
|
|
1617
|
+
mode,
|
|
1618
|
+
ready: true
|
|
1619
|
+
}));
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
// apps/control-plane/src/auth.ts
|
|
1623
|
+
import {
|
|
1624
|
+
createHash as createHash2,
|
|
1625
|
+
randomBytes as randomBytes2,
|
|
1626
|
+
scryptSync,
|
|
1627
|
+
timingSafeEqual
|
|
1628
|
+
} from "node:crypto";
|
|
1629
|
+
import {
|
|
1630
|
+
chmodSync as chmodSync2,
|
|
1631
|
+
existsSync,
|
|
1632
|
+
mkdirSync as mkdirSync2,
|
|
1633
|
+
readFileSync as readFileSync2,
|
|
1634
|
+
unlinkSync as unlinkSync2,
|
|
1635
|
+
writeFileSync as writeFileSync2
|
|
1636
|
+
} from "node:fs";
|
|
1637
|
+
import { join } from "node:path";
|
|
1638
|
+
var DEFAULT_SESSION_TTL_MS = 8 * 60 * 60 * 1e3;
|
|
1639
|
+
var DEFAULT_COOKIE_NAME = "hosted_agents_session";
|
|
1640
|
+
var DEFAULT_CSRF_COOKIE_NAME = "hosted_agents_csrf";
|
|
1641
|
+
var SCRYPT_COST = 16384;
|
|
1642
|
+
var SCRYPT_BLOCK_SIZE = 8;
|
|
1643
|
+
var SCRYPT_PARALLELIZATION = 1;
|
|
1644
|
+
var SCRYPT_KEY_LENGTH = 32;
|
|
1645
|
+
var UiAuth = class {
|
|
1646
|
+
enabled;
|
|
1647
|
+
cookieName;
|
|
1648
|
+
csrfCookieName;
|
|
1649
|
+
secureCookies;
|
|
1650
|
+
verifier;
|
|
1651
|
+
sessionTtlMs;
|
|
1652
|
+
sessions = /* @__PURE__ */ new Map();
|
|
1653
|
+
loginAttempts = /* @__PURE__ */ new Map();
|
|
1654
|
+
constructor(options = {}) {
|
|
1655
|
+
const passwordHash = options.passwordHash ?? process.env.HOSTED_AGENTS_UI_PASSWORD_HASH;
|
|
1656
|
+
const password = options.password ?? process.env.HOSTED_AGENTS_UI_PASSWORD;
|
|
1657
|
+
this.verifier = passwordHash ? parsePasswordVerifier(passwordHash) : password ? createPasswordVerifier(password) : void 0;
|
|
1658
|
+
this.enabled = this.verifier !== void 0;
|
|
1659
|
+
this.sessionTtlMs = options.sessionTtlMs ?? Number(process.env.HOSTED_AGENTS_UI_SESSION_TTL_MS ?? DEFAULT_SESSION_TTL_MS);
|
|
1660
|
+
this.cookieName = options.cookieName ?? DEFAULT_COOKIE_NAME;
|
|
1661
|
+
this.csrfCookieName = options.csrfCookieName ?? DEFAULT_CSRF_COOKIE_NAME;
|
|
1662
|
+
this.secureCookies = options.secureCookies ?? process.env.HOSTED_AGENTS_COOKIE_SECURE === "true";
|
|
1663
|
+
}
|
|
1664
|
+
authenticate(request, bearerToken) {
|
|
1665
|
+
if (bearerToken && request.headers.authorization === `Bearer ${bearerToken}`) {
|
|
1666
|
+
return { authenticated: true, method: "bearer" };
|
|
1667
|
+
}
|
|
1668
|
+
if (!this.enabled) return { authenticated: true, method: "none" };
|
|
1669
|
+
const cookies = parseCookies(request.headers.cookie);
|
|
1670
|
+
const sessionToken = cookies[this.cookieName];
|
|
1671
|
+
if (!sessionToken) return { authenticated: false, method: "none" };
|
|
1672
|
+
const session = this.sessions.get(sessionToken);
|
|
1673
|
+
if (!session || session.expiresAt <= Date.now()) {
|
|
1674
|
+
if (session) this.sessions.delete(sessionToken);
|
|
1675
|
+
return { authenticated: false, method: "none" };
|
|
1676
|
+
}
|
|
1677
|
+
return {
|
|
1678
|
+
authenticated: true,
|
|
1679
|
+
method: "ui",
|
|
1680
|
+
sessionToken,
|
|
1681
|
+
csrfToken: session.csrfToken
|
|
1682
|
+
};
|
|
1683
|
+
}
|
|
1684
|
+
canAttemptLogin(clientKey) {
|
|
1685
|
+
const attempt = this.loginAttempts.get(clientKey);
|
|
1686
|
+
return !attempt || attempt.blockedUntil <= Date.now();
|
|
1687
|
+
}
|
|
1688
|
+
verifyPassword(clientKey, password) {
|
|
1689
|
+
if (!this.enabled || !this.verifier || !this.canAttemptLogin(clientKey)) return false;
|
|
1690
|
+
if (verifyPassword(password, this.verifier)) {
|
|
1691
|
+
this.loginAttempts.delete(clientKey);
|
|
1692
|
+
return true;
|
|
1693
|
+
}
|
|
1694
|
+
const now = Date.now();
|
|
1695
|
+
const current = this.loginAttempts.get(clientKey);
|
|
1696
|
+
const attempt = current && now - current.firstFailureAt < 15 * 6e4 ? current : { failures: 0, firstFailureAt: now, blockedUntil: 0 };
|
|
1697
|
+
attempt.failures += 1;
|
|
1698
|
+
attempt.blockedUntil = attempt.failures >= 10 ? now + 15 * 6e4 : 0;
|
|
1699
|
+
this.loginAttempts.set(clientKey, attempt);
|
|
1700
|
+
return false;
|
|
1701
|
+
}
|
|
1702
|
+
createSession() {
|
|
1703
|
+
if (!this.enabled) throw new Error("UI password authentication is disabled");
|
|
1704
|
+
const token = randomToken();
|
|
1705
|
+
const csrfToken = randomToken();
|
|
1706
|
+
const expiresAt = Date.now() + this.sessionTtlMs;
|
|
1707
|
+
this.sessions.set(token, { csrfToken, expiresAt });
|
|
1708
|
+
this.pruneSessions();
|
|
1709
|
+
return { token, csrfToken, expiresAt };
|
|
1710
|
+
}
|
|
1711
|
+
revoke(request) {
|
|
1712
|
+
const token = parseCookies(request.headers.cookie)[this.cookieName];
|
|
1713
|
+
if (token) this.sessions.delete(token);
|
|
1714
|
+
}
|
|
1715
|
+
isCsrfValid(request, auth) {
|
|
1716
|
+
if (!this.enabled || auth.method !== "ui") return true;
|
|
1717
|
+
const origin = request.headers.origin;
|
|
1718
|
+
if (origin) return sameOrigin(origin, request);
|
|
1719
|
+
const referer = request.headers.referer;
|
|
1720
|
+
if (typeof referer === "string") {
|
|
1721
|
+
try {
|
|
1722
|
+
if (sameOrigin(new URL(referer).origin, request)) return true;
|
|
1723
|
+
} catch {
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
const header = request.headers["x-csrf-token"];
|
|
1727
|
+
return typeof header === "string" && typeof auth.csrfToken === "string" && safeEqual(header, auth.csrfToken);
|
|
1728
|
+
}
|
|
1729
|
+
isOriginValid(request) {
|
|
1730
|
+
const origin = request.headers.origin;
|
|
1731
|
+
return !origin || sameOrigin(origin, request);
|
|
1732
|
+
}
|
|
1733
|
+
sessionCookie(token, request) {
|
|
1734
|
+
return serializeCookie(
|
|
1735
|
+
this.cookieName,
|
|
1736
|
+
token,
|
|
1737
|
+
this.cookieOptions(request, true)
|
|
1738
|
+
);
|
|
1739
|
+
}
|
|
1740
|
+
csrfCookie(token, request) {
|
|
1741
|
+
return serializeCookie(
|
|
1742
|
+
this.csrfCookieName,
|
|
1743
|
+
token,
|
|
1744
|
+
this.cookieOptions(request, false)
|
|
1745
|
+
);
|
|
1746
|
+
}
|
|
1747
|
+
clearCookies(request) {
|
|
1748
|
+
return [
|
|
1749
|
+
serializeCookie(this.cookieName, "", {
|
|
1750
|
+
...this.cookieOptions(request, true),
|
|
1751
|
+
maxAge: 0
|
|
1752
|
+
}),
|
|
1753
|
+
serializeCookie(this.csrfCookieName, "", {
|
|
1754
|
+
...this.cookieOptions(request, false),
|
|
1755
|
+
maxAge: 0
|
|
1756
|
+
})
|
|
1757
|
+
];
|
|
1758
|
+
}
|
|
1759
|
+
getCsrfToken(request, auth) {
|
|
1760
|
+
if (!this.enabled || auth.method !== "ui") return void 0;
|
|
1761
|
+
return auth.csrfToken;
|
|
1762
|
+
}
|
|
1763
|
+
cookieOptions(request, httpOnly) {
|
|
1764
|
+
const forwardedProto = request.headers["x-forwarded-proto"];
|
|
1765
|
+
const isHttps = this.secureCookies || forwardedProto === "https" || request.socket.encrypted === true;
|
|
1766
|
+
return {
|
|
1767
|
+
httpOnly,
|
|
1768
|
+
secure: isHttps,
|
|
1769
|
+
sameSite: "Lax",
|
|
1770
|
+
path: "/",
|
|
1771
|
+
maxAge: Math.floor(this.sessionTtlMs / 1e3)
|
|
1772
|
+
};
|
|
1773
|
+
}
|
|
1774
|
+
pruneSessions() {
|
|
1775
|
+
const now = Date.now();
|
|
1776
|
+
for (const [token, session] of this.sessions) {
|
|
1777
|
+
if (session.expiresAt <= now) this.sessions.delete(token);
|
|
1778
|
+
}
|
|
1779
|
+
if (this.sessions.size <= 1e3) return;
|
|
1780
|
+
const oldest = [...this.sessions.entries()].sort((left, right) => left[1].expiresAt - right[1].expiresAt).slice(0, this.sessions.size - 1e3);
|
|
1781
|
+
for (const [token] of oldest) this.sessions.delete(token);
|
|
1782
|
+
}
|
|
1783
|
+
};
|
|
1784
|
+
function createPasswordVerifier(password) {
|
|
1785
|
+
const salt = randomBytes2(16);
|
|
1786
|
+
return {
|
|
1787
|
+
salt,
|
|
1788
|
+
hash: scryptSync(password, salt, SCRYPT_KEY_LENGTH, {
|
|
1789
|
+
N: SCRYPT_COST,
|
|
1790
|
+
r: SCRYPT_BLOCK_SIZE,
|
|
1791
|
+
p: SCRYPT_PARALLELIZATION
|
|
1792
|
+
}),
|
|
1793
|
+
cost: SCRYPT_COST,
|
|
1794
|
+
blockSize: SCRYPT_BLOCK_SIZE,
|
|
1795
|
+
parallelization: SCRYPT_PARALLELIZATION
|
|
1796
|
+
};
|
|
1797
|
+
}
|
|
1798
|
+
function parsePasswordVerifier(encoded) {
|
|
1799
|
+
const [algorithm, cost, blockSize, parallelization, saltText, hashText] = encoded.split("$");
|
|
1800
|
+
if (algorithm !== "scrypt" || !Number.isInteger(Number(cost)) || !Number.isInteger(Number(blockSize)) || !Number.isInteger(Number(parallelization)) || !saltText || !hashText) {
|
|
1801
|
+
throw new Error("HOSTED_AGENTS_UI_PASSWORD_HASH must use scrypt$N$r$p$salt$hash format");
|
|
1802
|
+
}
|
|
1803
|
+
const salt = Buffer.from(saltText, "base64url");
|
|
1804
|
+
const hash = Buffer.from(hashText, "base64url");
|
|
1805
|
+
if (salt.length < 16 || hash.length !== SCRYPT_KEY_LENGTH) {
|
|
1806
|
+
throw new Error("HOSTED_AGENTS_UI_PASSWORD_HASH has invalid salt or hash");
|
|
1807
|
+
}
|
|
1808
|
+
return {
|
|
1809
|
+
salt,
|
|
1810
|
+
hash,
|
|
1811
|
+
cost: Number(cost),
|
|
1812
|
+
blockSize: Number(blockSize),
|
|
1813
|
+
parallelization: Number(parallelization)
|
|
1814
|
+
};
|
|
1815
|
+
}
|
|
1816
|
+
function verifyPassword(password, verifier) {
|
|
1817
|
+
const actual = scryptSync(password, verifier.salt, verifier.hash.length, {
|
|
1818
|
+
N: verifier.cost,
|
|
1819
|
+
r: verifier.blockSize,
|
|
1820
|
+
p: verifier.parallelization
|
|
1821
|
+
});
|
|
1822
|
+
return safeEqual(actual, verifier.hash);
|
|
1823
|
+
}
|
|
1824
|
+
function encodePasswordVerifier(verifier) {
|
|
1825
|
+
return [
|
|
1826
|
+
"scrypt",
|
|
1827
|
+
verifier.cost,
|
|
1828
|
+
verifier.blockSize,
|
|
1829
|
+
verifier.parallelization,
|
|
1830
|
+
verifier.salt.toString("base64url"),
|
|
1831
|
+
verifier.hash.toString("base64url")
|
|
1832
|
+
].join("$");
|
|
1833
|
+
}
|
|
1834
|
+
function createUiPasswordHash(password) {
|
|
1835
|
+
return encodePasswordVerifier(createPasswordVerifier(password));
|
|
1836
|
+
}
|
|
1837
|
+
var BOOTSTRAP_AUTH_FILE = "hub-ui-auth.json";
|
|
1838
|
+
function resolveUiAuth(options = {}) {
|
|
1839
|
+
const passwordHash = options.passwordHash ?? process.env.HOSTED_AGENTS_UI_PASSWORD_HASH;
|
|
1840
|
+
const password = options.password ?? process.env.HOSTED_AGENTS_UI_PASSWORD;
|
|
1841
|
+
const common = {
|
|
1842
|
+
sessionTtlMs: options.sessionTtlMs,
|
|
1843
|
+
secureCookies: options.secureCookies
|
|
1844
|
+
};
|
|
1845
|
+
if (passwordHash || password) {
|
|
1846
|
+
if (options.dataDir) clearBootstrapPlaintext(options.dataDir);
|
|
1847
|
+
return {
|
|
1848
|
+
auth: new UiAuth({
|
|
1849
|
+
...common,
|
|
1850
|
+
passwordHash,
|
|
1851
|
+
password
|
|
1852
|
+
})
|
|
1853
|
+
};
|
|
1854
|
+
}
|
|
1855
|
+
if (!options.dataDir) {
|
|
1856
|
+
return { auth: new UiAuth(common) };
|
|
1857
|
+
}
|
|
1858
|
+
const bootstrap = loadOrCreateBootstrapAuth(options.dataDir);
|
|
1859
|
+
return {
|
|
1860
|
+
auth: new UiAuth({
|
|
1861
|
+
...common,
|
|
1862
|
+
passwordHash: bootstrap.hash
|
|
1863
|
+
}),
|
|
1864
|
+
revealPassword: bootstrap.password
|
|
1865
|
+
};
|
|
1866
|
+
}
|
|
1867
|
+
function bootstrapAuthPath(dataDir) {
|
|
1868
|
+
return join(dataDir, BOOTSTRAP_AUTH_FILE);
|
|
1869
|
+
}
|
|
1870
|
+
function loadOrCreateBootstrapAuth(dataDir) {
|
|
1871
|
+
mkdirSync2(dataDir, { recursive: true });
|
|
1872
|
+
const path = bootstrapAuthPath(dataDir);
|
|
1873
|
+
if (existsSync(path)) {
|
|
1874
|
+
const raw = JSON.parse(readFileSync2(path, "utf8"));
|
|
1875
|
+
if (typeof raw.hash !== "string" || raw.hash.length === 0) {
|
|
1876
|
+
throw new Error(`${BOOTSTRAP_AUTH_FILE} is missing a password hash`);
|
|
1877
|
+
}
|
|
1878
|
+
parsePasswordVerifier(raw.hash);
|
|
1879
|
+
if (typeof raw.password === "string" && raw.password.length > 0) {
|
|
1880
|
+
return { hash: raw.hash, password: raw.password };
|
|
1881
|
+
}
|
|
1882
|
+
return { hash: raw.hash };
|
|
1883
|
+
}
|
|
1884
|
+
const password = randomBytes2(18).toString("base64url");
|
|
1885
|
+
const hash = createUiPasswordHash(password);
|
|
1886
|
+
writeBootstrapAuth(dataDir, { hash, password });
|
|
1887
|
+
return { hash, password };
|
|
1888
|
+
}
|
|
1889
|
+
function writeBootstrapAuth(dataDir, value) {
|
|
1890
|
+
mkdirSync2(dataDir, { recursive: true });
|
|
1891
|
+
const path = bootstrapAuthPath(dataDir);
|
|
1892
|
+
writeFileSync2(path, `${JSON.stringify(value, null, 2)}
|
|
1893
|
+
`, { encoding: "utf8", mode: 384 });
|
|
1894
|
+
try {
|
|
1895
|
+
chmodSync2(path, 384);
|
|
1896
|
+
} catch {
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
function clearBootstrapPlaintext(dataDir) {
|
|
1900
|
+
const path = bootstrapAuthPath(dataDir);
|
|
1901
|
+
if (!existsSync(path)) return;
|
|
1902
|
+
try {
|
|
1903
|
+
const raw = JSON.parse(readFileSync2(path, "utf8"));
|
|
1904
|
+
if (typeof raw.hash === "string" && raw.hash.length > 0 && raw.password) {
|
|
1905
|
+
writeBootstrapAuth(dataDir, { hash: raw.hash });
|
|
1906
|
+
return;
|
|
1907
|
+
}
|
|
1908
|
+
if (raw.password) unlinkSync2(path);
|
|
1909
|
+
} catch {
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
function randomToken() {
|
|
1913
|
+
return randomBytes2(32).toString("base64url");
|
|
1914
|
+
}
|
|
1915
|
+
function serializeCookie(name, value, options) {
|
|
1916
|
+
return [
|
|
1917
|
+
`${name}=${encodeURIComponent(value)}`,
|
|
1918
|
+
`Path=${options.path}`,
|
|
1919
|
+
`Max-Age=${options.maxAge}`,
|
|
1920
|
+
`SameSite=${options.sameSite}`,
|
|
1921
|
+
options.httpOnly ? "HttpOnly" : "",
|
|
1922
|
+
options.secure ? "Secure" : ""
|
|
1923
|
+
].filter(Boolean).join("; ");
|
|
1924
|
+
}
|
|
1925
|
+
function parseCookies(header) {
|
|
1926
|
+
const cookies = {};
|
|
1927
|
+
for (const item of header?.split(";") ?? []) {
|
|
1928
|
+
const separator = item.indexOf("=");
|
|
1929
|
+
if (separator < 1) continue;
|
|
1930
|
+
const name = item.slice(0, separator).trim();
|
|
1931
|
+
const value = item.slice(separator + 1).trim();
|
|
1932
|
+
try {
|
|
1933
|
+
cookies[name] = decodeURIComponent(value);
|
|
1934
|
+
} catch {
|
|
1935
|
+
cookies[name] = value;
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
return cookies;
|
|
1939
|
+
}
|
|
1940
|
+
function safeEqual(left, right) {
|
|
1941
|
+
const leftBuffer = typeof left === "string" ? createHash2("sha256").update(left).digest() : left;
|
|
1942
|
+
const rightBuffer = typeof right === "string" ? createHash2("sha256").update(right).digest() : right;
|
|
1943
|
+
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
|
|
1944
|
+
}
|
|
1945
|
+
function sameOrigin(origin, request) {
|
|
1946
|
+
try {
|
|
1947
|
+
const parsed = new URL(origin);
|
|
1948
|
+
const forwardedHost = request.headers["x-forwarded-host"];
|
|
1949
|
+
const host = typeof forwardedHost === "string" ? forwardedHost.split(",")[0].trim() : request.headers.host;
|
|
1950
|
+
return parsed.host === host;
|
|
1951
|
+
} catch {
|
|
1952
|
+
return false;
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
// apps/control-plane/src/server.ts
|
|
1957
|
+
var MAX_BODY_BYTES = 1048576;
|
|
1958
|
+
var CODEMAN_INSTANCE_COOKIE = "hosted_agents_codeman_instance";
|
|
1959
|
+
function resolveDashboardRoot() {
|
|
1960
|
+
const here = dirname2(fileURLToPath(import.meta.url));
|
|
1961
|
+
const candidates = [];
|
|
1962
|
+
try {
|
|
1963
|
+
const require2 = createRequire(import.meta.url);
|
|
1964
|
+
const packageRoot = dirname2(require2.resolve("@cloudagentfleet/ui/package.json"));
|
|
1965
|
+
candidates.push(join2(packageRoot, "dist", "public"), join2(packageRoot, "public"));
|
|
1966
|
+
} catch {
|
|
1967
|
+
}
|
|
1968
|
+
candidates.push(
|
|
1969
|
+
resolve(here, "../../dashboard/dist/public"),
|
|
1970
|
+
resolve(here, "../../dashboard/public")
|
|
1971
|
+
);
|
|
1972
|
+
for (const candidate of candidates) {
|
|
1973
|
+
if (existsSync2(candidate)) return candidate;
|
|
1974
|
+
}
|
|
1975
|
+
throw new Error("Unable to resolve @cloudagentfleet/ui public assets");
|
|
1976
|
+
}
|
|
1977
|
+
var DASHBOARD_ROOT = resolveDashboardRoot();
|
|
1978
|
+
function startControllerServer(options = {}) {
|
|
1979
|
+
const controller = createControllerServer(options);
|
|
1980
|
+
controller.server.listen(controller.port, controller.host, () => {
|
|
1981
|
+
console.log(`Hosted Agents Controller listening on ${controller.host}:${controller.port}`);
|
|
1982
|
+
if (controller.revealPassword) {
|
|
1983
|
+
console.log(`Hub UI password: ${controller.revealPassword}`);
|
|
1984
|
+
console.log(
|
|
1985
|
+
"Shown on every start until you set HOSTED_AGENTS_UI_PASSWORD or HOSTED_AGENTS_UI_PASSWORD_HASH."
|
|
1986
|
+
);
|
|
1987
|
+
}
|
|
1988
|
+
});
|
|
1989
|
+
return controller;
|
|
1990
|
+
}
|
|
1991
|
+
function createControllerServer(options = {}) {
|
|
1992
|
+
const host = options.host ?? process.env.HOSTED_AGENTS_HOST ?? "127.0.0.1";
|
|
1993
|
+
const port = options.port ?? Number(process.env.HOSTED_AGENTS_PORT ?? 8787);
|
|
1994
|
+
const authToken = options.authToken ?? process.env.HOSTED_AGENTS_AUTH_TOKEN;
|
|
1995
|
+
const statePath = options.statePath ?? process.env.HOSTED_AGENTS_DATA_PATH ?? "data/hub.json";
|
|
1996
|
+
const dataDir = statePath ? dirname2(resolve(statePath)) : void 0;
|
|
1997
|
+
const resolvedAuth = resolveUiAuth({
|
|
1998
|
+
dataDir,
|
|
1999
|
+
password: options.uiPassword,
|
|
2000
|
+
passwordHash: options.uiPasswordHash,
|
|
2001
|
+
sessionTtlMs: options.uiSessionTtlMs,
|
|
2002
|
+
secureCookies: options.uiCookieSecure
|
|
2003
|
+
});
|
|
2004
|
+
const uiAuth = resolvedAuth.auth;
|
|
2005
|
+
const controlPlane = options.controlPlane ?? new ControlPlane();
|
|
2006
|
+
const hub = options.hub ?? new CodemanHub({
|
|
2007
|
+
statePath,
|
|
2008
|
+
credentials: options.codemanCredentials ?? readCodemanCredentials(),
|
|
2009
|
+
connectorRequest: (nodeId, instanceId, operation, payload) => controlPlane.requestConnector(nodeId, instanceId, operation, payload),
|
|
2010
|
+
connectorSubscribeEvents: (nodeId, instanceId, listener) => controlPlane.subscribeConnectorEvents(nodeId, instanceId, listener)
|
|
2011
|
+
});
|
|
2012
|
+
if (!isLoopback(host) && !authToken) {
|
|
2013
|
+
throw new Error("A bearer token is required when the Controller binds beyond loopback");
|
|
2014
|
+
}
|
|
2015
|
+
const server = createServer(async (request, response) => {
|
|
2016
|
+
try {
|
|
2017
|
+
const url = new URL(request.url ?? "/", "http://controller.local");
|
|
2018
|
+
const auth = uiAuth.authenticate(request, authToken);
|
|
2019
|
+
const isHealthCheck = request.method === "GET" && url.pathname === "/healthz";
|
|
2020
|
+
const isLoginRoute = url.pathname === "/api/auth/login";
|
|
2021
|
+
const isSessionRoute = url.pathname === "/api/auth/session";
|
|
2022
|
+
const isLogoutRoute = url.pathname === "/api/auth/logout";
|
|
2023
|
+
if (!isHealthCheck && !isLoginRoute && !isSessionRoute && !auth.authenticated) {
|
|
2024
|
+
if (request.method === "GET" && !url.pathname.startsWith("/api/")) {
|
|
2025
|
+
sendLoginPage(response, uiAuth.enabled);
|
|
2026
|
+
} else {
|
|
2027
|
+
sendJson(response, 401, { error: "Authentication required" });
|
|
2028
|
+
}
|
|
2029
|
+
return;
|
|
2030
|
+
}
|
|
2031
|
+
if (isLogoutRoute && uiAuth.enabled && !auth.authenticated) {
|
|
2032
|
+
sendJson(response, 401, { error: "Unauthorized" });
|
|
2033
|
+
return;
|
|
2034
|
+
}
|
|
2035
|
+
const selectedForCsrf = selectedInstance(request);
|
|
2036
|
+
const proxiedCodemanMutation = Boolean(
|
|
2037
|
+
selectedForCsrf && request.method !== "GET" && request.method !== "HEAD" && isCodemanProxiedApiPath(url.pathname)
|
|
2038
|
+
);
|
|
2039
|
+
if (auth.authenticated && request.method !== "GET" && request.method !== "HEAD" && !proxiedCodemanMutation && !uiAuth.isCsrfValid(request, auth)) {
|
|
2040
|
+
sendJson(response, 403, { error: "Cross-site request blocked" });
|
|
2041
|
+
return;
|
|
2042
|
+
}
|
|
2043
|
+
if (auth.authenticated && uiAuth.enabled && isWorkerOrJobRoute(url.pathname) && auth.method === "ui" && authToken) {
|
|
2044
|
+
sendJson(response, 403, { error: "Worker credentials are required" });
|
|
2045
|
+
return;
|
|
2046
|
+
}
|
|
2047
|
+
await route(request, response, controlPlane, hub, uiAuth, auth);
|
|
2048
|
+
} catch (error) {
|
|
2049
|
+
const message = error instanceof Error ? error.message : "Request failed";
|
|
2050
|
+
const status = error instanceof HttpError ? error.status : message.startsWith("Unknown Codeman instance") || message.startsWith("Unknown session") ? 404 : message.startsWith("Codeman instance is not connected") ? 503 : 400;
|
|
2051
|
+
sendJson(response, status, { error: message });
|
|
2052
|
+
}
|
|
2053
|
+
});
|
|
2054
|
+
server.on("upgrade", (request, socket, head) => {
|
|
2055
|
+
void handleTerminalUpgrade(request, socket, head, hub, uiAuth, authToken);
|
|
2056
|
+
});
|
|
2057
|
+
return { server, controlPlane, hub, host, port, revealPassword: resolvedAuth.revealPassword };
|
|
2058
|
+
}
|
|
2059
|
+
async function handleTerminalUpgrade(request, socket, head, hub, uiAuth, authToken) {
|
|
2060
|
+
const url = new URL(request.url ?? "/", "http://controller.local");
|
|
2061
|
+
const explicitMatch = url.pathname.match(
|
|
2062
|
+
/^\/ws\/instances\/([^/]+)\/sessions\/([^/]+)\/terminal$/
|
|
2063
|
+
);
|
|
2064
|
+
const nativeMatch = url.pathname.match(/^\/ws\/sessions\/([^/]+)\/terminal$/);
|
|
2065
|
+
if (!explicitMatch && !nativeMatch) {
|
|
2066
|
+
socket.destroy();
|
|
2067
|
+
return;
|
|
2068
|
+
}
|
|
2069
|
+
const auth = uiAuth.authenticate(request, authToken);
|
|
2070
|
+
if (!auth.authenticated || !uiAuth.isOriginValid(request) || typeof request.headers.upgrade !== "string" || request.headers.upgrade.toLowerCase() !== "websocket") {
|
|
2071
|
+
rejectUpgrade(socket, 401, "Unauthorized");
|
|
2072
|
+
return;
|
|
2073
|
+
}
|
|
2074
|
+
const instanceId = explicitMatch ? decodeURIComponent(explicitMatch[1]) : selectedInstance(request);
|
|
2075
|
+
const sessionId = explicitMatch ? decodeURIComponent(explicitMatch[2]) : decodeURIComponent(nativeMatch[1]);
|
|
2076
|
+
if (!instanceId) {
|
|
2077
|
+
rejectUpgrade(socket, 400, "A Codeman host must be selected");
|
|
2078
|
+
return;
|
|
2079
|
+
}
|
|
2080
|
+
try {
|
|
2081
|
+
await hub.proxyTerminal(instanceId, sessionId, request, socket, head);
|
|
2082
|
+
} catch (error) {
|
|
2083
|
+
rejectUpgrade(
|
|
2084
|
+
socket,
|
|
2085
|
+
502,
|
|
2086
|
+
error instanceof Error ? error.message : "Terminal proxy unavailable"
|
|
2087
|
+
);
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
async function route(request, response, controlPlane, hub, uiAuth, auth) {
|
|
2091
|
+
const method = request.method ?? "GET";
|
|
2092
|
+
const url = new URL(request.url ?? "/", "http://controller.local");
|
|
2093
|
+
if (method === "GET" && url.pathname === "/healthz") {
|
|
2094
|
+
sendJson(response, 200, {
|
|
2095
|
+
ok: true,
|
|
2096
|
+
role: "controller",
|
|
2097
|
+
execution: "codeman-native-proxy-with-worker-fallback"
|
|
2098
|
+
});
|
|
2099
|
+
return;
|
|
2100
|
+
}
|
|
2101
|
+
if (method === "GET" && url.pathname === "/api/auth/session") {
|
|
2102
|
+
sendJson(response, 200, {
|
|
2103
|
+
enabled: uiAuth.enabled,
|
|
2104
|
+
authenticated: auth.authenticated,
|
|
2105
|
+
...uiAuth.getCsrfToken(request, auth) ? { csrfToken: uiAuth.getCsrfToken(request, auth) } : {}
|
|
2106
|
+
});
|
|
2107
|
+
return;
|
|
2108
|
+
}
|
|
2109
|
+
if (method === "POST" && url.pathname === "/api/auth/login") {
|
|
2110
|
+
const body = await readJson(request);
|
|
2111
|
+
const clientKey = clientAddress(request);
|
|
2112
|
+
if (!uiAuth.enabled) {
|
|
2113
|
+
sendJson(response, 400, { error: "UI password authentication is disabled" });
|
|
2114
|
+
return;
|
|
2115
|
+
}
|
|
2116
|
+
if (typeof body.password !== "string" || !uiAuth.verifyPassword(clientKey, body.password)) {
|
|
2117
|
+
sendJson(response, 401, { error: "Invalid password" });
|
|
2118
|
+
return;
|
|
2119
|
+
}
|
|
2120
|
+
const session = uiAuth.createSession();
|
|
2121
|
+
response.setHeader("set-cookie", [
|
|
2122
|
+
uiAuth.sessionCookie(session.token, request),
|
|
2123
|
+
uiAuth.csrfCookie(session.csrfToken, request)
|
|
2124
|
+
]);
|
|
2125
|
+
sendJson(response, 200, {
|
|
2126
|
+
authenticated: true,
|
|
2127
|
+
expiresAt: new Date(session.expiresAt).toISOString()
|
|
2128
|
+
});
|
|
2129
|
+
return;
|
|
2130
|
+
}
|
|
2131
|
+
if (method === "POST" && url.pathname === "/api/auth/logout") {
|
|
2132
|
+
uiAuth.revoke(request);
|
|
2133
|
+
response.setHeader("set-cookie", [
|
|
2134
|
+
...uiAuth.clearCookies(request),
|
|
2135
|
+
`${CODEMAN_INSTANCE_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax${isSecureRequest(request) ? "; Secure" : ""}`
|
|
2136
|
+
]);
|
|
2137
|
+
sendJson(response, 204, {});
|
|
2138
|
+
return;
|
|
2139
|
+
}
|
|
2140
|
+
const selectedInstanceId = selectedInstance(request);
|
|
2141
|
+
const fleetSelectMatch = url.pathname.match(/^\/api\/fleet\/select\/([^/]+)$/);
|
|
2142
|
+
if (method === "GET" && fleetSelectMatch) {
|
|
2143
|
+
const instanceId = decodeURIComponent(fleetSelectMatch[1]);
|
|
2144
|
+
const instance = hub.getInstance(instanceId);
|
|
2145
|
+
if (instance.connectionMode !== "tailscale-url" || !instance.endpoint) {
|
|
2146
|
+
throw new HttpError(400, "This instance is not available through the tailnet proxy");
|
|
2147
|
+
}
|
|
2148
|
+
try {
|
|
2149
|
+
await hub.prepareNativeUi(instanceId);
|
|
2150
|
+
} catch {
|
|
2151
|
+
}
|
|
2152
|
+
response.setHeader(
|
|
2153
|
+
"set-cookie",
|
|
2154
|
+
`${CODEMAN_INSTANCE_COOKIE}=${encodeURIComponent(instanceId)}; Path=/; Max-Age=86400; SameSite=Lax${isSecureRequest(request) ? "; Secure" : ""}`
|
|
2155
|
+
);
|
|
2156
|
+
response.writeHead(303, { location: "/" });
|
|
2157
|
+
response.end();
|
|
2158
|
+
return;
|
|
2159
|
+
}
|
|
2160
|
+
if (method === "GET" && url.pathname === "/api/fleet/clear") {
|
|
2161
|
+
response.setHeader(
|
|
2162
|
+
"set-cookie",
|
|
2163
|
+
`${CODEMAN_INSTANCE_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax${isSecureRequest(request) ? "; Secure" : ""}`
|
|
2164
|
+
);
|
|
2165
|
+
response.writeHead(303, { location: "/" });
|
|
2166
|
+
response.end();
|
|
2167
|
+
return;
|
|
2168
|
+
}
|
|
2169
|
+
if (method === "GET" && url.pathname === "/api/fleet/current") {
|
|
2170
|
+
sendJson(response, 200, {
|
|
2171
|
+
instanceId: selectedInstanceId,
|
|
2172
|
+
instance: selectedInstanceId ? publicInstance(hub.getInstance(selectedInstanceId)) : null
|
|
2173
|
+
});
|
|
2174
|
+
return;
|
|
2175
|
+
}
|
|
2176
|
+
if (method === "GET" && url.pathname === "/fleet-host-switcher.js") {
|
|
2177
|
+
await serveDashboardAsset("/fleet-host-switcher.js", response);
|
|
2178
|
+
return;
|
|
2179
|
+
}
|
|
2180
|
+
if (method === "GET" && !url.pathname.startsWith("/api/")) {
|
|
2181
|
+
if (selectedInstanceId) {
|
|
2182
|
+
await hub.proxyCodemanRequest(selectedInstanceId, request, response);
|
|
2183
|
+
return;
|
|
2184
|
+
}
|
|
2185
|
+
await serveDashboardAsset(url.pathname, response);
|
|
2186
|
+
return;
|
|
2187
|
+
}
|
|
2188
|
+
if (method === "GET" && url.pathname === "/api/instances") {
|
|
2189
|
+
sendJson(response, 200, { instances: hub.listInstances().map(publicInstance) });
|
|
2190
|
+
return;
|
|
2191
|
+
}
|
|
2192
|
+
if (method === "POST" && url.pathname === "/api/instances") {
|
|
2193
|
+
const body = await readJson(request);
|
|
2194
|
+
const credentials = body.credentials;
|
|
2195
|
+
const registration = { ...body };
|
|
2196
|
+
delete registration.credentials;
|
|
2197
|
+
if (credentials !== void 0 && (typeof credentials.username !== "string" || credentials.username.length === 0 || typeof credentials.password !== "string")) {
|
|
2198
|
+
throw new HttpError(400, "Codeman credentials must include a username and password");
|
|
2199
|
+
}
|
|
2200
|
+
if (registration.connectionMode === "tailscale-url" && (!registration.endpoint || !isTailnetEndpoint(registration.endpoint) && !isLoopbackEndpoint(registration.endpoint))) {
|
|
2201
|
+
throw new HttpError(400, "Codeman endpoint must be a Tailscale HTTPS hostname or loopback development URL");
|
|
2202
|
+
}
|
|
2203
|
+
if (registration.connectionMode === "connector") {
|
|
2204
|
+
const node = controlPlane.nodes.list().find((candidate) => candidate.id === registration.nodeId);
|
|
2205
|
+
if (!node) throw new HttpError(404, `Unknown node: ${registration.nodeId}`);
|
|
2206
|
+
if (node.status === "revoked") throw new HttpError(403, `Node is revoked: ${node.id}`);
|
|
2207
|
+
if (!node.capabilities.includes("codeman")) {
|
|
2208
|
+
throw new HttpError(400, `Node does not support Codeman: ${node.id}`);
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
const instance = hub.registerInstance(
|
|
2212
|
+
registration,
|
|
2213
|
+
credentials ? { username: credentials.username, password: credentials.password } : void 0
|
|
2214
|
+
);
|
|
2215
|
+
sendJson(response, 201, { instance: publicInstance(instance) });
|
|
2216
|
+
return;
|
|
2217
|
+
}
|
|
2218
|
+
const instanceMatch = url.pathname.match(/^\/api\/instances\/([^/]+)$/);
|
|
2219
|
+
if (method === "GET" && instanceMatch) {
|
|
2220
|
+
const instance = hub.getInstance(decodeURIComponent(instanceMatch[1]));
|
|
2221
|
+
sendJson(response, 200, { instance: publicInstance(instance) });
|
|
2222
|
+
return;
|
|
2223
|
+
}
|
|
2224
|
+
const instanceCapabilitiesMatch = url.pathname.match(
|
|
2225
|
+
/^\/api\/instances\/([^/]+)\/capabilities$/
|
|
2226
|
+
);
|
|
2227
|
+
if (method === "GET" && instanceCapabilitiesMatch) {
|
|
2228
|
+
const instanceId = decodeURIComponent(instanceCapabilitiesMatch[1]);
|
|
2229
|
+
const agents = await hub.listCapabilities(instanceId);
|
|
2230
|
+
sendJson(response, 200, { agents });
|
|
2231
|
+
return;
|
|
2232
|
+
}
|
|
2233
|
+
const instanceHealthMatch = url.pathname.match(/^\/api\/instances\/([^/]+)\/health$/);
|
|
2234
|
+
if (method === "POST" && instanceHealthMatch) {
|
|
2235
|
+
const instanceId = decodeURIComponent(instanceHealthMatch[1]);
|
|
2236
|
+
const instance = await hub.checkInstance(instanceId);
|
|
2237
|
+
sendJson(response, 200, { instance: publicInstance(instance) });
|
|
2238
|
+
return;
|
|
2239
|
+
}
|
|
2240
|
+
if (method === "DELETE" && instanceMatch) {
|
|
2241
|
+
const instanceId = decodeURIComponent(instanceMatch[1]);
|
|
2242
|
+
hub.removeInstance(instanceId);
|
|
2243
|
+
sendJson(response, 204, {});
|
|
2244
|
+
return;
|
|
2245
|
+
}
|
|
2246
|
+
if (method === "GET" && url.pathname === "/api/sessions" && !selectedInstanceId) {
|
|
2247
|
+
const instanceId = url.searchParams.get("instanceId");
|
|
2248
|
+
if (instanceId) hub.getInstance(instanceId);
|
|
2249
|
+
sendJson(response, 200, {
|
|
2250
|
+
sessions: instanceId ? hub.listSessions().filter((session) => session.instanceId === instanceId) : hub.listSessions()
|
|
2251
|
+
});
|
|
2252
|
+
return;
|
|
2253
|
+
}
|
|
2254
|
+
const instanceWorkspaceMatch = url.pathname.match(/^\/api\/instances\/([^/]+)\/workspaces$/);
|
|
2255
|
+
const instanceWorkspaceSuggestionMatch = url.pathname.match(
|
|
2256
|
+
/^\/api\/instances\/([^/]+)\/workspaces\/suggestions$/
|
|
2257
|
+
);
|
|
2258
|
+
if (method === "GET" && instanceWorkspaceSuggestionMatch) {
|
|
2259
|
+
const instanceId = decodeURIComponent(instanceWorkspaceSuggestionMatch[1]);
|
|
2260
|
+
const suggestions = await hub.suggestWorkspacePaths(
|
|
2261
|
+
instanceId,
|
|
2262
|
+
url.searchParams.get("path") ?? ""
|
|
2263
|
+
);
|
|
2264
|
+
sendJson(response, 200, { suggestions });
|
|
2265
|
+
return;
|
|
2266
|
+
}
|
|
2267
|
+
if (method === "GET" && instanceWorkspaceMatch) {
|
|
2268
|
+
const instance = hub.getInstance(decodeURIComponent(instanceWorkspaceMatch[1]));
|
|
2269
|
+
sendJson(response, 200, { workspaces: instance.workspaces });
|
|
2270
|
+
return;
|
|
2271
|
+
}
|
|
2272
|
+
const instanceSessionsMatch = url.pathname.match(/^\/api\/instances\/([^/]+)\/sessions$/);
|
|
2273
|
+
if (method === "GET" && instanceSessionsMatch) {
|
|
2274
|
+
const instanceId = decodeURIComponent(instanceSessionsMatch[1]);
|
|
2275
|
+
await Promise.all([
|
|
2276
|
+
hub.listCapabilities(instanceId),
|
|
2277
|
+
hub.syncSessions(instanceId)
|
|
2278
|
+
]);
|
|
2279
|
+
sendJson(response, 200, {
|
|
2280
|
+
sessions: hub.listSessions().filter((session) => session.instanceId === instanceId)
|
|
2281
|
+
});
|
|
2282
|
+
return;
|
|
2283
|
+
}
|
|
2284
|
+
if (method === "POST" && instanceSessionsMatch) {
|
|
2285
|
+
const instanceId = decodeURIComponent(instanceSessionsMatch[1]);
|
|
2286
|
+
const body = await readJson(request);
|
|
2287
|
+
const session = await hub.createSession(
|
|
2288
|
+
instanceId,
|
|
2289
|
+
body.workspaceId,
|
|
2290
|
+
body.agentId,
|
|
2291
|
+
body.title,
|
|
2292
|
+
body.workspacePath
|
|
2293
|
+
);
|
|
2294
|
+
sendJson(response, 201, { session });
|
|
2295
|
+
return;
|
|
2296
|
+
}
|
|
2297
|
+
const sessionRemoveMatch = url.pathname.match(
|
|
2298
|
+
/^\/api\/instances\/([^/]+)\/sessions\/([^/]+)$/
|
|
2299
|
+
);
|
|
2300
|
+
if (method === "DELETE" && sessionRemoveMatch) {
|
|
2301
|
+
hub.removeSession(
|
|
2302
|
+
decodeURIComponent(sessionRemoveMatch[1]),
|
|
2303
|
+
decodeURIComponent(sessionRemoveMatch[2])
|
|
2304
|
+
);
|
|
2305
|
+
sendJson(response, 204, {});
|
|
2306
|
+
return;
|
|
2307
|
+
}
|
|
2308
|
+
const sessionInputMatch = url.pathname.match(
|
|
2309
|
+
/^\/api\/instances\/([^/]+)\/sessions\/([^/]+)\/input$/
|
|
2310
|
+
);
|
|
2311
|
+
if (method === "POST" && sessionInputMatch) {
|
|
2312
|
+
const body = await readJson(request);
|
|
2313
|
+
await hub.sendInput(
|
|
2314
|
+
decodeURIComponent(sessionInputMatch[1]),
|
|
2315
|
+
decodeURIComponent(sessionInputMatch[2]),
|
|
2316
|
+
body.input,
|
|
2317
|
+
body.raw === true
|
|
2318
|
+
);
|
|
2319
|
+
sendJson(response, 202, { accepted: true });
|
|
2320
|
+
return;
|
|
2321
|
+
}
|
|
2322
|
+
const sessionResizeMatch = url.pathname.match(
|
|
2323
|
+
/^\/api\/instances\/([^/]+)\/sessions\/([^/]+)\/resize$/
|
|
2324
|
+
);
|
|
2325
|
+
if (method === "POST" && sessionResizeMatch) {
|
|
2326
|
+
const body = await readJson(request);
|
|
2327
|
+
if (!Number.isInteger(body.cols) || !Number.isInteger(body.rows) || body.cols < 1 || body.cols > 500 || body.rows < 1 || body.rows > 500) {
|
|
2328
|
+
throw new HttpError(400, "Terminal size must be between 1 and 500 columns and rows");
|
|
2329
|
+
}
|
|
2330
|
+
await hub.resizeSession(
|
|
2331
|
+
decodeURIComponent(sessionResizeMatch[1]),
|
|
2332
|
+
decodeURIComponent(sessionResizeMatch[2]),
|
|
2333
|
+
body.cols,
|
|
2334
|
+
body.rows
|
|
2335
|
+
);
|
|
2336
|
+
sendJson(response, 202, { accepted: true });
|
|
2337
|
+
return;
|
|
2338
|
+
}
|
|
2339
|
+
const sessionStopMatch = url.pathname.match(
|
|
2340
|
+
/^\/api\/instances\/([^/]+)\/sessions\/([^/]+)\/stop$/
|
|
2341
|
+
);
|
|
2342
|
+
if (method === "POST" && sessionStopMatch) {
|
|
2343
|
+
const instanceId = decodeURIComponent(sessionStopMatch[1]);
|
|
2344
|
+
const sessionId = decodeURIComponent(sessionStopMatch[2]);
|
|
2345
|
+
await hub.stopSession(instanceId, sessionId);
|
|
2346
|
+
sendJson(response, 202, { accepted: true });
|
|
2347
|
+
return;
|
|
2348
|
+
}
|
|
2349
|
+
if (method === "GET" && url.pathname === "/api/events") {
|
|
2350
|
+
if (selectedInstanceId) {
|
|
2351
|
+
await hub.proxyCodemanRequest(selectedInstanceId, request, response);
|
|
2352
|
+
return;
|
|
2353
|
+
}
|
|
2354
|
+
const instanceId = url.searchParams.get("instanceId");
|
|
2355
|
+
if (instanceId) hub.getInstance(instanceId);
|
|
2356
|
+
response.writeHead(200, {
|
|
2357
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
2358
|
+
"cache-control": "no-cache, no-store",
|
|
2359
|
+
connection: "keep-alive"
|
|
2360
|
+
});
|
|
2361
|
+
response.write(`event: snapshot
|
|
2362
|
+
data: ${JSON.stringify({
|
|
2363
|
+
instances: instanceId ? hub.listInstances().filter((instance) => instance.id === instanceId) : hub.listInstances(),
|
|
2364
|
+
sessions: instanceId ? hub.listSessions().filter((session) => session.instanceId === instanceId) : hub.listSessions()
|
|
2365
|
+
})}
|
|
2366
|
+
|
|
2367
|
+
`);
|
|
2368
|
+
const unsubscribe = hub.subscribeEvents((event) => {
|
|
2369
|
+
if (!response.writableEnded && (!instanceId || event.instanceId === instanceId)) {
|
|
2370
|
+
response.write(`event: session
|
|
2371
|
+
data: ${JSON.stringify(event)}
|
|
2372
|
+
|
|
2373
|
+
`);
|
|
2374
|
+
}
|
|
2375
|
+
});
|
|
2376
|
+
const keepAlive = setInterval(() => {
|
|
2377
|
+
if (!response.writableEnded) response.write(": keep-alive\n\n");
|
|
2378
|
+
}, 15e3);
|
|
2379
|
+
response.on("close", () => {
|
|
2380
|
+
clearInterval(keepAlive);
|
|
2381
|
+
unsubscribe();
|
|
2382
|
+
});
|
|
2383
|
+
return;
|
|
2384
|
+
}
|
|
2385
|
+
if (method === "GET" && url.pathname === "/api/workers") {
|
|
2386
|
+
sendJson(response, 200, { workers: controlPlane.nodes.list() });
|
|
2387
|
+
return;
|
|
2388
|
+
}
|
|
2389
|
+
if (method === "POST" && url.pathname === "/api/workers") {
|
|
2390
|
+
const registration = await readJson(request);
|
|
2391
|
+
const worker = controlPlane.enrollNode(registration);
|
|
2392
|
+
sendJson(response, 201, { worker });
|
|
2393
|
+
return;
|
|
2394
|
+
}
|
|
2395
|
+
const connectMatch = url.pathname.match(/^\/api\/workers\/([^/]+)\/connect$/);
|
|
2396
|
+
if (method === "GET" && connectMatch) {
|
|
2397
|
+
const nodeId = decodeURIComponent(connectMatch[1]);
|
|
2398
|
+
const node = controlPlane.nodes.list().find((candidate) => candidate.id === nodeId);
|
|
2399
|
+
if (!node) {
|
|
2400
|
+
sendJson(response, 404, { error: "Worker not found" });
|
|
2401
|
+
return;
|
|
2402
|
+
}
|
|
2403
|
+
response.writeHead(200, {
|
|
2404
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
2405
|
+
"cache-control": "no-cache, no-store",
|
|
2406
|
+
connection: "keep-alive"
|
|
2407
|
+
});
|
|
2408
|
+
response.write(`event: connected
|
|
2409
|
+
data: ${JSON.stringify({ nodeId })}
|
|
2410
|
+
|
|
2411
|
+
`);
|
|
2412
|
+
const connection = {
|
|
2413
|
+
node,
|
|
2414
|
+
send: async (message) => {
|
|
2415
|
+
if (!response.writableEnded) {
|
|
2416
|
+
response.write(`data: ${JSON.stringify(message)}
|
|
2417
|
+
|
|
2418
|
+
`);
|
|
2419
|
+
}
|
|
2420
|
+
},
|
|
2421
|
+
close: async () => {
|
|
2422
|
+
if (!response.writableEnded) response.end();
|
|
2423
|
+
}
|
|
2424
|
+
};
|
|
2425
|
+
controlPlane.registerConnection(connection);
|
|
2426
|
+
response.on("close", () => controlPlane.removeConnection(nodeId, connection));
|
|
2427
|
+
return;
|
|
2428
|
+
}
|
|
2429
|
+
const messageMatch = url.pathname.match(/^\/api\/workers\/([^/]+)\/messages$/);
|
|
2430
|
+
if (method === "POST" && messageMatch) {
|
|
2431
|
+
const message = await readJson(request);
|
|
2432
|
+
controlPlane.receiveNodeMessage(decodeURIComponent(messageMatch[1]), message);
|
|
2433
|
+
sendJson(response, 202, { accepted: true });
|
|
2434
|
+
return;
|
|
2435
|
+
}
|
|
2436
|
+
const heartbeatMatch = url.pathname.match(/^\/api\/workers\/([^/]+)\/heartbeat$/);
|
|
2437
|
+
if (method === "POST" && heartbeatMatch) {
|
|
2438
|
+
const heartbeat = await readJson(request);
|
|
2439
|
+
const worker = controlPlane.heartbeatNode({
|
|
2440
|
+
...heartbeat,
|
|
2441
|
+
nodeId: heartbeatMatch[1]
|
|
2442
|
+
});
|
|
2443
|
+
sendJson(response, 200, { worker });
|
|
2444
|
+
return;
|
|
2445
|
+
}
|
|
2446
|
+
if (method === "POST" && url.pathname === "/api/jobs") {
|
|
2447
|
+
const jobRequest = await readJson(request);
|
|
2448
|
+
const job = await controlPlane.createJob(jobRequest);
|
|
2449
|
+
sendJson(response, 202, { job });
|
|
2450
|
+
return;
|
|
2451
|
+
}
|
|
2452
|
+
const jobMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)$/);
|
|
2453
|
+
if (method === "GET" && jobMatch) {
|
|
2454
|
+
const job = controlPlane.jobs.get(jobMatch[1]);
|
|
2455
|
+
if (!job) {
|
|
2456
|
+
sendJson(response, 404, { error: "Job not found" });
|
|
2457
|
+
return;
|
|
2458
|
+
}
|
|
2459
|
+
sendJson(response, 200, { job });
|
|
2460
|
+
return;
|
|
2461
|
+
}
|
|
2462
|
+
const cancelMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)\/cancel$/);
|
|
2463
|
+
if (method === "POST" && cancelMatch) {
|
|
2464
|
+
await controlPlane.cancelJob(cancelMatch[1]);
|
|
2465
|
+
sendJson(response, 202, { job: controlPlane.jobs.get(cancelMatch[1]) });
|
|
2466
|
+
return;
|
|
2467
|
+
}
|
|
2468
|
+
if (method === "GET" && url.pathname === "/api/audit") {
|
|
2469
|
+
sendJson(response, 200, {
|
|
2470
|
+
events: [...controlPlane.audit.list(), ...hub.listAudit()]
|
|
2471
|
+
});
|
|
2472
|
+
return;
|
|
2473
|
+
}
|
|
2474
|
+
if (selectedInstanceId) {
|
|
2475
|
+
await hub.proxyCodemanRequest(selectedInstanceId, request, response);
|
|
2476
|
+
return;
|
|
2477
|
+
}
|
|
2478
|
+
sendJson(response, 404, { error: "Not found" });
|
|
2479
|
+
}
|
|
2480
|
+
async function serveDashboardAsset(pathname, response) {
|
|
2481
|
+
let relativePath;
|
|
2482
|
+
try {
|
|
2483
|
+
relativePath = pathname === "/" ? "index.html" : decodeURIComponent(pathname.slice(1));
|
|
2484
|
+
} catch {
|
|
2485
|
+
sendJson(response, 400, { error: "Invalid asset path" });
|
|
2486
|
+
return;
|
|
2487
|
+
}
|
|
2488
|
+
const filePath = resolve(DASHBOARD_ROOT, relativePath);
|
|
2489
|
+
if (filePath !== DASHBOARD_ROOT && !filePath.startsWith(`${DASHBOARD_ROOT}${sep}`)) {
|
|
2490
|
+
sendJson(response, 404, { error: "Not found" });
|
|
2491
|
+
return;
|
|
2492
|
+
}
|
|
2493
|
+
try {
|
|
2494
|
+
const body = await readFile(filePath);
|
|
2495
|
+
response.writeHead(200, {
|
|
2496
|
+
"content-type": contentType(filePath),
|
|
2497
|
+
"cache-control": "no-cache"
|
|
2498
|
+
});
|
|
2499
|
+
response.end(body);
|
|
2500
|
+
} catch {
|
|
2501
|
+
sendJson(response, 404, { error: "Not found" });
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
function contentType(filePath) {
|
|
2505
|
+
if (filePath.endsWith(".html")) return "text/html; charset=utf-8";
|
|
2506
|
+
if (filePath.endsWith(".js")) return "application/javascript; charset=utf-8";
|
|
2507
|
+
if (filePath.endsWith(".css")) return "text/css; charset=utf-8";
|
|
2508
|
+
return "application/octet-stream";
|
|
2509
|
+
}
|
|
2510
|
+
function isWorkerOrJobRoute(pathname) {
|
|
2511
|
+
return pathname === "/api/workers" || pathname.startsWith("/api/workers/") || pathname === "/api/jobs" || pathname.startsWith("/api/jobs/");
|
|
2512
|
+
}
|
|
2513
|
+
function isCodemanProxiedApiPath(pathname) {
|
|
2514
|
+
if (!pathname.startsWith("/api/")) return false;
|
|
2515
|
+
return !(pathname === "/api/auth" || pathname.startsWith("/api/auth/") || pathname === "/api/fleet" || pathname.startsWith("/api/fleet/") || pathname === "/api/instances" || pathname.startsWith("/api/instances/") || pathname === "/api/workers" || pathname.startsWith("/api/workers/") || pathname === "/api/jobs" || pathname.startsWith("/api/jobs/") || pathname === "/api/audit");
|
|
2516
|
+
}
|
|
2517
|
+
function readCodemanCredentials() {
|
|
2518
|
+
const encoded = process.env.HOSTED_AGENTS_CODEMAN_CREDENTIALS;
|
|
2519
|
+
if (!encoded) return {};
|
|
2520
|
+
let parsed;
|
|
2521
|
+
try {
|
|
2522
|
+
parsed = JSON.parse(encoded);
|
|
2523
|
+
} catch {
|
|
2524
|
+
throw new Error("HOSTED_AGENTS_CODEMAN_CREDENTIALS must be valid JSON");
|
|
2525
|
+
}
|
|
2526
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
2527
|
+
throw new Error("HOSTED_AGENTS_CODEMAN_CREDENTIALS must be an object");
|
|
2528
|
+
}
|
|
2529
|
+
const credentials = {};
|
|
2530
|
+
for (const [instanceId, value] of Object.entries(parsed)) {
|
|
2531
|
+
if (typeof value !== "object" || value === null || Array.isArray(value) || typeof value.username !== "string" || typeof value.password !== "string") {
|
|
2532
|
+
throw new Error(`Invalid Codeman credentials for instance ${instanceId}`);
|
|
2533
|
+
}
|
|
2534
|
+
credentials[instanceId] = {
|
|
2535
|
+
username: value.username,
|
|
2536
|
+
password: value.password
|
|
2537
|
+
};
|
|
2538
|
+
}
|
|
2539
|
+
return credentials;
|
|
2540
|
+
}
|
|
2541
|
+
function clientAddress(request) {
|
|
2542
|
+
const forwarded = request.headers["x-forwarded-for"];
|
|
2543
|
+
if (typeof forwarded === "string" && forwarded.trim()) {
|
|
2544
|
+
return forwarded.split(",")[0].trim();
|
|
2545
|
+
}
|
|
2546
|
+
return request.socket.remoteAddress ?? "unknown";
|
|
2547
|
+
}
|
|
2548
|
+
function selectedInstance(request) {
|
|
2549
|
+
const cookie = request.headers.cookie;
|
|
2550
|
+
for (const item of cookie?.split(";") ?? []) {
|
|
2551
|
+
const separator = item.indexOf("=");
|
|
2552
|
+
if (separator < 1 || item.slice(0, separator).trim() !== CODEMAN_INSTANCE_COOKIE) continue;
|
|
2553
|
+
const value = item.slice(separator + 1).trim();
|
|
2554
|
+
try {
|
|
2555
|
+
return decodeURIComponent(value) || void 0;
|
|
2556
|
+
} catch {
|
|
2557
|
+
return value || void 0;
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
return void 0;
|
|
2561
|
+
}
|
|
2562
|
+
function isSecureRequest(request) {
|
|
2563
|
+
return request.headers["x-forwarded-proto"] === "https" || request.socket.encrypted === true;
|
|
2564
|
+
}
|
|
2565
|
+
function isTailnetEndpoint(value) {
|
|
2566
|
+
try {
|
|
2567
|
+
const endpoint = new URL(value);
|
|
2568
|
+
return endpoint.protocol === "https:" && (endpoint.hostname.endsWith(".ts.net") || endpoint.hostname.endsWith(".ts.net.") || isTailscaleIpv4(endpoint.hostname));
|
|
2569
|
+
} catch {
|
|
2570
|
+
return false;
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
function isLoopbackEndpoint(value) {
|
|
2574
|
+
try {
|
|
2575
|
+
const endpoint = new URL(value);
|
|
2576
|
+
return endpoint.hostname === "localhost" || endpoint.hostname === "127.0.0.1" || endpoint.hostname === "::1" || endpoint.hostname === "[::1]";
|
|
2577
|
+
} catch {
|
|
2578
|
+
return false;
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
function isTailscaleIpv4(hostname) {
|
|
2582
|
+
const parts = hostname.split(".");
|
|
2583
|
+
if (parts.length !== 4 || parts.some((part) => !/^\d+$/.test(part))) return false;
|
|
2584
|
+
const [first, second] = parts.map(Number);
|
|
2585
|
+
return first === 100 && second >= 64 && second <= 127;
|
|
2586
|
+
}
|
|
2587
|
+
function publicInstance(instance) {
|
|
2588
|
+
return { ...instance, endpoint: null };
|
|
2589
|
+
}
|
|
2590
|
+
function isLoopback(host) {
|
|
2591
|
+
return host === "127.0.0.1" || host === "::1" || host === "localhost";
|
|
2592
|
+
}
|
|
2593
|
+
function sendJson(response, status, body) {
|
|
2594
|
+
if (status === 204) {
|
|
2595
|
+
response.writeHead(status);
|
|
2596
|
+
response.end();
|
|
2597
|
+
return;
|
|
2598
|
+
}
|
|
2599
|
+
response.writeHead(status, {
|
|
2600
|
+
"content-type": "application/json; charset=utf-8",
|
|
2601
|
+
"cache-control": "no-store"
|
|
2602
|
+
});
|
|
2603
|
+
response.end(JSON.stringify(body));
|
|
2604
|
+
}
|
|
2605
|
+
function sendLoginPage(response, authEnabled) {
|
|
2606
|
+
if (!authEnabled) {
|
|
2607
|
+
sendJson(response, 401, { error: "Unauthorized" });
|
|
2608
|
+
return;
|
|
2609
|
+
}
|
|
2610
|
+
response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
2611
|
+
response.end(`<!doctype html>
|
|
2612
|
+
<html lang="en">
|
|
2613
|
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
2614
|
+
<title>Hosted Agents \u2014 Sign in</title>
|
|
2615
|
+
<style>
|
|
2616
|
+
body{margin:0;min-height:100vh;display:grid;place-items:center;background:#090c10;color:#d7dee7;font:16px system-ui,sans-serif}
|
|
2617
|
+
main{width:min(360px,calc(100vw - 48px));padding:28px;border:1px solid #253044;border-radius:14px;background:#111722;box-shadow:0 20px 60px #0008}
|
|
2618
|
+
h1{margin:0 0 8px;font-size:22px}p{color:#98a6ba;margin:0 0 22px}
|
|
2619
|
+
label{display:grid;gap:8px;color:#b9c5d6}input{padding:12px;border:1px solid #3a465b;border-radius:8px;background:#090c10;color:inherit;font:inherit}
|
|
2620
|
+
button{width:100%;margin-top:16px;padding:12px;border:0;border-radius:8px;background:#77e0af;color:#07110d;font:600 15px system-ui;cursor:pointer}
|
|
2621
|
+
#error{min-height:20px;margin-top:12px;color:#ff8585}
|
|
2622
|
+
</style></head>
|
|
2623
|
+
<body><main><h1>Hosted Agents</h1><p>Sign in to the private fleet Hub.</p>
|
|
2624
|
+
<form id="login"><label>Password<input id="password" type="password" autocomplete="current-password" required autofocus></label>
|
|
2625
|
+
<button type="submit">Sign in</button><div id="error" role="alert"></div></form></main>
|
|
2626
|
+
<script>
|
|
2627
|
+
document.getElementById("login").addEventListener("submit",async function(event){
|
|
2628
|
+
event.preventDefault();const error=document.getElementById("error");error.textContent="";
|
|
2629
|
+
try{const response=await fetch("/api/auth/login",{method:"POST",credentials:"same-origin",headers:{"content-type":"application/json"},body:JSON.stringify({password:document.getElementById("password").value})});
|
|
2630
|
+
if(!response.ok)throw new Error((await response.json().catch(function(){return {}})).error||"Sign in failed");
|
|
2631
|
+
location.assign("/");}catch(cause){error.textContent=cause.message||"Sign in failed";}
|
|
2632
|
+
});
|
|
2633
|
+
</script></body></html>`);
|
|
2634
|
+
}
|
|
2635
|
+
function rejectUpgrade(socket, status, message) {
|
|
2636
|
+
if (socket.destroyed) return;
|
|
2637
|
+
socket.write(
|
|
2638
|
+
`HTTP/1.1 ${status} ${message}\r
|
|
2639
|
+
Connection: close\r
|
|
2640
|
+
Content-Type: text/plain; charset=utf-8\r
|
|
2641
|
+
Content-Length: ${Buffer.byteLength(message)}\r
|
|
2642
|
+
\r
|
|
2643
|
+
` + message
|
|
2644
|
+
);
|
|
2645
|
+
socket.destroy();
|
|
2646
|
+
}
|
|
2647
|
+
var HttpError = class extends Error {
|
|
2648
|
+
status;
|
|
2649
|
+
constructor(status, message) {
|
|
2650
|
+
super(message);
|
|
2651
|
+
this.name = "HttpError";
|
|
2652
|
+
this.status = status;
|
|
2653
|
+
}
|
|
2654
|
+
};
|
|
2655
|
+
async function readJson(request) {
|
|
2656
|
+
const chunks = [];
|
|
2657
|
+
const decoder = new TextDecoder();
|
|
2658
|
+
let size = 0;
|
|
2659
|
+
for await (const chunk of request) {
|
|
2660
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
2661
|
+
size += buffer.length;
|
|
2662
|
+
if (size > MAX_BODY_BYTES) throw new Error("Request body is too large");
|
|
2663
|
+
chunks.push(decoder.decode(buffer, { stream: true }));
|
|
2664
|
+
}
|
|
2665
|
+
if (chunks.length === 0) throw new Error("Request body is required");
|
|
2666
|
+
chunks.push(decoder.decode());
|
|
2667
|
+
return JSON.parse(chunks.join(""));
|
|
2668
|
+
}
|
|
2669
|
+
if (process.argv[1]?.endsWith("/server.ts") || process.argv[1]?.endsWith("\\server.ts") || process.argv[1]?.endsWith("/server.mjs") || process.argv[1]?.endsWith("\\server.mjs")) {
|
|
2670
|
+
startControllerServer();
|
|
2671
|
+
}
|
|
2672
|
+
export {
|
|
2673
|
+
createControllerServer,
|
|
2674
|
+
startControllerServer
|
|
2675
|
+
};
|