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.
- package/.mochi/manifest.json +522 -443
- package/.mochi/svelte-client/HydratableIsland-smgsf0ma.js +1 -0
- package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-9vc087ep.js +2 -0
- package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-a48xq47a.js +14 -0
- package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-d4zehrqz.js +3 -0
- package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-t5175pf5.js +44 -0
- package/.mochi/svelte-client/chunk-0mstgbtr.js +2 -0
- package/.mochi/svelte-client/chunk-0q9g0kpg.js +1 -0
- package/.mochi/svelte-client/chunk-3fwjzw1r.js +1 -0
- package/.mochi/svelte-client/{chunk-spk1b2r4.js → chunk-5pg0be5f.js} +1 -1
- package/.mochi/svelte-client/{chunk-ccfh8hxz.js → chunk-a2hty99b.js} +1 -1
- package/.mochi/svelte-client/{chunk-0mc09cyb.js → chunk-ds9yn9cq.js} +1 -1
- package/.mochi/svelte-client/{chunk-phf7v5jj.js → chunk-fn03gxer.js} +1 -1
- package/.mochi/svelte-client/chunk-jhgq7f65.js +2 -0
- package/.mochi/svelte-client/chunk-p5a07kx1.js +1 -0
- package/.mochi/svelte-client/chunk-p5wpyhsf.js +2 -0
- package/.mochi/svelte-client/chunk-t2kfvtb6.js +2 -0
- package/.mochi/svelte-client/{chunk-x0m3k0rn.js → chunk-v3wg9mdy.js} +1 -1
- package/.mochi/svelte-compile/{App-94xsdn0w.server.js → App-68f9a9fq.server.js} +64 -59
- package/.mochi/svelte-compile/{Avatars-fnccw47h.server.js → Avatars-vyh3rwxx.server.js} +14 -29
- package/.mochi/svelte-compile/Instance-yfw7s5mp.server.js +627 -0
- package/.mochi/svelte-compile/{Settings-h7gsjyz3.server.js → Settings-0b5t9kw9.server.js} +348 -151
- package/.mochi/svelte-compile/{chunk-w0hca7va.js → chunk-0y3wrae9.js} +13 -1
- package/.mochi/svelte-compile/{chunk-ed46m58e.js → chunk-2qzc07gt.js} +13 -1
- package/.mochi/svelte-compile/chunk-ef3hvegw.js +21 -0
- package/.mochi/svelte-compile/chunk-f9td8ej2.js +24 -0
- package/.mochi/svelte-compile/{chunk-pqb403fh.js → chunk-g0eravvj.js} +5 -16
- package/.mochi/svelte-css/AgentLogBox-38adby5iygny7.css +1 -0
- package/.mochi/svelte-css/{InstanceCard-25z7nas7y98zk.css → InstanceCard-3vxozrp9uoads.css} +1 -1
- package/.mochi/svelte-css/{SettingsView-bccw34agd2bj.css → SettingsView-2400myxb9ot56.css} +1 -1
- package/README.md +32 -1
- package/migrations/0009_agent_runs.sql +24 -0
- package/migrations/0010_agent_run_model.sql +2 -0
- package/package.json +6 -2
- package/public/favicon.svg +9 -0
- package/src/components/AgentLogBox.svelte +458 -0
- package/src/components/AppShell.svelte +11 -0
- package/src/components/FolderBrowser.svelte +13 -5
- package/src/components/InstanceCard.svelte +38 -0
- package/src/components/InstanceView.svelte +8 -0
- package/src/components/SettingsView.svelte +260 -0
- package/src/components/UiShowcase.svelte +4 -2
- package/src/container-injections/claude-code-credentials.ts +2 -3
- package/src/container-injections/claude-code-skills.ts +125 -0
- package/src/container-injections/claude-output-style.ts +2 -3
- package/src/container-injections/claude-statusline.ts +9 -17
- package/src/container-injections/github-credentials.ts +12 -1
- package/src/index.ts +32 -7
- package/src/lib/agent-run-stream.ts +246 -0
- package/src/lib/agent-runs.server.ts +724 -0
- package/src/lib/auth.server.ts +11 -2
- package/src/lib/build-freshness.server.ts +71 -0
- package/src/lib/config.server.ts +29 -6
- package/src/lib/container-files.server.ts +23 -1
- package/src/lib/data-dir-lock.server.ts +78 -0
- package/src/lib/db.server.ts +120 -1
- package/src/lib/devcontainer.server.ts +60 -14
- 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/host-claude.server.ts +31 -0
- package/src/lib/injections.server.ts +2 -0
- package/src/lib/instances.server.ts +45 -3
- package/src/lib/log-capture.server.ts +17 -5
- package/src/lib/mcp-auth.server.ts +52 -0
- package/src/lib/picker.server.ts +23 -2
- package/src/lib/sandbox-ops.server.ts +263 -0
- package/src/mcp/routes.server.ts +25 -0
- package/src/mcp/server.server.ts +52 -0
- package/src/mcp/tools.server.ts +494 -0
- package/src/pages/Settings.svelte +15 -0
- package/src/routes.ts +72 -1
- package/src/shell.html +1 -0
- package/src/types.ts +15 -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-wedc9x9y.js +0 -39
- package/.mochi/svelte-client/chunk-5jwfs4p9.js +0 -2
- package/.mochi/svelte-client/chunk-6qg4q98a.js +0 -2
- package/.mochi/svelte-client/chunk-tmeh70wg.js +0 -2
- package/.mochi/svelte-client/chunk-vs1yjbqt.js +0 -2
- package/.mochi/svelte-client/chunk-y7f4jbdm.js +0 -2
- 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
|
+
}
|