codebay 0.13.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 (85) hide show
  1. package/.mochi/manifest.json +522 -443
  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-h7gsjyz3.server.js → Settings-0b5t9kw9.server.js} +348 -151
  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/public/favicon.svg +9 -0
  36. package/src/components/AgentLogBox.svelte +458 -0
  37. package/src/components/AppShell.svelte +11 -0
  38. package/src/components/FolderBrowser.svelte +13 -5
  39. package/src/components/InstanceCard.svelte +38 -0
  40. package/src/components/InstanceView.svelte +8 -0
  41. package/src/components/SettingsView.svelte +260 -0
  42. package/src/components/UiShowcase.svelte +4 -2
  43. package/src/container-injections/claude-code-credentials.ts +2 -3
  44. package/src/container-injections/claude-code-skills.ts +125 -0
  45. package/src/container-injections/claude-output-style.ts +2 -3
  46. package/src/container-injections/claude-statusline.ts +9 -17
  47. package/src/container-injections/github-credentials.ts +12 -1
  48. package/src/index.ts +32 -7
  49. package/src/lib/agent-run-stream.ts +246 -0
  50. package/src/lib/agent-runs.server.ts +724 -0
  51. package/src/lib/auth.server.ts +11 -2
  52. package/src/lib/build-freshness.server.ts +71 -0
  53. package/src/lib/config.server.ts +29 -6
  54. package/src/lib/container-files.server.ts +23 -1
  55. package/src/lib/data-dir-lock.server.ts +78 -0
  56. package/src/lib/db.server.ts +120 -1
  57. package/src/lib/devcontainer.server.ts +60 -14
  58. package/src/lib/docker-client.server.ts +11 -5
  59. package/src/lib/drives.ts +11 -0
  60. package/src/lib/exec.server.ts +66 -5
  61. package/src/lib/host-claude.server.ts +31 -0
  62. package/src/lib/injections.server.ts +2 -0
  63. package/src/lib/instances.server.ts +45 -3
  64. package/src/lib/log-capture.server.ts +17 -5
  65. package/src/lib/mcp-auth.server.ts +52 -0
  66. package/src/lib/picker.server.ts +23 -2
  67. package/src/lib/sandbox-ops.server.ts +263 -0
  68. package/src/mcp/routes.server.ts +25 -0
  69. package/src/mcp/server.server.ts +52 -0
  70. package/src/mcp/tools.server.ts +494 -0
  71. package/src/pages/Settings.svelte +15 -0
  72. package/src/routes.ts +72 -1
  73. package/src/shell.html +1 -0
  74. package/src/types.ts +15 -0
  75. package/.mochi/svelte-client/HydratableIsland-zrdsrsmg.js +0 -1
  76. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-v7y7dh53.js +0 -2
  77. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-tmwd6fpb.js +0 -14
  78. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-m7t64dbh.js +0 -1
  79. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-wedc9x9y.js +0 -39
  80. package/.mochi/svelte-client/chunk-5jwfs4p9.js +0 -2
  81. package/.mochi/svelte-client/chunk-6qg4q98a.js +0 -2
  82. package/.mochi/svelte-client/chunk-tmeh70wg.js +0 -2
  83. package/.mochi/svelte-client/chunk-vs1yjbqt.js +0 -2
  84. package/.mochi/svelte-client/chunk-y7f4jbdm.js +0 -2
  85. package/.mochi/svelte-compile/Instance-knnkcn2p.server.js +0 -377
@@ -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,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,52 @@
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, so delete_sandbox when you are finished with one.
16
+ A sandbox runs one agent at a time. Runs are asynchronous: run_agent returns a handle immediately and
17
+ the work continues in the background, so never assume a run has finished without checking get_run.`;
18
+
19
+ /** Built once and pinned, so dev-mode hot reload doesn't drop live MCP sessions on the floor. */
20
+ interface McpRegistry {
21
+ transport: HttpTransport;
22
+ }
23
+
24
+ const globalForMcp = globalThis as unknown as { __codebayMcp?: McpRegistry };
25
+
26
+ function build(): McpRegistry {
27
+ const server = new McpServer<v.GenericSchema>(
28
+ {
29
+ name: 'codebay',
30
+ version: APP_VERSION,
31
+ description: 'Create devcontainer sandboxes and run Claude Code in them.'
32
+ },
33
+ {
34
+ adapter: new ValibotJsonSchemaAdapter(),
35
+ instructions: INSTRUCTIONS,
36
+ capabilities: { tools: { listChanged: false } }
37
+ }
38
+ );
39
+ registerTools(server);
40
+ return {
41
+ transport: new HttpTransport(server, {
42
+ path: MCP_PATH,
43
+ // The bearer token is for programmatic clients; no browser page should be reaching this.
44
+ allowedOrigins: [],
45
+ cors: false
46
+ })
47
+ };
48
+ }
49
+
50
+ export function mcpTransport(): HttpTransport {
51
+ return (globalForMcp.__codebayMcp ??= build()).transport;
52
+ }