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.
- package/.mochi/manifest.json +778 -646
- package/.mochi/svelte-client/HydratableIsland-6y9p903x.js +1 -0
- package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-nv6yzk0x.js +2 -0
- package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-ry83ck8a.js +14 -0
- package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-vjt4jscf.js +3 -0
- package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-1jdrk8x6.js +48 -0
- package/.mochi/svelte-client/{chunk-spk1b2r4.js → chunk-0vqvn81s.js} +1 -1
- package/.mochi/svelte-client/chunk-2dc6352a.js +1 -0
- package/.mochi/svelte-client/chunk-3cxaw5hr.js +2 -0
- package/.mochi/svelte-client/chunk-3jqc40sp.js +2 -0
- package/.mochi/svelte-client/chunk-3pgw6t5j.js +4 -0
- package/.mochi/svelte-client/chunk-7g3xt6ez.js +2 -0
- package/.mochi/svelte-client/chunk-b5s3mpg0.js +1 -0
- package/.mochi/svelte-client/chunk-bhseezf3.js +2 -0
- package/.mochi/svelte-client/chunk-bhwkq8er.js +1 -0
- package/.mochi/svelte-client/chunk-jm3ge348.js +1 -0
- package/.mochi/svelte-client/{chunk-0mc09cyb.js → chunk-sxcr9gzp.js} +1 -1
- package/.mochi/svelte-client/{chunk-ccfh8hxz.js → chunk-tss2473w.js} +1 -1
- package/.mochi/svelte-compile/{App-94xsdn0w.server.js → App-8mpgrxza.server.js} +470 -271
- package/.mochi/svelte-compile/{Avatars-fnccw47h.server.js → Avatars-y5nf6q25.server.js} +111 -110
- package/.mochi/svelte-compile/{DefaultError-je52k17d.server.js → DefaultError-rrjye0s2.server.js} +1 -2
- package/.mochi/svelte-compile/Instance-3sbzyp0g.server.js +695 -0
- package/.mochi/svelte-compile/{Settings-a7b11v90.server.js → Settings-ysvh5vhh.server.js} +838 -607
- package/.mochi/svelte-compile/chunk-7zaw9t5a.js +25 -0
- package/.mochi/svelte-compile/chunk-878xcmp4.js +65 -0
- package/.mochi/svelte-compile/chunk-d2z3qxxe.js +28 -0
- package/.mochi/svelte-compile/{chunk-60mpagr9.js → chunk-gnf5cnct.js} +661 -22
- package/.mochi/svelte-compile/{chunk-w0hca7va.js → chunk-jq7jj8jd.js} +18 -2
- package/.mochi/svelte-compile/chunk-vr0e32mv.js +24 -0
- package/.mochi/svelte-compile/{chunk-apdte4nh.js → chunk-xmygdsj4.js} +35 -612
- package/.mochi/svelte-compile/{chunk-pqb403fh.js → chunk-zk4a9zms.js} +28 -31
- package/.mochi/svelte-css/AgentLogBox-38adby5iygny7.css +1 -0
- package/.mochi/svelte-css/DropZone-1zeitd761uwyd.css +1 -0
- package/.mochi/svelte-css/IdeBar-d3un7kuigxc5.css +1 -0
- package/.mochi/svelte-css/{InstanceCard-25z7nas7y98zk.css → InstanceCard-3vxozrp9uoads.css} +1 -1
- package/.mochi/svelte-css/InstanceView-3ucotech5097w.css +1 -0
- package/.mochi/svelte-css/{SettingsView-bccw34agd2bj.css → SettingsView-2400myxb9ot56.css} +1 -1
- package/README.md +43 -3
- package/migrations/0009_agent_runs.sql +24 -0
- package/migrations/0010_agent_run_model.sql +2 -0
- package/migrations/0011_agent_run_rejected_structured_output.sql +2 -0
- package/package.json +13 -9
- package/src/__fixtures__/IdeBarStrip.svelte +41 -0
- package/src/avatars/dog.ts +1 -1
- package/src/avatars/lightning.ts +8 -9
- package/src/avatars/rocket.ts +4 -5
- package/src/components/AgentLogBox.svelte +458 -0
- package/src/components/AppShell.svelte +112 -1
- package/src/components/DropZone.svelte +77 -0
- package/src/components/FolderBrowser.svelte +13 -5
- package/src/components/HealthBox.svelte +6 -5
- package/src/components/IdeBar.svelte +222 -76
- package/src/components/InstanceCard.svelte +38 -0
- package/src/components/InstanceView.svelte +83 -2
- package/src/components/SettingsView.svelte +273 -0
- package/src/components/TerminalPane.svelte +54 -0
- package/src/components/UiShowcase.svelte +23 -3
- package/src/container-injections/claude-code-install.ts +1 -1
- package/src/container-injections/claude-history.ts +52 -0
- package/src/container-injections/claude-statusline.ts +4 -1
- package/src/container-injections/github-credentials.ts +12 -1
- package/src/container-injections/tmux.ts +6 -2
- package/src/index.ts +35 -7
- package/src/lib/agent-run-stream.ts +286 -0
- package/src/lib/agent-runs.server.ts +735 -0
- package/src/lib/auth.server.ts +11 -2
- package/src/lib/build-freshness.server.ts +71 -0
- package/src/lib/config.server.ts +36 -7
- package/src/lib/container-files.server.ts +1 -1
- package/src/lib/data-dir-lock.server.ts +78 -0
- package/src/lib/db.server.ts +124 -1
- package/src/lib/devcontainer.server.ts +93 -16
- package/src/lib/docker-client.server.ts +11 -5
- package/src/lib/drives.ts +11 -0
- package/src/lib/exec.server.ts +66 -5
- package/src/lib/injections.server.ts +5 -1
- package/src/lib/instances.server.ts +66 -7
- package/src/lib/log-capture.server.ts +63 -23
- package/src/lib/mcp-auth.server.ts +52 -0
- package/src/lib/osc52.ts +18 -0
- package/src/lib/picker.server.ts +23 -2
- package/src/lib/sandbox-ops.server.ts +263 -0
- package/src/lib/upload.ts +32 -0
- package/src/lib/uploads.server.ts +164 -0
- package/src/mcp/routes.server.ts +25 -0
- package/src/mcp/server.server.ts +55 -0
- package/src/mcp/tools.server.ts +660 -0
- package/src/pages/Instance.svelte +6 -2
- package/src/pages/Settings.svelte +15 -0
- package/src/routes.ts +79 -3
- package/src/types.ts +17 -0
- package/.mochi/svelte-client/HydratableIsland-zrdsrsmg.js +0 -1
- package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-v7y7dh53.js +0 -2
- package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-tmwd6fpb.js +0 -14
- package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-m7t64dbh.js +0 -1
- package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-4a7ann1f.js +0 -40
- package/.mochi/svelte-client/chunk-5jwfs4p9.js +0 -2
- package/.mochi/svelte-client/chunk-6qg4q98a.js +0 -2
- package/.mochi/svelte-client/chunk-phf7v5jj.js +0 -4
- package/.mochi/svelte-client/chunk-tmeh70wg.js +0 -2
- package/.mochi/svelte-client/chunk-vs1yjbqt.js +0 -2
- package/.mochi/svelte-client/chunk-x0m3k0rn.js +0 -1
- package/.mochi/svelte-client/chunk-y7f4jbdm.js +0 -2
- package/.mochi/svelte-compile/Instance-knnkcn2p.server.js +0 -377
- package/.mochi/svelte-compile/chunk-ed46m58e.js +0 -22
- package/.mochi/svelte-compile/chunk-jx6h1xzp.js +0 -20
- package/.mochi/svelte-css/IdeBar-1mtgemqfuvqzi.css +0 -1
- 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
|
+
}
|