codebay 0.14.0 → 0.15.0

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.
Files changed (78) hide show
  1. package/.mochi/manifest.json +526 -455
  2. package/.mochi/svelte-client/HydratableIsland-smgsf0ma.js +1 -0
  3. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-9vc087ep.js +2 -0
  4. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-a48xq47a.js +14 -0
  5. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-d4zehrqz.js +3 -0
  6. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-t5175pf5.js +44 -0
  7. package/.mochi/svelte-client/chunk-0mstgbtr.js +2 -0
  8. package/.mochi/svelte-client/chunk-0q9g0kpg.js +1 -0
  9. package/.mochi/svelte-client/chunk-3fwjzw1r.js +1 -0
  10. package/.mochi/svelte-client/{chunk-spk1b2r4.js → chunk-5pg0be5f.js} +1 -1
  11. package/.mochi/svelte-client/{chunk-ccfh8hxz.js → chunk-a2hty99b.js} +1 -1
  12. package/.mochi/svelte-client/{chunk-0mc09cyb.js → chunk-ds9yn9cq.js} +1 -1
  13. package/.mochi/svelte-client/{chunk-phf7v5jj.js → chunk-fn03gxer.js} +1 -1
  14. package/.mochi/svelte-client/chunk-jhgq7f65.js +2 -0
  15. package/.mochi/svelte-client/chunk-p5a07kx1.js +1 -0
  16. package/.mochi/svelte-client/chunk-p5wpyhsf.js +2 -0
  17. package/.mochi/svelte-client/chunk-t2kfvtb6.js +2 -0
  18. package/.mochi/svelte-client/{chunk-x0m3k0rn.js → chunk-v3wg9mdy.js} +1 -1
  19. package/.mochi/svelte-compile/{App-94xsdn0w.server.js → App-68f9a9fq.server.js} +64 -59
  20. package/.mochi/svelte-compile/{Avatars-fnccw47h.server.js → Avatars-vyh3rwxx.server.js} +14 -29
  21. package/.mochi/svelte-compile/Instance-yfw7s5mp.server.js +627 -0
  22. package/.mochi/svelte-compile/{Settings-a7b11v90.server.js → Settings-0b5t9kw9.server.js} +145 -25
  23. package/.mochi/svelte-compile/{chunk-w0hca7va.js → chunk-0y3wrae9.js} +13 -1
  24. package/.mochi/svelte-compile/{chunk-ed46m58e.js → chunk-2qzc07gt.js} +13 -1
  25. package/.mochi/svelte-compile/chunk-ef3hvegw.js +21 -0
  26. package/.mochi/svelte-compile/chunk-f9td8ej2.js +24 -0
  27. package/.mochi/svelte-compile/{chunk-pqb403fh.js → chunk-g0eravvj.js} +5 -16
  28. package/.mochi/svelte-css/AgentLogBox-38adby5iygny7.css +1 -0
  29. package/.mochi/svelte-css/{InstanceCard-25z7nas7y98zk.css → InstanceCard-3vxozrp9uoads.css} +1 -1
  30. package/.mochi/svelte-css/{SettingsView-bccw34agd2bj.css → SettingsView-2400myxb9ot56.css} +1 -1
  31. package/README.md +32 -1
  32. package/migrations/0009_agent_runs.sql +24 -0
  33. package/migrations/0010_agent_run_model.sql +2 -0
  34. package/package.json +6 -2
  35. package/src/components/AgentLogBox.svelte +458 -0
  36. package/src/components/AppShell.svelte +11 -0
  37. package/src/components/FolderBrowser.svelte +13 -5
  38. package/src/components/InstanceCard.svelte +38 -0
  39. package/src/components/InstanceView.svelte +8 -0
  40. package/src/components/SettingsView.svelte +186 -0
  41. package/src/components/UiShowcase.svelte +4 -2
  42. package/src/container-injections/claude-statusline.ts +4 -1
  43. package/src/container-injections/github-credentials.ts +12 -1
  44. package/src/index.ts +32 -7
  45. package/src/lib/agent-run-stream.ts +246 -0
  46. package/src/lib/agent-runs.server.ts +724 -0
  47. package/src/lib/auth.server.ts +11 -2
  48. package/src/lib/build-freshness.server.ts +71 -0
  49. package/src/lib/config.server.ts +29 -6
  50. package/src/lib/container-files.server.ts +1 -1
  51. package/src/lib/data-dir-lock.server.ts +78 -0
  52. package/src/lib/db.server.ts +120 -1
  53. package/src/lib/devcontainer.server.ts +60 -14
  54. package/src/lib/docker-client.server.ts +11 -5
  55. package/src/lib/drives.ts +11 -0
  56. package/src/lib/exec.server.ts +66 -5
  57. package/src/lib/instances.server.ts +45 -3
  58. package/src/lib/log-capture.server.ts +17 -5
  59. package/src/lib/mcp-auth.server.ts +52 -0
  60. package/src/lib/picker.server.ts +23 -2
  61. package/src/lib/sandbox-ops.server.ts +263 -0
  62. package/src/mcp/routes.server.ts +25 -0
  63. package/src/mcp/server.server.ts +52 -0
  64. package/src/mcp/tools.server.ts +494 -0
  65. package/src/pages/Settings.svelte +12 -0
  66. package/src/routes.ts +63 -1
  67. package/src/types.ts +15 -0
  68. package/.mochi/svelte-client/HydratableIsland-zrdsrsmg.js +0 -1
  69. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-v7y7dh53.js +0 -2
  70. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-tmwd6fpb.js +0 -14
  71. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-m7t64dbh.js +0 -1
  72. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-4a7ann1f.js +0 -40
  73. package/.mochi/svelte-client/chunk-5jwfs4p9.js +0 -2
  74. package/.mochi/svelte-client/chunk-6qg4q98a.js +0 -2
  75. package/.mochi/svelte-client/chunk-tmeh70wg.js +0 -2
  76. package/.mochi/svelte-client/chunk-vs1yjbqt.js +0 -2
  77. package/.mochi/svelte-client/chunk-y7f4jbdm.js +0 -2
  78. package/.mochi/svelte-compile/Instance-knnkcn2p.server.js +0 -377
@@ -22,6 +22,37 @@ function collector(): { stream: Writable; text: () => string } {
22
22
  const STDIN_ENV = '__CODEBAY_EXEC_STDIN';
23
23
  const STDIN_VAR = 'CODEBAY_STDIN';
24
24
 
25
+ /**
26
+ * Linux rejects any single argv/env string over 128 KiB (`MAX_ARG_STRLEN`), and the stdin carrier
27
+ * is one env entry — so this, minus headroom for the name, is the hard ceiling on any payload.
28
+ */
29
+ export const EXEC_STDIN_MAX_BYTES = 128 * 1024 - 1024;
30
+
31
+ /** The largest UTF-8 payload that still fits the carrier once base64 has inflated it by 4/3. */
32
+ export const EXEC_STDIN_MAX_BYTES_BASE64 = Math.floor((EXEC_STDIN_MAX_BYTES / 4) * 3);
33
+
34
+ /** Builds the exec target from an instance row; `container_id` must already be known non-null. */
35
+ export function execTargetFor(row: {
36
+ container_id: string | null;
37
+ remote_user: string | null;
38
+ }): ExecTarget {
39
+ return { containerId: row.container_id!, remoteUser: row.remote_user };
40
+ }
41
+
42
+ /** Everything after the last `marker`, or null when absent — login-shell profile noise precedes every script's own output. */
43
+ export function afterMarker(stdout: string, marker: string): string | null {
44
+ const at = stdout.lastIndexOf(marker);
45
+ return at === -1 ? null : stdout.slice(at + marker.length);
46
+ }
47
+
48
+ /** Just the marker's own line, untrimmed — an empty leading field is meaningful to some callers. */
49
+ export function markerLine(stdout: string, marker: string): string | null {
50
+ const rest = afterMarker(stdout, marker);
51
+ if (rest === null) return null;
52
+ const nl = rest.indexOf('\n');
53
+ return (nl === -1 ? rest : rest.slice(0, nl)).replace(/\r$/, '');
54
+ }
55
+
25
56
  /**
26
57
  * Real exec stdin is unusable on Bun — dockerode's hijack path needs an `upgrade` event
27
58
  * Bun never emits, and `openStdin` needs full-duplex client HTTP Bun also lacks.
@@ -31,16 +62,41 @@ function wrapWithStdin(script: string): string {
31
62
  return `${STDIN_VAR}="$${STDIN_ENV}"; unset ${STDIN_ENV}\n${script}`;
32
63
  }
33
64
 
65
+ /**
66
+ * Resolves false on timeout. A host-side backstop only: the container process keeps running, so a
67
+ * caller that needs the command itself killed must also wrap it in the container's own `timeout`.
68
+ */
69
+ function streamEnded(stream: NodeJS.ReadableStream, timeoutMs?: number): Promise<boolean> {
70
+ return new Promise((resolve, reject) => {
71
+ const timer = timeoutMs ? setTimeout(() => resolve(false), timeoutMs) : null;
72
+ const settle = (fn: () => void) => {
73
+ if (timer) clearTimeout(timer);
74
+ fn();
75
+ };
76
+ stream.on('end', () => settle(() => resolve(true)));
77
+ stream.on('error', (e) => settle(() => reject(e)));
78
+ });
79
+ }
80
+
34
81
  /**
35
82
  * The one place the `bash -lc` container-exec pattern lives; every injection goes through it.
36
83
  * `stdin` reaches the script as `$CODEBAY_STDIN`, and `args` as `$0`, `$1`, ….
37
84
  */
38
85
  export async function execInContainer(
39
86
  target: ExecTarget,
40
- opts: { script: string; stdin?: string; args?: string[]; capture?: boolean }
87
+ opts: { script: string; stdin?: string; args?: string[]; capture?: boolean; timeoutMs?: number }
41
88
  ): Promise<{ ok: boolean; stdout: string; error?: string }> {
42
89
  const user = target.remoteUser?.trim() || 'root';
43
90
  const hasStdin = opts.stdin !== undefined;
91
+ const stdinBytes = hasStdin ? Buffer.byteLength(opts.stdin!, 'utf8') : 0;
92
+ if (stdinBytes > EXEC_STDIN_MAX_BYTES) {
93
+ // The kernel would fail the exec with an opaque E2BIG; say what the limit actually is.
94
+ return {
95
+ ok: false,
96
+ stdout: '',
97
+ error: `payload of ${stdinBytes} bytes exceeds the ${EXEC_STDIN_MAX_BYTES}-byte limit a container exec can carry`
98
+ };
99
+ }
44
100
  try {
45
101
  const exec = await (await getDocker()).getContainer(target.containerId).exec({
46
102
  Cmd: [
@@ -60,10 +116,15 @@ export async function execInContainer(
60
116
  const out = collector();
61
117
  const err = collector();
62
118
  exec.modem.demuxStream(stream, out.stream, err.stream);
63
- await new Promise<void>((resolve, reject) => {
64
- stream.on('end', resolve);
65
- stream.on('error', reject);
66
- });
119
+ if (!(await streamEnded(stream, opts.timeoutMs))) {
120
+ // Otherwise the exec socket and its demux collectors live on for as long as the command does.
121
+ stream.destroy();
122
+ return {
123
+ ok: false,
124
+ stdout: opts.capture ? out.text().trim() : '',
125
+ error: `timed out after ${opts.timeoutMs}ms`
126
+ };
127
+ }
67
128
 
68
129
  const code = (await exec.inspect()).ExitCode ?? 1;
69
130
  const stdout = opts.capture ? out.text().trim() : '';
@@ -18,6 +18,9 @@ import {
18
18
  deleteInstanceRow,
19
19
  getInstance,
20
20
  getOption,
21
+ getRun,
22
+ openRunFor,
23
+ openRuns,
21
24
  insertForward,
22
25
  insertInstance,
23
26
  listForwards,
@@ -38,6 +41,7 @@ import {
38
41
  stopContainer
39
42
  } from './docker.server.ts';
40
43
  import {
44
+ AGENT_RUN_MARKER,
41
45
  copyWorkspace,
42
46
  devcontainerCliAvailable,
43
47
  devcontainerUp,
@@ -67,12 +71,14 @@ import {
67
71
  syncHealthMonitors
68
72
  } from './health.server.ts';
69
73
  import { runCapturePass, stopLogCapture, syncLogCapture } from './log-capture.server.ts';
74
+ import { currentRunSummaries, runSummary, setRunChangeHook, stopRun } from './agent-runs.server.ts';
70
75
  import { isHostPortBindable, pickBindablePort } from './ports.server.ts';
71
76
  import { pickAvatar, pickUniqueAvatar } from '../avatars/pick.server.ts';
72
77
  import type { ServerWebSocket } from 'bun';
73
78
  import {
74
79
  isInstanceFilter,
75
80
  normalizeMode,
81
+ type AgentRunSummary,
76
82
  type Instance,
77
83
  type InstanceFilter,
78
84
  type InstanceHealth,
@@ -186,6 +192,9 @@ export function subscribeLogs(id: string, onChunk: (chunk: string) => void): ()
186
192
  export type StreamEvent =
187
193
  | { type: 'instances'; data: Instance[] }
188
194
  | { type: 'health'; data: { id: string; health: InstanceHealth } }
195
+ // Its own frame rather than a list reconcile: run progress ticks every few seconds, and
196
+ // `reconcileInstances` runs a `docker inspect` per instance.
197
+ | { type: 'run'; data: AgentRunSummary }
189
198
  | { type: 'preflight'; data: { docker: boolean; cli: boolean } }
190
199
  // `name` is the chosen sprite, or null for the default box logo. Lets the header swap live.
191
200
  | { type: 'pet'; data: { name: string | null } }
@@ -240,6 +249,11 @@ export function broadcastHealth(id: string, health: InstanceHealth): void {
240
249
  broadcast({ type: 'health', data: { id, health } });
241
250
  }
242
251
 
252
+ setRunChangeHook((runId) => {
253
+ const row = getRun(runId);
254
+ if (row) broadcast({ type: 'run', data: runSummary(row) });
255
+ });
256
+
243
257
  /** Called from the settings action so every open dashboard swaps its header logo without a reload. */
244
258
  export function broadcastPet(name: string | null): void {
245
259
  broadcast({ type: 'pet', data: { name } });
@@ -301,6 +315,7 @@ export function streamOpen(ws: ServerWebSocket<unknown>): void {
301
315
  // Seed everything up front so a fresh or reconnected client is correct without waiting a tick.
302
316
  void listInstances().then((list) => sendTo(ws, { type: 'instances', data: list }));
303
317
  for (const snap of currentHealthSnapshots()) sendTo(ws, { type: 'health', data: snap });
318
+ for (const run of currentRunSummaries()) sendTo(ws, { type: 'run', data: run });
304
319
  void backgroundPreflight().then((pf) => sendTo(ws, { type: 'preflight', data: pf }));
305
320
  // Keeps a reconnecting client correct even if the pet changed while it was away.
306
321
  sendTo(ws, { type: 'pet', data: { name: getOption('pet') || null } });
@@ -381,7 +396,18 @@ async function boot(row: InstanceRow, opts: { branch?: string } = {}): Promise<v
381
396
  } else {
382
397
  appendLog(row.id, `Copying ${row.source_path} → ${row.workspace_path}\n`);
383
398
  const ignore = parseCopyIgnore(getOption('copy_ignore_patterns') ?? DEFAULT_COPY_IGNORE);
384
- await copyWorkspace(row.source_path, row.workspace_path, ignore);
399
+ const { dereferencedSymlinks } = await copyWorkspace(
400
+ row.source_path,
401
+ row.workspace_path,
402
+ ignore
403
+ );
404
+ if (dereferencedSymlinks > 0) {
405
+ appendLog(
406
+ row.id,
407
+ `⚠ ${dereferencedSymlinks} symlink(s) copied as regular files — Windows needs Developer Mode (or admin rights) to recreate symlinks, so edits to them no longer write through to their targets
408
+ `
409
+ );
410
+ }
385
411
  appendLog(row.id, `⏱ copy: ${elapsed(sourceStart)}\n`);
386
412
  }
387
413
  await seedDeclaredPorts(row);
@@ -680,6 +706,8 @@ export async function listInstances(): Promise<Instance[]> {
680
706
  for (const { id, health } of currentHealthSnapshots()) {
681
707
  openPorts.set(id, new Set(health.openPorts));
682
708
  }
709
+ // One query for every sandbox rather than one per row, since this runs on every reconcile tick.
710
+ const runs = new Map(openRuns().map((run) => [run.instance_id, runSummary(run)]));
683
711
  const forwards = new Map<
684
712
  string,
685
713
  { container_port: number; host_port: number; open: boolean }[]
@@ -700,7 +728,8 @@ export async function listInstances(): Promise<Instance[]> {
700
728
  avatar: effectiveAvatarName(row),
701
729
  git_branch: branches.get(row.id) ?? null,
702
730
  attention: getAttention(row.id),
703
- forwarded_ports: forwards.get(row.id) ?? []
731
+ forwarded_ports: forwards.get(row.id) ?? [],
732
+ active_run: runs.get(row.id) ?? null
704
733
  }));
705
734
  }
706
735
 
@@ -771,6 +800,9 @@ export function rebuildInstance(id: string, opts: { noCache?: boolean } = {}): I
771
800
  const row = getInstance(id);
772
801
  if (!row) throw new Error('Instance not found');
773
802
  if (inFlight.has(id)) return row; // a build is already in flight
803
+ // The rebuild replaces the container the run's detached process lives in, so it can never
804
+ // report back — fail it now rather than leaving the poller to time it out.
805
+ void cancelActiveRun(id, 'the sandbox was rebuilt');
774
806
  // Claimed before the broadcast below, which reconciles synchronously: an unclaimed `creating`
775
807
  // row reads as orphaned, and reconcile would flip it straight back off `creating`.
776
808
  inFlight.add(id);
@@ -807,7 +839,9 @@ function quote(value: string): string {
807
839
  export async function relaunchSurface(row: InstanceRow): Promise<void> {
808
840
  if (!row.container_id) return;
809
841
 
810
- const steps: string[] = [];
842
+ // No run survives a container restart, but its marker does when the container was stopped out
843
+ // from under it — left in place, every launcher would keep opening a plain shell instead of Claude.
844
+ const steps: string[] = [`rm -f "$HOME/${AGENT_RUN_MARKER}"`];
811
845
  // The folderOpen task's run-once gate is meant to span one container run, but the marker file
812
846
  // outlives it — left in place, a restarted IDE container never reopens the Claude terminal.
813
847
  if (row.mode !== 'terminal') steps.push(`rm -f "$HOME/${TERMINAL_LAUNCHED_MARKER}"`);
@@ -845,9 +879,16 @@ export async function startInstance(id: string): Promise<InstanceRow> {
845
879
  return getInstance(id)!;
846
880
  }
847
881
 
882
+ /** Runs are per-container, so any transition that replaces or stops one has to settle them first. */
883
+ async function cancelActiveRun(id: string, reason: string): Promise<void> {
884
+ const open = openRunFor(id);
885
+ if (open) await stopRun(open.id, reason).catch(() => undefined);
886
+ }
887
+
848
888
  export async function stopInstance(id: string): Promise<InstanceRow> {
849
889
  const row = getInstance(id);
850
890
  if (!row) throw new Error('Instance not found');
891
+ await cancelActiveRun(id, 'the sandbox was stopped');
851
892
  // A later delete can't exec into a stopped container, so this is the last chance to mirror.
852
893
  stopLogCapture(id);
853
894
  if (row.container_id) await runCapturePass(row).catch(() => undefined);
@@ -864,6 +905,7 @@ export async function stopInstance(id: string): Promise<InstanceRow> {
864
905
  export async function deleteInstance(id: string): Promise<void> {
865
906
  const row = getInstance(id);
866
907
  if (!row) return;
908
+ await cancelActiveRun(id, 'the sandbox was deleted');
867
909
  stopHealthMonitor(id);
868
910
  // Mirror the tail of the session before the transcripts die with the container; the periodic
869
911
  // chain is stopped first so it can't race this last pass. Retention outlives the instance —
@@ -17,7 +17,7 @@ import { execInContainer } from './exec.server.ts';
17
17
  const CAPTURE_MS = 10_000;
18
18
 
19
19
  const MANIFEST_MARKER = '__CODEBAY_MANIFEST__';
20
- const FETCH_MARKER = '__CODEBAY_FETCH__';
20
+ export const FETCH_MARKER = '__CODEBAY_FETCH__';
21
21
  const FILE_MARKER = '__CODEBAY_FILE__';
22
22
  const END_MARKER = '__CODEBAY_END__';
23
23
 
@@ -48,14 +48,26 @@ function manifestScript(): string {
48
48
  );
49
49
  }
50
50
 
51
+ /**
52
+ * One framed base64 block of `pathExpr` from `offsetExpr` to EOF (or `maxBytes`) — the shape
53
+ * `parseFetchBlocks` reads. Both exprs are spliced into a shell script, so callers pass shell
54
+ * exprs, not values.
55
+ */
56
+ export function tailBlockScript(pathExpr: string, offsetExpr: string, maxBytes?: number): string {
57
+ const cap = maxBytes ? ` | head -c ${maxBytes}` : '';
58
+ return (
59
+ `printf '${FILE_MARKER}\\t%s\\n' "${pathExpr}"; ` +
60
+ `tail -c "+${offsetExpr}" "${pathExpr}" 2>/dev/null${cap} | base64; ` +
61
+ `printf '\\n${END_MARKER}\\n'`
62
+ );
63
+ }
64
+
51
65
  /** Emits, per requested file, a framed block of base64 bytes from `startByte` to EOF. */
52
66
  function fetchScript(): string {
53
67
  return (
54
68
  `printf '%s\\n' '${FETCH_MARKER}'; ` +
55
69
  `while [ "$#" -ge 2 ]; do p="$1"; o="$2"; shift 2; ` +
56
- `printf '${FILE_MARKER}\\t%s\\n' "$p"; ` +
57
- `tail -c "+$o" "$p" 2>/dev/null | base64; ` +
58
- `printf '\\n${END_MARKER}\\n'; done`
70
+ `${tailBlockScript('$p', '$o')}; done`
59
71
  );
60
72
  }
61
73
 
@@ -118,7 +130,7 @@ export function parseFetchBlocks(stdout: string): { path: string; base64: string
118
130
  return blocks;
119
131
  }
120
132
 
121
- function sizeOnDisk(path: string): number {
133
+ export function sizeOnDisk(path: string): number {
122
134
  try {
123
135
  return statSync(path).size;
124
136
  } catch {
@@ -0,0 +1,52 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { getOption, setOption } from './db.server.ts';
3
+ import { timingSafeEqualStr } from './crypto.server.ts';
4
+
5
+ /** The MCP endpoint's path; `auth.server.ts` and the CSRF filter both key off this exact value. */
6
+ export const MCP_PATH = '/mcp';
7
+
8
+ export const MCP_ENABLED_KEY = 'mcp_enabled';
9
+ export const MCP_TOKEN_KEY = 'mcp_token';
10
+
11
+ /**
12
+ * Off until someone opts in. The token gates the endpoint, but an install that never wanted an
13
+ * agent-facing control plane shouldn't expose one at all.
14
+ */
15
+ export function mcpEnabled(): boolean {
16
+ return getOption(MCP_ENABLED_KEY) === '1';
17
+ }
18
+
19
+ export function setMcpEnabled(enabled: boolean): void {
20
+ setOption(MCP_ENABLED_KEY, enabled ? '1' : '0');
21
+ if (enabled) getMcpToken();
22
+ }
23
+
24
+ function mint(): string {
25
+ return `cb_${randomBytes(32).toString('base64url')}`;
26
+ }
27
+
28
+ /**
29
+ * Minted on first read and persisted, so the settings page and the route can't disagree. Unlike
30
+ * every other secret here this one is shown to the user in plaintext — copying it into an MCP
31
+ * client is the whole point of it existing.
32
+ */
33
+ export function getMcpToken(): string {
34
+ const existing = getOption(MCP_TOKEN_KEY);
35
+ if (existing) return existing;
36
+ const token = mint();
37
+ setOption(MCP_TOKEN_KEY, token);
38
+ return token;
39
+ }
40
+
41
+ export function regenerateMcpToken(): string {
42
+ const token = mint();
43
+ setOption(MCP_TOKEN_KEY, token);
44
+ return token;
45
+ }
46
+
47
+ /** Constant-time, like the bridge token check, so a wrong guess leaks nothing by timing. */
48
+ export function mcpAuthOk(request: Request): boolean {
49
+ const header = request.headers.get('authorization');
50
+ if (!header?.startsWith('Bearer ')) return false;
51
+ return timingSafeEqualStr(header.slice(7).trim(), getMcpToken());
52
+ }
@@ -1,17 +1,30 @@
1
1
  import { readdir, stat } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
- import { dirname, join } from 'node:path';
4
+ import { dirname, join, sep } from 'node:path';
5
5
  import { getOption, setOption } from './db.server';
6
6
  import { findDevcontainerConfig } from './devcontainer.server.ts';
7
+ import { DRIVES_ROOT } from './drives.ts';
7
8
  import type { BrowseResult, DirEntry } from '../types.ts';
8
9
 
9
10
  const LAST_VIEWED_FOLDER = 'last_viewed_folder';
10
11
 
12
+ const isWindows = process.platform === 'win32';
13
+
11
14
  function hasDevcontainer(dir: string): boolean {
12
15
  return findDevcontainerConfig(dir) !== null;
13
16
  }
14
17
 
18
+ /** Probed rather than enumerated, so listing the drives needs no shell-out. */
19
+ function driveRoots(): DirEntry[] {
20
+ const roots: DirEntry[] = [];
21
+ for (let i = 0; i < 26; i++) {
22
+ const path = `${String.fromCharCode(65 + i)}:${sep}`;
23
+ if (existsSync(path)) roots.push({ name: path, path, hasDevcontainer: hasDevcontainer(path) });
24
+ }
25
+ return roots;
26
+ }
27
+
15
28
  /** Persists every folder it lists, so an argument-less call resumes where the user left off. */
16
29
  export async function browse(path?: string): Promise<BrowseResult> {
17
30
  let target: string;
@@ -22,6 +35,12 @@ export async function browse(path?: string): Promise<BrowseResult> {
22
35
  target = saved && existsSync(saved) ? saved : homedir();
23
36
  }
24
37
 
38
+ // Deliberately not persisted as the last-viewed folder: it is not a real directory, so a
39
+ // resume would fall through to the home dir anyway.
40
+ if (isWindows && target === DRIVES_ROOT) {
41
+ return { path: DRIVES_ROOT, hasDevcontainer: false, parent: null, entries: driveRoots() };
42
+ }
43
+
25
44
  const info = await stat(target);
26
45
  if (!info.isDirectory()) throw new Error(`Not a folder: ${target}`);
27
46
 
@@ -40,7 +59,9 @@ export async function browse(path?: string): Promise<BrowseResult> {
40
59
  return {
41
60
  path: target,
42
61
  hasDevcontainer: hasDevcontainer(target),
43
- parent: parent === target ? null : parent,
62
+ // A Windows drive root is its own dirname, which would otherwise strand the user on
63
+ // whichever drive their home directory sits on.
64
+ parent: parent === target ? (isWindows ? DRIVES_ROOT : null) : parent,
44
65
  entries
45
66
  };
46
67
  }
@@ -0,0 +1,263 @@
1
+ import {
2
+ EXEC_STDIN_MAX_BYTES_BASE64,
3
+ afterMarker,
4
+ execInContainer,
5
+ execTargetFor,
6
+ markerLine
7
+ } from './exec.server.ts';
8
+ import { SOURCE_INJECTED_ENV } from './devcontainer.server.ts';
9
+ import { getOption, type InstanceRow } from './db.server.ts';
10
+ import { HOME_PRELUDE, shellSingleQuote as quote } from './container-files.server.ts';
11
+
12
+ /** Enough for a big diff or a test run's output, small enough not to blow a caller's context. */
13
+ export const OUTPUT_CAP = 200_000;
14
+
15
+ /** What `write_file` can carry in one call: the content rides base64'd through the exec env carrier. */
16
+ export const WRITE_FILE_MAX_BYTES = EXEC_STDIN_MAX_BYTES_BASE64;
17
+
18
+ const CODE_MARKER = '__CODEBAY_CMD__';
19
+ const OUT_MARKER = '__CODEBAY_CMD_OUT__';
20
+ const ERR_MARKER = '__CODEBAY_CMD_ERR__';
21
+ const END_MARKER = '__CODEBAY_CMD_END__';
22
+
23
+ /** `timeout`'s own exit code for a command it had to kill. */
24
+ const TIMEOUT_EXIT = 124;
25
+
26
+ export interface CommandResult {
27
+ exitCode: number;
28
+ stdout: string;
29
+ stderr: string;
30
+ timedOut: boolean;
31
+ truncated: boolean;
32
+ }
33
+
34
+ /** Everything here runs from the workspace, since an exec only inherits the image's WorkingDir. */
35
+ function workspacePrelude(row: InstanceRow): string {
36
+ const cd = row.remote_workspace_folder
37
+ ? `cd ${quote(row.remote_workspace_folder)} || exit 1; `
38
+ : '';
39
+ return `${HOME_PRELUDE}[ -n "$HOME" ] || export HOME="$h"; ${cd}`;
40
+ }
41
+
42
+ function decodeBlock(stdout: string, marker: string): string {
43
+ const rest = afterMarker(stdout, marker);
44
+ if (rest === null) return '';
45
+ const end = rest.indexOf(END_MARKER);
46
+ // GNU base64 wraps at 76 columns, and a trimmed capture can drop the final newline.
47
+ const b64 = (end === -1 ? rest : rest.slice(0, end)).replace(/\s+/g, '');
48
+ return b64 ? Buffer.from(b64, 'base64').toString('utf8') : '';
49
+ }
50
+
51
+ function cap(text: string): { text: string; truncated: boolean } {
52
+ return text.length > OUTPUT_CAP
53
+ ? { text: text.slice(0, OUTPUT_CAP) + '\n… output truncated', truncated: true }
54
+ : { text, truncated: false };
55
+ }
56
+
57
+ /**
58
+ * The command rides in through the stdin carrier rather than the script body, so nothing has to be
59
+ * escaped and it never reaches the container's process list; output is base64'd because the
60
+ * capture path trims and the demuxed stream is text.
61
+ */
62
+ export async function execCommand(
63
+ row: InstanceRow,
64
+ command: string,
65
+ opts: { timeoutSeconds?: number; cwd?: string } = {}
66
+ ): Promise<CommandResult> {
67
+ const seconds = Math.max(1, Math.min(opts.timeoutSeconds ?? 120, 3600));
68
+ const script =
69
+ workspacePrelude(row) +
70
+ SOURCE_INJECTED_ENV +
71
+ `o=$(mktemp); e=$(mktemp); ` +
72
+ // The in-container `timeout` is what actually kills the process; execInContainer's own
73
+ // timeoutMs below is only a backstop for a wedged docker stream.
74
+ `if command -v timeout >/dev/null 2>&1; then timeout -k 5 ${seconds} bash -c "$CODEBAY_STDIN" >"$o" 2>"$e"; ` +
75
+ `else bash -c "$CODEBAY_STDIN" >"$o" 2>"$e"; fi; c=$?; ` +
76
+ `printf '${CODE_MARKER}%s\\n' "$c"; ` +
77
+ `printf '${OUT_MARKER}\\n'; base64 < "$o"; printf '\\n${END_MARKER}\\n'; ` +
78
+ `printf '${ERR_MARKER}\\n'; base64 < "$e"; printf '\\n${END_MARKER}\\n'; ` +
79
+ `rm -f "$o" "$e"; true`;
80
+
81
+ const res = await execInContainer(execTargetFor(row), {
82
+ script,
83
+ stdin: opts.cwd ? `cd ${quote(opts.cwd)} || exit 1\n${command}` : command,
84
+ capture: true,
85
+ timeoutMs: (seconds + 15) * 1000
86
+ });
87
+ if (!res.ok) {
88
+ return {
89
+ exitCode: -1,
90
+ stdout: '',
91
+ stderr: res.error ?? 'the command could not be run',
92
+ timedOut: false,
93
+ truncated: false
94
+ };
95
+ }
96
+
97
+ const exitCode = Number.parseInt(markerLine(res.stdout, CODE_MARKER) ?? '', 10);
98
+ const out = cap(decodeBlock(res.stdout, OUT_MARKER));
99
+ const err = cap(decodeBlock(res.stdout, ERR_MARKER));
100
+ return {
101
+ exitCode: Number.isFinite(exitCode) ? exitCode : -1,
102
+ stdout: out.text,
103
+ stderr: err.text,
104
+ timedOut: exitCode === TIMEOUT_EXIT,
105
+ truncated: out.truncated || err.truncated
106
+ };
107
+ }
108
+
109
+ /** Throws the command's own stderr, so a tool caller sees why git or gh refused. */
110
+ async function must(row: InstanceRow, command: string, timeoutSeconds = 120): Promise<string> {
111
+ const res = await execCommand(row, command, { timeoutSeconds });
112
+ if (res.exitCode !== 0) {
113
+ throw new Error((res.stderr || res.stdout || `command failed (exit ${res.exitCode})`).trim());
114
+ }
115
+ return res.stdout;
116
+ }
117
+
118
+ /**
119
+ * The path arrives as an argv entry rather than spliced into the script, so no amount of quoting
120
+ * in a caller-supplied filename can escape into the shell.
121
+ */
122
+ export async function readWorkspaceFile(row: InstanceRow, path: string): Promise<string> {
123
+ const script =
124
+ workspacePrelude(row) +
125
+ `if [ ! -f "$1" ]; then printf '${CODE_MARKER}1\\n'; exit 0; fi; ` +
126
+ `printf '${CODE_MARKER}0\\n'; printf '${OUT_MARKER}\\n'; base64 < "$1"; printf '\\n${END_MARKER}\\n'`;
127
+ const res = await execInContainer(execTargetFor(row), {
128
+ script,
129
+ args: ['read-file', path],
130
+ capture: true,
131
+ timeoutMs: 30_000
132
+ });
133
+ if (!res.ok) throw new Error(res.error ?? 'could not read the file');
134
+ if (res.stdout.includes(`${CODE_MARKER}1`)) throw new Error(`no such file: ${path}`);
135
+ return cap(decodeBlock(res.stdout, OUT_MARKER)).text;
136
+ }
137
+
138
+ export async function writeWorkspaceFile(
139
+ row: InstanceRow,
140
+ path: string,
141
+ content: string
142
+ ): Promise<void> {
143
+ const bytes = Buffer.byteLength(content, 'utf8');
144
+ if (bytes > WRITE_FILE_MAX_BYTES) {
145
+ throw new Error(
146
+ `content is ${bytes} bytes; write_file carries at most ${WRITE_FILE_MAX_BYTES} — split it, or ` +
147
+ 'build the file inside the sandbox with exec_command'
148
+ );
149
+ }
150
+ const script =
151
+ workspacePrelude(row) +
152
+ `set -e; mkdir -p "$(dirname "$1")"; printf '%s' "$CODEBAY_STDIN" | base64 -d > "$1"`;
153
+ const res = await execInContainer(execTargetFor(row), {
154
+ script,
155
+ args: ['write-file', path],
156
+ stdin: Buffer.from(content, 'utf8').toString('base64'),
157
+ timeoutMs: 30_000
158
+ });
159
+ if (!res.ok) throw new Error(res.error ?? 'could not write the file');
160
+ }
161
+
162
+ export interface DiffResult {
163
+ diff: string;
164
+ status: string;
165
+ branch: string;
166
+ truncated: boolean;
167
+ }
168
+
169
+ /** `--stat`-free full patch plus a porcelain summary, which is what an agent usually wants next. */
170
+ export async function gitDiff(
171
+ row: InstanceRow,
172
+ opts: { base?: string; staged?: boolean } = {}
173
+ ): Promise<DiffResult> {
174
+ const range = opts.base ? ` ${quote(opts.base)}...HEAD` : opts.staged ? ' --staged' : '';
175
+ const [diff, status, branch] = await Promise.all([
176
+ execCommand(row, `git --no-pager diff${range}`),
177
+ execCommand(row, 'git status --short'),
178
+ execCommand(row, 'git rev-parse --abbrev-ref HEAD')
179
+ ]);
180
+ if (diff.exitCode !== 0) throw new Error((diff.stderr || 'git diff failed').trim());
181
+ return {
182
+ diff: diff.stdout,
183
+ status: status.stdout,
184
+ branch: branch.stdout.trim(),
185
+ truncated: diff.truncated
186
+ };
187
+ }
188
+
189
+ export interface PushResult {
190
+ branch: string;
191
+ committed: boolean;
192
+ output: string;
193
+ }
194
+
195
+ /**
196
+ * The `github-credentials` injection has already authed git and `gh` inside the container, so this
197
+ * is a plain push — nothing here handles credentials.
198
+ */
199
+ export async function gitPush(
200
+ row: InstanceRow,
201
+ opts: { branch?: string; commitMessage?: string; force?: boolean } = {}
202
+ ): Promise<PushResult> {
203
+ const current = (await must(row, 'git rev-parse --abbrev-ref HEAD')).trim();
204
+ const branch = opts.branch?.trim() || current;
205
+ if (!branch || branch === 'HEAD') {
206
+ throw new Error('the sandbox is on a detached HEAD — pass an explicit branch');
207
+ }
208
+
209
+ let committed = false;
210
+ if (opts.commitMessage) {
211
+ const dirty = await execCommand(row, 'git status --porcelain');
212
+ if (dirty.stdout.trim()) {
213
+ await must(row, `git add -A && git commit -m ${quote(opts.commitMessage)}`);
214
+ committed = true;
215
+ }
216
+ }
217
+
218
+ if (current !== branch) await must(row, `git checkout -B ${quote(branch)}`);
219
+
220
+ // --force-with-lease rather than --force: it still refuses to discard commits we never saw.
221
+ const force = opts.force ? ' --force-with-lease' : '';
222
+ const output = await must(
223
+ row,
224
+ `git push${force} -u origin HEAD:refs/heads/${quote(branch)} 2>&1`,
225
+ 300
226
+ );
227
+ return { branch, committed, output: output.trim() };
228
+ }
229
+
230
+ export const PR_ATTRIBUTION_KEY = 'mcp_pr_attribution';
231
+
232
+ /** The footer appended to an MCP-opened PR body when the setting is on. */
233
+ export const PR_ATTRIBUTION =
234
+ '🤖 Written by Claude Code in a [Codebay](https://github.com/khromov/codebay) sandbox, driven over MCP.';
235
+
236
+ /** Off unless explicitly enabled — an attribution line on someone's PR is theirs to opt into. */
237
+ export function prAttributionEnabled(): boolean {
238
+ return getOption(PR_ATTRIBUTION_KEY) === '1';
239
+ }
240
+
241
+ /** Appends the footer, unless the body already carries it (a caller may have written its own). */
242
+ export function withAttribution(body: string): string {
243
+ if (!prAttributionEnabled() || body.includes(PR_ATTRIBUTION)) return body;
244
+ return `${body.trimEnd()}\n\n---\n${PR_ATTRIBUTION}\n`;
245
+ }
246
+
247
+ export async function createPr(
248
+ row: InstanceRow,
249
+ opts: { title: string; body?: string; base?: string; draft?: boolean }
250
+ ): Promise<{ url: string }> {
251
+ const parts = [
252
+ 'gh pr create',
253
+ `--title ${quote(opts.title)}`,
254
+ `--body ${quote(withAttribution(opts.body ?? ''))}`
255
+ ];
256
+ if (opts.base) parts.push(`--base ${quote(opts.base)}`);
257
+ if (opts.draft) parts.push('--draft');
258
+ const out = await must(row, `${parts.join(' ')} 2>&1`, 300);
259
+ // gh prints the PR URL as its last line; anything else is progress chatter.
260
+ const url = out.trim().split('\n').filter(Boolean).at(-1) ?? '';
261
+ if (!url.startsWith('http')) throw new Error(out.trim() || 'gh did not return a PR url');
262
+ return { url };
263
+ }