@electric-agent/studio 1.12.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/bridge/claude-md-generator.d.ts +5 -2
- package/dist/bridge/claude-md-generator.d.ts.map +1 -1
- package/dist/bridge/claude-md-generator.js +33 -1
- package/dist/bridge/claude-md-generator.js.map +1 -1
- package/dist/client/assets/index-CtOOaA2Q.js +235 -0
- package/dist/client/index.html +1 -1
- 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/room-router.d.ts.map +1 -1
- package/dist/room-router.js +1 -1
- package/dist/room-router.js.map +1 -1
- package/dist/sandbox/docker.d.ts +1 -0
- package/dist/sandbox/docker.d.ts.map +1 -1
- package/dist/sandbox/docker.js +51 -0
- package/dist/sandbox/docker.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 +51 -0
- package/dist/sandbox/sprites.js.map +1 -1
- package/dist/sandbox/types.d.ts +5 -0
- package/dist/sandbox/types.d.ts.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +171 -125
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- package/dist/client/assets/index-CiwD5LkP.js +0 -235
package/dist/server.js
CHANGED
|
@@ -17,6 +17,7 @@ import { HostedStreamBridge } from "./bridge/hosted.js";
|
|
|
17
17
|
import { DEFAULT_ELECTRIC_URL, getClaimUrl, provisionElectricResources } from "./electric-api.js";
|
|
18
18
|
import { createGate, rejectAllGates, resolveGate } from "./gate.js";
|
|
19
19
|
import { ghListAccounts, ghListBranches, ghListRepos, isGhAuthenticated } from "./git.js";
|
|
20
|
+
import { createOrgRepo, getInstallationToken } from "./github-app.js";
|
|
20
21
|
import { generateInviteCode } from "./invite-code.js";
|
|
21
22
|
import { resolveProjectDir } from "./project-utils.js";
|
|
22
23
|
import { RoomRouter } from "./room-router.js";
|
|
@@ -73,8 +74,17 @@ function resolveStudioUrl(port) {
|
|
|
73
74
|
// Rate limiting — in-memory sliding window per IP
|
|
74
75
|
// ---------------------------------------------------------------------------
|
|
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);
|
|
76
78
|
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
|
77
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;
|
|
78
88
|
function extractClientIp(c) {
|
|
79
89
|
return (c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
|
|
80
90
|
c.req.header("cf-connecting-ip") ||
|
|
@@ -94,6 +104,20 @@ function checkSessionRateLimit(ip) {
|
|
|
94
104
|
sessionCreationsByIp.set(ip, timestamps);
|
|
95
105
|
return true;
|
|
96
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
|
+
}
|
|
97
121
|
// ---------------------------------------------------------------------------
|
|
98
122
|
// Per-session cost budget
|
|
99
123
|
// ---------------------------------------------------------------------------
|
|
@@ -186,31 +210,6 @@ function closeBridge(sessionId) {
|
|
|
186
210
|
bridges.delete(sessionId);
|
|
187
211
|
}
|
|
188
212
|
}
|
|
189
|
-
/**
|
|
190
|
-
* Detect git operations from natural language prompts.
|
|
191
|
-
* Returns structured gitOp fields if matched, null otherwise.
|
|
192
|
-
*/
|
|
193
|
-
function detectGitOp(request) {
|
|
194
|
-
const lower = request.toLowerCase().trim();
|
|
195
|
-
// Commit: "commit", "commit the code", "commit changes", "commit with message ..."
|
|
196
|
-
if (/^(git\s+)?commit\b/.test(lower) || /^save\s+(my\s+)?(changes|progress|work)\b/.test(lower)) {
|
|
197
|
-
// Extract commit message after "commit" keyword, or after "message:" / "msg:"
|
|
198
|
-
const msgMatch = request.match(/(?:commit\s+(?:with\s+(?:message\s+)?)?|message:\s*|msg:\s*)["']?(.+?)["']?\s*$/i);
|
|
199
|
-
const message = msgMatch?.[1]?.replace(/^(the\s+)?(code|changes)\s*/i, "").trim();
|
|
200
|
-
return { gitOp: "commit", gitMessage: message || undefined };
|
|
201
|
-
}
|
|
202
|
-
// Push: "push", "push to github", "push to remote", "git push"
|
|
203
|
-
if (/^(git\s+)?push\b/.test(lower)) {
|
|
204
|
-
return { gitOp: "push" };
|
|
205
|
-
}
|
|
206
|
-
// Create PR: "create pr", "open pr", "make pr", "create pull request"
|
|
207
|
-
if (/^(create|open|make)\s+(a\s+)?(pr|pull\s*request)\b/.test(lower)) {
|
|
208
|
-
// Try to extract title after the PR keyword
|
|
209
|
-
const titleMatch = request.match(/(?:pr|pull\s*request)\s+(?:(?:titled?|called|named)\s+)?["']?(.+?)["']?\s*$/i);
|
|
210
|
-
return { gitOp: "create-pr", gitPrTitle: titleMatch?.[1] || undefined };
|
|
211
|
-
}
|
|
212
|
-
return null;
|
|
213
|
-
}
|
|
214
213
|
/**
|
|
215
214
|
* Map a Claude Code hook event JSON payload to an EngineEvent.
|
|
216
215
|
*
|
|
@@ -343,7 +342,10 @@ export function createApp(config) {
|
|
|
343
342
|
});
|
|
344
343
|
// Public config — exposes non-sensitive flags to the client
|
|
345
344
|
app.get("/api/config", (c) => {
|
|
346
|
-
return c.json({
|
|
345
|
+
return c.json({
|
|
346
|
+
devMode: config.devMode,
|
|
347
|
+
maxSessionCostUsd: config.devMode ? undefined : MAX_SESSION_COST_USD,
|
|
348
|
+
});
|
|
347
349
|
});
|
|
348
350
|
// Provision Electric Cloud resources via the Claim API
|
|
349
351
|
app.post("/api/provision-electric", async (c) => {
|
|
@@ -802,6 +804,10 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
802
804
|
const body = await validateBody(c, createSessionSchema);
|
|
803
805
|
if (isResponse(body))
|
|
804
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;
|
|
805
811
|
// Block freeform sessions in production mode
|
|
806
812
|
if (body.freeform && !config.devMode) {
|
|
807
813
|
return c.json({ error: "Freeform sessions are not available" }, 403);
|
|
@@ -812,14 +818,19 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
812
818
|
if (!checkSessionRateLimit(ip)) {
|
|
813
819
|
return c.json({ error: "Too many sessions. Please try again later." }, 429);
|
|
814
820
|
}
|
|
821
|
+
if (checkGlobalSessionCap(config.sessions)) {
|
|
822
|
+
return c.json({ error: "Service at capacity, please try again later" }, 503);
|
|
823
|
+
}
|
|
815
824
|
}
|
|
816
825
|
const sessionId = crypto.randomUUID();
|
|
817
|
-
const inferredName =
|
|
818
|
-
body.
|
|
819
|
-
.
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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)}`;
|
|
823
834
|
const baseDir = body.baseDir || process.cwd();
|
|
824
835
|
const { projectName } = resolveProjectDir(baseDir, inferredName);
|
|
825
836
|
console.log(`[session] Creating new session: id=${sessionId} project=${projectName}`);
|
|
@@ -856,11 +867,10 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
856
867
|
// Freeform sessions skip the infra config gate — no Electric/DB setup needed
|
|
857
868
|
let ghAccounts = [];
|
|
858
869
|
if (!body.freeform) {
|
|
859
|
-
// Gather GitHub accounts for the merged setup gate
|
|
860
|
-
|
|
861
|
-
if (body.ghToken && isGhAuthenticated(body.ghToken)) {
|
|
870
|
+
// Gather GitHub accounts for the merged setup gate (dev mode only)
|
|
871
|
+
if (config.devMode && ghToken && isGhAuthenticated(ghToken)) {
|
|
862
872
|
try {
|
|
863
|
-
ghAccounts = ghListAccounts(
|
|
873
|
+
ghAccounts = ghListAccounts(ghToken);
|
|
864
874
|
}
|
|
865
875
|
catch {
|
|
866
876
|
// gh not available — no repo setup
|
|
@@ -943,9 +953,15 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
943
953
|
const handle = await config.sandbox.create(sessionId, {
|
|
944
954
|
projectName,
|
|
945
955
|
infra,
|
|
946
|
-
apiKey
|
|
947
|
-
oauthToken
|
|
948
|
-
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
|
+
}),
|
|
949
965
|
});
|
|
950
966
|
console.log(`[session:${sessionId}] Sandbox created: projectDir=${handle.projectDir} port=${handle.port} previewUrl=${handle.previewUrl ?? "none"}`);
|
|
951
967
|
await bridge.emit({
|
|
@@ -1011,6 +1027,54 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1011
1027
|
ts: ts(),
|
|
1012
1028
|
});
|
|
1013
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
|
+
}
|
|
1014
1078
|
// Write CLAUDE.md to the sandbox workspace.
|
|
1015
1079
|
// Our generator includes hardcoded playbook paths and reading order
|
|
1016
1080
|
// so we don't depend on @tanstack/intent generating a skill block.
|
|
@@ -1020,15 +1084,17 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1020
1084
|
projectDir: handle.projectDir,
|
|
1021
1085
|
runtime: config.sandbox.runtime,
|
|
1022
1086
|
production: !config.devMode,
|
|
1023
|
-
...(
|
|
1024
|
-
? {
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
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
|
+
: {}),
|
|
1032
1098
|
});
|
|
1033
1099
|
try {
|
|
1034
1100
|
await config.sandbox.exec(handle, `cat > '${handle.projectDir}/CLAUDE.md' << 'CLAUDEMD_EOF'\n${claudeMd}\nCLAUDEMD_EOF`);
|
|
@@ -1193,69 +1259,6 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1193
1259
|
const body = await validateBody(c, iterateSessionSchema);
|
|
1194
1260
|
if (isResponse(body))
|
|
1195
1261
|
return body;
|
|
1196
|
-
// Intercept operational commands (start/stop/restart the app/server)
|
|
1197
|
-
const normalised = body.request
|
|
1198
|
-
.toLowerCase()
|
|
1199
|
-
.replace(/[^a-z ]/g, "")
|
|
1200
|
-
.trim();
|
|
1201
|
-
const appOrServer = /\b(app|server|dev server|dev|vite)\b/;
|
|
1202
|
-
const isStartCmd = /^(start|run|launch|boot)\b/.test(normalised) && appOrServer.test(normalised);
|
|
1203
|
-
const isStopCmd = /^(stop|kill|shutdown|shut down)\b/.test(normalised) && appOrServer.test(normalised);
|
|
1204
|
-
const isRestartCmd = /^restart\b/.test(normalised) && appOrServer.test(normalised);
|
|
1205
|
-
if (isStartCmd || isStopCmd || isRestartCmd) {
|
|
1206
|
-
const bridge = getOrCreateBridge(config, sessionId);
|
|
1207
|
-
await bridge.emit({ type: "user_prompt", message: body.request, ts: ts() });
|
|
1208
|
-
try {
|
|
1209
|
-
const handle = config.sandbox.get(sessionId);
|
|
1210
|
-
if (isStopCmd) {
|
|
1211
|
-
if (handle && config.sandbox.isAlive(handle))
|
|
1212
|
-
await config.sandbox.stopApp(handle);
|
|
1213
|
-
await bridge.emit({ type: "log", level: "done", message: "App stopped", ts: ts() });
|
|
1214
|
-
}
|
|
1215
|
-
else {
|
|
1216
|
-
if (!handle || !config.sandbox.isAlive(handle)) {
|
|
1217
|
-
return c.json({ error: "Container is not running" }, 400);
|
|
1218
|
-
}
|
|
1219
|
-
if (isRestartCmd)
|
|
1220
|
-
await config.sandbox.stopApp(handle);
|
|
1221
|
-
await config.sandbox.startApp(handle);
|
|
1222
|
-
await bridge.emit({
|
|
1223
|
-
type: "log",
|
|
1224
|
-
level: "done",
|
|
1225
|
-
message: "App started",
|
|
1226
|
-
ts: ts(),
|
|
1227
|
-
});
|
|
1228
|
-
await bridge.emit({
|
|
1229
|
-
type: "app_status",
|
|
1230
|
-
status: "running",
|
|
1231
|
-
port: session.appPort,
|
|
1232
|
-
previewUrl: session.previewUrl,
|
|
1233
|
-
ts: ts(),
|
|
1234
|
-
});
|
|
1235
|
-
}
|
|
1236
|
-
}
|
|
1237
|
-
catch (err) {
|
|
1238
|
-
const msg = err instanceof Error ? err.message : "Operation failed";
|
|
1239
|
-
await bridge.emit({ type: "log", level: "error", message: msg, ts: ts() });
|
|
1240
|
-
}
|
|
1241
|
-
return c.json({ ok: true });
|
|
1242
|
-
}
|
|
1243
|
-
// Intercept git commands (commit, push, create PR)
|
|
1244
|
-
const gitOp = detectGitOp(body.request);
|
|
1245
|
-
if (gitOp) {
|
|
1246
|
-
const bridge = getOrCreateBridge(config, sessionId);
|
|
1247
|
-
await bridge.emit({ type: "user_prompt", message: body.request, ts: ts() });
|
|
1248
|
-
const handle = config.sandbox.get(sessionId);
|
|
1249
|
-
if (!handle || !config.sandbox.isAlive(handle)) {
|
|
1250
|
-
return c.json({ error: "Container is not running" }, 400);
|
|
1251
|
-
}
|
|
1252
|
-
// Send git requests as user messages via Claude Code bridge
|
|
1253
|
-
await bridge.sendCommand({
|
|
1254
|
-
command: "iterate",
|
|
1255
|
-
request: body.request,
|
|
1256
|
-
});
|
|
1257
|
-
return c.json({ ok: true });
|
|
1258
|
-
}
|
|
1259
1262
|
const handle = config.sandbox.get(sessionId);
|
|
1260
1263
|
if (!handle || !config.sandbox.isAlive(handle)) {
|
|
1261
1264
|
return c.json({ error: "Container is not running" }, 400);
|
|
@@ -1272,6 +1275,28 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1272
1275
|
});
|
|
1273
1276
|
return c.json({ ok: true });
|
|
1274
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
|
+
});
|
|
1275
1300
|
// Respond to a gate (approval, clarification, continue, revision)
|
|
1276
1301
|
app.post("/api/sessions/:id/respond", async (c) => {
|
|
1277
1302
|
const sessionId = c.req.param("id");
|
|
@@ -1658,8 +1683,8 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1658
1683
|
console.log(`[room] Created: id=${roomId} name=${body.name} code=${code}`);
|
|
1659
1684
|
return c.json({ roomId, code, roomToken }, 201);
|
|
1660
1685
|
});
|
|
1661
|
-
// Join an agent room by id + invite code
|
|
1662
|
-
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) => {
|
|
1663
1688
|
const id = c.req.param("id");
|
|
1664
1689
|
const code = c.req.param("code");
|
|
1665
1690
|
const room = config.rooms.getRoom(id);
|
|
@@ -1697,6 +1722,19 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1697
1722
|
const body = await validateBody(c, addAgentSchema);
|
|
1698
1723
|
if (isResponse(body))
|
|
1699
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
|
+
}
|
|
1734
|
+
}
|
|
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;
|
|
1700
1738
|
const sessionId = crypto.randomUUID();
|
|
1701
1739
|
const randomSuffix = sessionId.slice(0, 6);
|
|
1702
1740
|
const agentName = body.name?.trim() || `agent-${randomSuffix}`;
|
|
@@ -1745,9 +1783,15 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1745
1783
|
const handle = await config.sandbox.create(sessionId, {
|
|
1746
1784
|
projectName,
|
|
1747
1785
|
infra: { mode: "local" },
|
|
1748
|
-
apiKey
|
|
1749
|
-
oauthToken
|
|
1750
|
-
ghToken
|
|
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
|
+
}),
|
|
1751
1795
|
});
|
|
1752
1796
|
config.sessions.update(sessionId, {
|
|
1753
1797
|
appPort: handle.port,
|
|
@@ -2199,6 +2243,8 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
2199
2243
|
});
|
|
2200
2244
|
// List GitHub accounts (personal + orgs) — requires client-provided token
|
|
2201
2245
|
app.get("/api/github/accounts", (c) => {
|
|
2246
|
+
if (!config.devMode)
|
|
2247
|
+
return c.json({ error: "Not available" }, 403);
|
|
2202
2248
|
const token = c.req.header("X-GH-Token");
|
|
2203
2249
|
if (!token)
|
|
2204
2250
|
return c.json({ accounts: [] });
|
|
@@ -2210,8 +2256,10 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
2210
2256
|
return c.json({ error: e instanceof Error ? e.message : "Failed to list accounts" }, 500);
|
|
2211
2257
|
}
|
|
2212
2258
|
});
|
|
2213
|
-
// 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)
|
|
2214
2260
|
app.get("/api/github/repos", (c) => {
|
|
2261
|
+
if (!config.devMode)
|
|
2262
|
+
return c.json({ error: "Not available" }, 403);
|
|
2215
2263
|
const token = c.req.header("X-GH-Token");
|
|
2216
2264
|
if (!token)
|
|
2217
2265
|
return c.json({ repos: [] });
|
|
@@ -2224,6 +2272,8 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
2224
2272
|
}
|
|
2225
2273
|
});
|
|
2226
2274
|
app.get("/api/github/repos/:owner/:repo/branches", (c) => {
|
|
2275
|
+
if (!config.devMode)
|
|
2276
|
+
return c.json({ error: "Not available" }, 403);
|
|
2227
2277
|
const owner = c.req.param("owner");
|
|
2228
2278
|
const repo = c.req.param("repo");
|
|
2229
2279
|
const token = c.req.header("X-GH-Token");
|
|
@@ -2261,18 +2311,14 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
2261
2311
|
}
|
|
2262
2312
|
});
|
|
2263
2313
|
}
|
|
2264
|
-
// Resume a project from a GitHub repo
|
|
2314
|
+
// Resume a project from a GitHub repo (dev mode only)
|
|
2265
2315
|
app.post("/api/sessions/resume", async (c) => {
|
|
2316
|
+
if (!config.devMode) {
|
|
2317
|
+
return c.json({ error: "Resume from repo not available" }, 403);
|
|
2318
|
+
}
|
|
2266
2319
|
const body = await validateBody(c, resumeSessionSchema);
|
|
2267
2320
|
if (isResponse(body))
|
|
2268
2321
|
return body;
|
|
2269
|
-
// Rate-limit session creation in production mode
|
|
2270
|
-
if (!config.devMode) {
|
|
2271
|
-
const ip = extractClientIp(c);
|
|
2272
|
-
if (!checkSessionRateLimit(ip)) {
|
|
2273
|
-
return c.json({ error: "Too many sessions. Please try again later." }, 429);
|
|
2274
|
-
}
|
|
2275
|
-
}
|
|
2276
2322
|
const sessionId = crypto.randomUUID();
|
|
2277
2323
|
const repoName = body.repoUrl
|
|
2278
2324
|
.split("/")
|