codebay 0.15.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 +493 -432
- 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-5pg0be5f.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-ds9yn9cq.js → chunk-sxcr9gzp.js} +1 -1
- package/.mochi/svelte-client/{chunk-a2hty99b.js → chunk-tss2473w.js} +1 -1
- package/.mochi/svelte-compile/{App-68f9a9fq.server.js → App-8mpgrxza.server.js} +489 -295
- package/.mochi/svelte-compile/{Avatars-vyh3rwxx.server.js → Avatars-y5nf6q25.server.js} +102 -86
- package/.mochi/svelte-compile/{DefaultError-je52k17d.server.js → DefaultError-rrjye0s2.server.js} +1 -2
- package/.mochi/svelte-compile/{Instance-yfw7s5mp.server.js → Instance-3sbzyp0g.server.js} +186 -118
- package/.mochi/svelte-compile/{Settings-0b5t9kw9.server.js → Settings-ysvh5vhh.server.js} +739 -628
- 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-0y3wrae9.js → chunk-jq7jj8jd.js} +10 -6
- 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-g0eravvj.js → chunk-zk4a9zms.js} +25 -17
- package/.mochi/svelte-css/DropZone-1zeitd761uwyd.css +1 -0
- package/.mochi/svelte-css/IdeBar-d3un7kuigxc5.css +1 -0
- package/.mochi/svelte-css/InstanceView-3ucotech5097w.css +1 -0
- package/README.md +13 -4
- package/migrations/0011_agent_run_rejected_structured_output.sql +2 -0
- package/package.json +9 -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/AppShell.svelte +101 -1
- package/src/components/DropZone.svelte +77 -0
- package/src/components/HealthBox.svelte +6 -5
- package/src/components/IdeBar.svelte +222 -76
- package/src/components/InstanceView.svelte +75 -2
- package/src/components/SettingsView.svelte +87 -0
- package/src/components/TerminalPane.svelte +54 -0
- package/src/components/UiShowcase.svelte +19 -1
- package/src/container-injections/claude-code-install.ts +1 -1
- package/src/container-injections/claude-history.ts +52 -0
- package/src/container-injections/tmux.ts +6 -2
- package/src/index.ts +3 -0
- package/src/lib/agent-run-stream.ts +41 -1
- package/src/lib/agent-runs.server.ts +14 -3
- package/src/lib/config.server.ts +7 -1
- package/src/lib/db.server.ts +6 -2
- package/src/lib/devcontainer.server.ts +33 -2
- package/src/lib/injections.server.ts +5 -1
- package/src/lib/instances.server.ts +21 -4
- package/src/lib/log-capture.server.ts +46 -18
- package/src/lib/osc52.ts +18 -0
- package/src/lib/upload.ts +32 -0
- package/src/lib/uploads.server.ts +164 -0
- package/src/mcp/server.server.ts +4 -1
- package/src/mcp/tools.server.ts +178 -12
- package/src/pages/Instance.svelte +6 -2
- package/src/pages/Settings.svelte +3 -0
- package/src/routes.ts +16 -2
- package/src/types.ts +2 -0
- package/.mochi/svelte-client/HydratableIsland-smgsf0ma.js +0 -1
- package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-9vc087ep.js +0 -2
- package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-a48xq47a.js +0 -14
- package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-d4zehrqz.js +0 -3
- package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-t5175pf5.js +0 -44
- package/.mochi/svelte-client/chunk-0mstgbtr.js +0 -2
- package/.mochi/svelte-client/chunk-0q9g0kpg.js +0 -1
- package/.mochi/svelte-client/chunk-3fwjzw1r.js +0 -1
- package/.mochi/svelte-client/chunk-fn03gxer.js +0 -4
- package/.mochi/svelte-client/chunk-jhgq7f65.js +0 -2
- package/.mochi/svelte-client/chunk-p5a07kx1.js +0 -1
- package/.mochi/svelte-client/chunk-p5wpyhsf.js +0 -2
- package/.mochi/svelte-client/chunk-t2kfvtb6.js +0 -2
- package/.mochi/svelte-client/chunk-v3wg9mdy.js +0 -1
- package/.mochi/svelte-compile/chunk-2qzc07gt.js +0 -34
- package/.mochi/svelte-compile/chunk-ef3hvegw.js +0 -21
- package/.mochi/svelte-compile/chunk-f9td8ej2.js +0 -24
- 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
|
@@ -7,8 +7,8 @@ import {
|
|
|
7
7
|
statSync,
|
|
8
8
|
writeFileSync
|
|
9
9
|
} from 'node:fs';
|
|
10
|
-
import { join } from 'node:path';
|
|
11
|
-
import { LOGS_DIR } from './config.server.ts';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import { claudeMirrorDir, LOGS_DIR } from './config.server.ts';
|
|
12
12
|
import { getInstance, type InstanceRow } from './db.server.ts';
|
|
13
13
|
import { isRunning } from './docker.server.ts';
|
|
14
14
|
import { execInContainer } from './exec.server.ts';
|
|
@@ -23,25 +23,30 @@ const END_MARKER = '__CODEBAY_END__';
|
|
|
23
23
|
|
|
24
24
|
export interface ManifestEntry {
|
|
25
25
|
path: string;
|
|
26
|
+
/** Path relative to the container's Claude config dir, e.g. `projects/-workspaces-foo/x.jsonl`. */
|
|
27
|
+
relPath: string;
|
|
26
28
|
size: number;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
interface FetchJob {
|
|
30
32
|
path: string;
|
|
33
|
+
relPath: string;
|
|
31
34
|
hostFileName: string;
|
|
32
35
|
/** 1-indexed byte to `tail -c +N` from; 1 re-pulls the whole file. */
|
|
33
36
|
startByte: number;
|
|
34
|
-
mode: 'append' | 'rollover';
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
/**
|
|
38
|
-
* Lists the transcript + history files with their byte sizes
|
|
39
|
-
*
|
|
40
|
+
* Lists the transcript + history files with their byte sizes, led by the config dir itself so the
|
|
41
|
+
* parser can derive each file's layout-preserving relative path rather than guess at the encoding.
|
|
42
|
+
* `find`/`wc -c` (not GNU `-printf`) so it works on busybox containers too; the marker line lets
|
|
43
|
+
* the parser skip login-shell noise.
|
|
40
44
|
*/
|
|
41
|
-
function manifestScript(): string {
|
|
45
|
+
export function manifestScript(): string {
|
|
42
46
|
return (
|
|
43
47
|
`h=$(eval echo ~$(id -un)); cfg="\${CLAUDE_CONFIG_DIR:-$h/.claude}"; ` +
|
|
44
48
|
`printf '%s\\n' '${MANIFEST_MARKER}'; ` +
|
|
49
|
+
`printf '%s\\n' "$cfg"; ` +
|
|
45
50
|
`{ find "$cfg" -maxdepth 1 -type f -name 'history.jsonl' 2>/dev/null; ` +
|
|
46
51
|
`find "$cfg/projects" -type f -name '*.jsonl' 2>/dev/null; } | ` +
|
|
47
52
|
`while IFS= read -r f; do printf '%s\\t%s\\n' "$f" "$(wc -c < "$f" | tr -d ' ')"; done; true`
|
|
@@ -74,13 +79,21 @@ function fetchScript(): string {
|
|
|
74
79
|
export function parseManifest(stdout: string): ManifestEntry[] {
|
|
75
80
|
const at = stdout.indexOf(MANIFEST_MARKER);
|
|
76
81
|
if (at === -1) return [];
|
|
82
|
+
const [, cfgLine, ...lines] = stdout.slice(at + MANIFEST_MARKER.length).split('\n');
|
|
83
|
+
// Trailing slashes would leave the relative path leading with one, breaking the host join.
|
|
84
|
+
const cfg = (cfgLine ?? '').trim().replace(/\/+$/, '');
|
|
85
|
+
if (!cfg) return [];
|
|
77
86
|
const out: ManifestEntry[] = [];
|
|
78
|
-
for (const line of
|
|
87
|
+
for (const line of lines) {
|
|
79
88
|
const tab = line.indexOf('\t');
|
|
80
89
|
if (tab === -1) continue;
|
|
81
90
|
const path = line.slice(0, tab);
|
|
82
91
|
const size = Number(line.slice(tab + 1).trim());
|
|
83
|
-
if (path
|
|
92
|
+
if (!path || !Number.isFinite(size) || !path.startsWith(`${cfg}/`)) continue;
|
|
93
|
+
const relPath = path.slice(cfg.length + 1);
|
|
94
|
+
// `find` can't emit `..` today, but this gets joined onto a host dir, so don't rely on that.
|
|
95
|
+
if (relPath.split('/').includes('..')) continue;
|
|
96
|
+
out.push({ path, relPath, size });
|
|
84
97
|
}
|
|
85
98
|
return out;
|
|
86
99
|
}
|
|
@@ -94,19 +107,22 @@ export function hostFileNameFor(instanceId: string, containerPath: string): stri
|
|
|
94
107
|
return `history-${instanceId}.jsonl`;
|
|
95
108
|
}
|
|
96
109
|
|
|
97
|
-
/**
|
|
110
|
+
/**
|
|
111
|
+
* Diffs container sizes against the host mirrors: append new bytes, or re-pull a file that shrank.
|
|
112
|
+
* Sized against the layout-preserving mirror, not the flat archive, because that one tracks the
|
|
113
|
+
* container 1:1 — it's what gets mounted back in, so it must stay a byte-exact copy.
|
|
114
|
+
*/
|
|
98
115
|
export function planFetch(
|
|
99
116
|
instanceId: string,
|
|
100
117
|
manifest: ManifestEntry[],
|
|
101
|
-
hostSize: (
|
|
118
|
+
hostSize: (relPath: string) => number
|
|
102
119
|
): FetchJob[] {
|
|
103
120
|
const jobs: FetchJob[] = [];
|
|
104
|
-
for (const { path, size } of manifest) {
|
|
121
|
+
for (const { path, relPath, size } of manifest) {
|
|
105
122
|
const hostFileName = hostFileNameFor(instanceId, path);
|
|
106
|
-
const existing = hostSize(
|
|
123
|
+
const existing = hostSize(relPath);
|
|
107
124
|
if (size === existing) continue;
|
|
108
|
-
|
|
109
|
-
else jobs.push({ path, hostFileName, startByte: existing + 1, mode: 'append' });
|
|
125
|
+
jobs.push({ path, relPath, hostFileName, startByte: size < existing ? 1 : existing + 1 });
|
|
110
126
|
}
|
|
111
127
|
return jobs;
|
|
112
128
|
}
|
|
@@ -178,6 +194,7 @@ function updateIndex(logsDir: string, row: InstanceRow): void {
|
|
|
178
194
|
interface CaptureDeps {
|
|
179
195
|
exec?: typeof execInContainer;
|
|
180
196
|
logsDir?: string;
|
|
197
|
+
mirrorDir?: string;
|
|
181
198
|
}
|
|
182
199
|
|
|
183
200
|
/**
|
|
@@ -188,6 +205,7 @@ export async function runCapturePass(row: InstanceRow, deps: CaptureDeps = {}):
|
|
|
188
205
|
if (!row.container_id) return false;
|
|
189
206
|
const exec = deps.exec ?? execInContainer;
|
|
190
207
|
const logsDir = deps.logsDir ?? LOGS_DIR;
|
|
208
|
+
const mirrorDir = deps.mirrorDir ?? claudeMirrorDir(row.id);
|
|
191
209
|
const target = { containerId: row.container_id, remoteUser: row.remote_user };
|
|
192
210
|
|
|
193
211
|
const manifestRes = await exec(target, { script: manifestScript(), capture: true });
|
|
@@ -195,7 +213,7 @@ export async function runCapturePass(row: InstanceRow, deps: CaptureDeps = {}):
|
|
|
195
213
|
const manifest = parseManifest(manifestRes.stdout);
|
|
196
214
|
|
|
197
215
|
mkdirSync(logsDir, { recursive: true });
|
|
198
|
-
const jobs = planFetch(row.id, manifest, (
|
|
216
|
+
const jobs = planFetch(row.id, manifest, (rel) => sizeOnDisk(join(mirrorDir, rel)));
|
|
199
217
|
|
|
200
218
|
if (jobs.length) {
|
|
201
219
|
const args = ['fetch'];
|
|
@@ -207,9 +225,19 @@ export async function runCapturePass(row: InstanceRow, deps: CaptureDeps = {}):
|
|
|
207
225
|
const job = byPath.get(block.path);
|
|
208
226
|
if (!job) continue;
|
|
209
227
|
const bytes = Buffer.from(block.base64, 'base64');
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
228
|
+
const archive = join(logsDir, job.hostFileName);
|
|
229
|
+
const mirror = join(mirrorDir, job.relPath);
|
|
230
|
+
mkdirSync(dirname(mirror), { recursive: true });
|
|
231
|
+
// The archive keeps every generation; the mirror only ever holds what the container
|
|
232
|
+
// holds, since it gets mounted back in and a stale byte would resurface as history.
|
|
233
|
+
// Any whole-file pull rolls, since a pre-mirror instance's archive would otherwise double.
|
|
234
|
+
if (job.startByte === 1) {
|
|
235
|
+
rollOver(archive);
|
|
236
|
+
writeFileSync(mirror, bytes);
|
|
237
|
+
} else {
|
|
238
|
+
appendFileSync(mirror, bytes);
|
|
239
|
+
}
|
|
240
|
+
appendFileSync(archive, bytes);
|
|
213
241
|
}
|
|
214
242
|
}
|
|
215
243
|
}
|
package/src/lib/osc52.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OSC 52 is how a program inside the container asks the *outer* terminal to put text on the host
|
|
3
|
+
* clipboard — what tmux's `set-clipboard on` emits on copy. xterm.js ships no handler for it, so
|
|
4
|
+
* every such write was silently dropped and copy out of an instance was impossible.
|
|
5
|
+
*/
|
|
6
|
+
export function decodeOsc52(data: string): string | null {
|
|
7
|
+
const sep = data.indexOf(';');
|
|
8
|
+
if (sep === -1) return null;
|
|
9
|
+
const payload = data.slice(sep + 1).replace(/\s+/g, '');
|
|
10
|
+
// `?` is a read request; answering it would hand the host clipboard to the container.
|
|
11
|
+
if (payload === '' || payload === '?') return null;
|
|
12
|
+
try {
|
|
13
|
+
const bytes = Uint8Array.from(atob(payload), (c) => c.charCodeAt(0));
|
|
14
|
+
return new TextDecoder().decode(bytes);
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -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
|
+
};
|
package/src/mcp/server.server.ts
CHANGED
|
@@ -12,7 +12,10 @@ The normal flow is: create_sandbox (from a Git URL or a local folder) → poll g
|
|
|
12
12
|
status is "running" → run_agent with a prompt → poll get_run until it is done → get_diff to see what
|
|
13
13
|
changed → git_push / create_pr to land it.
|
|
14
14
|
|
|
15
|
-
Sandboxes are persistent and cost real resources
|
|
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.
|
|
16
19
|
A sandbox runs one agent at a time. Runs are asynchronous: run_agent returns a handle immediately and
|
|
17
20
|
the work continues in the background, so never assume a run has finished without checking get_run.`;
|
|
18
21
|
|
package/src/mcp/tools.server.ts
CHANGED
|
@@ -2,13 +2,27 @@ import * as v from 'valibot';
|
|
|
2
2
|
import type { McpServer } from 'tmcp';
|
|
3
3
|
import { tool } from 'tmcp/utils';
|
|
4
4
|
import {
|
|
5
|
+
addForwardedPort,
|
|
5
6
|
createInstance,
|
|
6
7
|
deleteInstance,
|
|
7
8
|
listInstances,
|
|
9
|
+
rebuildInstance,
|
|
10
|
+
removeForwardedPort,
|
|
11
|
+
renameInstance,
|
|
8
12
|
sanitizeInstance,
|
|
13
|
+
startInstance,
|
|
14
|
+
stopInstance,
|
|
9
15
|
subscribeLogs
|
|
10
16
|
} from '../lib/instances.server.ts';
|
|
11
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
getInstance,
|
|
19
|
+
getRun,
|
|
20
|
+
listForwards,
|
|
21
|
+
listRuns,
|
|
22
|
+
openRunFor,
|
|
23
|
+
type AgentRunRow,
|
|
24
|
+
type InstanceRow
|
|
25
|
+
} from '../lib/db.server.ts';
|
|
12
26
|
import {
|
|
13
27
|
PROMPT_MAX_BYTES,
|
|
14
28
|
pollRunNow,
|
|
@@ -36,21 +50,34 @@ const MAX_WAIT_SECONDS = 60;
|
|
|
36
50
|
|
|
37
51
|
const sandboxId = v.pipe(v.string(), v.minLength(1), v.description('The sandbox id.'));
|
|
38
52
|
|
|
53
|
+
const containerPort = v.pipe(
|
|
54
|
+
v.number(),
|
|
55
|
+
v.integer(),
|
|
56
|
+
v.minValue(1),
|
|
57
|
+
v.maxValue(65535),
|
|
58
|
+
v.description('The port inside the container.')
|
|
59
|
+
);
|
|
60
|
+
|
|
39
61
|
function requireInstance(id: string) {
|
|
40
62
|
const row = getInstance(id);
|
|
41
63
|
if (!row) throw new Error(`No sandbox with id ${id}`);
|
|
42
64
|
return row;
|
|
43
65
|
}
|
|
44
66
|
|
|
67
|
+
/** A boot owns its row until the last injection lands, so nothing else may move it before then. */
|
|
68
|
+
function requireSettled(id: string) {
|
|
69
|
+
const row = requireInstance(id);
|
|
70
|
+
if (row.status === 'creating') {
|
|
71
|
+
throw new Error('the sandbox is still building — poll get_sandbox until status is "running"');
|
|
72
|
+
}
|
|
73
|
+
return row;
|
|
74
|
+
}
|
|
75
|
+
|
|
45
76
|
/** Every tool that touches the container needs one that is actually up. */
|
|
46
77
|
function requireRunning(id: string) {
|
|
47
|
-
const row =
|
|
78
|
+
const row = requireSettled(id);
|
|
48
79
|
if (!row.container_id || row.status !== 'running') {
|
|
49
|
-
throw new Error(
|
|
50
|
-
row.status === 'creating'
|
|
51
|
-
? 'the sandbox is still building — poll get_sandbox until status is "running"'
|
|
52
|
-
: `the sandbox is ${row.status}, not running`
|
|
53
|
-
);
|
|
80
|
+
throw new Error(`the sandbox is ${row.status}, not running`);
|
|
54
81
|
}
|
|
55
82
|
return row;
|
|
56
83
|
}
|
|
@@ -58,8 +85,20 @@ function requireRunning(id: string) {
|
|
|
58
85
|
/** The IDE link is what makes a sandbox inspectable by a human, so every sandbox payload carries it. */
|
|
59
86
|
const ideUrl = (id: string) => `${PUBLIC_ORIGIN}${proxyPathFor(id)}`;
|
|
60
87
|
|
|
61
|
-
|
|
62
|
-
|
|
88
|
+
const healthFor = (id: string) => currentHealthSnapshots().find((s) => s.id === id)?.health ?? null;
|
|
89
|
+
|
|
90
|
+
/** Forwards ride along because add/remove_port_forward hand back the sandbox as their receipt. */
|
|
91
|
+
function sandboxPayload(row: InstanceRow) {
|
|
92
|
+
const open = new Set(healthFor(row.id)?.openPorts ?? []);
|
|
93
|
+
return {
|
|
94
|
+
...sanitizeInstance(row),
|
|
95
|
+
ide_url: ideUrl(row.id),
|
|
96
|
+
forwarded_ports: listForwards(row.id).map((f) => ({
|
|
97
|
+
container_port: f.container_port,
|
|
98
|
+
host_port: f.host_port,
|
|
99
|
+
open: open.has(f.container_port)
|
|
100
|
+
}))
|
|
101
|
+
};
|
|
63
102
|
}
|
|
64
103
|
|
|
65
104
|
/** The prompt is left out: the caller wrote it, and it can be as large as the carrier allows. */
|
|
@@ -68,6 +107,11 @@ function runPayload(run: AgentRunRow) {
|
|
|
68
107
|
return { run_id: id, sandbox_id: instance_id, ...detail };
|
|
69
108
|
}
|
|
70
109
|
|
|
110
|
+
/** The run a stop/rebuild settled, re-read after the fact so the caller sees it as cancelled. */
|
|
111
|
+
function cancelledRun(open: AgentRunRow | null) {
|
|
112
|
+
return open ? runPayload(getRun(open.id)!) : null;
|
|
113
|
+
}
|
|
114
|
+
|
|
71
115
|
/**
|
|
72
116
|
* A failed call reaches the model as readable content rather than a JSON-RPC protocol error: the
|
|
73
117
|
* caller is an agent, and "that sandbox is still building" is information it can act on.
|
|
@@ -103,7 +147,10 @@ const runOptions = {
|
|
|
103
147
|
v.pipe(
|
|
104
148
|
v.string(),
|
|
105
149
|
v.description(
|
|
106
|
-
'A JSON Schema (as a string). When set, the run’s structured_output matches it.'
|
|
150
|
+
'A JSON Schema (as a string). When set, the run’s structured_output matches it. If no ' +
|
|
151
|
+
'payload ever validates the run fails with error "structured_output_failed", and its ' +
|
|
152
|
+
'result holds Claude’s plain-text answer with rejected_structured_output the last ' +
|
|
153
|
+
'payload the schema refused.'
|
|
107
154
|
)
|
|
108
155
|
)
|
|
109
156
|
),
|
|
@@ -224,11 +271,10 @@ export function registerTools(server: McpServer<v.GenericSchema>): void {
|
|
|
224
271
|
},
|
|
225
272
|
async (input) => {
|
|
226
273
|
const row = requireInstance(input.sandbox_id);
|
|
227
|
-
const health = currentHealthSnapshots().find((s) => s.id === row.id)?.health ?? null;
|
|
228
274
|
const runs = listRuns(row.id, 5);
|
|
229
275
|
return {
|
|
230
276
|
sandbox: sandboxPayload(row),
|
|
231
|
-
health,
|
|
277
|
+
health: healthFor(row.id),
|
|
232
278
|
runs: runs.map(runPayload)
|
|
233
279
|
};
|
|
234
280
|
}
|
|
@@ -249,6 +295,126 @@ export function registerTools(server: McpServer<v.GenericSchema>): void {
|
|
|
249
295
|
}
|
|
250
296
|
);
|
|
251
297
|
|
|
298
|
+
define(
|
|
299
|
+
{
|
|
300
|
+
name: 'stop_sandbox',
|
|
301
|
+
description:
|
|
302
|
+
'Stop a sandbox’s container without destroying it: the workspace, its files and its run ' +
|
|
303
|
+
'history are kept, and start_sandbox brings it back. Any active run is cancelled first, ' +
|
|
304
|
+
'the same way the dashboard’s Stop does, and comes back as cancelled_run so the ' +
|
|
305
|
+
'cancellation is never silent. Synchronous: the returned sandbox already has its new ' +
|
|
306
|
+
'status ("stopped", or "error" with the reason if Docker refused).',
|
|
307
|
+
schema: v.object({ sandbox_id: sandboxId })
|
|
308
|
+
},
|
|
309
|
+
async (input) => {
|
|
310
|
+
const row = requireSettled(input.sandbox_id);
|
|
311
|
+
if (!row.container_id) throw new Error('the sandbox has no container to stop');
|
|
312
|
+
const open = openRunFor(row.id);
|
|
313
|
+
const sandbox = sandboxPayload(await stopInstance(row.id));
|
|
314
|
+
return { sandbox, cancelled_run: cancelledRun(open) };
|
|
315
|
+
}
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
define(
|
|
319
|
+
{
|
|
320
|
+
name: 'start_sandbox',
|
|
321
|
+
description:
|
|
322
|
+
'Start a stopped sandbox’s container again. Synchronous, unlike create_sandbox: it ' +
|
|
323
|
+
'returns once Docker has started the container, with status already "running" (or ' +
|
|
324
|
+
'"error", with the reason in `error`), so there is nothing to poll for the status itself. ' +
|
|
325
|
+
'The editor surface inside takes a few more seconds to come up; get_sandbox’s ' +
|
|
326
|
+
'health.codeServerAccessible reports when it has. run_agent, exec_command and the file ' +
|
|
327
|
+
'tools work as soon as status is "running".',
|
|
328
|
+
schema: v.object({ sandbox_id: sandboxId })
|
|
329
|
+
},
|
|
330
|
+
async (input) => {
|
|
331
|
+
const row = requireSettled(input.sandbox_id);
|
|
332
|
+
if (!row.container_id) throw new Error('the sandbox has no container to start');
|
|
333
|
+
return { sandbox: sandboxPayload(await startInstance(row.id)) };
|
|
334
|
+
}
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
define(
|
|
338
|
+
{
|
|
339
|
+
name: 'rebuild_sandbox',
|
|
340
|
+
description:
|
|
341
|
+
'Recreate a sandbox’s container from its devcontainer config while keeping the workspace ' +
|
|
342
|
+
'copy, so files edited inside survive. This is what applies add_port_forward / ' +
|
|
343
|
+
'remove_port_forward and re-runs the Codebay injections (credentials, hooks). Any active ' +
|
|
344
|
+
'run is cancelled and comes back as cancelled_run. Returns immediately with status ' +
|
|
345
|
+
'"creating" while the build runs in the background; poll get_sandbox until status is ' +
|
|
346
|
+
'"running" (or "error" — get_logs kind "boot" says why).',
|
|
347
|
+
schema: v.object({
|
|
348
|
+
sandbox_id: sandboxId,
|
|
349
|
+
no_cache: v.optional(
|
|
350
|
+
v.pipe(v.boolean(), v.description('Rebuild the image without Docker’s build cache.'))
|
|
351
|
+
)
|
|
352
|
+
})
|
|
353
|
+
},
|
|
354
|
+
(input) => {
|
|
355
|
+
const row = requireSettled(input.sandbox_id);
|
|
356
|
+
const open = openRunFor(row.id);
|
|
357
|
+
const sandbox = sandboxPayload(rebuildInstance(row.id, { noCache: input.no_cache }));
|
|
358
|
+
return {
|
|
359
|
+
sandbox,
|
|
360
|
+
cancelled_run: cancelledRun(open),
|
|
361
|
+
note: 'The sandbox is rebuilding. Poll get_sandbox until status is "running".'
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
define(
|
|
367
|
+
{
|
|
368
|
+
name: 'rename_sandbox',
|
|
369
|
+
description:
|
|
370
|
+
'Rename a sandbox. Names are unique, so a clash gets a " #2"-style suffix; the returned ' +
|
|
371
|
+
'sandbox carries the name actually applied.',
|
|
372
|
+
schema: v.object({ sandbox_id: sandboxId, name: v.pipe(v.string(), v.minLength(1)) })
|
|
373
|
+
},
|
|
374
|
+
(input) => {
|
|
375
|
+
requireInstance(input.sandbox_id);
|
|
376
|
+
return { sandbox: sandboxPayload(renameInstance(input.sandbox_id, input.name)) };
|
|
377
|
+
}
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
define(
|
|
381
|
+
{
|
|
382
|
+
name: 'add_port_forward',
|
|
383
|
+
description:
|
|
384
|
+
'Publish a port inside the sandbox (e.g. a dev server an agent started) on a host port of ' +
|
|
385
|
+
'the Codebay host, so a human can open it; Codebay picks the host port. Only persisted ' +
|
|
386
|
+
'here: the mapping goes live on the next rebuild_sandbox, and forwarded_ports[].open on ' +
|
|
387
|
+
'get_sandbox shows once the mapping is published (after a rebuild).',
|
|
388
|
+
schema: v.object({ sandbox_id: sandboxId, port: containerPort })
|
|
389
|
+
},
|
|
390
|
+
async (input) => {
|
|
391
|
+
requireInstance(input.sandbox_id);
|
|
392
|
+
const sandbox = sandboxPayload(await addForwardedPort(input.sandbox_id, input.port));
|
|
393
|
+
return {
|
|
394
|
+
sandbox,
|
|
395
|
+
forward: sandbox.forwarded_ports.find((f) => f.container_port === input.port) ?? null,
|
|
396
|
+
note: 'Forwards are applied by rebuild_sandbox; the port is not published until then.'
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
define(
|
|
402
|
+
{
|
|
403
|
+
name: 'remove_port_forward',
|
|
404
|
+
description:
|
|
405
|
+
'Drop a forwarded port. Like add_port_forward this only updates the persisted set; the ' +
|
|
406
|
+
'mapping is actually unpublished by the next rebuild_sandbox.',
|
|
407
|
+
schema: v.object({ sandbox_id: sandboxId, port: containerPort })
|
|
408
|
+
},
|
|
409
|
+
(input) => {
|
|
410
|
+
requireInstance(input.sandbox_id);
|
|
411
|
+
return {
|
|
412
|
+
sandbox: sandboxPayload(removeForwardedPort(input.sandbox_id, input.port)),
|
|
413
|
+
note: 'Forwards are applied by rebuild_sandbox; the port stays published until then.'
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
);
|
|
417
|
+
|
|
252
418
|
define(
|
|
253
419
|
{
|
|
254
420
|
name: 'run_agent',
|
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
import '@fontsource-variable/jetbrains-mono';
|
|
4
4
|
import InstanceView from '../components/InstanceView.svelte';
|
|
5
5
|
|
|
6
|
-
let {
|
|
6
|
+
let {
|
|
7
|
+
id,
|
|
8
|
+
injectionChecks,
|
|
9
|
+
uploadEnabled
|
|
10
|
+
}: { id: string; injectionChecks: number; uploadEnabled: boolean } = $props();
|
|
7
11
|
</script>
|
|
8
12
|
|
|
9
|
-
<InstanceView {id} {injectionChecks} mochi:hydrate />
|
|
13
|
+
<InstanceView {id} {injectionChecks} {uploadEnabled} mochi:hydrate />
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
defaultImage,
|
|
20
20
|
builtinImage,
|
|
21
21
|
disableBuildCache,
|
|
22
|
+
workspaceUploadEnabled,
|
|
22
23
|
copyIgnorePatterns,
|
|
23
24
|
builtinCopyIgnore,
|
|
24
25
|
claudeConfigDir,
|
|
@@ -65,6 +66,7 @@
|
|
|
65
66
|
defaultImage: string;
|
|
66
67
|
builtinImage: string;
|
|
67
68
|
disableBuildCache: boolean;
|
|
69
|
+
workspaceUploadEnabled: boolean;
|
|
68
70
|
copyIgnorePatterns: string;
|
|
69
71
|
builtinCopyIgnore: string;
|
|
70
72
|
claudeConfigDir: string;
|
|
@@ -114,6 +116,7 @@
|
|
|
114
116
|
{defaultImage}
|
|
115
117
|
{builtinImage}
|
|
116
118
|
{disableBuildCache}
|
|
119
|
+
{workspaceUploadEnabled}
|
|
117
120
|
{copyIgnorePatterns}
|
|
118
121
|
{builtinCopyIgnore}
|
|
119
122
|
{claudeConfigDir}
|