@electric-agent/studio 1.7.0 → 1.12.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/active-sessions.d.ts +2 -0
- package/dist/active-sessions.d.ts.map +1 -1
- package/dist/active-sessions.js +4 -0
- package/dist/active-sessions.js.map +1 -1
- package/dist/api-schemas.d.ts +225 -0
- package/dist/api-schemas.d.ts.map +1 -0
- package/dist/api-schemas.js +95 -0
- package/dist/api-schemas.js.map +1 -0
- package/dist/bridge/claude-code-base.d.ts +121 -0
- package/dist/bridge/claude-code-base.d.ts.map +1 -0
- package/dist/bridge/claude-code-base.js +263 -0
- package/dist/bridge/claude-code-base.js.map +1 -0
- package/dist/bridge/claude-code-docker.d.ts +13 -73
- package/dist/bridge/claude-code-docker.d.ts.map +1 -1
- package/dist/bridge/claude-code-docker.js +91 -302
- package/dist/bridge/claude-code-docker.js.map +1 -1
- package/dist/bridge/claude-code-sprites.d.ts +12 -59
- package/dist/bridge/claude-code-sprites.d.ts.map +1 -1
- package/dist/bridge/claude-code-sprites.js +88 -281
- package/dist/bridge/claude-code-sprites.js.map +1 -1
- package/dist/bridge/claude-md-generator.d.ts +15 -3
- package/dist/bridge/claude-md-generator.d.ts.map +1 -1
- package/dist/bridge/claude-md-generator.js +79 -98
- package/dist/bridge/claude-md-generator.js.map +1 -1
- package/dist/bridge/codex-docker.d.ts +56 -51
- package/dist/bridge/codex-docker.js +222 -230
- package/dist/bridge/codex-json-parser.d.ts +11 -11
- package/dist/bridge/codex-json-parser.js +231 -238
- package/dist/bridge/codex-md-generator.d.ts +3 -3
- package/dist/bridge/codex-md-generator.js +42 -32
- package/dist/bridge/codex-sprites.d.ts +50 -45
- package/dist/bridge/codex-sprites.js +212 -222
- package/dist/bridge/daytona.d.ts +25 -25
- package/dist/bridge/daytona.js +131 -136
- package/dist/bridge/docker-stdio.d.ts +21 -21
- package/dist/bridge/docker-stdio.js +126 -132
- package/dist/bridge/hosted.d.ts +3 -2
- package/dist/bridge/hosted.d.ts.map +1 -1
- package/dist/bridge/hosted.js +4 -0
- package/dist/bridge/hosted.js.map +1 -1
- package/dist/bridge/message-parser.d.ts +24 -0
- package/dist/bridge/message-parser.d.ts.map +1 -0
- package/dist/bridge/message-parser.js +39 -0
- package/dist/bridge/message-parser.js.map +1 -0
- package/dist/bridge/role-skills.d.ts +25 -0
- package/dist/bridge/role-skills.d.ts.map +1 -0
- package/dist/bridge/role-skills.js +120 -0
- package/dist/bridge/role-skills.js.map +1 -0
- package/dist/bridge/room-messaging-skill.d.ts +11 -0
- package/dist/bridge/room-messaging-skill.d.ts.map +1 -0
- package/dist/bridge/room-messaging-skill.js +41 -0
- package/dist/bridge/room-messaging-skill.js.map +1 -0
- package/dist/bridge/sprites.d.ts +22 -22
- package/dist/bridge/sprites.js +123 -128
- package/dist/bridge/types.d.ts +4 -10
- package/dist/bridge/types.d.ts.map +1 -1
- package/dist/client/assets/index-BfvQSMwH.css +1 -0
- package/dist/client/assets/index-CtOOaA2Q.js +235 -0
- package/dist/client/index.html +2 -2
- package/dist/github-app.d.ts +14 -0
- package/dist/github-app.d.ts.map +1 -0
- package/dist/github-app.js +62 -0
- package/dist/github-app.js.map +1 -0
- package/dist/github-app.test.d.ts +2 -0
- package/dist/github-app.test.d.ts.map +1 -0
- package/dist/github-app.test.js +62 -0
- package/dist/github-app.test.js.map +1 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/invite-code.d.ts +5 -0
- package/dist/invite-code.d.ts.map +1 -0
- package/dist/invite-code.js +14 -0
- package/dist/invite-code.js.map +1 -0
- package/dist/project-utils.d.ts.map +1 -1
- package/dist/project-utils.js.map +1 -1
- package/dist/registry.d.ts +11 -4
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +1 -1
- package/dist/registry.js.map +1 -1
- package/dist/room-router.d.ts +73 -0
- package/dist/room-router.d.ts.map +1 -0
- package/dist/room-router.js +345 -0
- package/dist/room-router.js.map +1 -0
- package/dist/sandbox/docker.d.ts +1 -0
- package/dist/sandbox/docker.d.ts.map +1 -1
- package/dist/sandbox/docker.js +56 -6
- package/dist/sandbox/docker.js.map +1 -1
- package/dist/sandbox/index.d.ts +0 -1
- package/dist/sandbox/index.d.ts.map +1 -1
- package/dist/sandbox/index.js +0 -1
- package/dist/sandbox/index.js.map +1 -1
- package/dist/sandbox/sprites.d.ts +1 -0
- package/dist/sandbox/sprites.d.ts.map +1 -1
- package/dist/sandbox/sprites.js +91 -10
- package/dist/sandbox/sprites.js.map +1 -1
- package/dist/sandbox/types.d.ts +9 -2
- package/dist/sandbox/types.d.ts.map +1 -1
- package/dist/server.d.ts +12 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +906 -445
- package/dist/server.js.map +1 -1
- package/dist/session-auth.d.ts +9 -0
- package/dist/session-auth.d.ts.map +1 -1
- package/dist/session-auth.js +30 -0
- package/dist/session-auth.js.map +1 -1
- package/dist/sessions.d.ts +1 -1
- package/dist/streams.d.ts +2 -6
- package/dist/streams.d.ts.map +1 -1
- package/dist/streams.js +6 -17
- package/dist/streams.js.map +1 -1
- package/dist/validate.d.ts +10 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +24 -0
- package/dist/validate.js.map +1 -0
- package/package.json +6 -9
- package/dist/client/assets/index-D5-jqAV-.js +0 -234
- package/dist/client/assets/index-YyyiO26y.css +0 -1
package/dist/server.js
CHANGED
|
@@ -7,23 +7,27 @@ import { ts } from "@electric-agent/protocol";
|
|
|
7
7
|
import { serve } from "@hono/node-server";
|
|
8
8
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
9
9
|
import { Hono } from "hono";
|
|
10
|
-
import { cors } from "hono/cors";
|
|
11
10
|
import { ActiveSessions } from "./active-sessions.js";
|
|
11
|
+
import { addAgentSchema, addSessionToRoomSchema, createRoomSchema, createSandboxSchema, createSessionSchema, iterateRoomSessionSchema, iterateSessionSchema, resumeSessionSchema, sendRoomMessageSchema, } from "./api-schemas.js";
|
|
12
|
+
import { PRODUCTION_ALLOWED_TOOLS } from "./bridge/claude-code-base.js";
|
|
12
13
|
import { ClaudeCodeDockerBridge } from "./bridge/claude-code-docker.js";
|
|
13
14
|
import { ClaudeCodeSpritesBridge, } from "./bridge/claude-code-sprites.js";
|
|
14
|
-
import { createAppSkillContent, generateClaudeMd } from "./bridge/claude-md-generator.js";
|
|
15
|
+
import { createAppSkillContent, generateClaudeMd, resolveRoleSkill, roomMessagingSkillContent, } from "./bridge/claude-md-generator.js";
|
|
15
16
|
import { HostedStreamBridge } from "./bridge/hosted.js";
|
|
16
17
|
import { DEFAULT_ELECTRIC_URL, getClaimUrl, provisionElectricResources } from "./electric-api.js";
|
|
17
18
|
import { createGate, rejectAllGates, resolveGate } from "./gate.js";
|
|
18
19
|
import { ghListAccounts, ghListBranches, ghListRepos, isGhAuthenticated } from "./git.js";
|
|
20
|
+
import { createOrgRepo, getInstallationToken } from "./github-app.js";
|
|
21
|
+
import { generateInviteCode } from "./invite-code.js";
|
|
19
22
|
import { resolveProjectDir } from "./project-utils.js";
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
+
import { RoomRouter } from "./room-router.js";
|
|
24
|
+
import { deriveGlobalHookSecret, deriveHookToken, deriveRoomToken, deriveSessionToken, validateGlobalHookSecret, validateHookToken, validateRoomToken, validateSessionToken, } from "./session-auth.js";
|
|
25
|
+
import { getRoomStreamConnectionInfo, getStreamConnectionInfo, } from "./streams.js";
|
|
26
|
+
import { isResponse, validateBody } from "./validate.js";
|
|
23
27
|
/** Active session bridges — one per running session */
|
|
24
28
|
const bridges = new Map();
|
|
25
|
-
/**
|
|
26
|
-
const
|
|
29
|
+
/** Active room routers — one per room with agent-to-agent messaging */
|
|
30
|
+
const roomRouters = new Map();
|
|
27
31
|
/** Inflight hook session creations — prevents duplicate sessions from concurrent hooks */
|
|
28
32
|
const inflightHookCreations = new Map();
|
|
29
33
|
function parseRepoNameFromUrl(url) {
|
|
@@ -36,9 +40,9 @@ function parseRepoNameFromUrl(url) {
|
|
|
36
40
|
function sessionStream(config, sessionId) {
|
|
37
41
|
return getStreamConnectionInfo(sessionId, config.streamConfig);
|
|
38
42
|
}
|
|
39
|
-
/** Get stream connection info for a
|
|
40
|
-
function
|
|
41
|
-
return
|
|
43
|
+
/** Get stream connection info for a room */
|
|
44
|
+
function roomStream(config, roomId) {
|
|
45
|
+
return getRoomStreamConnectionInfo(roomId, config.streamConfig);
|
|
42
46
|
}
|
|
43
47
|
/** Create or retrieve the SessionBridge for a session */
|
|
44
48
|
function getOrCreateBridge(config, sessionId) {
|
|
@@ -66,9 +70,62 @@ function resolveStudioUrl(port) {
|
|
|
66
70
|
// Fallback — won't work from sprites VMs, but at least logs a useful URL
|
|
67
71
|
return `http://localhost:${port}`;
|
|
68
72
|
}
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Rate limiting — in-memory sliding window per IP
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
const MAX_SESSIONS_PER_IP_PER_HOUR = Number(process.env.MAX_SESSIONS_PER_IP_PER_HOUR) || 5;
|
|
77
|
+
const MAX_TOTAL_SESSIONS = Number(process.env.MAX_TOTAL_SESSIONS || 50);
|
|
78
|
+
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
|
79
|
+
const sessionCreationsByIp = new Map();
|
|
80
|
+
// GitHub App config (prod mode — repo creation in electric-apps org)
|
|
81
|
+
const GITHUB_APP_ID = process.env.GITHUB_APP_ID;
|
|
82
|
+
const GITHUB_INSTALLATION_ID = process.env.GITHUB_INSTALLATION_ID;
|
|
83
|
+
const GITHUB_PRIVATE_KEY = process.env.GITHUB_PRIVATE_KEY?.replace(/\\n/g, "\n");
|
|
84
|
+
const GITHUB_ORG = "electric-apps";
|
|
85
|
+
// Rate limiting for GitHub token endpoint
|
|
86
|
+
const githubTokenRequestsBySession = new Map();
|
|
87
|
+
const MAX_GITHUB_TOKENS_PER_SESSION_PER_HOUR = 10;
|
|
88
|
+
function extractClientIp(c) {
|
|
89
|
+
return (c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
|
|
90
|
+
c.req.header("cf-connecting-ip") ||
|
|
91
|
+
"unknown");
|
|
92
|
+
}
|
|
93
|
+
function checkSessionRateLimit(ip) {
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
const cutoff = now - RATE_LIMIT_WINDOW_MS;
|
|
96
|
+
let timestamps = sessionCreationsByIp.get(ip) ?? [];
|
|
97
|
+
// Prune stale entries
|
|
98
|
+
timestamps = timestamps.filter((t) => t > cutoff);
|
|
99
|
+
if (timestamps.length >= MAX_SESSIONS_PER_IP_PER_HOUR) {
|
|
100
|
+
sessionCreationsByIp.set(ip, timestamps);
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
timestamps.push(now);
|
|
104
|
+
sessionCreationsByIp.set(ip, timestamps);
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
function checkGlobalSessionCap(sessions) {
|
|
108
|
+
return sessions.size() >= MAX_TOTAL_SESSIONS;
|
|
109
|
+
}
|
|
110
|
+
function checkGithubTokenRateLimit(sessionId) {
|
|
111
|
+
const now = Date.now();
|
|
112
|
+
const requests = githubTokenRequestsBySession.get(sessionId) ?? [];
|
|
113
|
+
const recent = requests.filter((t) => now - t < RATE_LIMIT_WINDOW_MS);
|
|
114
|
+
if (recent.length >= MAX_GITHUB_TOKENS_PER_SESSION_PER_HOUR) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
recent.push(now);
|
|
118
|
+
githubTokenRequestsBySession.set(sessionId, recent);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Per-session cost budget
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
const MAX_SESSION_COST_USD = Number(process.env.MAX_SESSION_COST_USD) || 5;
|
|
69
125
|
/**
|
|
70
126
|
* Accumulate cost and turn metrics from a session_end event into the session's totals.
|
|
71
127
|
* Called each time a Claude Code run finishes (initial + iterate runs).
|
|
128
|
+
* In production mode, enforces a per-session cost budget.
|
|
72
129
|
*/
|
|
73
130
|
function accumulateSessionCost(config, sessionId, event) {
|
|
74
131
|
if (event.type !== "session_end")
|
|
@@ -89,12 +146,39 @@ function accumulateSessionCost(config, sessionId, event) {
|
|
|
89
146
|
}
|
|
90
147
|
config.sessions.update(sessionId, updates);
|
|
91
148
|
console.log(`[session:${sessionId}] Cost: $${updates.totalCostUsd?.toFixed(4) ?? "?"} (${updates.totalTurns ?? "?"} turns)`);
|
|
149
|
+
// Enforce budget in production mode
|
|
150
|
+
if (!config.devMode &&
|
|
151
|
+
updates.totalCostUsd != null &&
|
|
152
|
+
updates.totalCostUsd > MAX_SESSION_COST_USD) {
|
|
153
|
+
console.log(`[session:${sessionId}] Budget exceeded: $${updates.totalCostUsd.toFixed(2)} > $${MAX_SESSION_COST_USD}`);
|
|
154
|
+
const bridge = bridges.get(sessionId);
|
|
155
|
+
if (bridge) {
|
|
156
|
+
bridge
|
|
157
|
+
.emit({
|
|
158
|
+
type: "budget_exceeded",
|
|
159
|
+
budget_usd: MAX_SESSION_COST_USD,
|
|
160
|
+
spent_usd: updates.totalCostUsd,
|
|
161
|
+
ts: ts(),
|
|
162
|
+
})
|
|
163
|
+
.catch(() => { });
|
|
164
|
+
}
|
|
165
|
+
config.sessions.update(sessionId, { status: "error" });
|
|
166
|
+
closeBridge(sessionId);
|
|
167
|
+
}
|
|
92
168
|
}
|
|
93
169
|
/**
|
|
94
170
|
* Create a Claude Code bridge for a session.
|
|
95
171
|
* Spawns `claude` CLI with stream-json I/O inside the sandbox.
|
|
172
|
+
* In production mode, enforces tool restrictions and hardcodes the model.
|
|
96
173
|
*/
|
|
97
174
|
function createClaudeCodeBridge(config, sessionId, claudeConfig) {
|
|
175
|
+
// Production mode: restrict tools and hardcode model
|
|
176
|
+
if (!config.devMode) {
|
|
177
|
+
if (!claudeConfig.allowedTools) {
|
|
178
|
+
claudeConfig.allowedTools = PRODUCTION_ALLOWED_TOOLS;
|
|
179
|
+
}
|
|
180
|
+
claudeConfig.model = undefined; // force default (claude-sonnet-4-6)
|
|
181
|
+
}
|
|
98
182
|
const conn = sessionStream(config, sessionId);
|
|
99
183
|
let bridge;
|
|
100
184
|
if (config.sandbox.runtime === "sprites") {
|
|
@@ -126,31 +210,6 @@ function closeBridge(sessionId) {
|
|
|
126
210
|
bridges.delete(sessionId);
|
|
127
211
|
}
|
|
128
212
|
}
|
|
129
|
-
/**
|
|
130
|
-
* Detect git operations from natural language prompts.
|
|
131
|
-
* Returns structured gitOp fields if matched, null otherwise.
|
|
132
|
-
*/
|
|
133
|
-
function detectGitOp(request) {
|
|
134
|
-
const lower = request.toLowerCase().trim();
|
|
135
|
-
// Commit: "commit", "commit the code", "commit changes", "commit with message ..."
|
|
136
|
-
if (/^(git\s+)?commit\b/.test(lower) || /^save\s+(my\s+)?(changes|progress|work)\b/.test(lower)) {
|
|
137
|
-
// Extract commit message after "commit" keyword, or after "message:" / "msg:"
|
|
138
|
-
const msgMatch = request.match(/(?:commit\s+(?:with\s+(?:message\s+)?)?|message:\s*|msg:\s*)["']?(.+?)["']?\s*$/i);
|
|
139
|
-
const message = msgMatch?.[1]?.replace(/^(the\s+)?(code|changes)\s*/i, "").trim();
|
|
140
|
-
return { gitOp: "commit", gitMessage: message || undefined };
|
|
141
|
-
}
|
|
142
|
-
// Push: "push", "push to github", "push to remote", "git push"
|
|
143
|
-
if (/^(git\s+)?push\b/.test(lower)) {
|
|
144
|
-
return { gitOp: "push" };
|
|
145
|
-
}
|
|
146
|
-
// Create PR: "create pr", "open pr", "make pr", "create pull request"
|
|
147
|
-
if (/^(create|open|make)\s+(a\s+)?(pr|pull\s*request)\b/.test(lower)) {
|
|
148
|
-
// Try to extract title after the PR keyword
|
|
149
|
-
const titleMatch = request.match(/(?:pr|pull\s*request)\s+(?:(?:titled?|called|named)\s+)?["']?(.+?)["']?\s*$/i);
|
|
150
|
-
return { gitOp: "create-pr", gitPrTitle: titleMatch?.[1] || undefined };
|
|
151
|
-
}
|
|
152
|
-
return null;
|
|
153
|
-
}
|
|
154
213
|
/**
|
|
155
214
|
* Map a Claude Code hook event JSON payload to an EngineEvent.
|
|
156
215
|
*
|
|
@@ -264,8 +323,6 @@ function mapHookToEngineEvent(body) {
|
|
|
264
323
|
}
|
|
265
324
|
export function createApp(config) {
|
|
266
325
|
const app = new Hono();
|
|
267
|
-
// CORS for local development
|
|
268
|
-
app.use("*", cors({ origin: "*" }));
|
|
269
326
|
// --- API Routes ---
|
|
270
327
|
// Health check
|
|
271
328
|
app.get("/api/health", (c) => {
|
|
@@ -283,6 +340,13 @@ export function createApp(config) {
|
|
|
283
340
|
checks.sandbox = config.sandbox.runtime;
|
|
284
341
|
return c.json({ healthy, checks }, healthy ? 200 : 503);
|
|
285
342
|
});
|
|
343
|
+
// Public config — exposes non-sensitive flags to the client
|
|
344
|
+
app.get("/api/config", (c) => {
|
|
345
|
+
return c.json({
|
|
346
|
+
devMode: config.devMode,
|
|
347
|
+
maxSessionCostUsd: config.devMode ? undefined : MAX_SESSION_COST_USD,
|
|
348
|
+
});
|
|
349
|
+
});
|
|
286
350
|
// Provision Electric Cloud resources via the Claim API
|
|
287
351
|
app.post("/api/provision-electric", async (c) => {
|
|
288
352
|
try {
|
|
@@ -307,7 +371,7 @@ export function createApp(config) {
|
|
|
307
371
|
// Hono's wildcard middleware matches creation routes like /api/sessions/local as
|
|
308
372
|
// :id="local", so we must explicitly skip those.
|
|
309
373
|
const authExemptIds = new Set(["local", "auto", "resume"]);
|
|
310
|
-
|
|
374
|
+
// Hook-event auth is handled in the endpoint handler via validateHookToken
|
|
311
375
|
/** Extract session token from Authorization header or query param. */
|
|
312
376
|
function extractToken(c) {
|
|
313
377
|
const authHeader = c.req.header("Authorization");
|
|
@@ -321,7 +385,8 @@ export function createApp(config) {
|
|
|
321
385
|
if (authExemptIds.has(id))
|
|
322
386
|
return next();
|
|
323
387
|
const subPath = c.req.path.replace(/^\/api\/sessions\/[^/]+/, "");
|
|
324
|
-
|
|
388
|
+
// Hook-event uses a purpose-scoped hook token (validated in the handler)
|
|
389
|
+
if (subPath === "/hook-event")
|
|
325
390
|
return next();
|
|
326
391
|
const token = extractToken(c);
|
|
327
392
|
if (!token || !validateSessionToken(config.streamConfig.secret, id, token)) {
|
|
@@ -382,8 +447,9 @@ export function createApp(config) {
|
|
|
382
447
|
// Pre-create a bridge so hook-event can emit to it immediately
|
|
383
448
|
getOrCreateBridge(config, sessionId);
|
|
384
449
|
const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
|
|
450
|
+
const hookToken = deriveHookToken(config.streamConfig.secret, sessionId);
|
|
385
451
|
console.log(`[local-session] Created session: ${sessionId}`);
|
|
386
|
-
return c.json({ sessionId, sessionToken }, 201);
|
|
452
|
+
return c.json({ sessionId, sessionToken, hookToken }, 201);
|
|
387
453
|
});
|
|
388
454
|
// Auto-register a local session on first hook event (SessionStart).
|
|
389
455
|
// Eliminates the manual `curl POST /api/sessions/local` step.
|
|
@@ -429,14 +495,20 @@ export function createApp(config) {
|
|
|
429
495
|
await bridge.emit(hookEvent);
|
|
430
496
|
}
|
|
431
497
|
const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
|
|
498
|
+
const hookToken = deriveHookToken(config.streamConfig.secret, sessionId);
|
|
432
499
|
console.log(`[auto-session] Created session: ${sessionId} (project: ${projectName})`);
|
|
433
|
-
return c.json({ sessionId, sessionToken }, 201);
|
|
500
|
+
return c.json({ sessionId, sessionToken, hookToken }, 201);
|
|
434
501
|
});
|
|
435
502
|
// Receive a hook event from Claude Code (via forward.sh) and write it
|
|
436
503
|
// to the session's durable stream as an EngineEvent.
|
|
437
504
|
// For AskUserQuestion, this blocks until the user answers in the web UI.
|
|
438
505
|
app.post("/api/sessions/:id/hook-event", async (c) => {
|
|
439
506
|
const sessionId = c.req.param("id");
|
|
507
|
+
// Validate hook token (scoped per-session, separate from session token)
|
|
508
|
+
const token = extractToken(c);
|
|
509
|
+
if (!token || !validateHookToken(config.streamConfig.secret, sessionId, token)) {
|
|
510
|
+
return c.json({ error: "Invalid or missing hook token" }, 401);
|
|
511
|
+
}
|
|
440
512
|
const body = (await c.req.json());
|
|
441
513
|
const bridge = getOrCreateBridge(config, sessionId);
|
|
442
514
|
// Map Claude Code hook JSON → EngineEvent
|
|
@@ -498,6 +570,16 @@ export function createApp(config) {
|
|
|
498
570
|
return c.json({ ok: true });
|
|
499
571
|
});
|
|
500
572
|
// --- Unified Hook Endpoint (transcript_path correlation) ---
|
|
573
|
+
// Protect the unified hook endpoint with a global hook secret derived from
|
|
574
|
+
// the DS secret. The hook setup script embeds this secret in the forwarder
|
|
575
|
+
// so that only local Claude Code instances can post events.
|
|
576
|
+
app.use("/api/hook", async (c, next) => {
|
|
577
|
+
const token = extractToken(c);
|
|
578
|
+
if (!token || !validateGlobalHookSecret(config.streamConfig.secret, token)) {
|
|
579
|
+
return c.json({ error: "Invalid or missing hook secret" }, 401);
|
|
580
|
+
}
|
|
581
|
+
return next();
|
|
582
|
+
});
|
|
501
583
|
// Single endpoint for all Claude Code hook events. Uses transcript_path
|
|
502
584
|
// from the hook JSON as the correlation key — stable across resume/compact,
|
|
503
585
|
// changes on /clear. Replaces the need for client-side session tracking.
|
|
@@ -635,6 +717,7 @@ export function createApp(config) {
|
|
|
635
717
|
// Usage: cd <project> && curl -s http://localhost:4400/api/hooks/setup | bash
|
|
636
718
|
app.get("/api/hooks/setup", (c) => {
|
|
637
719
|
const port = config.port;
|
|
720
|
+
const hookSecret = deriveGlobalHookSecret(config.streamConfig.secret);
|
|
638
721
|
const script = `#!/bin/bash
|
|
639
722
|
# Electric Agent — Claude Code hook installer (project-scoped)
|
|
640
723
|
# Installs the hook forwarder into the current project's .claude/ directory.
|
|
@@ -655,10 +738,12 @@ cat > "\${FORWARD_SH}" << 'HOOKEOF'
|
|
|
655
738
|
# Installed by: curl -s http://localhost:EA_PORT/api/hooks/setup | bash
|
|
656
739
|
|
|
657
740
|
EA_PORT="\${EA_PORT:-EA_PORT_PLACEHOLDER}"
|
|
741
|
+
EA_HOOK_SECRET="\${EA_HOOK_SECRET:-EA_HOOK_SECRET_PLACEHOLDER}"
|
|
658
742
|
BODY="$(cat)"
|
|
659
743
|
|
|
660
744
|
RESPONSE=$(curl -s -X POST "http://localhost:\${EA_PORT}/api/hook" \\
|
|
661
745
|
-H "Content-Type: application/json" \\
|
|
746
|
+
-H "Authorization: Bearer \${EA_HOOK_SECRET}" \\
|
|
662
747
|
-d "\${BODY}" \\
|
|
663
748
|
--max-time 360 \\
|
|
664
749
|
--connect-timeout 2 \\
|
|
@@ -672,8 +757,9 @@ fi
|
|
|
672
757
|
exit 0
|
|
673
758
|
HOOKEOF
|
|
674
759
|
|
|
675
|
-
# Replace
|
|
760
|
+
# Replace placeholders with actual values
|
|
676
761
|
sed -i.bak "s/EA_PORT_PLACEHOLDER/${port}/" "\${FORWARD_SH}" && rm -f "\${FORWARD_SH}.bak"
|
|
762
|
+
sed -i.bak "s/EA_HOOK_SECRET_PLACEHOLDER/${hookSecret}/" "\${FORWARD_SH}" && rm -f "\${FORWARD_SH}.bak"
|
|
677
763
|
chmod +x "\${FORWARD_SH}"
|
|
678
764
|
|
|
679
765
|
# Merge hook config into project-level settings.local.json
|
|
@@ -715,17 +801,36 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
715
801
|
});
|
|
716
802
|
// Start new project
|
|
717
803
|
app.post("/api/sessions", async (c) => {
|
|
718
|
-
const body =
|
|
719
|
-
if (
|
|
720
|
-
return
|
|
804
|
+
const body = await validateBody(c, createSessionSchema);
|
|
805
|
+
if (isResponse(body))
|
|
806
|
+
return body;
|
|
807
|
+
// In prod mode, use server-side API key; ignore user-provided credentials
|
|
808
|
+
const apiKey = config.devMode ? body.apiKey : process.env.ANTHROPIC_API_KEY;
|
|
809
|
+
const oauthToken = config.devMode ? body.oauthToken : undefined;
|
|
810
|
+
const ghToken = config.devMode ? body.ghToken : undefined;
|
|
811
|
+
// Block freeform sessions in production mode
|
|
812
|
+
if (body.freeform && !config.devMode) {
|
|
813
|
+
return c.json({ error: "Freeform sessions are not available" }, 403);
|
|
814
|
+
}
|
|
815
|
+
// Rate-limit session creation in production mode
|
|
816
|
+
if (!config.devMode) {
|
|
817
|
+
const ip = extractClientIp(c);
|
|
818
|
+
if (!checkSessionRateLimit(ip)) {
|
|
819
|
+
return c.json({ error: "Too many sessions. Please try again later." }, 429);
|
|
820
|
+
}
|
|
821
|
+
if (checkGlobalSessionCap(config.sessions)) {
|
|
822
|
+
return c.json({ error: "Service at capacity, please try again later" }, 503);
|
|
823
|
+
}
|
|
721
824
|
}
|
|
722
825
|
const sessionId = crypto.randomUUID();
|
|
723
|
-
const inferredName =
|
|
724
|
-
body.
|
|
725
|
-
.
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
826
|
+
const inferredName = config.devMode
|
|
827
|
+
? body.name ||
|
|
828
|
+
body.description
|
|
829
|
+
.slice(0, 40)
|
|
830
|
+
.replace(/[^a-z0-9]+/gi, "-")
|
|
831
|
+
.replace(/^-|-$/g, "")
|
|
832
|
+
.toLowerCase()
|
|
833
|
+
: `electric-${sessionId.slice(0, 8)}`;
|
|
729
834
|
const baseDir = body.baseDir || process.cwd();
|
|
730
835
|
const { projectName } = resolveProjectDir(baseDir, inferredName);
|
|
731
836
|
console.log(`[session] Creating new session: id=${sessionId} project=${projectName}`);
|
|
@@ -759,74 +864,83 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
759
864
|
config.sessions.add(session);
|
|
760
865
|
// Write user prompt to the stream so it shows in the UI
|
|
761
866
|
await bridge.emit({ type: "user_prompt", message: body.description, ts: ts() });
|
|
762
|
-
//
|
|
763
|
-
// Only check if the client provided a token — never fall back to server-side GH_TOKEN
|
|
867
|
+
// Freeform sessions skip the infra config gate — no Electric/DB setup needed
|
|
764
868
|
let ghAccounts = [];
|
|
765
|
-
if (body.
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
869
|
+
if (!body.freeform) {
|
|
870
|
+
// Gather GitHub accounts for the merged setup gate (dev mode only)
|
|
871
|
+
if (config.devMode && ghToken && isGhAuthenticated(ghToken)) {
|
|
872
|
+
try {
|
|
873
|
+
ghAccounts = ghListAccounts(ghToken);
|
|
874
|
+
}
|
|
875
|
+
catch {
|
|
876
|
+
// gh not available — no repo setup
|
|
877
|
+
}
|
|
771
878
|
}
|
|
879
|
+
// Emit combined infra + repo setup gate
|
|
880
|
+
await bridge.emit({
|
|
881
|
+
type: "infra_config_prompt",
|
|
882
|
+
projectName,
|
|
883
|
+
ghAccounts,
|
|
884
|
+
runtime: config.sandbox.runtime,
|
|
885
|
+
ts: ts(),
|
|
886
|
+
});
|
|
772
887
|
}
|
|
773
|
-
// Emit combined infra + repo setup gate
|
|
774
|
-
await bridge.emit({
|
|
775
|
-
type: "infra_config_prompt",
|
|
776
|
-
projectName,
|
|
777
|
-
ghAccounts,
|
|
778
|
-
runtime: config.sandbox.runtime,
|
|
779
|
-
ts: ts(),
|
|
780
|
-
});
|
|
781
888
|
// Launch async flow: wait for setup gate → create sandbox → start agent
|
|
782
889
|
const asyncFlow = async () => {
|
|
783
|
-
// 1. Wait for combined infra + repo config
|
|
890
|
+
// 1. Wait for combined infra + repo config (skip for freeform)
|
|
784
891
|
let infra;
|
|
785
892
|
let repoConfig = null;
|
|
786
|
-
console.log(`[session:${sessionId}] Waiting for infra_config gate...`);
|
|
787
893
|
let claimId;
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
894
|
+
if (body.freeform) {
|
|
895
|
+
// Freeform sessions don't need Electric infrastructure
|
|
896
|
+
infra = { mode: "none" };
|
|
897
|
+
console.log(`[session:${sessionId}] Freeform session — skipping infra gate`);
|
|
898
|
+
}
|
|
899
|
+
else {
|
|
900
|
+
console.log(`[session:${sessionId}] Waiting for infra_config gate...`);
|
|
901
|
+
try {
|
|
902
|
+
const gateValue = await createGate(sessionId, "infra_config");
|
|
903
|
+
console.log(`[session:${sessionId}] Infra gate resolved: mode=${gateValue.mode}`);
|
|
904
|
+
if (gateValue.mode === "cloud" || gateValue.mode === "claim") {
|
|
905
|
+
// Normalize claim → cloud for the sandbox layer (same env vars)
|
|
906
|
+
infra = {
|
|
907
|
+
mode: "cloud",
|
|
908
|
+
databaseUrl: gateValue.databaseUrl,
|
|
909
|
+
electricUrl: gateValue.electricUrl,
|
|
910
|
+
sourceId: gateValue.sourceId,
|
|
911
|
+
secret: gateValue.secret,
|
|
912
|
+
};
|
|
913
|
+
if (gateValue.mode === "claim") {
|
|
914
|
+
claimId = gateValue.claimId;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
else {
|
|
918
|
+
infra = { mode: "local" };
|
|
919
|
+
}
|
|
920
|
+
// Extract repo config if provided
|
|
921
|
+
if (gateValue.repoAccount && gateValue.repoName?.trim()) {
|
|
922
|
+
repoConfig = {
|
|
923
|
+
account: gateValue.repoAccount,
|
|
924
|
+
repoName: gateValue.repoName,
|
|
925
|
+
visibility: gateValue.repoVisibility ?? "private",
|
|
926
|
+
};
|
|
927
|
+
config.sessions.update(sessionId, {
|
|
928
|
+
git: {
|
|
929
|
+
branch: "main",
|
|
930
|
+
remoteUrl: null,
|
|
931
|
+
repoName: `${repoConfig.account}/${repoConfig.repoName}`,
|
|
932
|
+
repoVisibility: repoConfig.visibility,
|
|
933
|
+
lastCommitHash: null,
|
|
934
|
+
lastCommitMessage: null,
|
|
935
|
+
lastCheckpointAt: null,
|
|
936
|
+
},
|
|
937
|
+
});
|
|
802
938
|
}
|
|
803
939
|
}
|
|
804
|
-
|
|
940
|
+
catch (err) {
|
|
941
|
+
console.log(`[session:${sessionId}] Infra gate error (defaulting to local):`, err);
|
|
805
942
|
infra = { mode: "local" };
|
|
806
943
|
}
|
|
807
|
-
// Extract repo config if provided
|
|
808
|
-
if (gateValue.repoAccount && gateValue.repoName?.trim()) {
|
|
809
|
-
repoConfig = {
|
|
810
|
-
account: gateValue.repoAccount,
|
|
811
|
-
repoName: gateValue.repoName,
|
|
812
|
-
visibility: gateValue.repoVisibility ?? "private",
|
|
813
|
-
};
|
|
814
|
-
config.sessions.update(sessionId, {
|
|
815
|
-
git: {
|
|
816
|
-
branch: "main",
|
|
817
|
-
remoteUrl: null,
|
|
818
|
-
repoName: `${repoConfig.account}/${repoConfig.repoName}`,
|
|
819
|
-
repoVisibility: repoConfig.visibility,
|
|
820
|
-
lastCommitHash: null,
|
|
821
|
-
lastCommitMessage: null,
|
|
822
|
-
lastCheckpointAt: null,
|
|
823
|
-
},
|
|
824
|
-
});
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
catch (err) {
|
|
828
|
-
console.log(`[session:${sessionId}] Infra gate error (defaulting to local):`, err);
|
|
829
|
-
infra = { mode: "local" };
|
|
830
944
|
}
|
|
831
945
|
// 2. Create sandbox — emit progress events so the UI shows feedback
|
|
832
946
|
await bridge.emit({
|
|
@@ -839,9 +953,15 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
839
953
|
const handle = await config.sandbox.create(sessionId, {
|
|
840
954
|
projectName,
|
|
841
955
|
infra,
|
|
842
|
-
apiKey
|
|
843
|
-
oauthToken
|
|
844
|
-
ghToken
|
|
956
|
+
apiKey,
|
|
957
|
+
oauthToken,
|
|
958
|
+
ghToken,
|
|
959
|
+
...(!config.devMode && {
|
|
960
|
+
prodMode: {
|
|
961
|
+
sessionToken: deriveSessionToken(config.streamConfig.secret, sessionId),
|
|
962
|
+
studioUrl: resolveStudioUrl(config.port),
|
|
963
|
+
},
|
|
964
|
+
}),
|
|
845
965
|
});
|
|
846
966
|
console.log(`[session:${sessionId}] Sandbox created: projectDir=${handle.projectDir} port=${handle.port} previewUrl=${handle.previewUrl ?? "none"}`);
|
|
847
967
|
await bridge.emit({
|
|
@@ -859,85 +979,169 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
859
979
|
// 3. Write CLAUDE.md and create a ClaudeCode bridge.
|
|
860
980
|
{
|
|
861
981
|
console.log(`[session:${sessionId}] Setting up Claude Code bridge...`);
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
type: "log",
|
|
865
|
-
level: "build",
|
|
866
|
-
message: "Setting up project...",
|
|
867
|
-
ts: ts(),
|
|
868
|
-
});
|
|
869
|
-
try {
|
|
870
|
-
if (config.sandbox.runtime === "docker") {
|
|
871
|
-
// Docker: copy the pre-built scaffold base (baked into the image)
|
|
872
|
-
await config.sandbox.exec(handle, `cp -r /opt/scaffold-base '${handle.projectDir}'`);
|
|
873
|
-
await config.sandbox.exec(handle, `cd '${handle.projectDir}' && sed -i 's/"name": "scaffold-base"/"name": "${projectName}"/' package.json`);
|
|
874
|
-
}
|
|
875
|
-
else {
|
|
876
|
-
// Sprites/Daytona: run scaffold from globally installed electric-agent
|
|
877
|
-
await config.sandbox.exec(handle, `source /etc/profile.d/npm-global.sh 2>/dev/null; electric-agent scaffold '${handle.projectDir}' --name '${projectName}' --skip-git`);
|
|
878
|
-
}
|
|
879
|
-
console.log(`[session:${sessionId}] Project setup complete`);
|
|
982
|
+
if (!body.freeform) {
|
|
983
|
+
// Copy pre-scaffolded project from the image and customize per-session
|
|
880
984
|
await bridge.emit({
|
|
881
985
|
type: "log",
|
|
882
|
-
level: "
|
|
883
|
-
message: "
|
|
986
|
+
level: "build",
|
|
987
|
+
message: "Setting up project...",
|
|
884
988
|
ts: ts(),
|
|
885
989
|
});
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
990
|
+
try {
|
|
991
|
+
if (config.sandbox.runtime === "docker") {
|
|
992
|
+
// Docker: copy the pre-built scaffold base (baked into the image)
|
|
993
|
+
await config.sandbox.exec(handle, `cp -r /opt/scaffold-base '${handle.projectDir}'`);
|
|
994
|
+
await config.sandbox.exec(handle, `cd '${handle.projectDir}' && sed -i 's/"name": "scaffold-base"/"name": "${projectName.replace(/[^a-z0-9_-]/gi, "-")}"/' package.json`);
|
|
995
|
+
}
|
|
996
|
+
else {
|
|
997
|
+
// Sprites: run scaffold from globally installed electric-agent
|
|
998
|
+
await config.sandbox.exec(handle, `source /etc/profile.d/npm-global.sh 2>/dev/null; electric-agent scaffold '${handle.projectDir}' --name '${projectName}' --skip-git`);
|
|
999
|
+
}
|
|
1000
|
+
console.log(`[session:${sessionId}] Project setup complete`);
|
|
1001
|
+
await bridge.emit({
|
|
1002
|
+
type: "log",
|
|
1003
|
+
level: "done",
|
|
1004
|
+
message: "Project ready",
|
|
1005
|
+
ts: ts(),
|
|
1006
|
+
});
|
|
1007
|
+
// Log the agent package version installed in the sandbox
|
|
1008
|
+
try {
|
|
1009
|
+
const agentVersion = (await config.sandbox.exec(handle, "electric-agent --version 2>/dev/null | tail -1")).trim();
|
|
1010
|
+
await bridge.emit({
|
|
1011
|
+
type: "log",
|
|
1012
|
+
level: "verbose",
|
|
1013
|
+
message: `electric-agent@${agentVersion}`,
|
|
1014
|
+
ts: ts(),
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
catch {
|
|
1018
|
+
// Non-critical — don't block session creation
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
catch (err) {
|
|
1022
|
+
console.error(`[session:${sessionId}] Project setup failed:`, err);
|
|
1023
|
+
await bridge.emit({
|
|
1024
|
+
type: "log",
|
|
1025
|
+
level: "error",
|
|
1026
|
+
message: `Project setup failed: ${err instanceof Error ? err.message : "unknown"}`,
|
|
1027
|
+
ts: ts(),
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
// In prod mode, create GitHub repo and initialize git in the sandbox
|
|
1031
|
+
let prodGitConfig;
|
|
1032
|
+
if (!config.devMode && GITHUB_APP_ID && GITHUB_INSTALLATION_ID && GITHUB_PRIVATE_KEY) {
|
|
1033
|
+
try {
|
|
1034
|
+
// Repo name matches the project name (already has random slug)
|
|
1035
|
+
const repoSlug = projectName;
|
|
1036
|
+
await bridge.emit({
|
|
1037
|
+
type: "log",
|
|
1038
|
+
level: "build",
|
|
1039
|
+
message: "Creating GitHub repository...",
|
|
1040
|
+
ts: ts(),
|
|
1041
|
+
});
|
|
1042
|
+
const { token } = await getInstallationToken(GITHUB_APP_ID, GITHUB_INSTALLATION_ID, GITHUB_PRIVATE_KEY);
|
|
1043
|
+
const repo = await createOrgRepo(GITHUB_ORG, repoSlug, token);
|
|
1044
|
+
if (repo) {
|
|
1045
|
+
const actualRepoName = `${GITHUB_ORG}/${repo.htmlUrl.split("/").pop()}`;
|
|
1046
|
+
// Initialize git and set remote in the sandbox
|
|
1047
|
+
await config.sandbox.exec(handle, `cd '${handle.projectDir}' && git init -b main && git remote add origin '${repo.cloneUrl}'`);
|
|
1048
|
+
prodGitConfig = {
|
|
1049
|
+
mode: "pre-created",
|
|
1050
|
+
repoName: actualRepoName,
|
|
1051
|
+
repoUrl: repo.htmlUrl,
|
|
1052
|
+
};
|
|
1053
|
+
config.sessions.update(sessionId, {
|
|
1054
|
+
git: {
|
|
1055
|
+
branch: "main",
|
|
1056
|
+
remoteUrl: repo.htmlUrl,
|
|
1057
|
+
repoName: actualRepoName,
|
|
1058
|
+
lastCommitHash: null,
|
|
1059
|
+
lastCommitMessage: null,
|
|
1060
|
+
lastCheckpointAt: null,
|
|
1061
|
+
},
|
|
1062
|
+
});
|
|
1063
|
+
await bridge.emit({
|
|
1064
|
+
type: "log",
|
|
1065
|
+
level: "done",
|
|
1066
|
+
message: `GitHub repo created: ${repo.htmlUrl}`,
|
|
1067
|
+
ts: ts(),
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
else {
|
|
1071
|
+
console.warn(`[session:${sessionId}] Failed to create GitHub repo`);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
catch (err) {
|
|
1075
|
+
console.error(`[session:${sessionId}] GitHub repo creation error:`, err);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
// Write CLAUDE.md to the sandbox workspace.
|
|
1079
|
+
// Our generator includes hardcoded playbook paths and reading order
|
|
1080
|
+
// so we don't depend on @tanstack/intent generating a skill block.
|
|
1081
|
+
const claudeMd = generateClaudeMd({
|
|
1082
|
+
description: body.description,
|
|
1083
|
+
projectName,
|
|
1084
|
+
projectDir: handle.projectDir,
|
|
1085
|
+
runtime: config.sandbox.runtime,
|
|
1086
|
+
production: !config.devMode,
|
|
1087
|
+
...(prodGitConfig
|
|
1088
|
+
? { git: prodGitConfig }
|
|
1089
|
+
: repoConfig
|
|
1090
|
+
? {
|
|
1091
|
+
git: {
|
|
1092
|
+
mode: "create",
|
|
1093
|
+
repoName: `${repoConfig.account}/${repoConfig.repoName}`,
|
|
1094
|
+
visibility: repoConfig.visibility,
|
|
1095
|
+
},
|
|
1096
|
+
}
|
|
1097
|
+
: {}),
|
|
894
1098
|
});
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
}
|
|
1099
|
+
try {
|
|
1100
|
+
await config.sandbox.exec(handle, `cat > '${handle.projectDir}/CLAUDE.md' << 'CLAUDEMD_EOF'\n${claudeMd}\nCLAUDEMD_EOF`);
|
|
1101
|
+
}
|
|
1102
|
+
catch (err) {
|
|
1103
|
+
console.error(`[session:${sessionId}] Failed to write CLAUDE.md:`, err);
|
|
1104
|
+
}
|
|
1105
|
+
// Ensure the create-app skill is present in the project.
|
|
1106
|
+
// The npm-installed electric-agent may be an older version that
|
|
1107
|
+
// doesn't include .claude/skills/ in its template directory.
|
|
1108
|
+
if (createAppSkillContent) {
|
|
1109
|
+
try {
|
|
1110
|
+
const skillDir = `${handle.projectDir}/.claude/skills/create-app`;
|
|
1111
|
+
const skillB64 = Buffer.from(createAppSkillContent).toString("base64");
|
|
1112
|
+
await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
|
|
909
1113
|
}
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
}
|
|
915
|
-
catch (err) {
|
|
916
|
-
console.error(`[session:${sessionId}] Failed to write CLAUDE.md:`, err);
|
|
1114
|
+
catch (err) {
|
|
1115
|
+
console.error(`[session:${sessionId}] Failed to write create-app skill:`, err);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
917
1118
|
}
|
|
918
|
-
// Ensure the
|
|
919
|
-
//
|
|
920
|
-
|
|
921
|
-
if (createAppSkillContent) {
|
|
1119
|
+
// Ensure the room-messaging skill is present so agents have
|
|
1120
|
+
// persistent access to the multi-agent protocol reference.
|
|
1121
|
+
if (roomMessagingSkillContent) {
|
|
922
1122
|
try {
|
|
923
|
-
const skillDir = `${handle.projectDir}/.claude/skills/
|
|
924
|
-
const skillB64 = Buffer.from(
|
|
1123
|
+
const skillDir = `${handle.projectDir}/.claude/skills/room-messaging`;
|
|
1124
|
+
const skillB64 = Buffer.from(roomMessagingSkillContent).toString("base64");
|
|
925
1125
|
await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
|
|
926
1126
|
}
|
|
927
1127
|
catch (err) {
|
|
928
|
-
console.error(`[session:${sessionId}] Failed to write
|
|
1128
|
+
console.error(`[session:${sessionId}] Failed to write room-messaging skill:`, err);
|
|
929
1129
|
}
|
|
930
1130
|
}
|
|
1131
|
+
const sessionPrompt = body.freeform ? body.description : `/create-app ${body.description}`;
|
|
1132
|
+
const sessionHookToken = deriveHookToken(config.streamConfig.secret, sessionId);
|
|
931
1133
|
const claudeConfig = config.sandbox.runtime === "sprites"
|
|
932
1134
|
? {
|
|
933
|
-
prompt:
|
|
1135
|
+
prompt: sessionPrompt,
|
|
934
1136
|
cwd: handle.projectDir,
|
|
935
1137
|
studioUrl: resolveStudioUrl(config.port),
|
|
1138
|
+
hookToken: sessionHookToken,
|
|
936
1139
|
}
|
|
937
1140
|
: {
|
|
938
|
-
prompt:
|
|
1141
|
+
prompt: sessionPrompt,
|
|
939
1142
|
cwd: handle.projectDir,
|
|
940
1143
|
studioPort: config.port,
|
|
1144
|
+
hookToken: sessionHookToken,
|
|
941
1145
|
};
|
|
942
1146
|
bridge = createClaudeCodeBridge(config, sessionId, claudeConfig);
|
|
943
1147
|
}
|
|
@@ -1011,7 +1215,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1011
1215
|
await bridge.emit({
|
|
1012
1216
|
type: "log",
|
|
1013
1217
|
level: "build",
|
|
1014
|
-
message:
|
|
1218
|
+
message: body.freeform
|
|
1219
|
+
? `Running: claude "${body.description}"`
|
|
1220
|
+
: `Running: claude "/create-app ${body.description}"`,
|
|
1015
1221
|
ts: ts(),
|
|
1016
1222
|
});
|
|
1017
1223
|
console.log(`[session:${sessionId}] Starting bridge listener...`);
|
|
@@ -1029,6 +1235,17 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1029
1235
|
asyncFlow().catch(async (err) => {
|
|
1030
1236
|
console.error(`[session:${sessionId}] Session creation flow failed:`, err);
|
|
1031
1237
|
config.sessions.update(sessionId, { status: "error" });
|
|
1238
|
+
try {
|
|
1239
|
+
await bridge.emit({
|
|
1240
|
+
type: "log",
|
|
1241
|
+
level: "error",
|
|
1242
|
+
message: `Session failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1243
|
+
ts: ts(),
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
catch {
|
|
1247
|
+
// Bridge may not be usable if the failure happened early
|
|
1248
|
+
}
|
|
1032
1249
|
});
|
|
1033
1250
|
const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
|
|
1034
1251
|
return c.json({ sessionId, session, sessionToken }, 201);
|
|
@@ -1039,73 +1256,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1039
1256
|
const session = config.sessions.get(sessionId);
|
|
1040
1257
|
if (!session)
|
|
1041
1258
|
return c.json({ error: "Session not found" }, 404);
|
|
1042
|
-
const body =
|
|
1043
|
-
if (
|
|
1044
|
-
return
|
|
1045
|
-
}
|
|
1046
|
-
// Intercept operational commands (start/stop/restart the app/server)
|
|
1047
|
-
const normalised = body.request
|
|
1048
|
-
.toLowerCase()
|
|
1049
|
-
.replace(/[^a-z ]/g, "")
|
|
1050
|
-
.trim();
|
|
1051
|
-
const appOrServer = /\b(app|server|dev server|dev|vite)\b/;
|
|
1052
|
-
const isStartCmd = /^(start|run|launch|boot)\b/.test(normalised) && appOrServer.test(normalised);
|
|
1053
|
-
const isStopCmd = /^(stop|kill|shutdown|shut down)\b/.test(normalised) && appOrServer.test(normalised);
|
|
1054
|
-
const isRestartCmd = /^restart\b/.test(normalised) && appOrServer.test(normalised);
|
|
1055
|
-
if (isStartCmd || isStopCmd || isRestartCmd) {
|
|
1056
|
-
const bridge = getOrCreateBridge(config, sessionId);
|
|
1057
|
-
await bridge.emit({ type: "user_prompt", message: body.request, ts: ts() });
|
|
1058
|
-
try {
|
|
1059
|
-
const handle = config.sandbox.get(sessionId);
|
|
1060
|
-
if (isStopCmd) {
|
|
1061
|
-
if (handle && config.sandbox.isAlive(handle))
|
|
1062
|
-
await config.sandbox.stopApp(handle);
|
|
1063
|
-
await bridge.emit({ type: "log", level: "done", message: "App stopped", ts: ts() });
|
|
1064
|
-
}
|
|
1065
|
-
else {
|
|
1066
|
-
if (!handle || !config.sandbox.isAlive(handle)) {
|
|
1067
|
-
return c.json({ error: "Container is not running" }, 400);
|
|
1068
|
-
}
|
|
1069
|
-
if (isRestartCmd)
|
|
1070
|
-
await config.sandbox.stopApp(handle);
|
|
1071
|
-
await config.sandbox.startApp(handle);
|
|
1072
|
-
await bridge.emit({
|
|
1073
|
-
type: "log",
|
|
1074
|
-
level: "done",
|
|
1075
|
-
message: "App started",
|
|
1076
|
-
ts: ts(),
|
|
1077
|
-
});
|
|
1078
|
-
await bridge.emit({
|
|
1079
|
-
type: "app_status",
|
|
1080
|
-
status: "running",
|
|
1081
|
-
port: session.appPort,
|
|
1082
|
-
previewUrl: session.previewUrl,
|
|
1083
|
-
ts: ts(),
|
|
1084
|
-
});
|
|
1085
|
-
}
|
|
1086
|
-
}
|
|
1087
|
-
catch (err) {
|
|
1088
|
-
const msg = err instanceof Error ? err.message : "Operation failed";
|
|
1089
|
-
await bridge.emit({ type: "log", level: "error", message: msg, ts: ts() });
|
|
1090
|
-
}
|
|
1091
|
-
return c.json({ ok: true });
|
|
1092
|
-
}
|
|
1093
|
-
// Intercept git commands (commit, push, create PR)
|
|
1094
|
-
const gitOp = detectGitOp(body.request);
|
|
1095
|
-
if (gitOp) {
|
|
1096
|
-
const bridge = getOrCreateBridge(config, sessionId);
|
|
1097
|
-
await bridge.emit({ type: "user_prompt", message: body.request, ts: ts() });
|
|
1098
|
-
const handle = config.sandbox.get(sessionId);
|
|
1099
|
-
if (!handle || !config.sandbox.isAlive(handle)) {
|
|
1100
|
-
return c.json({ error: "Container is not running" }, 400);
|
|
1101
|
-
}
|
|
1102
|
-
// Send git requests as user messages via Claude Code bridge
|
|
1103
|
-
await bridge.sendCommand({
|
|
1104
|
-
command: "iterate",
|
|
1105
|
-
request: body.request,
|
|
1106
|
-
});
|
|
1107
|
-
return c.json({ ok: true });
|
|
1108
|
-
}
|
|
1259
|
+
const body = await validateBody(c, iterateSessionSchema);
|
|
1260
|
+
if (isResponse(body))
|
|
1261
|
+
return body;
|
|
1109
1262
|
const handle = config.sandbox.get(sessionId);
|
|
1110
1263
|
if (!handle || !config.sandbox.isAlive(handle)) {
|
|
1111
1264
|
return c.json({ error: "Container is not running" }, 400);
|
|
@@ -1122,6 +1275,28 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1122
1275
|
});
|
|
1123
1276
|
return c.json({ ok: true });
|
|
1124
1277
|
});
|
|
1278
|
+
// Generate a GitHub installation token for the sandbox (prod mode only)
|
|
1279
|
+
app.post("/api/sessions/:id/github-token", async (c) => {
|
|
1280
|
+
const sessionId = c.req.param("id");
|
|
1281
|
+
if (config.devMode) {
|
|
1282
|
+
return c.json({ error: "Not available in dev mode" }, 403);
|
|
1283
|
+
}
|
|
1284
|
+
if (!GITHUB_APP_ID || !GITHUB_INSTALLATION_ID || !GITHUB_PRIVATE_KEY) {
|
|
1285
|
+
return c.json({ error: "GitHub App not configured" }, 500);
|
|
1286
|
+
}
|
|
1287
|
+
if (!checkGithubTokenRateLimit(sessionId)) {
|
|
1288
|
+
return c.json({ error: "Too many token requests" }, 429);
|
|
1289
|
+
}
|
|
1290
|
+
try {
|
|
1291
|
+
const result = await getInstallationToken(GITHUB_APP_ID, GITHUB_INSTALLATION_ID, GITHUB_PRIVATE_KEY);
|
|
1292
|
+
return c.json(result);
|
|
1293
|
+
}
|
|
1294
|
+
catch (err) {
|
|
1295
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
1296
|
+
console.error(`GitHub token error for session ${sessionId}:`, message);
|
|
1297
|
+
return c.json({ error: "Failed to generate GitHub token" }, 500);
|
|
1298
|
+
}
|
|
1299
|
+
});
|
|
1125
1300
|
// Respond to a gate (approval, clarification, continue, revision)
|
|
1126
1301
|
app.post("/api/sessions/:id/respond", async (c) => {
|
|
1127
1302
|
const sessionId = c.req.param("id");
|
|
@@ -1171,6 +1346,22 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1171
1346
|
}
|
|
1172
1347
|
// No pending gate — fall through to bridge.sendGateResponse()
|
|
1173
1348
|
}
|
|
1349
|
+
// Outbound message gates (room agent → room stream): resolved in-process
|
|
1350
|
+
if (gate === "outbound_message_gate") {
|
|
1351
|
+
const gateId = body.gateId;
|
|
1352
|
+
const action = body.action;
|
|
1353
|
+
if (!gateId || !action) {
|
|
1354
|
+
return c.json({ error: "gateId and action are required for outbound_message_gate" }, 400);
|
|
1355
|
+
}
|
|
1356
|
+
const resolved = resolveGate(sessionId, gateId, {
|
|
1357
|
+
action,
|
|
1358
|
+
editedBody: body.editedBody,
|
|
1359
|
+
});
|
|
1360
|
+
if (resolved) {
|
|
1361
|
+
return c.json({ ok: true });
|
|
1362
|
+
}
|
|
1363
|
+
return c.json({ error: "No pending gate found" }, 404);
|
|
1364
|
+
}
|
|
1174
1365
|
// Server-side gates are resolved in-process (they run on the server, not inside the container)
|
|
1175
1366
|
const serverGates = new Set(["infra_config"]);
|
|
1176
1367
|
// Forward agent gate responses via the bridge
|
|
@@ -1396,7 +1587,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1396
1587
|
});
|
|
1397
1588
|
// Create a standalone sandbox (not tied to session creation flow)
|
|
1398
1589
|
app.post("/api/sandboxes", async (c) => {
|
|
1399
|
-
const body =
|
|
1590
|
+
const body = await validateBody(c, createSandboxSchema);
|
|
1591
|
+
if (isResponse(body))
|
|
1592
|
+
return body;
|
|
1400
1593
|
const sessionId = body.sessionId ?? crypto.randomUUID();
|
|
1401
1594
|
try {
|
|
1402
1595
|
const handle = await config.sandbox.create(sessionId, {
|
|
@@ -1426,30 +1619,40 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1426
1619
|
await config.sandbox.destroy(handle);
|
|
1427
1620
|
return c.json({ ok: true });
|
|
1428
1621
|
});
|
|
1429
|
-
// ---
|
|
1430
|
-
//
|
|
1431
|
-
//
|
|
1432
|
-
|
|
1433
|
-
|
|
1622
|
+
// --- Room Routes (agent-to-agent messaging) ---
|
|
1623
|
+
// Extract room token from X-Room-Token header or ?token= query param.
|
|
1624
|
+
// This is separate from extractToken() (which reads Authorization) so that
|
|
1625
|
+
// Authorization remains available for session tokens on endpoints that need both.
|
|
1626
|
+
function extractRoomToken(c) {
|
|
1627
|
+
return c.req.header("X-Room-Token") ?? c.req.query("token") ?? undefined;
|
|
1628
|
+
}
|
|
1629
|
+
// Protect room-scoped routes via X-Room-Token header
|
|
1630
|
+
app.use("/api/rooms/:id/*", async (c, next) => {
|
|
1434
1631
|
const id = c.req.param("id");
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
const token = extractToken(c);
|
|
1438
|
-
if (!token || !validateSessionToken(config.streamConfig.secret, id, token)) {
|
|
1632
|
+
const token = extractRoomToken(c);
|
|
1633
|
+
if (!token || !validateRoomToken(config.streamConfig.secret, id, token)) {
|
|
1439
1634
|
return c.json({ error: "Invalid or missing room token" }, 401);
|
|
1440
1635
|
}
|
|
1441
1636
|
return next();
|
|
1442
1637
|
});
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1638
|
+
app.use("/api/rooms/:id", async (c, next) => {
|
|
1639
|
+
if (c.req.method !== "GET" && c.req.method !== "DELETE")
|
|
1640
|
+
return next();
|
|
1641
|
+
const id = c.req.param("id");
|
|
1642
|
+
const token = extractRoomToken(c);
|
|
1643
|
+
if (!token || !validateRoomToken(config.streamConfig.secret, id, token)) {
|
|
1644
|
+
return c.json({ error: "Invalid or missing room token" }, 401);
|
|
1448
1645
|
}
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1646
|
+
return next();
|
|
1647
|
+
});
|
|
1648
|
+
// Create a room
|
|
1649
|
+
app.post("/api/rooms", async (c) => {
|
|
1650
|
+
const body = await validateBody(c, createRoomSchema);
|
|
1651
|
+
if (isResponse(body))
|
|
1652
|
+
return body;
|
|
1653
|
+
const roomId = crypto.randomUUID();
|
|
1654
|
+
// Create the room's durable stream
|
|
1655
|
+
const conn = roomStream(config, roomId);
|
|
1453
1656
|
try {
|
|
1454
1657
|
await DurableStream.create({
|
|
1455
1658
|
url: conn.url,
|
|
@@ -1458,191 +1661,360 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1458
1661
|
});
|
|
1459
1662
|
}
|
|
1460
1663
|
catch (err) {
|
|
1461
|
-
console.error(`[
|
|
1462
|
-
return c.json({ error: "Failed to create
|
|
1664
|
+
console.error(`[room] Failed to create durable stream:`, err);
|
|
1665
|
+
return c.json({ error: "Failed to create room stream" }, 500);
|
|
1463
1666
|
}
|
|
1464
|
-
//
|
|
1465
|
-
const
|
|
1466
|
-
|
|
1467
|
-
headers: conn.headers,
|
|
1468
|
-
contentType: "application/json",
|
|
1667
|
+
// Create and start the router
|
|
1668
|
+
const router = new RoomRouter(roomId, body.name, config.streamConfig, {
|
|
1669
|
+
maxRounds: body.maxRounds,
|
|
1469
1670
|
});
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
createdBy: body.participant,
|
|
1475
|
-
ts: ts(),
|
|
1476
|
-
};
|
|
1477
|
-
await stream.append(JSON.stringify(createdEvent));
|
|
1478
|
-
// Write participant_joined for the creator
|
|
1479
|
-
const joinedEvent = {
|
|
1480
|
-
type: "participant_joined",
|
|
1481
|
-
participant: body.participant,
|
|
1482
|
-
ts: ts(),
|
|
1483
|
-
};
|
|
1484
|
-
await stream.append(JSON.stringify(joinedEvent));
|
|
1485
|
-
// Save to room registry
|
|
1671
|
+
await router.start();
|
|
1672
|
+
roomRouters.set(roomId, router);
|
|
1673
|
+
// Save to room registry for persistence
|
|
1674
|
+
const code = generateInviteCode();
|
|
1486
1675
|
await config.rooms.addRoom({
|
|
1487
|
-
id,
|
|
1676
|
+
id: roomId,
|
|
1488
1677
|
code,
|
|
1489
1678
|
name: body.name,
|
|
1490
1679
|
createdAt: new Date().toISOString(),
|
|
1491
1680
|
revoked: false,
|
|
1492
1681
|
});
|
|
1493
|
-
const roomToken =
|
|
1494
|
-
console.log(`[
|
|
1495
|
-
return c.json({
|
|
1682
|
+
const roomToken = deriveRoomToken(config.streamConfig.secret, roomId);
|
|
1683
|
+
console.log(`[room] Created: id=${roomId} name=${body.name} code=${code}`);
|
|
1684
|
+
return c.json({ roomId, code, roomToken }, 201);
|
|
1496
1685
|
});
|
|
1497
|
-
//
|
|
1498
|
-
app.get("/api/
|
|
1686
|
+
// Join an agent room by id + invite code (outside /api/rooms/:id to avoid auth middleware)
|
|
1687
|
+
app.get("/api/join-room/:id/:code", (c) => {
|
|
1688
|
+
const id = c.req.param("id");
|
|
1499
1689
|
const code = c.req.param("code");
|
|
1500
|
-
const
|
|
1501
|
-
if (!
|
|
1502
|
-
return c.json({ error: "
|
|
1503
|
-
|
|
1504
|
-
|
|
1690
|
+
const room = config.rooms.getRoom(id);
|
|
1691
|
+
if (!room || room.code !== code)
|
|
1692
|
+
return c.json({ error: "Room not found" }, 404);
|
|
1693
|
+
if (room.revoked)
|
|
1694
|
+
return c.json({ error: "Room has been revoked" }, 410);
|
|
1695
|
+
const roomToken = deriveRoomToken(config.streamConfig.secret, room.id);
|
|
1696
|
+
return c.json({ id: room.id, code: room.code, name: room.name, roomToken });
|
|
1505
1697
|
});
|
|
1506
|
-
//
|
|
1507
|
-
app.
|
|
1508
|
-
const
|
|
1509
|
-
const
|
|
1510
|
-
if (!
|
|
1511
|
-
return c.json({ error: "
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
contentType: "application/json",
|
|
1698
|
+
// Get room state
|
|
1699
|
+
app.get("/api/rooms/:id", (c) => {
|
|
1700
|
+
const roomId = c.req.param("id");
|
|
1701
|
+
const router = roomRouters.get(roomId);
|
|
1702
|
+
if (!router)
|
|
1703
|
+
return c.json({ error: "Room not found" }, 404);
|
|
1704
|
+
return c.json({
|
|
1705
|
+
roomId,
|
|
1706
|
+
state: router.state,
|
|
1707
|
+
roundCount: router.roundCount,
|
|
1708
|
+
participants: router.participants.map((p) => ({
|
|
1709
|
+
sessionId: p.sessionId,
|
|
1710
|
+
name: p.name,
|
|
1711
|
+
role: p.role,
|
|
1712
|
+
running: p.bridge.isRunning(),
|
|
1713
|
+
})),
|
|
1523
1714
|
});
|
|
1524
|
-
const event = {
|
|
1525
|
-
type: "participant_joined",
|
|
1526
|
-
participant: body.participant,
|
|
1527
|
-
ts: ts(),
|
|
1528
|
-
};
|
|
1529
|
-
await stream.append(JSON.stringify(event));
|
|
1530
|
-
return c.json({ ok: true });
|
|
1531
1715
|
});
|
|
1532
|
-
//
|
|
1533
|
-
app.post("/api/
|
|
1534
|
-
const
|
|
1535
|
-
const
|
|
1536
|
-
if (!
|
|
1537
|
-
return c.json({ error: "
|
|
1716
|
+
// Add an agent to a room
|
|
1717
|
+
app.post("/api/rooms/:id/agents", async (c) => {
|
|
1718
|
+
const roomId = c.req.param("id");
|
|
1719
|
+
const router = roomRouters.get(roomId);
|
|
1720
|
+
if (!router)
|
|
1721
|
+
return c.json({ error: "Room not found" }, 404);
|
|
1722
|
+
const body = await validateBody(c, addAgentSchema);
|
|
1723
|
+
if (isResponse(body))
|
|
1724
|
+
return body;
|
|
1725
|
+
// Rate-limit and gate credentials in production mode
|
|
1726
|
+
if (!config.devMode) {
|
|
1727
|
+
const ip = extractClientIp(c);
|
|
1728
|
+
if (!checkSessionRateLimit(ip)) {
|
|
1729
|
+
return c.json({ error: "Too many sessions. Please try again later." }, 429);
|
|
1730
|
+
}
|
|
1731
|
+
if (checkGlobalSessionCap(config.sessions)) {
|
|
1732
|
+
return c.json({ error: "Service at capacity, please try again later" }, 503);
|
|
1733
|
+
}
|
|
1538
1734
|
}
|
|
1539
|
-
const
|
|
1540
|
-
const
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
}
|
|
1545
|
-
const
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1735
|
+
const apiKey = config.devMode ? body.apiKey : process.env.ANTHROPIC_API_KEY;
|
|
1736
|
+
const oauthToken = config.devMode ? body.oauthToken : undefined;
|
|
1737
|
+
const ghToken = config.devMode ? body.ghToken : undefined;
|
|
1738
|
+
const sessionId = crypto.randomUUID();
|
|
1739
|
+
const randomSuffix = sessionId.slice(0, 6);
|
|
1740
|
+
const agentName = body.name?.trim() || `agent-${randomSuffix}`;
|
|
1741
|
+
const projectName = `room-${agentName}-${sessionId.slice(0, 8)}`;
|
|
1742
|
+
console.log(`[room:${roomId}] Adding agent: name=${agentName} session=${sessionId}`);
|
|
1743
|
+
// Create the session's durable stream
|
|
1744
|
+
const conn = sessionStream(config, sessionId);
|
|
1745
|
+
try {
|
|
1746
|
+
await DurableStream.create({
|
|
1747
|
+
url: conn.url,
|
|
1748
|
+
headers: conn.headers,
|
|
1749
|
+
contentType: "application/json",
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1752
|
+
catch (err) {
|
|
1753
|
+
console.error(`[room:${roomId}] Failed to create session stream:`, err);
|
|
1754
|
+
return c.json({ error: "Failed to create session stream" }, 500);
|
|
1755
|
+
}
|
|
1756
|
+
// Create bridge
|
|
1757
|
+
const bridge = getOrCreateBridge(config, sessionId);
|
|
1758
|
+
// Record session
|
|
1759
|
+
const sandboxProjectDir = `/home/agent/workspace/${projectName}`;
|
|
1760
|
+
const session = {
|
|
1761
|
+
id: sessionId,
|
|
1762
|
+
projectName,
|
|
1763
|
+
sandboxProjectDir,
|
|
1764
|
+
description: `Room agent: ${agentName} (${body.role ?? "participant"})`,
|
|
1765
|
+
createdAt: new Date().toISOString(),
|
|
1766
|
+
lastActiveAt: new Date().toISOString(),
|
|
1767
|
+
status: "running",
|
|
1549
1768
|
};
|
|
1550
|
-
|
|
1551
|
-
|
|
1769
|
+
config.sessions.add(session);
|
|
1770
|
+
// Return early so the client can store the session token and show the
|
|
1771
|
+
// session in the sidebar immediately. The sandbox setup continues in
|
|
1772
|
+
// the background — events stream to the session's durable stream so
|
|
1773
|
+
// the UI stays up to date.
|
|
1774
|
+
const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
|
|
1775
|
+
(async () => {
|
|
1776
|
+
await bridge.emit({
|
|
1777
|
+
type: "log",
|
|
1778
|
+
level: "build",
|
|
1779
|
+
message: `Creating sandbox for room agent "${agentName}"...`,
|
|
1780
|
+
ts: ts(),
|
|
1781
|
+
});
|
|
1782
|
+
try {
|
|
1783
|
+
const handle = await config.sandbox.create(sessionId, {
|
|
1784
|
+
projectName,
|
|
1785
|
+
infra: { mode: "local" },
|
|
1786
|
+
apiKey,
|
|
1787
|
+
oauthToken,
|
|
1788
|
+
ghToken,
|
|
1789
|
+
...(!config.devMode && {
|
|
1790
|
+
prodMode: {
|
|
1791
|
+
sessionToken: deriveSessionToken(config.streamConfig.secret, sessionId),
|
|
1792
|
+
studioUrl: resolveStudioUrl(config.port),
|
|
1793
|
+
},
|
|
1794
|
+
}),
|
|
1795
|
+
});
|
|
1796
|
+
config.sessions.update(sessionId, {
|
|
1797
|
+
appPort: handle.port,
|
|
1798
|
+
sandboxProjectDir: handle.projectDir,
|
|
1799
|
+
previewUrl: handle.previewUrl,
|
|
1800
|
+
});
|
|
1801
|
+
// Inject room-messaging skill so agents know the @room protocol
|
|
1802
|
+
if (roomMessagingSkillContent) {
|
|
1803
|
+
try {
|
|
1804
|
+
const skillDir = `${handle.projectDir}/.claude/skills/room-messaging`;
|
|
1805
|
+
const skillB64 = Buffer.from(roomMessagingSkillContent).toString("base64");
|
|
1806
|
+
await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
|
|
1807
|
+
// Append room-messaging reference to CLAUDE.md so the agent knows to read it
|
|
1808
|
+
const roomRef = `\n\n## Room Messaging (CRITICAL)\nYou are a participant in a multi-agent room. Read .claude/skills/room-messaging/SKILL.md for the messaging protocol.\nAll communication with other agents MUST use @room or @<name> messages as described in that skill.\n`;
|
|
1809
|
+
const refB64 = Buffer.from(roomRef).toString("base64");
|
|
1810
|
+
await config.sandbox.exec(handle, `echo '${refB64}' | base64 -d >> '${handle.projectDir}/CLAUDE.md'`);
|
|
1811
|
+
}
|
|
1812
|
+
catch (err) {
|
|
1813
|
+
console.error(`[session:${sessionId}] Failed to write room-messaging skill:`, err);
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
// Resolve role skill (behavioral guidelines + tool permissions)
|
|
1817
|
+
const roleSkill = resolveRoleSkill(body.role);
|
|
1818
|
+
// Inject role skill file into sandbox
|
|
1819
|
+
if (roleSkill) {
|
|
1820
|
+
try {
|
|
1821
|
+
const skillDir = `${handle.projectDir}/.claude/skills/role`;
|
|
1822
|
+
const skillB64 = Buffer.from(roleSkill.skillContent).toString("base64");
|
|
1823
|
+
await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
|
|
1824
|
+
}
|
|
1825
|
+
catch (err) {
|
|
1826
|
+
console.error(`[session:${sessionId}] Failed to write role skill:`, err);
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
// Build prompt — reference the role skill if available
|
|
1830
|
+
const rolePromptSuffix = roleSkill
|
|
1831
|
+
? `\nRead .claude/skills/role/SKILL.md for your role guidelines before proceeding.`
|
|
1832
|
+
: "";
|
|
1833
|
+
const agentPrompt = `You are "${agentName}"${body.role ? `, role: ${body.role}` : ""}. You are joining a multi-agent room.${rolePromptSuffix}`;
|
|
1834
|
+
// Create Claude Code bridge (with role-specific tool permissions)
|
|
1835
|
+
const agentHookToken = deriveHookToken(config.streamConfig.secret, sessionId);
|
|
1836
|
+
const claudeConfig = config.sandbox.runtime === "sprites"
|
|
1837
|
+
? {
|
|
1838
|
+
prompt: agentPrompt,
|
|
1839
|
+
cwd: handle.projectDir,
|
|
1840
|
+
studioUrl: resolveStudioUrl(config.port),
|
|
1841
|
+
hookToken: agentHookToken,
|
|
1842
|
+
agentName: agentName,
|
|
1843
|
+
...(roleSkill?.allowedTools && { allowedTools: roleSkill.allowedTools }),
|
|
1844
|
+
}
|
|
1845
|
+
: {
|
|
1846
|
+
prompt: agentPrompt,
|
|
1847
|
+
cwd: handle.projectDir,
|
|
1848
|
+
studioPort: config.port,
|
|
1849
|
+
hookToken: agentHookToken,
|
|
1850
|
+
agentName: agentName,
|
|
1851
|
+
...(roleSkill?.allowedTools && { allowedTools: roleSkill.allowedTools }),
|
|
1852
|
+
};
|
|
1853
|
+
const ccBridge = createClaudeCodeBridge(config, sessionId, claudeConfig);
|
|
1854
|
+
// Track Claude Code session ID and cost
|
|
1855
|
+
ccBridge.onAgentEvent((event) => {
|
|
1856
|
+
if (event.type === "session_start") {
|
|
1857
|
+
const ccSessionId = event.session_id;
|
|
1858
|
+
if (ccSessionId) {
|
|
1859
|
+
config.sessions.update(sessionId, { lastCoderSessionId: ccSessionId });
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
if (event.type === "session_end") {
|
|
1863
|
+
accumulateSessionCost(config, sessionId, event);
|
|
1864
|
+
}
|
|
1865
|
+
// Route assistant_message output to the room router
|
|
1866
|
+
if (event.type === "assistant_message" && "text" in event) {
|
|
1867
|
+
router
|
|
1868
|
+
.handleAgentOutput(sessionId, event.text)
|
|
1869
|
+
.catch((err) => {
|
|
1870
|
+
console.error(`[room:${roomId}] handleAgentOutput error:`, err);
|
|
1871
|
+
});
|
|
1872
|
+
}
|
|
1873
|
+
});
|
|
1874
|
+
await bridge.emit({
|
|
1875
|
+
type: "log",
|
|
1876
|
+
level: "done",
|
|
1877
|
+
message: `Sandbox ready for "${agentName}"`,
|
|
1878
|
+
ts: ts(),
|
|
1879
|
+
});
|
|
1880
|
+
await ccBridge.start();
|
|
1881
|
+
// Add participant to room router
|
|
1882
|
+
const participant = {
|
|
1883
|
+
sessionId,
|
|
1884
|
+
name: agentName,
|
|
1885
|
+
role: body.role,
|
|
1886
|
+
bridge: ccBridge,
|
|
1887
|
+
};
|
|
1888
|
+
await router.addParticipant(participant, body.gated ?? false);
|
|
1889
|
+
// If there's an initial prompt, send it directly to this agent only (not broadcast)
|
|
1890
|
+
if (body.initialPrompt) {
|
|
1891
|
+
await ccBridge.sendCommand({ command: "iterate", request: body.initialPrompt });
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
catch (err) {
|
|
1895
|
+
const msg = err instanceof Error ? err.message : "Failed to create agent sandbox";
|
|
1896
|
+
console.error(`[room:${roomId}] Agent creation failed:`, err);
|
|
1897
|
+
await bridge.emit({ type: "log", level: "error", message: msg, ts: ts() });
|
|
1898
|
+
}
|
|
1899
|
+
})();
|
|
1900
|
+
return c.json({ sessionId, participantName: agentName, sessionToken }, 201);
|
|
1552
1901
|
});
|
|
1553
|
-
//
|
|
1554
|
-
app.post("/api/
|
|
1555
|
-
const
|
|
1556
|
-
const
|
|
1557
|
-
if (!
|
|
1558
|
-
return c.json({ error: "
|
|
1902
|
+
// Add an existing running session to a room
|
|
1903
|
+
app.post("/api/rooms/:id/sessions", async (c) => {
|
|
1904
|
+
const roomId = c.req.param("id");
|
|
1905
|
+
const router = roomRouters.get(roomId);
|
|
1906
|
+
if (!router)
|
|
1907
|
+
return c.json({ error: "Room not found" }, 404);
|
|
1908
|
+
const body = await validateBody(c, addSessionToRoomSchema);
|
|
1909
|
+
if (isResponse(body))
|
|
1910
|
+
return body;
|
|
1911
|
+
const { sessionId } = body;
|
|
1912
|
+
// Require a valid session token — caller must already own this session.
|
|
1913
|
+
// Room auth is handled by middleware via X-Room-Token; Authorization
|
|
1914
|
+
// carries the session ownership proof here.
|
|
1915
|
+
const authHeader = c.req.header("Authorization");
|
|
1916
|
+
const sessionToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : undefined;
|
|
1917
|
+
if (!sessionToken ||
|
|
1918
|
+
!validateSessionToken(config.streamConfig.secret, sessionId, sessionToken)) {
|
|
1919
|
+
return c.json({ error: "Invalid or missing session token" }, 401);
|
|
1559
1920
|
}
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1921
|
+
// Verify the session exists
|
|
1922
|
+
const sessionInfo = config.sessions.get(sessionId);
|
|
1923
|
+
if (!sessionInfo) {
|
|
1924
|
+
return c.json({ error: "Session not found" }, 404);
|
|
1564
1925
|
}
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1926
|
+
// Get the sandbox handle — must be running
|
|
1927
|
+
const handle = config.sandbox.get(sessionId);
|
|
1928
|
+
if (!handle) {
|
|
1929
|
+
return c.json({ error: "Session sandbox not found or not running" }, 400);
|
|
1930
|
+
}
|
|
1931
|
+
// Get or create bridge (it should already exist for a running session)
|
|
1932
|
+
const bridge = getOrCreateBridge(config, sessionId);
|
|
1933
|
+
console.log(`[room:${roomId}] Adding existing session: name=${body.name} session=${sessionId}`);
|
|
1934
|
+
try {
|
|
1935
|
+
// Inject room-messaging skill
|
|
1936
|
+
if (roomMessagingSkillContent) {
|
|
1937
|
+
try {
|
|
1938
|
+
const skillDir = `${handle.projectDir}/.claude/skills/room-messaging`;
|
|
1939
|
+
const skillB64 = Buffer.from(roomMessagingSkillContent).toString("base64");
|
|
1940
|
+
await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
|
|
1941
|
+
// Append room-messaging reference to CLAUDE.md so the agent knows to read it
|
|
1942
|
+
const roomRef = `\n\n## Room Messaging (CRITICAL)\nYou are a participant in a multi-agent room. Read .claude/skills/room-messaging/SKILL.md for the messaging protocol.\nAll communication with other agents MUST use @room or @<name> messages as described in that skill.\n`;
|
|
1943
|
+
const refB64 = Buffer.from(roomRef).toString("base64");
|
|
1944
|
+
await config.sandbox.exec(handle, `echo '${refB64}' | base64 -d >> '${handle.projectDir}/CLAUDE.md'`);
|
|
1582
1945
|
}
|
|
1583
|
-
|
|
1584
|
-
|
|
1946
|
+
catch (err) {
|
|
1947
|
+
console.error(`[session:${sessionId}] Failed to write room-messaging skill:`, err);
|
|
1585
1948
|
}
|
|
1586
1949
|
}
|
|
1950
|
+
// The existing bridge is already a Claude Code bridge — wire up room output handling
|
|
1951
|
+
bridge.onAgentEvent((event) => {
|
|
1952
|
+
if (event.type === "assistant_message" && "text" in event) {
|
|
1953
|
+
router
|
|
1954
|
+
.handleAgentOutput(sessionId, event.text)
|
|
1955
|
+
.catch((err) => {
|
|
1956
|
+
console.error(`[room:${roomId}] handleAgentOutput error:`, err);
|
|
1957
|
+
});
|
|
1958
|
+
}
|
|
1959
|
+
});
|
|
1960
|
+
// Add participant to room router
|
|
1961
|
+
const participant = {
|
|
1962
|
+
sessionId,
|
|
1963
|
+
name: body.name,
|
|
1964
|
+
bridge,
|
|
1965
|
+
};
|
|
1966
|
+
await router.addParticipant(participant, false);
|
|
1967
|
+
// If there's an initial prompt, send it directly to this agent
|
|
1968
|
+
if (body.initialPrompt) {
|
|
1969
|
+
await bridge.sendCommand({ command: "iterate", request: body.initialPrompt });
|
|
1970
|
+
}
|
|
1587
1971
|
}
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
app.post("/api/shared-sessions/:id/sessions", async (c) => {
|
|
1593
|
-
const id = c.req.param("id");
|
|
1594
|
-
const body = (await c.req.json());
|
|
1595
|
-
if (!body.sessionId || !body.linkedBy) {
|
|
1596
|
-
return c.json({ error: "sessionId and linkedBy are required" }, 400);
|
|
1972
|
+
catch (err) {
|
|
1973
|
+
const msg = err instanceof Error ? err.message : "Failed to add session to room";
|
|
1974
|
+
console.error(`[room:${roomId}] Add session failed:`, err);
|
|
1975
|
+
return c.json({ error: msg }, 500);
|
|
1597
1976
|
}
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
url: conn.url,
|
|
1601
|
-
headers: conn.headers,
|
|
1602
|
-
contentType: "application/json",
|
|
1603
|
-
});
|
|
1604
|
-
const event = {
|
|
1605
|
-
type: "session_linked",
|
|
1606
|
-
sessionId: body.sessionId,
|
|
1607
|
-
sessionName: body.sessionName || "",
|
|
1608
|
-
sessionDescription: body.sessionDescription || "",
|
|
1609
|
-
linkedBy: body.linkedBy,
|
|
1610
|
-
ts: ts(),
|
|
1611
|
-
};
|
|
1612
|
-
await stream.append(JSON.stringify(event));
|
|
1613
|
-
return c.json({ ok: true });
|
|
1614
|
-
});
|
|
1615
|
-
// Get a session token for a linked session (allows room participants to read session streams)
|
|
1616
|
-
app.get("/api/shared-sessions/:id/sessions/:sessionId/token", (c) => {
|
|
1617
|
-
const sessionId = c.req.param("sessionId");
|
|
1618
|
-
const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
|
|
1619
|
-
return c.json({ sessionToken });
|
|
1977
|
+
// No need to return sessionToken — caller already proved they have it
|
|
1978
|
+
return c.json({ sessionId, participantName: body.name }, 201);
|
|
1620
1979
|
});
|
|
1621
|
-
//
|
|
1622
|
-
app.
|
|
1623
|
-
const
|
|
1980
|
+
// Send a message directly to a specific session in a room (bypasses room stream)
|
|
1981
|
+
app.post("/api/rooms/:id/sessions/:sessionId/iterate", async (c) => {
|
|
1982
|
+
const roomId = c.req.param("id");
|
|
1624
1983
|
const sessionId = c.req.param("sessionId");
|
|
1625
|
-
const
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1984
|
+
const router = roomRouters.get(roomId);
|
|
1985
|
+
if (!router)
|
|
1986
|
+
return c.json({ error: "Room not found" }, 404);
|
|
1987
|
+
const participant = router.participants.find((p) => p.sessionId === sessionId);
|
|
1988
|
+
if (!participant)
|
|
1989
|
+
return c.json({ error: "Session not found in this room" }, 404);
|
|
1990
|
+
const body = await validateBody(c, iterateRoomSessionSchema);
|
|
1991
|
+
if (isResponse(body))
|
|
1992
|
+
return body;
|
|
1993
|
+
await participant.bridge.sendCommand({
|
|
1994
|
+
command: "iterate",
|
|
1995
|
+
request: body.request,
|
|
1630
1996
|
});
|
|
1631
|
-
const event = {
|
|
1632
|
-
type: "session_unlinked",
|
|
1633
|
-
sessionId,
|
|
1634
|
-
ts: ts(),
|
|
1635
|
-
};
|
|
1636
|
-
await stream.append(JSON.stringify(event));
|
|
1637
1997
|
return c.json({ ok: true });
|
|
1638
1998
|
});
|
|
1639
|
-
//
|
|
1640
|
-
app.
|
|
1641
|
-
const
|
|
1642
|
-
const
|
|
1643
|
-
if (!
|
|
1644
|
-
return c.json({ error: "
|
|
1645
|
-
const
|
|
1999
|
+
// Send a message to a room (from human or API)
|
|
2000
|
+
app.post("/api/rooms/:id/messages", async (c) => {
|
|
2001
|
+
const roomId = c.req.param("id");
|
|
2002
|
+
const router = roomRouters.get(roomId);
|
|
2003
|
+
if (!router)
|
|
2004
|
+
return c.json({ error: "Room not found" }, 404);
|
|
2005
|
+
const body = await validateBody(c, sendRoomMessageSchema);
|
|
2006
|
+
if (isResponse(body))
|
|
2007
|
+
return body;
|
|
2008
|
+
await router.sendMessage(body.from, body.body, body.to);
|
|
2009
|
+
return c.json({ ok: true });
|
|
2010
|
+
});
|
|
2011
|
+
// SSE proxy for room events
|
|
2012
|
+
app.get("/api/rooms/:id/events", async (c) => {
|
|
2013
|
+
const roomId = c.req.param("id");
|
|
2014
|
+
const router = roomRouters.get(roomId);
|
|
2015
|
+
if (!router)
|
|
2016
|
+
return c.json({ error: "Room not found" }, 404);
|
|
2017
|
+
const connection = roomStream(config, roomId);
|
|
1646
2018
|
const lastEventId = c.req.header("Last-Event-ID") || c.req.query("offset") || "-1";
|
|
1647
2019
|
const reader = new DurableStream({
|
|
1648
2020
|
url: connection.url,
|
|
@@ -1681,23 +2053,27 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1681
2053
|
},
|
|
1682
2054
|
});
|
|
1683
2055
|
});
|
|
1684
|
-
//
|
|
1685
|
-
app.post("/api/
|
|
1686
|
-
const
|
|
1687
|
-
const
|
|
1688
|
-
if (!
|
|
1689
|
-
return c.json({ error: "
|
|
1690
|
-
|
|
2056
|
+
// Close a room
|
|
2057
|
+
app.post("/api/rooms/:id/close", async (c) => {
|
|
2058
|
+
const roomId = c.req.param("id");
|
|
2059
|
+
const router = roomRouters.get(roomId);
|
|
2060
|
+
if (!router)
|
|
2061
|
+
return c.json({ error: "Room not found" }, 404);
|
|
2062
|
+
// Emit room_closed event
|
|
2063
|
+
const conn = roomStream(config, roomId);
|
|
1691
2064
|
const stream = new DurableStream({
|
|
1692
2065
|
url: conn.url,
|
|
1693
2066
|
headers: conn.headers,
|
|
1694
2067
|
contentType: "application/json",
|
|
1695
2068
|
});
|
|
1696
2069
|
const event = {
|
|
1697
|
-
type: "
|
|
2070
|
+
type: "room_closed",
|
|
2071
|
+
closedBy: "human",
|
|
2072
|
+
summary: "Room closed by user",
|
|
1698
2073
|
ts: ts(),
|
|
1699
2074
|
};
|
|
1700
2075
|
await stream.append(JSON.stringify(event));
|
|
2076
|
+
router.close();
|
|
1701
2077
|
return c.json({ ok: true });
|
|
1702
2078
|
});
|
|
1703
2079
|
// --- SSE Proxy ---
|
|
@@ -1767,6 +2143,46 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1767
2143
|
},
|
|
1768
2144
|
});
|
|
1769
2145
|
});
|
|
2146
|
+
// --- Stream Append Proxy ---
|
|
2147
|
+
// Proxy endpoint for writing events to a session's durable stream.
|
|
2148
|
+
// Authenticates via session token so the caller never needs DS_SECRET.
|
|
2149
|
+
// Used by sandbox agents to write events back to the session stream.
|
|
2150
|
+
app.post("/api/sessions/:id/stream/append", async (c) => {
|
|
2151
|
+
const sessionId = c.req.param("id");
|
|
2152
|
+
const contentType = c.req.header("content-type") ?? "";
|
|
2153
|
+
if (!contentType.includes("application/json")) {
|
|
2154
|
+
return c.json({ error: "Content-Type must be application/json" }, 415);
|
|
2155
|
+
}
|
|
2156
|
+
const body = await c.req.text();
|
|
2157
|
+
if (!body) {
|
|
2158
|
+
return c.json({ error: "Request body is required" }, 400);
|
|
2159
|
+
}
|
|
2160
|
+
// Guard against oversized payloads (64 KB limit)
|
|
2161
|
+
if (body.length > 65_536) {
|
|
2162
|
+
return c.json({ error: "Payload too large" }, 413);
|
|
2163
|
+
}
|
|
2164
|
+
// Validate JSON before forwarding to the stream
|
|
2165
|
+
try {
|
|
2166
|
+
JSON.parse(body);
|
|
2167
|
+
}
|
|
2168
|
+
catch {
|
|
2169
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
2170
|
+
}
|
|
2171
|
+
const connection = sessionStream(config, sessionId);
|
|
2172
|
+
try {
|
|
2173
|
+
const writer = new DurableStream({
|
|
2174
|
+
url: connection.url,
|
|
2175
|
+
headers: connection.headers,
|
|
2176
|
+
contentType: "application/json",
|
|
2177
|
+
});
|
|
2178
|
+
await writer.append(body);
|
|
2179
|
+
return c.json({ ok: true });
|
|
2180
|
+
}
|
|
2181
|
+
catch (err) {
|
|
2182
|
+
console.error(`[stream-proxy] Append failed: session=${sessionId}`, err);
|
|
2183
|
+
return c.json({ error: "Failed to append to stream" }, 500);
|
|
2184
|
+
}
|
|
2185
|
+
});
|
|
1770
2186
|
// --- Git/GitHub Routes ---
|
|
1771
2187
|
// Get git status for a session
|
|
1772
2188
|
app.get("/api/sessions/:id/git-status", async (c) => {
|
|
@@ -1814,7 +2230,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1814
2230
|
if (!handle || !sandboxDir) {
|
|
1815
2231
|
return c.json({ error: "Container not available" }, 404);
|
|
1816
2232
|
}
|
|
1817
|
-
|
|
2233
|
+
const resolvedPath = path.resolve(filePath);
|
|
2234
|
+
const resolvedDir = path.resolve(sandboxDir) + path.sep;
|
|
2235
|
+
if (!resolvedPath.startsWith(resolvedDir) && resolvedPath !== path.resolve(sandboxDir)) {
|
|
1818
2236
|
return c.json({ error: "Path outside project directory" }, 403);
|
|
1819
2237
|
}
|
|
1820
2238
|
const content = await config.sandbox.readFile(handle, filePath);
|
|
@@ -1825,6 +2243,8 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1825
2243
|
});
|
|
1826
2244
|
// List GitHub accounts (personal + orgs) — requires client-provided token
|
|
1827
2245
|
app.get("/api/github/accounts", (c) => {
|
|
2246
|
+
if (!config.devMode)
|
|
2247
|
+
return c.json({ error: "Not available" }, 403);
|
|
1828
2248
|
const token = c.req.header("X-GH-Token");
|
|
1829
2249
|
if (!token)
|
|
1830
2250
|
return c.json({ accounts: [] });
|
|
@@ -1836,8 +2256,10 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1836
2256
|
return c.json({ error: e instanceof Error ? e.message : "Failed to list accounts" }, 500);
|
|
1837
2257
|
}
|
|
1838
2258
|
});
|
|
1839
|
-
// List GitHub repos for the authenticated user — requires client-provided token
|
|
2259
|
+
// List GitHub repos for the authenticated user — requires client-provided token (dev mode only)
|
|
1840
2260
|
app.get("/api/github/repos", (c) => {
|
|
2261
|
+
if (!config.devMode)
|
|
2262
|
+
return c.json({ error: "Not available" }, 403);
|
|
1841
2263
|
const token = c.req.header("X-GH-Token");
|
|
1842
2264
|
if (!token)
|
|
1843
2265
|
return c.json({ repos: [] });
|
|
@@ -1850,6 +2272,8 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1850
2272
|
}
|
|
1851
2273
|
});
|
|
1852
2274
|
app.get("/api/github/repos/:owner/:repo/branches", (c) => {
|
|
2275
|
+
if (!config.devMode)
|
|
2276
|
+
return c.json({ error: "Not available" }, 403);
|
|
1853
2277
|
const owner = c.req.param("owner");
|
|
1854
2278
|
const repo = c.req.param("repo");
|
|
1855
2279
|
const token = c.req.header("X-GH-Token");
|
|
@@ -1863,33 +2287,38 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1863
2287
|
return c.json({ error: e instanceof Error ? e.message : "Failed to list branches" }, 500);
|
|
1864
2288
|
}
|
|
1865
2289
|
});
|
|
1866
|
-
// Read Claude credentials from macOS Keychain (dev convenience)
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
const raw = execFileSync("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], { encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
1873
|
-
const parsed = JSON.parse(raw);
|
|
1874
|
-
const token = parsed.claudeAiOauth?.accessToken ?? null;
|
|
1875
|
-
if (token) {
|
|
1876
|
-
console.log(`[dev] Loaded OAuth token from keychain: ${token.slice(0, 20)}...${token.slice(-10)}`);
|
|
2290
|
+
// Read Claude credentials from macOS Keychain (dev convenience).
|
|
2291
|
+
// Disabled by default — enable via devMode: true or STUDIO_DEV_MODE=1.
|
|
2292
|
+
if (config.devMode) {
|
|
2293
|
+
app.get("/api/credentials/keychain", (c) => {
|
|
2294
|
+
if (process.platform !== "darwin") {
|
|
2295
|
+
return c.json({ apiKey: null });
|
|
1877
2296
|
}
|
|
1878
|
-
|
|
1879
|
-
|
|
2297
|
+
try {
|
|
2298
|
+
const raw = execFileSync("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], { encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
2299
|
+
const parsed = JSON.parse(raw);
|
|
2300
|
+
const token = parsed.claudeAiOauth?.accessToken ?? null;
|
|
2301
|
+
if (token) {
|
|
2302
|
+
console.log(`[dev] Loaded OAuth token from keychain (length: ${token.length})`);
|
|
2303
|
+
}
|
|
2304
|
+
else {
|
|
2305
|
+
console.log("[dev] No OAuth token found in keychain");
|
|
2306
|
+
}
|
|
2307
|
+
return c.json({ oauthToken: token });
|
|
1880
2308
|
}
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
// Resume a project from a GitHub repo
|
|
2309
|
+
catch {
|
|
2310
|
+
return c.json({ oauthToken: null });
|
|
2311
|
+
}
|
|
2312
|
+
});
|
|
2313
|
+
}
|
|
2314
|
+
// Resume a project from a GitHub repo (dev mode only)
|
|
1888
2315
|
app.post("/api/sessions/resume", async (c) => {
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
return c.json({ error: "repoUrl is required" }, 400);
|
|
2316
|
+
if (!config.devMode) {
|
|
2317
|
+
return c.json({ error: "Resume from repo not available" }, 403);
|
|
1892
2318
|
}
|
|
2319
|
+
const body = await validateBody(c, resumeSessionSchema);
|
|
2320
|
+
if (isResponse(body))
|
|
2321
|
+
return body;
|
|
1893
2322
|
const sessionId = crypto.randomUUID();
|
|
1894
2323
|
const repoName = body.repoUrl
|
|
1895
2324
|
.split("/")
|
|
@@ -1969,6 +2398,7 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1969
2398
|
projectName: repoName,
|
|
1970
2399
|
projectDir: handle.projectDir,
|
|
1971
2400
|
runtime: config.sandbox.runtime,
|
|
2401
|
+
production: !config.devMode,
|
|
1972
2402
|
git: {
|
|
1973
2403
|
mode: "existing",
|
|
1974
2404
|
repoName: parseRepoNameFromUrl(body.repoUrl) ?? repoName,
|
|
@@ -1992,18 +2422,33 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1992
2422
|
console.error(`[session:${sessionId}] Failed to write create-app skill:`, err);
|
|
1993
2423
|
}
|
|
1994
2424
|
}
|
|
2425
|
+
// Ensure the room-messaging skill is present so agents have
|
|
2426
|
+
// persistent access to the multi-agent protocol reference.
|
|
2427
|
+
if (roomMessagingSkillContent) {
|
|
2428
|
+
try {
|
|
2429
|
+
const skillDir = `${handle.projectDir}/.claude/skills/room-messaging`;
|
|
2430
|
+
const skillB64 = Buffer.from(roomMessagingSkillContent).toString("base64");
|
|
2431
|
+
await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
|
|
2432
|
+
}
|
|
2433
|
+
catch (err) {
|
|
2434
|
+
console.error(`[session:${sessionId}] Failed to write room-messaging skill:`, err);
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
1995
2437
|
// 3. Create Claude Code bridge with a resume prompt
|
|
1996
2438
|
const resumePrompt = "You are resuming work on an existing project. Explore the codebase to understand its structure, then wait for instructions from the user.";
|
|
2439
|
+
const resumeHookToken = deriveHookToken(config.streamConfig.secret, sessionId);
|
|
1997
2440
|
const claudeConfig = config.sandbox.runtime === "sprites"
|
|
1998
2441
|
? {
|
|
1999
2442
|
prompt: resumePrompt,
|
|
2000
2443
|
cwd: handle.projectDir,
|
|
2001
2444
|
studioUrl: resolveStudioUrl(config.port),
|
|
2445
|
+
hookToken: resumeHookToken,
|
|
2002
2446
|
}
|
|
2003
2447
|
: {
|
|
2004
2448
|
prompt: resumePrompt,
|
|
2005
2449
|
cwd: handle.projectDir,
|
|
2006
2450
|
studioPort: config.port,
|
|
2451
|
+
hookToken: resumeHookToken,
|
|
2007
2452
|
};
|
|
2008
2453
|
const ccBridge = createClaudeCodeBridge(config, sessionId, claudeConfig);
|
|
2009
2454
|
// 4. Register event listeners (reuse pattern from normal flow)
|
|
@@ -2083,6 +2528,17 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
2083
2528
|
asyncFlow().catch(async (err) => {
|
|
2084
2529
|
console.error(`[session:${sessionId}] Resume flow failed:`, err);
|
|
2085
2530
|
config.sessions.update(sessionId, { status: "error" });
|
|
2531
|
+
try {
|
|
2532
|
+
await bridge.emit({
|
|
2533
|
+
type: "log",
|
|
2534
|
+
level: "error",
|
|
2535
|
+
message: `Resume failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
2536
|
+
ts: ts(),
|
|
2537
|
+
});
|
|
2538
|
+
}
|
|
2539
|
+
catch {
|
|
2540
|
+
// Bridge may not be usable if the failure happened early
|
|
2541
|
+
}
|
|
2086
2542
|
});
|
|
2087
2543
|
const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
|
|
2088
2544
|
return c.json({ sessionId, session, sessionToken }, 201);
|
|
@@ -2107,6 +2563,10 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
2107
2563
|
return app;
|
|
2108
2564
|
}
|
|
2109
2565
|
export async function startWebServer(opts) {
|
|
2566
|
+
const devMode = opts.devMode ?? process.env.STUDIO_DEV_MODE === "1";
|
|
2567
|
+
if (devMode) {
|
|
2568
|
+
console.log("[studio] Dev mode enabled — keychain endpoint active");
|
|
2569
|
+
}
|
|
2110
2570
|
const config = {
|
|
2111
2571
|
port: opts.port ?? 4400,
|
|
2112
2572
|
dataDir: opts.dataDir ?? path.resolve(process.cwd(), ".electric-agent"),
|
|
@@ -2115,6 +2575,7 @@ export async function startWebServer(opts) {
|
|
|
2115
2575
|
sandbox: opts.sandbox,
|
|
2116
2576
|
streamConfig: opts.streamConfig,
|
|
2117
2577
|
bridgeMode: opts.bridgeMode ?? "claude-code",
|
|
2578
|
+
devMode,
|
|
2118
2579
|
};
|
|
2119
2580
|
fs.mkdirSync(config.dataDir, { recursive: true });
|
|
2120
2581
|
const app = createApp(config);
|