codebay 0.14.0 → 0.16.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 (108) hide show
  1. package/.mochi/manifest.json +778 -646
  2. package/.mochi/svelte-client/HydratableIsland-6y9p903x.js +1 -0
  3. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-nv6yzk0x.js +2 -0
  4. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-ry83ck8a.js +14 -0
  5. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-vjt4jscf.js +3 -0
  6. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-1jdrk8x6.js +48 -0
  7. package/.mochi/svelte-client/{chunk-spk1b2r4.js → chunk-0vqvn81s.js} +1 -1
  8. package/.mochi/svelte-client/chunk-2dc6352a.js +1 -0
  9. package/.mochi/svelte-client/chunk-3cxaw5hr.js +2 -0
  10. package/.mochi/svelte-client/chunk-3jqc40sp.js +2 -0
  11. package/.mochi/svelte-client/chunk-3pgw6t5j.js +4 -0
  12. package/.mochi/svelte-client/chunk-7g3xt6ez.js +2 -0
  13. package/.mochi/svelte-client/chunk-b5s3mpg0.js +1 -0
  14. package/.mochi/svelte-client/chunk-bhseezf3.js +2 -0
  15. package/.mochi/svelte-client/chunk-bhwkq8er.js +1 -0
  16. package/.mochi/svelte-client/chunk-jm3ge348.js +1 -0
  17. package/.mochi/svelte-client/{chunk-0mc09cyb.js → chunk-sxcr9gzp.js} +1 -1
  18. package/.mochi/svelte-client/{chunk-ccfh8hxz.js → chunk-tss2473w.js} +1 -1
  19. package/.mochi/svelte-compile/{App-94xsdn0w.server.js → App-8mpgrxza.server.js} +470 -271
  20. package/.mochi/svelte-compile/{Avatars-fnccw47h.server.js → Avatars-y5nf6q25.server.js} +111 -110
  21. package/.mochi/svelte-compile/{DefaultError-je52k17d.server.js → DefaultError-rrjye0s2.server.js} +1 -2
  22. package/.mochi/svelte-compile/Instance-3sbzyp0g.server.js +695 -0
  23. package/.mochi/svelte-compile/{Settings-a7b11v90.server.js → Settings-ysvh5vhh.server.js} +838 -607
  24. package/.mochi/svelte-compile/chunk-7zaw9t5a.js +25 -0
  25. package/.mochi/svelte-compile/chunk-878xcmp4.js +65 -0
  26. package/.mochi/svelte-compile/chunk-d2z3qxxe.js +28 -0
  27. package/.mochi/svelte-compile/{chunk-60mpagr9.js → chunk-gnf5cnct.js} +661 -22
  28. package/.mochi/svelte-compile/{chunk-w0hca7va.js → chunk-jq7jj8jd.js} +18 -2
  29. package/.mochi/svelte-compile/chunk-vr0e32mv.js +24 -0
  30. package/.mochi/svelte-compile/{chunk-apdte4nh.js → chunk-xmygdsj4.js} +35 -612
  31. package/.mochi/svelte-compile/{chunk-pqb403fh.js → chunk-zk4a9zms.js} +28 -31
  32. package/.mochi/svelte-css/AgentLogBox-38adby5iygny7.css +1 -0
  33. package/.mochi/svelte-css/DropZone-1zeitd761uwyd.css +1 -0
  34. package/.mochi/svelte-css/IdeBar-d3un7kuigxc5.css +1 -0
  35. package/.mochi/svelte-css/{InstanceCard-25z7nas7y98zk.css → InstanceCard-3vxozrp9uoads.css} +1 -1
  36. package/.mochi/svelte-css/InstanceView-3ucotech5097w.css +1 -0
  37. package/.mochi/svelte-css/{SettingsView-bccw34agd2bj.css → SettingsView-2400myxb9ot56.css} +1 -1
  38. package/README.md +43 -3
  39. package/migrations/0009_agent_runs.sql +24 -0
  40. package/migrations/0010_agent_run_model.sql +2 -0
  41. package/migrations/0011_agent_run_rejected_structured_output.sql +2 -0
  42. package/package.json +13 -9
  43. package/src/__fixtures__/IdeBarStrip.svelte +41 -0
  44. package/src/avatars/dog.ts +1 -1
  45. package/src/avatars/lightning.ts +8 -9
  46. package/src/avatars/rocket.ts +4 -5
  47. package/src/components/AgentLogBox.svelte +458 -0
  48. package/src/components/AppShell.svelte +112 -1
  49. package/src/components/DropZone.svelte +77 -0
  50. package/src/components/FolderBrowser.svelte +13 -5
  51. package/src/components/HealthBox.svelte +6 -5
  52. package/src/components/IdeBar.svelte +222 -76
  53. package/src/components/InstanceCard.svelte +38 -0
  54. package/src/components/InstanceView.svelte +83 -2
  55. package/src/components/SettingsView.svelte +273 -0
  56. package/src/components/TerminalPane.svelte +54 -0
  57. package/src/components/UiShowcase.svelte +23 -3
  58. package/src/container-injections/claude-code-install.ts +1 -1
  59. package/src/container-injections/claude-history.ts +52 -0
  60. package/src/container-injections/claude-statusline.ts +4 -1
  61. package/src/container-injections/github-credentials.ts +12 -1
  62. package/src/container-injections/tmux.ts +6 -2
  63. package/src/index.ts +35 -7
  64. package/src/lib/agent-run-stream.ts +286 -0
  65. package/src/lib/agent-runs.server.ts +735 -0
  66. package/src/lib/auth.server.ts +11 -2
  67. package/src/lib/build-freshness.server.ts +71 -0
  68. package/src/lib/config.server.ts +36 -7
  69. package/src/lib/container-files.server.ts +1 -1
  70. package/src/lib/data-dir-lock.server.ts +78 -0
  71. package/src/lib/db.server.ts +124 -1
  72. package/src/lib/devcontainer.server.ts +93 -16
  73. package/src/lib/docker-client.server.ts +11 -5
  74. package/src/lib/drives.ts +11 -0
  75. package/src/lib/exec.server.ts +66 -5
  76. package/src/lib/injections.server.ts +5 -1
  77. package/src/lib/instances.server.ts +66 -7
  78. package/src/lib/log-capture.server.ts +63 -23
  79. package/src/lib/mcp-auth.server.ts +52 -0
  80. package/src/lib/osc52.ts +18 -0
  81. package/src/lib/picker.server.ts +23 -2
  82. package/src/lib/sandbox-ops.server.ts +263 -0
  83. package/src/lib/upload.ts +32 -0
  84. package/src/lib/uploads.server.ts +164 -0
  85. package/src/mcp/routes.server.ts +25 -0
  86. package/src/mcp/server.server.ts +55 -0
  87. package/src/mcp/tools.server.ts +660 -0
  88. package/src/pages/Instance.svelte +6 -2
  89. package/src/pages/Settings.svelte +15 -0
  90. package/src/routes.ts +79 -3
  91. package/src/types.ts +17 -0
  92. package/.mochi/svelte-client/HydratableIsland-zrdsrsmg.js +0 -1
  93. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-v7y7dh53.js +0 -2
  94. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-tmwd6fpb.js +0 -14
  95. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-m7t64dbh.js +0 -1
  96. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-4a7ann1f.js +0 -40
  97. package/.mochi/svelte-client/chunk-5jwfs4p9.js +0 -2
  98. package/.mochi/svelte-client/chunk-6qg4q98a.js +0 -2
  99. package/.mochi/svelte-client/chunk-phf7v5jj.js +0 -4
  100. package/.mochi/svelte-client/chunk-tmeh70wg.js +0 -2
  101. package/.mochi/svelte-client/chunk-vs1yjbqt.js +0 -2
  102. package/.mochi/svelte-client/chunk-x0m3k0rn.js +0 -1
  103. package/.mochi/svelte-client/chunk-y7f4jbdm.js +0 -2
  104. package/.mochi/svelte-compile/Instance-knnkcn2p.server.js +0 -377
  105. package/.mochi/svelte-compile/chunk-ed46m58e.js +0 -22
  106. package/.mochi/svelte-compile/chunk-jx6h1xzp.js +0 -20
  107. package/.mochi/svelte-css/IdeBar-1mtgemqfuvqzi.css +0 -1
  108. package/.mochi/svelte-css/InstanceView-32j12hyg8v9u0.css +0 -1
@@ -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
+ }
@@ -0,0 +1,32 @@
1
+ import { apiFetch } from '../api.ts';
2
+
3
+ export interface UploadedFile {
4
+ name: string;
5
+ hostPath: string;
6
+ containerPath: string;
7
+ bytes: number;
8
+ }
9
+
10
+ /** Unknown MIME subtype → `.bin`, so a pasted image never round-trips with no extension at all. */
11
+ function extFor(mimeType: string): string {
12
+ const subtype = mimeType.split('/')[1];
13
+ return subtype && /^[a-z0-9.+-]+$/i.test(subtype) ? subtype : 'bin';
14
+ }
15
+
16
+ /** Files from a drop or a paste; pasted images arrive nameless, so they get a timestamped one. */
17
+ export function filesFrom(dt: DataTransfer | null): File[] {
18
+ if (!dt) return [];
19
+ return [...dt.files].map((f) =>
20
+ f.name ? f : new File([f], `pasted-${Date.now()}.${extFor(f.type)}`, { type: f.type })
21
+ );
22
+ }
23
+
24
+ export async function uploadFile(id: string, file: File): Promise<UploadedFile> {
25
+ const q = new URLSearchParams({ name: file.name });
26
+ const { file: saved } = await apiFetch<{ file: UploadedFile }>(
27
+ `/api/instances/${id}/upload?${q}`,
28
+ { method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body: file },
29
+ 'Upload failed'
30
+ );
31
+ return saved;
32
+ }
@@ -0,0 +1,164 @@
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { open, unlink, type FileHandle } from 'node:fs/promises';
3
+ import { basename, join, posix, resolve, sep } from 'node:path';
4
+ import { Mochi, apiError, json, type MochiRouteValue } from 'mochi-framework';
5
+ import { getInstance, getOption, type InstanceRow } from './db.server.ts';
6
+
7
+ export const UPLOAD_ENABLED_KEY = 'workspace_upload_enabled';
8
+
9
+ /** Off until someone opts in — a write path into every workspace is not something to ship armed. */
10
+ export function uploadEnabled(): boolean {
11
+ return getOption(UPLOAD_ENABLED_KEY) === '1';
12
+ }
13
+
14
+ /** The drop folder inside every workspace; git-excluded (MANAGER_GIT_EXCLUDES + its own .gitignore). */
15
+ export const INBOX_DIR = 'codebay-inbox';
16
+
17
+ /** App-level ceiling, independent of Bun's `maxRequestBodySize` — kept in one place so they can't drift. */
18
+ export const UPLOAD_MAX_BYTES = 256 * 1024 * 1024;
19
+
20
+ /**
21
+ * A bare filename only. `basename()` alone isn't enough to stop `../x` or `a/b` — a path with
22
+ * separators would silently resolve to a different (real) name than what it looks like it means,
23
+ * so any input `basename()` had to change is rejected outright rather than normalized.
24
+ */
25
+ function hasControlChar(name: string): boolean {
26
+ for (let i = 0; i < name.length; i++) {
27
+ if (name.charCodeAt(i) < 0x20) return true;
28
+ }
29
+ return false;
30
+ }
31
+
32
+ export function safeUploadName(raw: string): string {
33
+ const trimmed = raw.trim();
34
+ const candidate = basename(trimmed);
35
+ if (!candidate || candidate === '.' || candidate === '..' || candidate.length > 200) {
36
+ throw new Error('invalid file name');
37
+ }
38
+ if (candidate !== trimmed || /[\\/]/.test(candidate) || hasControlChar(candidate)) {
39
+ throw new Error('invalid file name');
40
+ }
41
+ return candidate;
42
+ }
43
+
44
+ /** `name`, then `name-1.ext`, `name-2.ext`… — the candidates `openUnique` tries in order. */
45
+ function* candidateNames(name: string): Generator<string> {
46
+ yield name;
47
+ const dot = name.lastIndexOf('.');
48
+ const stem = dot > 0 ? name.slice(0, dot) : name;
49
+ const ext = dot > 0 ? name.slice(dot) : '';
50
+ for (let n = 1; ; n++) yield `${stem}-${n}${ext}`;
51
+ }
52
+
53
+ /**
54
+ * Claims the first free candidate with an exclusive create, so nothing already in the inbox is ever
55
+ * overwritten. `wx` (O_CREAT|O_EXCL) also refuses to follow a symlink — even a dangling one, which
56
+ * an `existsSync` probe would report as free and a plain write would follow outside the workspace.
57
+ */
58
+ export async function openUnique(
59
+ dir: string,
60
+ name: string
61
+ ): Promise<{ name: string; path: string; file: FileHandle }> {
62
+ for (const candidate of candidateNames(name)) {
63
+ const path = join(dir, candidate);
64
+ try {
65
+ return { name: candidate, path, file: await open(path, 'wx') };
66
+ } catch (err) {
67
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
68
+ }
69
+ }
70
+ throw new Error('unreachable');
71
+ }
72
+
73
+ const INBOX_GITIGNORE = '*\n';
74
+ // Earlier builds seeded this, whose negation left the folder itself showing in `git status`.
75
+ const LEGACY_INBOX_GITIGNORE = '*\n!.gitignore\n';
76
+
77
+ /** Self-excluding, so the inbox needs no entry in `MANAGER_GIT_EXCLUDES`-derived `.git/info/exclude`. */
78
+ function seedInboxGitignore(inbox: string): void {
79
+ const path = join(inbox, '.gitignore');
80
+ const st = lstatSync(path, { throwIfNoEntry: false });
81
+ if (!st) {
82
+ try {
83
+ writeFileSync(path, INBOX_GITIGNORE, { flag: 'wx' });
84
+ } catch (err) {
85
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
86
+ }
87
+ } else if (st.isFile() && readFileSync(path, 'utf8') === LEGACY_INBOX_GITIGNORE) {
88
+ writeFileSync(path, INBOX_GITIGNORE);
89
+ }
90
+ }
91
+
92
+ export interface SavedUpload {
93
+ name: string;
94
+ hostPath: string;
95
+ containerPath: string;
96
+ bytes: number;
97
+ }
98
+
99
+ /** Writes straight to the host bind mount — no `execInContainer`, no base64, no 128 KiB carrier cap. */
100
+ export async function saveUpload(
101
+ row: InstanceRow,
102
+ rawName: string,
103
+ body: ReadableStream<Uint8Array> | Blob
104
+ ): Promise<SavedUpload> {
105
+ if (!existsSync(row.workspace_path)) throw new Error('Workspace folder is not on disk yet');
106
+ if (!row.remote_workspace_folder) throw new Error('Instance has not booted yet');
107
+ const inbox = join(row.workspace_path, INBOX_DIR);
108
+ // A symlinked inbox could redirect the write outside the workspace, so refuse anything but a real
109
+ // dir — `lstat`, not `existsSync`, which reports a dangling link as absent.
110
+ const st = lstatSync(inbox, { throwIfNoEntry: false });
111
+ if (st && !st.isDirectory()) throw new Error(`${INBOX_DIR} is not a directory`);
112
+ mkdirSync(inbox, { recursive: true });
113
+ seedInboxGitignore(inbox);
114
+ const safe = safeUploadName(rawName);
115
+ if (!resolve(inbox, safe).startsWith(inbox + sep)) throw new Error('invalid file name'); // belt and braces
116
+ const { name, path: dest, file } = await openUnique(inbox, safe);
117
+ // Streamed to disk chunk by chunk, so the body never has to fit in memory.
118
+ let written = 0;
119
+ try {
120
+ const stream = body instanceof Blob ? body.stream() : body;
121
+ for await (const chunk of stream) {
122
+ for (let off = 0; off < chunk.byteLength;) {
123
+ off += (await file.write(chunk, off)).bytesWritten;
124
+ }
125
+ written += chunk.byteLength;
126
+ }
127
+ } catch (err) {
128
+ // An aborted or over-cap body would otherwise leave a truncated file that looks complete.
129
+ await file.close();
130
+ await unlink(dest);
131
+ throw err;
132
+ }
133
+ await file.close();
134
+ return {
135
+ name,
136
+ hostPath: dest,
137
+ bytes: written,
138
+ // posix.join, never `join` — the container path is POSIX even when the host is Windows.
139
+ containerPath: posix.join(row.remote_workspace_folder, INBOX_DIR, name)
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Raw body + `?name=` rather than multipart: nothing to parse, and the bytes stream straight to
145
+ * disk. Spread into the route table like `proxyRoutes`/`mcpRoutes`, and kept off `routes.ts`'s own
146
+ * `mutationRoute` helper (which always answers 200) because this needs 404 and 413.
147
+ */
148
+ export const uploadRoutes: Record<string, MochiRouteValue> = {
149
+ '/api/instances/:id/upload': Mochi.api(async ({ method, params, url, request }) => {
150
+ if (method !== 'POST') return apiError(405, 'Method Not Allowed');
151
+ if (!uploadEnabled()) return apiError(404, 'Not Found');
152
+ const row = getInstance(params.id!);
153
+ if (!row) return apiError(404, 'Instance not found');
154
+ const len = Number(request.headers.get('content-length') ?? '0');
155
+ if (len > UPLOAD_MAX_BYTES) return apiError(413, `File exceeds ${UPLOAD_MAX_BYTES} bytes`);
156
+ if (!request.body) return apiError(400, 'Empty body');
157
+ try {
158
+ const saved = await saveUpload(row, url.searchParams.get('name') ?? '', request.body);
159
+ return json({ file: saved }, { status: 201 });
160
+ } catch (err) {
161
+ return apiError(400, (err as Error).message);
162
+ }
163
+ })
164
+ };
@@ -0,0 +1,25 @@
1
+ import { Mochi, apiError, type MochiRouteValue } from 'mochi-framework';
2
+ import { MCP_PATH, mcpAuthOk, mcpEnabled } from '../lib/mcp-auth.server.ts';
3
+ import { mcpTransport } from './server.server.ts';
4
+
5
+ function unauthorized(): Response {
6
+ return new Response('Unauthorized', {
7
+ status: 401,
8
+ headers: { 'WWW-Authenticate': 'Bearer realm="Codebay MCP"' }
9
+ });
10
+ }
11
+
12
+ /**
13
+ * Mounted at `/mcp` rather than under `/api/` on purpose: `basicAuth` 403s every mutating `/api/`
14
+ * request without the `x-codebay-request` header, which no MCP client sends. The path is exempt
15
+ * from the Basic Auth gate and the CSRF filter, and authenticates by bearer token instead — the
16
+ * same trade the container bridge makes.
17
+ */
18
+ export const mcpRoutes: Record<string, MochiRouteValue> = {
19
+ [MCP_PATH]: Mochi.api(async ({ request }) => {
20
+ // A 404 while disabled, so an install that never opted in looks like it has no MCP at all.
21
+ if (!mcpEnabled()) return apiError(404, 'Not Found');
22
+ if (!mcpAuthOk(request)) return unauthorized();
23
+ return (await mcpTransport().respond(request)) ?? apiError(404, 'Not Found');
24
+ })
25
+ };
@@ -0,0 +1,55 @@
1
+ import { McpServer } from 'tmcp';
2
+ import { ValibotJsonSchemaAdapter } from '@tmcp/adapter-valibot';
3
+ import { HttpTransport } from '@tmcp/transport-http';
4
+ import type * as v from 'valibot';
5
+ import { APP_VERSION } from '../lib/config.server.ts';
6
+ import { MCP_PATH } from '../lib/mcp-auth.server.ts';
7
+ import { registerTools } from './tools.server.ts';
8
+
9
+ const INSTRUCTIONS = `Codebay runs isolated devcontainer sandboxes, each with an authenticated Claude Code inside it.
10
+
11
+ The normal flow is: create_sandbox (from a Git URL or a local folder) → poll get_sandbox until its
12
+ status is "running" → run_agent with a prompt → poll get_run until it is done → get_diff to see what
13
+ changed → git_push / create_pr to land it.
14
+
15
+ Sandboxes are persistent and cost real resources: stop_sandbox one you will come back to (its
16
+ workspace and run history stay; start_sandbox brings it back) and delete_sandbox one you are finished
17
+ with. rebuild_sandbox recreates the container from the devcontainer config, keeping the workspace —
18
+ it is what applies add_port_forward / remove_port_forward.
19
+ A sandbox runs one agent at a time. Runs are asynchronous: run_agent returns a handle immediately and
20
+ the work continues in the background, so never assume a run has finished without checking get_run.`;
21
+
22
+ /** Built once and pinned, so dev-mode hot reload doesn't drop live MCP sessions on the floor. */
23
+ interface McpRegistry {
24
+ transport: HttpTransport;
25
+ }
26
+
27
+ const globalForMcp = globalThis as unknown as { __codebayMcp?: McpRegistry };
28
+
29
+ function build(): McpRegistry {
30
+ const server = new McpServer<v.GenericSchema>(
31
+ {
32
+ name: 'codebay',
33
+ version: APP_VERSION,
34
+ description: 'Create devcontainer sandboxes and run Claude Code in them.'
35
+ },
36
+ {
37
+ adapter: new ValibotJsonSchemaAdapter(),
38
+ instructions: INSTRUCTIONS,
39
+ capabilities: { tools: { listChanged: false } }
40
+ }
41
+ );
42
+ registerTools(server);
43
+ return {
44
+ transport: new HttpTransport(server, {
45
+ path: MCP_PATH,
46
+ // The bearer token is for programmatic clients; no browser page should be reaching this.
47
+ allowedOrigins: [],
48
+ cors: false
49
+ })
50
+ };
51
+ }
52
+
53
+ export function mcpTransport(): HttpTransport {
54
+ return (globalForMcp.__codebayMcp ??= build()).transport;
55
+ }