eve-hub 0.14.4 → 0.14.6
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/CHANGELOG.md +12 -0
- package/bash-run.mjs +1 -1
- package/fork-env.mjs +10 -7
- package/hooks/sandbox.d.ts +1 -1
- package/hooks/sandbox.js +4 -15
- package/host-sandbox.d.ts +22 -39
- package/host-sandbox.js +77 -81
- package/kit-modules.json +1 -0
- package/package.json +1 -1
- package/stubs.mjs +2 -1
- package/tools/bash.js +1 -1
- package/tools/fetch_upload.d.ts +18 -0
- package/tools/fetch_upload.js +40 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# eve-hub
|
|
2
2
|
|
|
3
|
+
## 0.14.6
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- New `fetch_upload` tool: a bot copies a file another bot shared as a `hub://upload/<id>` link into its sandbox (for example a patch to `git apply`), with the hub credential its kit tools already send. A shell can't fetch these links: the hosted hub's Deployment Protection sends it to a login page.
|
|
8
|
+
|
|
9
|
+
## 0.14.5
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Mac bots' bash and read_file work again on eve 0.83.3: HostSandbox speaks eve's connection-based sandbox API (eve derives the session; `/workspace` and `~` still map to the bot's root and the user's home). The sandbox hook no longer deletes the host sandbox each turn; eve itself starts fresh when a session's recorded sandbox came from another provider.
|
|
14
|
+
|
|
3
15
|
## 0.14.4
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/bash-run.mjs
CHANGED
|
@@ -241,7 +241,7 @@ export function selfStopRefusal(command, env = process.env) {
|
|
|
241
241
|
export async function runBash(sandbox, { command, timeoutSeconds }, { hostRoot, code = workspaceCode(), graceMs = KILL_GRACE_MS, env = process.env } = {}) {
|
|
242
242
|
const selfStop = selfStopRefusal(command, env);
|
|
243
243
|
if (selfStop) return { exitCode: 1, stdout: "", stderr: selfStop, truncated: false };
|
|
244
|
-
const linked = linkedInstallTargets(command, sandbox.root ?? hostRoot);
|
|
244
|
+
const linked = linkedInstallTargets(command, sandbox.root?.() ?? hostRoot);
|
|
245
245
|
if (linked.length) {
|
|
246
246
|
const lines = linked.map(({ dir, target }) => `${join(dir, "node_modules")} -> ${target}`);
|
|
247
247
|
return {
|
package/fork-env.mjs
CHANGED
|
@@ -9,19 +9,22 @@ import { registryFetch, sandboxRegistry } from "./registry-fetch.mjs";
|
|
|
9
9
|
|
|
10
10
|
const TTL_MS = 3000;
|
|
11
11
|
|
|
12
|
+
// A fork's config, or null once the registry says the session isn't a fork
|
|
13
|
+
// (404). Any other answer throws and isn't cached: eve fixes a session's root
|
|
14
|
+
// when it opens the connection, so a guess of "no fork" would pin a fork to the
|
|
15
|
+
// shared checkout.
|
|
12
16
|
export function createForkEnv({ registry = sandboxRegistry(), fetchImpl = globalThis.fetch, now = Date.now } = {}) {
|
|
13
17
|
const cache = new Map(); // sessionId -> { config, at }
|
|
14
18
|
return async function lookup(sessionId) {
|
|
15
19
|
if (registry === null) return null; // no local hub: no forks
|
|
16
20
|
const hit = cache.get(sessionId);
|
|
17
21
|
if (hit && now() - hit.at < TTL_MS) return hit.config;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
return config?.root ? config : null;
|
|
22
|
+
const res = await registryFetch(`/api/sandbox/session-config/${encodeURIComponent(sessionId)}`, {}, { base: registry, fetchImpl });
|
|
23
|
+
if (res.status !== 404 && !res.ok) throw new Error(`fork config for ${sessionId}: HTTP ${res.status}`);
|
|
24
|
+
const body = res.ok ? await res.json() : null;
|
|
25
|
+
const config = body?.root ? body : null;
|
|
26
|
+
cache.set(sessionId, { config, at: now() });
|
|
27
|
+
return config;
|
|
25
28
|
};
|
|
26
29
|
}
|
|
27
30
|
|
package/hooks/sandbox.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
declare const _default: import("eve/hooks").HookDefinition<
|
|
1
|
+
declare const _default: import("eve/hooks").HookDefinition<never>;
|
|
2
2
|
export default _default;
|
package/hooks/sandbox.js
CHANGED
|
@@ -1,17 +1,6 @@
|
|
|
1
1
|
import { defineHook } from "eve/hooks";
|
|
2
|
-
//
|
|
2
|
+
// Once retired Docker sandboxes recorded by sessions created before the host
|
|
3
|
+
// sandbox. eve now does that itself: a session whose recorded sandbox came
|
|
4
|
+
// from another provider starts a fresh one. Kept so existing bot stubs load.
|
|
3
5
|
// Turn reporting is owned by the registry's eve stream consumer, not hooks.
|
|
4
|
-
export default defineHook({
|
|
5
|
-
events: {
|
|
6
|
-
async "turn.started"(_event, ctx) {
|
|
7
|
-
try {
|
|
8
|
-
const sandbox = await ctx.getSandbox();
|
|
9
|
-
if (sandbox.resolvePath("/workspace") === "/workspace")
|
|
10
|
-
await sandbox.delete();
|
|
11
|
-
}
|
|
12
|
-
catch (error) {
|
|
13
|
-
console.warn("[roster] sandbox migration skipped", error);
|
|
14
|
-
}
|
|
15
|
-
},
|
|
16
|
-
},
|
|
17
|
-
});
|
|
6
|
+
export default defineHook({ events: {} });
|
package/host-sandbox.d.ts
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
import { type ChildProcess } from "node:child_process";
|
|
2
2
|
import type { SandboxSession } from "eve/sandbox";
|
|
3
|
+
import { type SandboxConnection, type SandboxProvider, type WithPrepareOption } from "eve/sandbox/provider";
|
|
3
4
|
interface HostOptions {
|
|
4
5
|
readonly root?: string;
|
|
5
6
|
}
|
|
6
7
|
type HostArtifact = {
|
|
7
8
|
readonly root: string;
|
|
8
9
|
};
|
|
9
|
-
|
|
10
|
+
type HostState = {
|
|
10
11
|
readonly root: string;
|
|
11
12
|
readonly version: 1;
|
|
12
13
|
readonly worktree?: SessionWorktree;
|
|
13
14
|
readonly rootSessionId?: string;
|
|
14
|
-
}
|
|
15
|
+
};
|
|
15
16
|
interface SessionRef {
|
|
16
17
|
readonly id: string;
|
|
17
18
|
readonly parent?: {
|
|
@@ -19,11 +20,20 @@ interface SessionRef {
|
|
|
19
20
|
readonly sessionId?: string;
|
|
20
21
|
};
|
|
21
22
|
}
|
|
22
|
-
|
|
23
|
+
type SessionWorktree = {
|
|
23
24
|
readonly path: string;
|
|
24
25
|
readonly branch: string;
|
|
25
26
|
readonly repo: string;
|
|
27
|
+
};
|
|
28
|
+
export interface HostExtensions {
|
|
29
|
+
root(): string;
|
|
30
|
+
description(): string;
|
|
31
|
+
readonly worktree?: SessionWorktree;
|
|
26
32
|
}
|
|
33
|
+
export type HostConnection = SandboxConnection<HostExtensions> & {
|
|
34
|
+
readonly workspaceRoot: string;
|
|
35
|
+
ready(): Promise<void>;
|
|
36
|
+
};
|
|
27
37
|
export declare const HOST_ROOT: string;
|
|
28
38
|
export declare function hostEnvironment(options?: HostOptions): {
|
|
29
39
|
prepare(): Promise<{
|
|
@@ -32,42 +42,19 @@ export declare function hostEnvironment(options?: HostOptions): {
|
|
|
32
42
|
start(ctx: {
|
|
33
43
|
session: SessionRef;
|
|
34
44
|
}, _open: unknown, artifact: HostArtifact): Promise<{
|
|
35
|
-
|
|
36
|
-
sandbox: SandboxSession & {
|
|
37
|
-
description: string;
|
|
38
|
-
root: string;
|
|
39
|
-
worktree?: SessionWorktree;
|
|
40
|
-
};
|
|
41
|
-
onRuntimeShutdown: () => Promise<void>;
|
|
42
|
-
onSandboxStop: () => Promise<void>;
|
|
43
|
-
onSandboxDelete: () => Promise<void>;
|
|
44
|
-
};
|
|
45
|
+
connection: HostConnection;
|
|
45
46
|
state: HostState;
|
|
46
47
|
}>;
|
|
47
48
|
resume(ctx: {
|
|
48
49
|
session: SessionRef;
|
|
49
|
-
}, _artifact: unknown, state: HostState): Promise<
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
worktree?: SessionWorktree;
|
|
54
|
-
};
|
|
55
|
-
onRuntimeShutdown: () => Promise<void>;
|
|
56
|
-
onSandboxStop: () => Promise<void>;
|
|
57
|
-
onSandboxDelete: () => Promise<void>;
|
|
58
|
-
}>;
|
|
59
|
-
};
|
|
60
|
-
export declare const HostSandbox: import("eve/sandbox/provider").SandboxProvider<HostOptions, undefined, SandboxSession>;
|
|
61
|
-
export declare function hostHandle(rootOrState: string | HostState, sessionId: string): {
|
|
62
|
-
sandbox: SandboxSession & {
|
|
63
|
-
description: string;
|
|
64
|
-
root: string;
|
|
65
|
-
worktree?: SessionWorktree;
|
|
66
|
-
};
|
|
67
|
-
onRuntimeShutdown: () => Promise<void>;
|
|
68
|
-
onSandboxStop: () => Promise<void>;
|
|
69
|
-
onSandboxDelete: () => Promise<void>;
|
|
50
|
+
}, _artifact: unknown, state: HostState): Promise<HostConnection>;
|
|
51
|
+
onSessionEnd(ctx: {
|
|
52
|
+
session: SessionRef;
|
|
53
|
+
}): Promise<void>;
|
|
70
54
|
};
|
|
55
|
+
type HostSession = SandboxSession & HostExtensions;
|
|
56
|
+
export declare const HostSandbox: SandboxProvider<WithPrepareOption<HostOptions, HostSession>, undefined, HostSession>;
|
|
57
|
+
export declare function hostConnection(rootOrState: string | HostState, sessionId: string): Promise<HostConnection>;
|
|
71
58
|
export declare function killTree(child: ChildProcess): void;
|
|
72
59
|
declare const forkConfig: (sessionId: string) => Promise<{
|
|
73
60
|
root: string;
|
|
@@ -116,9 +103,5 @@ export declare function hostSession(baseRoot: string, children: Set<ChildProcess
|
|
|
116
103
|
rootSessionId?: string;
|
|
117
104
|
lookup?: typeof forkConfig;
|
|
118
105
|
guard?: Fence | ((what: string) => Promise<void>);
|
|
119
|
-
}):
|
|
120
|
-
description: string;
|
|
121
|
-
root: string;
|
|
122
|
-
worktree?: SessionWorktree;
|
|
123
|
-
};
|
|
106
|
+
}): HostConnection;
|
|
124
107
|
export {};
|
package/host-sandbox.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
// A sandbox provider that is the host machine itself: commands run in the
|
|
2
2
|
// user's real shell and files are the user's real files. `/workspace` maps
|
|
3
|
-
// to `root` (default ~/wrk); other absolute paths
|
|
3
|
+
// to `root` (default ~/wrk), `~` to the user's home; other absolute paths
|
|
4
|
+
// pass through.
|
|
4
5
|
//
|
|
5
6
|
// There is no isolation. Every bot using this can do anything the user can.
|
|
6
7
|
import { execFile, spawn as spawnChild } from "node:child_process";
|
|
7
8
|
import { createHash, randomUUID } from "node:crypto";
|
|
8
|
-
import {
|
|
9
|
-
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
9
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
10
10
|
import { request as httpRequest } from "node:http";
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
|
-
import { dirname,
|
|
12
|
+
import { dirname, join, resolve } from "node:path";
|
|
13
13
|
import { Readable } from "node:stream";
|
|
14
14
|
import { promisify } from "node:util";
|
|
15
15
|
import { defineSandboxProvider } from "eve/sandbox/provider";
|
|
@@ -31,7 +31,7 @@ export function hostEnvironment(options) {
|
|
|
31
31
|
const rootSessionId = ctx.session.parent?.rootSessionId;
|
|
32
32
|
const worktree = await sessionWorktree(ctx.session.id, rootSessionId);
|
|
33
33
|
const state = { ...(worktree ? { root: worktree.path, worktree } : { root: artifact.root }), version: 1, ...(rootSessionId ? { rootSessionId } : {}) };
|
|
34
|
-
return {
|
|
34
|
+
return { connection: await hostConnection(state, ctx.session.id), state };
|
|
35
35
|
},
|
|
36
36
|
async resume(ctx, _artifact, state) {
|
|
37
37
|
// Asked again: the registry restores a worktree whose directory went
|
|
@@ -41,10 +41,16 @@ export function hostEnvironment(options) {
|
|
|
41
41
|
const next = { ...(worktree ? { ...state, root: worktree.path, worktree } : state), ...(rootSessionId ? { rootSessionId } : {}) };
|
|
42
42
|
if (!worktree)
|
|
43
43
|
await mkdir(next.root, { recursive: true }); // never fake a missing worktree with an empty dir
|
|
44
|
-
return
|
|
44
|
+
return hostConnection(next, ctx.session.id);
|
|
45
|
+
},
|
|
46
|
+
// The session is over (or its sandbox deleted): stop what it still runs.
|
|
47
|
+
// The files are the user's own and stay.
|
|
48
|
+
async onSessionEnd(ctx) {
|
|
49
|
+
await stopSession(ctx.session.id);
|
|
45
50
|
},
|
|
46
51
|
};
|
|
47
52
|
}
|
|
53
|
+
// Annotated: the inferred type names eve's internal session module, which the published .d.ts can't reference (TS2883).
|
|
48
54
|
export const HostSandbox = defineSandboxProvider({
|
|
49
55
|
name: "host",
|
|
50
56
|
environment: hostEnvironment,
|
|
@@ -109,23 +115,35 @@ async function sessionWorktree(sessionId, rootSessionId) {
|
|
|
109
115
|
await new Promise((r) => setTimeout(r, 300));
|
|
110
116
|
}
|
|
111
117
|
}
|
|
112
|
-
// A
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
118
|
+
// A session's processes, shared by every connection eve opens to it (eve
|
|
119
|
+
// reconnects for each step), so stop() and session end reach all of them.
|
|
120
|
+
const sessionChildren = new Map();
|
|
121
|
+
const childrenOf = (sessionId) => {
|
|
122
|
+
let children = sessionChildren.get(sessionId);
|
|
123
|
+
if (!children)
|
|
124
|
+
sessionChildren.set(sessionId, (children = new Set()));
|
|
125
|
+
return children;
|
|
126
|
+
};
|
|
127
|
+
async function stopSession(sessionId, children = sessionChildren.get(sessionId)) {
|
|
128
|
+
if (children) {
|
|
117
129
|
for (const child of children)
|
|
118
130
|
killTree(child);
|
|
119
131
|
children.clear();
|
|
120
132
|
liveSessions.delete(children);
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
133
|
+
}
|
|
134
|
+
if (sessionChildren.get(sessionId) === children)
|
|
135
|
+
sessionChildren.delete(sessionId);
|
|
136
|
+
await closeBrowser(sessionId);
|
|
137
|
+
}
|
|
138
|
+
// A root path (the shared checkout; tests call it this way) or a session's
|
|
139
|
+
// state. Resolves once a fork's root is known, since eve reads workspaceRoot
|
|
140
|
+
// once, when it opens the connection: a failed fork lookup rejects here rather
|
|
141
|
+
// than anchor the connection at the shared checkout.
|
|
142
|
+
export async function hostConnection(rootOrState, sessionId) {
|
|
143
|
+
const { root, worktree, rootSessionId } = typeof rootOrState === "string" ? { root: rootOrState, worktree: undefined, rootSessionId: undefined } : rootOrState;
|
|
144
|
+
const connection = hostSession(root, childrenOf(sessionId), sessionId, { worktree, rootSessionId });
|
|
145
|
+
await connection.ready();
|
|
146
|
+
return connection;
|
|
129
147
|
}
|
|
130
148
|
// SIGTERM the whole process group; whatever ignores it gets SIGKILL after
|
|
131
149
|
// KILL_AFTER_MS, so a timed-out or stopped command can't linger.
|
|
@@ -598,6 +616,9 @@ const killGroup = (pid) => {
|
|
|
598
616
|
// `guard`: a Fence (ownershipGuard) that admits every op with a ticket, or a
|
|
599
617
|
// plain `(what) => Promise<void>` that is asked before each op instead, with
|
|
600
618
|
// no tickets at all (tests stand in an unguarded sandbox this way).
|
|
619
|
+
// Returns eve's byte-level connection: eve derives the session (resolvePath,
|
|
620
|
+
// run, the text and binary helpers) from it, anchoring relative paths at
|
|
621
|
+
// workspaceRoot, so every path that arrives here is absolute.
|
|
601
622
|
export function hostSession(baseRoot, children, sessionId, { worktree, rootSessionId, lookup = forkConfig, guard = ownershipGuard(sessionId, { rootSessionId }), } = {}) {
|
|
602
623
|
let root = baseRoot;
|
|
603
624
|
let forkEnv = {};
|
|
@@ -626,19 +647,19 @@ export function hostSession(baseRoot, children, sessionId, { worktree, rootSessi
|
|
|
626
647
|
if (forkEnv[dir])
|
|
627
648
|
await mkdir(forkEnv[dir], { recursive: true });
|
|
628
649
|
};
|
|
629
|
-
//
|
|
630
|
-
//
|
|
631
|
-
|
|
632
|
-
const resolvePath = (path) => {
|
|
650
|
+
// eve hands over `/workspace/...` as is, and a model's `~/x` anchored at the
|
|
651
|
+
// root (`<root>/~/x`).
|
|
652
|
+
const hostPath = (path) => {
|
|
633
653
|
if (path === "/workspace" || path.startsWith("/workspace/"))
|
|
634
654
|
return join(root, path.slice("/workspace".length));
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
655
|
+
const home = join(root, "~");
|
|
656
|
+
if (path === home || path.startsWith(`${home}/`))
|
|
657
|
+
return join(homedir(), path.slice(home.length));
|
|
658
|
+
return path;
|
|
638
659
|
};
|
|
639
660
|
const spawn = async ({ command, workingDirectory, env, abortSignal }) => {
|
|
640
661
|
await ready();
|
|
641
|
-
const cwd =
|
|
662
|
+
const cwd = hostPath(workingDirectory);
|
|
642
663
|
// Paused first: nothing of the user's runs until the ticket is granted.
|
|
643
664
|
const { child, go } = spawnPaused(process.env.SHELL ?? "/bin/bash", command, cwd, { ...process.env, ...env, ...forkEnv });
|
|
644
665
|
await new Promise((ok, fail) => {
|
|
@@ -705,17 +726,6 @@ export function hostSession(baseRoot, children, sessionId, { worktree, rootSessi
|
|
|
705
726
|
kill: async () => killTree(child),
|
|
706
727
|
};
|
|
707
728
|
};
|
|
708
|
-
const readBinary = async (path) => {
|
|
709
|
-
await ready();
|
|
710
|
-
try {
|
|
711
|
-
return new Uint8Array(await readFile(resolvePath(path)));
|
|
712
|
-
}
|
|
713
|
-
catch (error) {
|
|
714
|
-
if (error.code === "ENOENT")
|
|
715
|
-
return null;
|
|
716
|
-
throw error;
|
|
717
|
-
}
|
|
718
|
-
};
|
|
719
729
|
// The effect runs under the ticket and the ticket is released with how it went.
|
|
720
730
|
const fenced = async (ticket, effect) => {
|
|
721
731
|
try {
|
|
@@ -730,7 +740,7 @@ export function hostSession(baseRoot, children, sessionId, { worktree, rootSessi
|
|
|
730
740
|
};
|
|
731
741
|
const write = async (path, bytes) => {
|
|
732
742
|
await ready();
|
|
733
|
-
const target =
|
|
743
|
+
const target = hostPath(path);
|
|
734
744
|
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
735
745
|
const ticket = await admit("write", { path: target, sha256 }, `write ${path}`);
|
|
736
746
|
await fenced(ticket, async () => {
|
|
@@ -739,63 +749,49 @@ export function hostSession(baseRoot, children, sessionId, { worktree, rootSessi
|
|
|
739
749
|
});
|
|
740
750
|
};
|
|
741
751
|
return {
|
|
742
|
-
|
|
743
|
-
|
|
752
|
+
ready,
|
|
753
|
+
// Read once, when eve opens the connection; start/resume wait for ready().
|
|
754
|
+
get workspaceRoot() {
|
|
744
755
|
return root;
|
|
745
756
|
},
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
757
|
+
extensions: {
|
|
758
|
+
// Read by the bash tool (bash-run.mjs): where this session's commands run.
|
|
759
|
+
root: () => root,
|
|
760
|
+
...(worktree ? { worktree } : {}),
|
|
761
|
+
description: () => {
|
|
762
|
+
const shell = `The host machine's own shell (${process.platform}), not an isolated sandbox.`;
|
|
763
|
+
const tail = "You have the user's full environment, credentials, and filesystem, so be deliberate with anything destructive.";
|
|
764
|
+
if (forkEnv.ROSTER_FORK)
|
|
765
|
+
return `${shell} The working directory is ${root}; /workspace maps to it. This session is fork ${forkEnv.ROSTER_FORK}: commands default to its worktree with PORT=${forkEnv.PORT} and fork-local build/cache dirs. ${tail}`;
|
|
766
|
+
if (worktree)
|
|
767
|
+
return `${shell} The working directory is ${root}: a git worktree of ${worktree.repo} on branch ${worktree.branch}, private to this thread; /workspace maps to it. Do this thread's work in ${worktree.repo} here, not in ${worktree.repo} itself (other threads use that checkout). Other paths are the user's real files. ${tail}`;
|
|
768
|
+
return `${shell} The working directory is ${root}; /workspace maps to it. ${tail}`;
|
|
769
|
+
},
|
|
755
770
|
},
|
|
756
|
-
resolvePath,
|
|
757
771
|
spawn,
|
|
758
|
-
async run(options) {
|
|
759
|
-
const proc = await spawn(options);
|
|
760
|
-
const [stdout, stderr, { exitCode }] = await Promise.all([
|
|
761
|
-
new Response(proc.stdout).text(),
|
|
762
|
-
new Response(proc.stderr).text(),
|
|
763
|
-
proc.wait(),
|
|
764
|
-
]);
|
|
765
|
-
return { exitCode, stdout, stderr };
|
|
766
|
-
},
|
|
767
772
|
async readFile({ path }) {
|
|
768
773
|
await ready();
|
|
769
|
-
const target = resolvePath(path);
|
|
770
774
|
try {
|
|
771
|
-
await
|
|
775
|
+
return new Uint8Array(await readFile(hostPath(path)));
|
|
772
776
|
}
|
|
773
|
-
catch {
|
|
774
|
-
|
|
777
|
+
catch (error) {
|
|
778
|
+
if (error.code === "ENOENT")
|
|
779
|
+
return null;
|
|
780
|
+
throw error;
|
|
775
781
|
}
|
|
776
|
-
return Readable.toWeb(createReadStream(target));
|
|
777
|
-
},
|
|
778
|
-
readBinaryFile: ({ path }) => readBinary(path),
|
|
779
|
-
async readTextFile({ path, encoding = "utf-8", startLine, endLine }) {
|
|
780
|
-
const bytes = await readBinary(path);
|
|
781
|
-
if (bytes === null)
|
|
782
|
-
return null;
|
|
783
|
-
const text = Buffer.from(bytes).toString(encoding);
|
|
784
|
-
if (startLine === undefined && endLine === undefined)
|
|
785
|
-
return text;
|
|
786
|
-
const lines = text.split("\n");
|
|
787
|
-
return lines.slice((startLine ?? 1) - 1, endLine ?? lines.length).join("\n");
|
|
788
782
|
},
|
|
789
|
-
async
|
|
790
|
-
|
|
783
|
+
async writeFiles({ files }) {
|
|
784
|
+
for (const file of files)
|
|
785
|
+
await write(file.path, file.content);
|
|
791
786
|
},
|
|
792
|
-
writeBinaryFile: ({ path, content }) => write(path, content),
|
|
793
|
-
writeTextFile: ({ path, content, encoding = "utf-8" }) => write(path, new Uint8Array(Buffer.from(content, encoding))),
|
|
794
787
|
async removePath({ path, force, recursive }) {
|
|
795
788
|
await ready();
|
|
796
|
-
const target =
|
|
789
|
+
const target = hostPath(path);
|
|
797
790
|
const ticket = await admit("delete", { path: target, ...(recursive === undefined ? {} : { recursive }), ...(force === undefined ? {} : { force }) }, `remove ${path}`);
|
|
798
791
|
await fenced(ticket, () => rm(target, { force, recursive }));
|
|
799
792
|
},
|
|
793
|
+
// Releases compute: the session's processes and its browser. Also what
|
|
794
|
+
// eve calls on every open connection when the runtime shuts down.
|
|
795
|
+
stop: () => stopSession(sessionId, children),
|
|
800
796
|
};
|
|
801
797
|
}
|
package/kit-modules.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eve-hub",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.6",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "Bot-side code shared by every hub agent: registry tools, the roster hook, the eve channel, the host sandbox and browser, and model helpers.",
|
|
6
6
|
"type": "module",
|
package/stubs.mjs
CHANGED
|
@@ -10,6 +10,7 @@ export const KIT_FILES = {
|
|
|
10
10
|
"tools/react.ts": ["React to a message with an emoji."],
|
|
11
11
|
"tools/show.ts": ["Move the human's screen to a thread, DM, or message, or open a page or file beside the thread."],
|
|
12
12
|
"tools/browser.ts": ["Its own headless browser, watched live in the Computer view."],
|
|
13
|
+
"tools/fetch_upload.ts": ["Copies a file another bot shared (hub://upload/<id>) into the sandbox."],
|
|
13
14
|
"tools/view_image.ts": ["Shows the bot an image (a sandbox file or a browser screenshot) so it can see it."],
|
|
14
15
|
"tools/update_thread.ts": ["Update a thread this bot is in: title, track, domain and summary."],
|
|
15
16
|
"tools/list_threads.ts": ["Every workspace thread, from any session."],
|
|
@@ -57,7 +58,7 @@ export const DEPLOYED_KIT_FILES = new Set([
|
|
|
57
58
|
"schedules/dash.ts",
|
|
58
59
|
"skills/onboarding-profile.ts",
|
|
59
60
|
"tools/onboarding_scripts.ts",
|
|
60
|
-
...["react", "show", "update_thread", "list_threads", "read_thread", "post_message"].map((t) => `tools/${t}.ts`),
|
|
61
|
+
...["react", "show", "fetch_upload", "update_thread", "list_threads", "read_thread", "post_message"].map((t) => `tools/${t}.ts`),
|
|
61
62
|
"memory/team.ts",
|
|
62
63
|
"skills/forms.ts",
|
|
63
64
|
...["create_thread", "set_summary", "move_path", "archive_thread", "unarchive_thread", "list_agents", "create_agent", "remove_agent", "restart_agent", "tag_threads", "dash_update", "apply_tags"].map((t) => `tools/${t}.ts`),
|
package/tools/bash.js
CHANGED
|
@@ -27,6 +27,6 @@ export default defineTool({
|
|
|
27
27
|
async execute(input, ctx) {
|
|
28
28
|
// HostSandbox exposes the session's real root (its thread worktree, if any).
|
|
29
29
|
const sandbox = await ctx.getSandbox();
|
|
30
|
-
return runBash(sandbox, input, { hostRoot: sandbox.root ?? HOST_ROOT, code: workspaceCode() });
|
|
30
|
+
return runBash(sandbox, input, { hostRoot: sandbox.root?.() ?? HOST_ROOT, code: workspaceCode() });
|
|
31
31
|
},
|
|
32
32
|
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
declare const _default: import("eve/tools").ToolDefinition<{
|
|
2
|
+
uri: string;
|
|
3
|
+
path?: string | undefined;
|
|
4
|
+
}, {
|
|
5
|
+
path: string;
|
|
6
|
+
size: number;
|
|
7
|
+
mediaType: string | null;
|
|
8
|
+
}> & {
|
|
9
|
+
execute(input: {
|
|
10
|
+
uri: string;
|
|
11
|
+
path?: string | undefined;
|
|
12
|
+
}, ctx: import("eve/tools").ToolContext): Promise<{
|
|
13
|
+
path: string;
|
|
14
|
+
size: number;
|
|
15
|
+
mediaType: string | null;
|
|
16
|
+
}>;
|
|
17
|
+
};
|
|
18
|
+
export default _default;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// The `fetch_upload` tool: copies a file another bot shared (`hub://upload/<id>`, what
|
|
2
|
+
// `show(path)` returns on a hosted hub) into this bot's sandbox, e.g. a patch to `git apply`.
|
|
3
|
+
// A shell can't fetch it: the hosted hub's Deployment Protection and its auth take only the bot
|
|
4
|
+
// credential that registryFetch sends (registry-fetch.mjs).
|
|
5
|
+
import { defineTool } from "eve/tools";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { registryFetch } from "../registry-fetch.mjs";
|
|
8
|
+
const UPLOAD = /^hub:\/\/upload\/([0-9a-f]{64}\.[a-z0-9]{1,10})$/;
|
|
9
|
+
export default defineTool({
|
|
10
|
+
description: "Fetch a file another bot shared as a hub://upload/<id> link (e.g. a patch, a log, a brief) into your sandbox, then read or apply it there. Your shell can't fetch these links; this tool sends your hub credential. Returns the path it wrote, the size in bytes and the media type.",
|
|
11
|
+
inputSchema: z.object({
|
|
12
|
+
uri: z.string().describe("The hub://upload/<id> link."),
|
|
13
|
+
path: z.string().optional().describe("Where to write it in your sandbox. Default /tmp/uploads/<id>."),
|
|
14
|
+
}),
|
|
15
|
+
async execute({ uri, path }, ctx) {
|
|
16
|
+
const id = UPLOAD.exec(uri.trim())?.[1];
|
|
17
|
+
if (!id)
|
|
18
|
+
throw new Error(`not a hub://upload/<id> link: ${uri}`);
|
|
19
|
+
// A redirect is Deployment Protection sending the request to a login page: an error, not the file.
|
|
20
|
+
const res = await registryFetch(`/api/uploads/${id}`, { redirect: "manual", signal: AbortSignal.timeout(30_000) });
|
|
21
|
+
if (!res.ok) {
|
|
22
|
+
const text = await res.text();
|
|
23
|
+
// Deployment Protection answers with a whole HTML page: name the redirect, or keep the body short.
|
|
24
|
+
const error = (() => { try {
|
|
25
|
+
return JSON.parse(text).error;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
} })();
|
|
30
|
+
const location = res.headers.get("location");
|
|
31
|
+
const why = error ?? (location ? `redirected to ${location}` : text.slice(0, 200));
|
|
32
|
+
throw new Error(`fetch_upload failed: ${res.status} ${why}`.trim());
|
|
33
|
+
}
|
|
34
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
35
|
+
const sandbox = await ctx.getSandbox();
|
|
36
|
+
const target = path ?? `/tmp/uploads/${id}`;
|
|
37
|
+
await sandbox.writeBinaryFile({ path: target, content: bytes });
|
|
38
|
+
return { path: sandbox.resolvePath(target), size: bytes.length, mediaType: res.headers.get("content-type") };
|
|
39
|
+
},
|
|
40
|
+
});
|