talentos 1.1.5 → 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.
@@ -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
+ }
@@ -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
- const response = await gateway.request("/agent-gateway/heartbeat", { cliVersion: version }, signal);
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 = started + LEASE_SAFETY_MS;
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
- await delay(Math.max(1, HEARTBEAT_MS - (Date.now() - started)), undefined, { signal }).catch(() => {
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
  }
@@ -154,6 +170,9 @@ export async function serveAgent(config, directory, version, stop, verify) {
154
170
  console.error("Gateway update unavailable. Serving the current version; retrying in one minute.");
155
171
  }
156
172
  }
173
+ if (config.provider === "codex") {
174
+ await verify?.();
175
+ }
157
176
  wakePending = false;
158
177
  let job;
159
178
  const claimStartedAt = Date.now();
@@ -167,17 +186,67 @@ export async function serveAgent(config, directory, version, stop, verify) {
167
186
  if (job) {
168
187
  lastJobAt = Date.now();
169
188
  const cancel = new AbortController();
170
- active = { job, cancel, deadline: claimStartedAt + LEASE_SAFETY_MS };
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;
171
197
  let lastStatusAt = 0;
198
+ // Monotonic, so a wall-clock change cannot corrupt the marks.
199
+ const receivedAt = performance.now();
200
+ const timings = {};
172
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();
173
238
  const result = await runProvider({
174
239
  config,
175
240
  job,
176
241
  workDir,
177
- sessionId: await sessions.get(job.packet.thread.id),
242
+ owner: owner && { ...owner, watch: bridge },
243
+ sessionId: await sessions.get(sessionKey),
178
244
  signal: AbortSignal.any([signal, cancel.signal]),
179
245
  remember: async (sessionId) => {
180
- await sessions.remember(job.packet.thread.id, sessionId);
246
+ await sessions.remember(sessionKey, sessionId);
247
+ },
248
+ mark: (name) => {
249
+ timings[`${name}Ms`] = Math.round(performance.now() - receivedAt);
181
250
  },
182
251
  status: (line) => {
183
252
  if (Date.now() - lastStatusAt < 1000) {
@@ -193,7 +262,7 @@ export async function serveAgent(config, directory, version, stop, verify) {
193
262
  // never become a new provider run or a duplicate final reply.
194
263
  while (!(signal.aborted || cancel.signal.aborted)) {
195
264
  try {
196
- 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);
197
266
  break;
198
267
  }
199
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
+ }
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { verifyClaudeCode, verifyCodex } from "../agent/adapters.js";
4
+ import { CodexBoundaryError } from "../agent/codex-probe.js";
4
5
  import { AgentGateway, GatewayError } from "../agent/gateway.js";
5
6
  import { serveAgent } from "../agent/serve.js";
6
7
  import { agentWorkDir, localAgents, storeAgent, } from "../agent-config.js";
@@ -36,7 +37,7 @@ export async function runAgentServe(parsed, options = {}) {
36
37
  try {
37
38
  // Authenticate without publishing presence, including when the binary is gone.
38
39
  await fetchAgentStatus(config);
39
- await serveAgent(config, dirname(agentWorkDir(config.agentId, options.home)), version, stop.signal, () => verifyAgent(agent, options));
40
+ await serveAgent(config, dirname(agentWorkDir(config.agentId, options.home)), version, stop.signal, () => verifyAgent(agent, options, stop.signal));
40
41
  }
41
42
  catch (error) {
42
43
  if ((error instanceof GatewayError || error instanceof AgentHttpError) &&
@@ -52,7 +53,7 @@ export async function runAgentServe(parsed, options = {}) {
52
53
  process.removeListener("SIGINT", abort);
53
54
  }
54
55
  }
55
- async function verifyAgent(config, options) {
56
+ async function verifyAgent(config, options, signal) {
56
57
  try {
57
58
  let binary;
58
59
  try {
@@ -70,20 +71,22 @@ async function verifyAgent(config, options) {
70
71
  await verifyClaudeCode(binary);
71
72
  }
72
73
  else {
73
- await verifyCodex(binary);
74
+ await verifyCodex(binary, signal);
74
75
  }
75
76
  }
76
77
  catch (error) {
77
78
  if (error instanceof ProviderBinaryError) {
78
- try {
79
- await new AgentGateway(config).request("/agent-gateway/provider-unavailable");
80
- }
81
- catch (reportError) {
82
- if (reportError instanceof GatewayError &&
83
- (reportError.status === 401 || reportError.status === 410)) {
84
- throw reportError;
79
+ if (!(error instanceof CodexBoundaryError)) {
80
+ try {
81
+ await new AgentGateway(config).request("/agent-gateway/provider-unavailable");
82
+ }
83
+ catch (reportError) {
84
+ if (reportError instanceof GatewayError &&
85
+ (reportError.status === 401 || reportError.status === 410)) {
86
+ throw reportError;
87
+ }
88
+ // Older servers may not support diagnostics; local shutdown still applies.
85
89
  }
86
- // Older servers may not support diagnostics; local shutdown still applies.
87
90
  }
88
91
  console.error(error.message);
89
92
  // launchd cannot exclude exit 78; unload but retain the plist for repair.
@@ -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 () => await client.listTools());
12
- server.setRequestHandler(CallToolRequestSchema, async (request) => await client.callTool({
13
- name: request.params.name,
14
- arguments: request.params.arguments ?? {},
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talentos",
3
- "version": "1.1.5",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "TalentOS CLI: push and pull skills, read and write team memory, and delegate work to your Talent from any coding agent.",
6
6
  "repository": {