talentos 1.1.6 → 1.2.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/dist/agent/adapters.js +47 -5
- package/dist/agent/codex-policy.js +5 -0
- package/dist/agent/mcp-entry.js +5 -2
- package/dist/agent/native/approvals.js +188 -0
- package/dist/agent/native/config.js +118 -0
- package/dist/agent/native/disqualify.js +46 -0
- package/dist/agent/native/env.js +29 -0
- package/dist/agent/native/gate.js +102 -0
- package/dist/agent/native/hook-entry.js +170 -0
- package/dist/agent/native/launch.js +91 -0
- package/dist/agent/native/paths.js +137 -0
- package/dist/agent/native/policy.js +653 -0
- package/dist/agent/native/probe.js +474 -0
- package/dist/agent/native/shell.js +174 -0
- package/dist/agent/native/workspace.js +87 -0
- package/dist/agent/serve.js +74 -8
- package/dist/args.js +1 -0
- package/dist/commands/agent-native.js +64 -0
- package/dist/commands/agent.js +5 -1
- package/dist/mcp-proxy.js +47 -6
- package/dist/upload-file.js +172 -0
- package/package.json +1 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { mkdirSync, realpathSync, statSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { rootProblem } from "./config.js";
|
|
7
|
+
import { canonical, isInside } from "./paths.js";
|
|
8
|
+
const exec = promisify(execFile);
|
|
9
|
+
const safeId = (value, length) => value
|
|
10
|
+
.replace(/[^a-z\d]/giu, "")
|
|
11
|
+
.slice(0, length)
|
|
12
|
+
.toLowerCase() || "thread";
|
|
13
|
+
/**
|
|
14
|
+
* The folder one thread's owner jobs run in. In a repo it is a git worktree the
|
|
15
|
+
* CLI creates (never the model), so the owner's checkout stays untouched; with
|
|
16
|
+
* no repo, or one with no commits yet, a plain per-thread folder under the
|
|
17
|
+
* managed root. Throws when the configured root fails its checks.
|
|
18
|
+
*/
|
|
19
|
+
export async function prepareWorkspace(input) {
|
|
20
|
+
const { config, home } = input;
|
|
21
|
+
const problem = rootProblem(config, home);
|
|
22
|
+
if (problem) {
|
|
23
|
+
throw new Error(problem);
|
|
24
|
+
}
|
|
25
|
+
const thread = safeId(input.threadId, 12);
|
|
26
|
+
const managed = join(home, "TalentOS", "threads");
|
|
27
|
+
let cwd;
|
|
28
|
+
const useWorktree = config.kind === "repo" && (await hasCommit(config.root));
|
|
29
|
+
if (useWorktree) {
|
|
30
|
+
const repo = `${safeId(config.root.split("/").pop() ?? "repo", 20)}-${createHash("sha256").update(config.root).digest("hex").slice(0, 8)}`;
|
|
31
|
+
cwd = join(home, "TalentOS", ".worktrees", repo, thread);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
cwd = join(config.kind === "managed" ? config.root : managed, thread);
|
|
35
|
+
}
|
|
36
|
+
// The folder must resolve inside the TalentOS area or the managed root, and
|
|
37
|
+
// that is checked BEFORE anything is created, so a planted symlink cannot
|
|
38
|
+
// make the CLI build a worktree somewhere else.
|
|
39
|
+
const bases = [join(home, "TalentOS"), config.root].map((base) => canonical(base, home, home));
|
|
40
|
+
const inside = (path) => bases.some((base) => isInside(canonical(path, home, home), base));
|
|
41
|
+
if (!inside(cwd)) {
|
|
42
|
+
throw new Error("The workspace left its folder.");
|
|
43
|
+
}
|
|
44
|
+
if (useWorktree) {
|
|
45
|
+
await ensureWorktree(config.root, cwd, thread);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
mkdirSync(cwd, { recursive: true, mode: 0o700 });
|
|
49
|
+
}
|
|
50
|
+
const real = realpathSync.native(cwd);
|
|
51
|
+
if (!(statSync(real).isDirectory() && inside(real))) {
|
|
52
|
+
throw new Error("The workspace left its folder.");
|
|
53
|
+
}
|
|
54
|
+
return { cwd: real, writeRoots: [real] };
|
|
55
|
+
}
|
|
56
|
+
async function hasCommit(root) {
|
|
57
|
+
try {
|
|
58
|
+
await exec("git", ["rev-parse", "--verify", "HEAD"], {
|
|
59
|
+
cwd: root,
|
|
60
|
+
timeout: 5000,
|
|
61
|
+
});
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function ensureWorktree(root, path, thread) {
|
|
69
|
+
try {
|
|
70
|
+
if (statSync(path).isDirectory()) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Missing: create it below.
|
|
76
|
+
}
|
|
77
|
+
mkdirSync(join(path, ".."), { recursive: true, mode: 0o700 });
|
|
78
|
+
const branch = `talentos/${thread}`;
|
|
79
|
+
const options = { cwd: root, timeout: 60_000 };
|
|
80
|
+
try {
|
|
81
|
+
await exec("git", ["worktree", "add", "-b", branch, path, "HEAD"], options);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// The branch survives a deleted folder: reattach it.
|
|
85
|
+
await exec("git", ["worktree", "add", path, branch], options);
|
|
86
|
+
}
|
|
87
|
+
}
|
package/dist/agent/serve.js
CHANGED
|
@@ -4,11 +4,17 @@ import { dirname, join } from "node:path";
|
|
|
4
4
|
import { setTimeout as delay } from "node:timers/promises";
|
|
5
5
|
import { installSharedGateway } from "../gateway-install.js";
|
|
6
6
|
import { compareGatewayVersions } from "../gateway-version.js";
|
|
7
|
+
import { homeDir } from "../install.js";
|
|
7
8
|
import { runProvider } from "./adapters.js";
|
|
8
9
|
import { AgentGateway, GatewayError } from "./gateway.js";
|
|
10
|
+
import { ApprovalBridge } from "./native/approvals.js";
|
|
11
|
+
import { NativeGate } from "./native/gate.js";
|
|
9
12
|
import { SessionMap } from "./sessions.js";
|
|
10
13
|
const HEARTBEAT_MS = 20_000;
|
|
11
14
|
const LEASE_SAFETY_MS = 55_000;
|
|
15
|
+
// A native job holds a 30 s server lease and is re-derived on every renewal.
|
|
16
|
+
const NATIVE_RENEW_MS = 10_000;
|
|
17
|
+
const NATIVE_SAFETY_MS = 25_000;
|
|
12
18
|
const IDLE_MS = 30 * 60_000;
|
|
13
19
|
export function needsUpdate(current, minimum) {
|
|
14
20
|
if (compareGatewayVersions(minimum, minimum) === undefined) {
|
|
@@ -55,12 +61,14 @@ export async function installGatewayUpdate(directory, minimum, signal) {
|
|
|
55
61
|
signal,
|
|
56
62
|
});
|
|
57
63
|
}
|
|
58
|
-
export async function serveAgent(config, directory, version, stop, verify) {
|
|
64
|
+
export async function serveAgent(config, directory, version, stop, verify, options = {}) {
|
|
59
65
|
const release = await acquireLock(directory);
|
|
60
66
|
try {
|
|
61
67
|
const workDir = join(directory, "work"), sessions = new SessionMap(directory), gateway = new AgentGateway(config);
|
|
62
68
|
await mkdir(workDir, { recursive: true, mode: 0o700 });
|
|
63
69
|
const deviceId = randomUUID();
|
|
70
|
+
const gate = options.gate ??
|
|
71
|
+
new NativeGate({ agent: config, directory, home: homeDir() });
|
|
64
72
|
const service = new AbortController();
|
|
65
73
|
const signal = AbortSignal.any([stop, service.signal]);
|
|
66
74
|
let wakeResolve, wakePending = true;
|
|
@@ -81,7 +89,8 @@ export async function serveAgent(config, directory, version, stop, verify) {
|
|
|
81
89
|
}
|
|
82
90
|
};
|
|
83
91
|
const heartbeat = async () => {
|
|
84
|
-
|
|
92
|
+
gate.refresh();
|
|
93
|
+
const response = await gateway.request("/agent-gateway/heartbeat", { cliVersion: version, nativeOptIn: gate.reportable() }, signal);
|
|
85
94
|
if (response) {
|
|
86
95
|
minimum = response.cliMinVersion;
|
|
87
96
|
if (response.pendingJobs > 0 || needsUpdate(version, minimum)) {
|
|
@@ -100,7 +109,9 @@ export async function serveAgent(config, directory, version, stop, verify) {
|
|
|
100
109
|
.request(`/agent-gateway/jobs/${leased.job.jobId}/renew`, { leaseToken: leased.job.leaseToken }, signal)
|
|
101
110
|
.then(() => {
|
|
102
111
|
if (active === leased) {
|
|
103
|
-
leased.deadline =
|
|
112
|
+
leased.deadline =
|
|
113
|
+
started +
|
|
114
|
+
(leased.job.native ? NATIVE_SAFETY_MS : LEASE_SAFETY_MS);
|
|
104
115
|
}
|
|
105
116
|
})
|
|
106
117
|
.catch((error) => {
|
|
@@ -114,7 +125,12 @@ export async function serveAgent(config, directory, version, stop, verify) {
|
|
|
114
125
|
})
|
|
115
126
|
: Promise.resolve(),
|
|
116
127
|
]);
|
|
117
|
-
|
|
128
|
+
// The owner's local `native off` ends a running owner job at once.
|
|
129
|
+
if (active?.job.native && active.owner && !gate.optedIn()) {
|
|
130
|
+
active.cancel.abort();
|
|
131
|
+
}
|
|
132
|
+
await delay(Math.max(1, (active?.job.native ? NATIVE_RENEW_MS : HEARTBEAT_MS) -
|
|
133
|
+
(Date.now() - started)), undefined, { signal }).catch(() => {
|
|
118
134
|
/* Shutdown interrupts the timer. */
|
|
119
135
|
});
|
|
120
136
|
}
|
|
@@ -170,17 +186,67 @@ export async function serveAgent(config, directory, version, stop, verify) {
|
|
|
170
186
|
if (job) {
|
|
171
187
|
lastJobAt = Date.now();
|
|
172
188
|
const cancel = new AbortController();
|
|
173
|
-
|
|
189
|
+
const current = {
|
|
190
|
+
job,
|
|
191
|
+
cancel,
|
|
192
|
+
deadline: claimStartedAt +
|
|
193
|
+
(job.native ? NATIVE_SAFETY_MS : LEASE_SAFETY_MS),
|
|
194
|
+
owner: false,
|
|
195
|
+
};
|
|
196
|
+
active = current;
|
|
174
197
|
let lastStatusAt = 0;
|
|
198
|
+
// Monotonic, so a wall-clock change cannot corrupt the marks.
|
|
199
|
+
const receivedAt = performance.now();
|
|
200
|
+
const timings = {};
|
|
175
201
|
try {
|
|
202
|
+
// An owner job runs the owner's real setup only when the plane
|
|
203
|
+
// confirmed owner authority just now AND this computer is ready;
|
|
204
|
+
// anything less is the room-only profile, as before.
|
|
205
|
+
let owner;
|
|
206
|
+
if (job.native) {
|
|
207
|
+
const post = (statusLine) => gateway
|
|
208
|
+
.request(`/agent-gateway/jobs/${job.jobId}/status`, { leaseToken: job.leaseToken, statusLine }, signal)
|
|
209
|
+
.catch(failService);
|
|
210
|
+
post("Starting on your computer…");
|
|
211
|
+
lastStatusAt = Date.now();
|
|
212
|
+
const [, prepared] = await Promise.all([
|
|
213
|
+
gateway.request(`/agent-gateway/jobs/${job.jobId}/native-confirm`, { leaseToken: job.leaseToken }, signal),
|
|
214
|
+
gate.prepare(job.packet.thread.id).catch(() => undefined),
|
|
215
|
+
]);
|
|
216
|
+
owner = prepared;
|
|
217
|
+
current.owner = Boolean(owner);
|
|
218
|
+
}
|
|
219
|
+
const sessionKey = owner
|
|
220
|
+
? `${job.packet.thread.id}:owner`
|
|
221
|
+
: job.packet.thread.id;
|
|
222
|
+
const bridge = () => new ApprovalBridge({
|
|
223
|
+
gateway: {
|
|
224
|
+
request: async (ask) => (await gateway.request(`/agent-gateway/jobs/${job.jobId}/approval-request`, { leaseToken: job.leaseToken, ...ask }, signal)) ?? { status: "expired" },
|
|
225
|
+
status: async (actionKeys) => (await gateway.request(`/agent-gateway/jobs/${job.jobId}/approval-status`, { actionKeys, leaseToken: job.leaseToken }, signal)) ?? [],
|
|
226
|
+
},
|
|
227
|
+
onWaiting: (waiting) => {
|
|
228
|
+
gateway
|
|
229
|
+
.request(`/agent-gateway/jobs/${job.jobId}/status`, {
|
|
230
|
+
leaseToken: job.leaseToken,
|
|
231
|
+
statusLine: waiting
|
|
232
|
+
? "Waiting for your approval…"
|
|
233
|
+
: "Working on your computer…",
|
|
234
|
+
}, signal)
|
|
235
|
+
.catch(failService);
|
|
236
|
+
},
|
|
237
|
+
}).start();
|
|
176
238
|
const result = await runProvider({
|
|
177
239
|
config,
|
|
178
240
|
job,
|
|
179
241
|
workDir,
|
|
180
|
-
|
|
242
|
+
owner: owner && { ...owner, watch: bridge },
|
|
243
|
+
sessionId: await sessions.get(sessionKey),
|
|
181
244
|
signal: AbortSignal.any([signal, cancel.signal]),
|
|
182
245
|
remember: async (sessionId) => {
|
|
183
|
-
await sessions.remember(
|
|
246
|
+
await sessions.remember(sessionKey, sessionId);
|
|
247
|
+
},
|
|
248
|
+
mark: (name) => {
|
|
249
|
+
timings[`${name}Ms`] = Math.round(performance.now() - receivedAt);
|
|
184
250
|
},
|
|
185
251
|
status: (line) => {
|
|
186
252
|
if (Date.now() - lastStatusAt < 1000) {
|
|
@@ -196,7 +262,7 @@ export async function serveAgent(config, directory, version, stop, verify) {
|
|
|
196
262
|
// never become a new provider run or a duplicate final reply.
|
|
197
263
|
while (!(signal.aborted || cancel.signal.aborted)) {
|
|
198
264
|
try {
|
|
199
|
-
await gateway.request(`/agent-gateway/jobs/${job.jobId}/complete`, { leaseToken: job.leaseToken, text: result.text }, signal);
|
|
265
|
+
await gateway.request(`/agent-gateway/jobs/${job.jobId}/complete`, { leaseToken: job.leaseToken, text: result.text, timings }, signal);
|
|
200
266
|
break;
|
|
201
267
|
}
|
|
202
268
|
catch (error) {
|
package/dist/args.js
CHANGED
|
@@ -22,6 +22,7 @@ const CLI_OPTIONS = {
|
|
|
22
22
|
url: { type: "string" },
|
|
23
23
|
yes: { type: "boolean" },
|
|
24
24
|
agent: { type: "string" },
|
|
25
|
+
"upload-root": { type: "string" },
|
|
25
26
|
};
|
|
26
27
|
/** Groups whose first positional is a sub-command rather than an argument. */
|
|
27
28
|
const ACTION_GROUPS = new Set(["agent", "mcp", "skills", "memory", "routines"]);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { dirname } from "node:path";
|
|
2
|
+
import { disableNative, enableNative, readNativeConfig, } from "../agent/native/config.js";
|
|
3
|
+
import { NativeGate } from "../agent/native/gate.js";
|
|
4
|
+
import { agentWorkDir, localAgents, } from "../agent-config.js";
|
|
5
|
+
import { emit } from "../format.js";
|
|
6
|
+
import { homeDir } from "../install.js";
|
|
7
|
+
// `talentos agent native enable | off | status` (#10987): the owner's local yes
|
|
8
|
+
// to "let this agent work on this computer when you ask it in a room". The
|
|
9
|
+
// server still needs the owner's own confirmation in TalentOS; this only
|
|
10
|
+
// records consent on this machine and proves the provider can be guarded.
|
|
11
|
+
async function pickAgent(parsed, home) {
|
|
12
|
+
const agents = await localAgents(home);
|
|
13
|
+
const selector = parsed.options.agent;
|
|
14
|
+
let chosen;
|
|
15
|
+
if (selector) {
|
|
16
|
+
chosen = agents.find((agent) => agent.agentId === selector || agent.name === selector);
|
|
17
|
+
}
|
|
18
|
+
else if (agents.length === 1) {
|
|
19
|
+
chosen = agents[0];
|
|
20
|
+
}
|
|
21
|
+
if (!chosen) {
|
|
22
|
+
throw new Error("Choose a linked agent with `talentos agent native enable --agent <id>`.");
|
|
23
|
+
}
|
|
24
|
+
return chosen;
|
|
25
|
+
}
|
|
26
|
+
const shown = (path, home) => path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
|
|
27
|
+
export async function runAgentNative(parsed, home = homeDir()) {
|
|
28
|
+
const action = parsed.args[0];
|
|
29
|
+
if (action !== "enable" && action !== "off" && action !== "status") {
|
|
30
|
+
throw new Error("Use talentos agent native enable | off | status.");
|
|
31
|
+
}
|
|
32
|
+
const agent = await pickAgent(parsed, home);
|
|
33
|
+
const directory = dirname(agentWorkDir(agent.agentId, home));
|
|
34
|
+
if (action === "off") {
|
|
35
|
+
await disableNative(directory);
|
|
36
|
+
emit(`Turned off. ${agent.name} answers with room tools only, and any run in progress stops within seconds.`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (action === "status") {
|
|
40
|
+
const config = await readNativeConfig(directory);
|
|
41
|
+
emit(config
|
|
42
|
+
? `On. ${agent.name} works in ${shown(config.root, home)} when you ask it in a room where you are the only person.`
|
|
43
|
+
: `Off. Run talentos agent native enable to let ${agent.name} use your full setup in rooms only you are in.`);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (agent.provider !== "claude_code") {
|
|
47
|
+
throw new Error("Full-setup rooms support Claude Code first. Codex is not available yet.");
|
|
48
|
+
}
|
|
49
|
+
const config = await enableNative({
|
|
50
|
+
agentId: agent.agentId,
|
|
51
|
+
cwd: process.cwd(),
|
|
52
|
+
directory,
|
|
53
|
+
home,
|
|
54
|
+
name: agent.name,
|
|
55
|
+
});
|
|
56
|
+
try {
|
|
57
|
+
await new NativeGate({ agent, directory, home }).runProbe();
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
await disableNative(directory);
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
emit(`I'll work in ${shown(config.root, home)}${config.kind === "repo" ? " (each room thread gets its own copy of the branch)" : ""}. TalentOS will ask you to confirm once.`);
|
|
64
|
+
}
|
package/dist/commands/agent.js
CHANGED
|
@@ -92,6 +92,10 @@ export async function runAgent(parsed, dependencies = {}) {
|
|
|
92
92
|
await import("../agent/mcp-entry.js");
|
|
93
93
|
return;
|
|
94
94
|
}
|
|
95
|
+
case "native": {
|
|
96
|
+
const { runAgentNative } = await import("./agent-native.js");
|
|
97
|
+
return await runAgentNative(parsed, dependencies.home);
|
|
98
|
+
}
|
|
95
99
|
case "status":
|
|
96
100
|
case "list":
|
|
97
101
|
return await showAgents(parsed);
|
|
@@ -100,7 +104,7 @@ export async function runAgent(parsed, dependencies = {}) {
|
|
|
100
104
|
case "logs":
|
|
101
105
|
return await showLogs(parsed);
|
|
102
106
|
default:
|
|
103
|
-
throw new Error("Use talentos agent link | repair | status | list | unlink | logs.");
|
|
107
|
+
throw new Error("Use talentos agent link | repair | status | list | unlink | logs | native.");
|
|
104
108
|
}
|
|
105
109
|
}
|
|
106
110
|
export async function installLinkedAgent(parsed, dependencies = {}) {
|
package/dist/mcp-proxy.js
CHANGED
|
@@ -1,17 +1,58 @@
|
|
|
1
1
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
3
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
4
|
+
import { UploadRefusal, uploadFile } from "./upload-file.js";
|
|
5
|
+
// The two server steps behind upload_file. The model sees only upload_file.
|
|
6
|
+
const UPLOAD_STEP_TOOLS = new Set(["prepare_upload", "commit_upload"]);
|
|
4
7
|
/**
|
|
5
8
|
* stdio front end for clients that cannot speak streamable HTTP (Codex, older
|
|
6
9
|
* desktop builds). It forwards tool listing and calls verbatim so the tool set
|
|
7
10
|
* an agent sees over stdio is the one the remote server owns.
|
|
8
11
|
*/
|
|
9
|
-
export async function startMcpProxy(client
|
|
12
|
+
export async function startMcpProxy(client,
|
|
13
|
+
// The only folder upload_file reads from: the job's workspace, named by the
|
|
14
|
+
// launcher. Never a tool argument, never this process's own folder; without
|
|
15
|
+
// it upload_file refuses.
|
|
16
|
+
root) {
|
|
10
17
|
const server = new Server({ name: "talentos", version: "1.0.0" }, { capabilities: { tools: {} } });
|
|
11
|
-
server.setRequestHandler(ListToolsRequestSchema, async () =>
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
18
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
19
|
+
const listed = await client.listTools();
|
|
20
|
+
return {
|
|
21
|
+
...listed,
|
|
22
|
+
tools: listed.tools.filter((tool) => !UPLOAD_STEP_TOOLS.has(tool.name)),
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
26
|
+
const { name, arguments: args = {} } = request.params;
|
|
27
|
+
if (name === "upload_file") {
|
|
28
|
+
return await runUpload(client, args, root);
|
|
29
|
+
}
|
|
30
|
+
if (UPLOAD_STEP_TOOLS.has(name)) {
|
|
31
|
+
return {
|
|
32
|
+
content: [{ text: "Use upload_file instead.", type: "text" }],
|
|
33
|
+
isError: true,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return await client.callTool({ name, arguments: args });
|
|
37
|
+
});
|
|
16
38
|
await server.connect(new StdioServerTransport());
|
|
17
39
|
}
|
|
40
|
+
async function runUpload(client, args, root) {
|
|
41
|
+
try {
|
|
42
|
+
const text = await uploadFile(client, {
|
|
43
|
+
path: String(args.path ?? ""),
|
|
44
|
+
roomId: String(args.roomId ?? ""),
|
|
45
|
+
title: typeof args.title === "string" ? args.title : undefined,
|
|
46
|
+
}, root);
|
|
47
|
+
return { content: [{ text, type: "text" }] };
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
const message = error instanceof UploadRefusal || error instanceof Error
|
|
51
|
+
? error.message
|
|
52
|
+
: "The upload failed.";
|
|
53
|
+
return {
|
|
54
|
+
content: [{ text: message, type: "text" }],
|
|
55
|
+
isError: true,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, open, realpath } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { basename, isAbsolute, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { Readable } from "node:stream";
|
|
7
|
+
import { canonical, protectedPaths, protectedReason, } from "./agent/native/paths.js";
|
|
8
|
+
// The server caps and re-checks everything (type, size, names, content); this
|
|
9
|
+
// is the client's half of the boundary: only a regular file inside the folder
|
|
10
|
+
// the agent works in, and never a credential store, whatever the model asks.
|
|
11
|
+
export const UPLOAD_BYTES_MAX = 50 * 1024 * 1024;
|
|
12
|
+
const DENIED_SEGMENTS = new Set([
|
|
13
|
+
".aws",
|
|
14
|
+
".azure",
|
|
15
|
+
".docker",
|
|
16
|
+
".git",
|
|
17
|
+
".git-credentials",
|
|
18
|
+
".gnupg",
|
|
19
|
+
".kube",
|
|
20
|
+
".netrc",
|
|
21
|
+
".npmrc",
|
|
22
|
+
".pypirc",
|
|
23
|
+
".ssh",
|
|
24
|
+
".talentos",
|
|
25
|
+
".yarnrc",
|
|
26
|
+
".yarnrc.yml",
|
|
27
|
+
// Provider and hook configuration, inside a workspace as well as in $HOME.
|
|
28
|
+
".claude",
|
|
29
|
+
".codex",
|
|
30
|
+
".cursor",
|
|
31
|
+
".mcp.json",
|
|
32
|
+
]);
|
|
33
|
+
const DENIED_NAME = /^(?:\.env.*|id_(?:rsa|dsa|ecdsa|ed25519).*|credentials?(?:\..*)?|.*\.(?:pem|key|p12|pfx|jks|keystore)|.*_history|\.bash_profile|\.zshenv)$/iu;
|
|
34
|
+
export class UploadRefusal extends Error {
|
|
35
|
+
}
|
|
36
|
+
function refuse(message) {
|
|
37
|
+
throw new UploadRefusal(message);
|
|
38
|
+
}
|
|
39
|
+
function deniedSegment(segments) {
|
|
40
|
+
return segments.find((segment) => DENIED_SEGMENTS.has(segment.toLowerCase()) || DENIED_NAME.test(segment));
|
|
41
|
+
}
|
|
42
|
+
// Opens the file the model named, or refuses. Symlinks are resolved first and
|
|
43
|
+
// the real location must sit inside `root`; the file is then opened without
|
|
44
|
+
// following a final link and the opened descriptor, not the path, is what is
|
|
45
|
+
// measured and read, so a swap between check and read cannot widen access.
|
|
46
|
+
export async function openUploadSource(requested, root, home = homedir()) {
|
|
47
|
+
if (!root) {
|
|
48
|
+
refuse("upload_file needs the job's workspace folder, and this job has none. Create the file with create_file instead, or ask the owner to run you in a workspace.");
|
|
49
|
+
}
|
|
50
|
+
let rootReal;
|
|
51
|
+
try {
|
|
52
|
+
rootReal = await realpath(root);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return refuse("The job's workspace folder is not available.");
|
|
56
|
+
}
|
|
57
|
+
const guard = protectedPaths(home);
|
|
58
|
+
const guarded = (path) => {
|
|
59
|
+
const reason = protectedReason(path, "read", home, guard);
|
|
60
|
+
if (reason) {
|
|
61
|
+
refuse(`That file is in a protected location (${reason}), so it was not uploaded.`);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const candidate = resolve(rootReal, requested);
|
|
65
|
+
const lexical = relative(rootReal, candidate).split(sep);
|
|
66
|
+
guarded(candidate);
|
|
67
|
+
guarded(canonical(candidate, rootReal, home));
|
|
68
|
+
const lexicalDenied = deniedSegment(lexical);
|
|
69
|
+
if (lexicalDenied) {
|
|
70
|
+
refuse(`${lexicalDenied} is a credential or configuration file, so it was not uploaded.`);
|
|
71
|
+
}
|
|
72
|
+
let real;
|
|
73
|
+
try {
|
|
74
|
+
real = await realpath(candidate);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return refuse("That file does not exist in your working folder.");
|
|
78
|
+
}
|
|
79
|
+
const inside = relative(rootReal, real);
|
|
80
|
+
if (inside === "" || inside.startsWith("..") || isAbsolute(inside)) {
|
|
81
|
+
refuse("Only files inside your working folder can be uploaded. Copy the file there first.");
|
|
82
|
+
}
|
|
83
|
+
guarded(real);
|
|
84
|
+
const realDenied = deniedSegment(inside.split(sep));
|
|
85
|
+
if (realDenied) {
|
|
86
|
+
refuse(`${realDenied} is a credential or configuration file, so it was not uploaded.`);
|
|
87
|
+
}
|
|
88
|
+
let handle;
|
|
89
|
+
try {
|
|
90
|
+
// biome-ignore lint/suspicious/noBitwiseOperators: open(2) flags combine by OR.
|
|
91
|
+
handle = await open(real, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return refuse("That file could not be opened.");
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const opened = await handle.stat();
|
|
98
|
+
if (!opened.isFile()) {
|
|
99
|
+
refuse("Only regular files can be uploaded, not folders or devices.");
|
|
100
|
+
}
|
|
101
|
+
const atPath = await lstat(real);
|
|
102
|
+
if (atPath.dev !== opened.dev || atPath.ino !== opened.ino) {
|
|
103
|
+
refuse("The file changed while it was being opened. Try again.");
|
|
104
|
+
}
|
|
105
|
+
if (opened.size < 1) {
|
|
106
|
+
refuse("That file is empty.");
|
|
107
|
+
}
|
|
108
|
+
if (opened.size > UPLOAD_BYTES_MAX) {
|
|
109
|
+
refuse(`Files up to ${UPLOAD_BYTES_MAX / 1024 / 1024} MiB can be uploaded. Make it smaller or split it.`);
|
|
110
|
+
}
|
|
111
|
+
const hash = createHash("sha256");
|
|
112
|
+
for await (const chunk of handle.createReadStream({
|
|
113
|
+
autoClose: false,
|
|
114
|
+
start: 0,
|
|
115
|
+
})) {
|
|
116
|
+
hash.update(chunk);
|
|
117
|
+
}
|
|
118
|
+
const stream = () => handle.createReadStream({ autoClose: false, start: 0 });
|
|
119
|
+
return {
|
|
120
|
+
handle,
|
|
121
|
+
name: basename(candidate),
|
|
122
|
+
sha256: hash.digest("hex"),
|
|
123
|
+
size: opened.size,
|
|
124
|
+
stream,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
await handle.close();
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// prepare -> stream the bytes straight to storage -> commit. Nothing about the
|
|
133
|
+
// file's contents passes through the model or an MCP argument.
|
|
134
|
+
export async function uploadFile(client, args, root, fetchImpl = fetch, home = homedir()) {
|
|
135
|
+
const source = await openUploadSource(args.path, root, home);
|
|
136
|
+
try {
|
|
137
|
+
const prepared = await client.call("prepare_upload", {
|
|
138
|
+
filename: source.name,
|
|
139
|
+
roomId: args.roomId,
|
|
140
|
+
sha256: source.sha256,
|
|
141
|
+
sizeBytes: source.size,
|
|
142
|
+
...(args.title ? { title: args.title } : {}),
|
|
143
|
+
});
|
|
144
|
+
if (prepared.data.status === "committed" || !prepared.data.uploadUrl) {
|
|
145
|
+
return prepared.text;
|
|
146
|
+
}
|
|
147
|
+
const response = await fetchImpl(prepared.data.uploadUrl, {
|
|
148
|
+
body: Readable.toWeb(source.stream()),
|
|
149
|
+
duplex: "half",
|
|
150
|
+
headers: {
|
|
151
|
+
"Content-Length": String(source.size),
|
|
152
|
+
"Content-Type": "application/octet-stream",
|
|
153
|
+
},
|
|
154
|
+
method: "POST",
|
|
155
|
+
});
|
|
156
|
+
if (!response.ok) {
|
|
157
|
+
refuse(`The upload failed (${response.status}). Try again.`);
|
|
158
|
+
}
|
|
159
|
+
const { storageId } = (await response.json());
|
|
160
|
+
if (!storageId) {
|
|
161
|
+
refuse("The upload failed. Try again.");
|
|
162
|
+
}
|
|
163
|
+
const committed = await client.call("commit_upload", {
|
|
164
|
+
sessionId: prepared.data.sessionId,
|
|
165
|
+
storageId,
|
|
166
|
+
});
|
|
167
|
+
return committed.text;
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
await source.handle.close();
|
|
171
|
+
}
|
|
172
|
+
}
|
package/package.json
CHANGED