@stage-labs/metro 0.1.0-beta.243 → 0.1.0-beta.245
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/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/agent-fs.ts +121 -9
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/metro-home.ts +42 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/schedule-detail.ts +9 -15
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +6 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/accounts.ts +10 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/request-identity.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-resolve.ts +46 -0
- package/runtime/node_modules/@metro-labs/http/src/api-http.ts +5 -0
- package/runtime/runtime.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.245",
|
|
4
4
|
"description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { spawn, spawnSync } from 'node:child_process';
|
|
2
2
|
import * as fs from 'node:fs';
|
|
3
|
+
import { resolve, sep } from 'node:path';
|
|
3
4
|
import type { Readable } from 'node:stream';
|
|
4
5
|
import { runningAsMetro } from '../metro-user/privilege.js';
|
|
5
|
-
import { agentUser, asUser } from './user.js';
|
|
6
|
+
import { agentUser, asUser, type AgentUser } from './user.js';
|
|
6
7
|
|
|
7
8
|
export interface AgentStat {
|
|
8
9
|
size: number;
|
|
@@ -23,11 +24,72 @@ export interface AgentDirent {
|
|
|
23
24
|
|
|
24
25
|
const MAX_OUTPUT = 256 * 1024 * 1024;
|
|
25
26
|
|
|
27
|
+
const codeOf = (err: unknown): unknown => (err as { code?: unknown } | null)?.code;
|
|
28
|
+
|
|
26
29
|
const refused = (err: unknown): boolean => {
|
|
27
|
-
const code = (err
|
|
30
|
+
const code = codeOf(err);
|
|
28
31
|
return (code === 'EACCES' || code === 'EPERM') && runningAsMetro();
|
|
29
32
|
};
|
|
30
33
|
|
|
34
|
+
const realHome = new Map<string, string>();
|
|
35
|
+
|
|
36
|
+
function homeOf(user: AgentUser): string {
|
|
37
|
+
let real = realHome.get(user.home);
|
|
38
|
+
if (real === undefined) {
|
|
39
|
+
try {
|
|
40
|
+
real = fs.realpathSync(user.home);
|
|
41
|
+
} catch {
|
|
42
|
+
real = resolve(user.home);
|
|
43
|
+
}
|
|
44
|
+
realHome.set(user.home, real);
|
|
45
|
+
}
|
|
46
|
+
return real;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const within = (home: string, path: string): boolean => path === home || path.startsWith(`${home}${sep}`);
|
|
50
|
+
|
|
51
|
+
function realOf(path: string, fd: number | undefined): string | null {
|
|
52
|
+
if (fd !== undefined) return fs.readlinkSync(`/proc/self/fd/${String(fd)}`);
|
|
53
|
+
try {
|
|
54
|
+
return fs.realpathSync(path);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
if (codeOf(err) === 'ENOENT' || codeOf(err) === 'ENOTDIR') return null;
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function escapesHome(path: string, user: AgentUser, fd?: number): boolean {
|
|
62
|
+
const home = homeOf(user);
|
|
63
|
+
const asked = resolve(path);
|
|
64
|
+
if (!within(resolve(user.home), asked) && !within(home, asked)) return false;
|
|
65
|
+
const real = realOf(path, fd);
|
|
66
|
+
return real !== null && !within(home, real);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const confinedUser = (): AgentUser | null => (runningAsMetro() ? agentUser() : null);
|
|
70
|
+
|
|
71
|
+
function outside(path: string): Error {
|
|
72
|
+
const err = new Error(`${path} leads out of the agent's home, so metro reads it as the agent`) as Error & { code: string };
|
|
73
|
+
err.code = 'EACCES';
|
|
74
|
+
return err;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function guardPath(path: string, fd?: number): void {
|
|
78
|
+
const user = confinedUser();
|
|
79
|
+
if (user !== null && escapesHome(path, user, fd)) throw outside(path);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function openGuarded(path: string): number {
|
|
83
|
+
const fd = fs.openSync(path, 'r');
|
|
84
|
+
try {
|
|
85
|
+
guardPath(path, fd);
|
|
86
|
+
return fd;
|
|
87
|
+
} catch (err) {
|
|
88
|
+
fs.closeSync(fd);
|
|
89
|
+
throw err;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
31
93
|
function asAgentSh(script: string, args: string[]): Buffer {
|
|
32
94
|
const [file, argv] = asUser(agentUser(), 'sh', ['-c', script, 'metro', ...args]);
|
|
33
95
|
const run = spawnSync(file, argv, { maxBuffer: MAX_OUTPUT, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
@@ -51,6 +113,7 @@ function viaAgent<T>(native: () => T, fallback: () => T): T {
|
|
|
51
113
|
|
|
52
114
|
export function existsSync(path: string): boolean {
|
|
53
115
|
try {
|
|
116
|
+
guardPath(path);
|
|
54
117
|
fs.accessSync(path, fs.constants.F_OK);
|
|
55
118
|
return true;
|
|
56
119
|
} catch (err) {
|
|
@@ -78,19 +141,42 @@ function agentStat(path: string, follow: boolean): AgentStat {
|
|
|
78
141
|
return { size: Number(size), mtime: new Date(ms), mtimeMs: ms, mode: parseInt(mode, 16), ...kindTests(kind) };
|
|
79
142
|
}
|
|
80
143
|
|
|
81
|
-
export const statSync = (path: string): AgentStat =>
|
|
144
|
+
export const statSync = (path: string): AgentStat =>
|
|
145
|
+
viaAgent(
|
|
146
|
+
() => {
|
|
147
|
+
guardPath(path);
|
|
148
|
+
return fs.statSync(path);
|
|
149
|
+
},
|
|
150
|
+
() => agentStat(path, true),
|
|
151
|
+
);
|
|
82
152
|
|
|
83
153
|
export const realpathSync = (path: string): string =>
|
|
84
|
-
viaAgent(
|
|
154
|
+
viaAgent(
|
|
155
|
+
() => {
|
|
156
|
+
guardPath(path);
|
|
157
|
+
return fs.realpathSync(path);
|
|
158
|
+
},
|
|
159
|
+
() => asAgentSh('realpath -e -- "$1"', [path]).toString('utf8').trim(),
|
|
160
|
+
);
|
|
85
161
|
|
|
86
162
|
export function readFileSync(path: string, encoding: 'utf8'): string {
|
|
87
|
-
return viaAgent(
|
|
163
|
+
return viaAgent(
|
|
164
|
+
() => {
|
|
165
|
+
const fd = openGuarded(path);
|
|
166
|
+
try {
|
|
167
|
+
return fs.readFileSync(fd, encoding);
|
|
168
|
+
} finally {
|
|
169
|
+
fs.closeSync(fd);
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
() => asAgentSh('cat -- "$1"', [path]).toString('utf8'),
|
|
173
|
+
);
|
|
88
174
|
}
|
|
89
175
|
|
|
90
176
|
export function readRange(path: string, start: number, length: number): Buffer {
|
|
91
177
|
return viaAgent(
|
|
92
178
|
() => {
|
|
93
|
-
const fd =
|
|
179
|
+
const fd = openGuarded(path);
|
|
94
180
|
try {
|
|
95
181
|
const buffer = Buffer.alloc(length);
|
|
96
182
|
return buffer.subarray(0, fs.readSync(fd, buffer, 0, length, start));
|
|
@@ -108,7 +194,10 @@ export function readdirNames(path: string): string[] {
|
|
|
108
194
|
|
|
109
195
|
export function readdirEntries(path: string): AgentDirent[] {
|
|
110
196
|
return viaAgent(
|
|
111
|
-
() =>
|
|
197
|
+
() => {
|
|
198
|
+
guardPath(path);
|
|
199
|
+
return fs.readdirSync(path, { withFileTypes: true });
|
|
200
|
+
},
|
|
112
201
|
() =>
|
|
113
202
|
asAgentSh('find -H "$1" -mindepth 1 -maxdepth 1 -printf "%y\\t%f\\0"', [path])
|
|
114
203
|
.toString('utf8')
|
|
@@ -123,8 +212,7 @@ export function readdirEntries(path: string): AgentDirent[] {
|
|
|
123
212
|
|
|
124
213
|
export function createReadStream(path: string): Readable {
|
|
125
214
|
try {
|
|
126
|
-
fs.
|
|
127
|
-
return fs.createReadStream(path);
|
|
215
|
+
return fs.createReadStream(path, { fd: openGuarded(path) });
|
|
128
216
|
} catch (err) {
|
|
129
217
|
if (!refused(err)) return fs.createReadStream(path);
|
|
130
218
|
const [file, argv] = asUser(agentUser(), 'cat', ['--', path]);
|
|
@@ -132,3 +220,27 @@ export function createReadStream(path: string): Readable {
|
|
|
132
220
|
return child.stdout;
|
|
133
221
|
}
|
|
134
222
|
}
|
|
223
|
+
|
|
224
|
+
export function readAsAgent(path: string, maxBytes: number): Buffer | null {
|
|
225
|
+
if (!runningAsMetro() || agentUser() === null) {
|
|
226
|
+
try {
|
|
227
|
+
const st = fs.statSync(path);
|
|
228
|
+
if (!st.isFile()) return null;
|
|
229
|
+
const start = Math.max(0, st.size - maxBytes);
|
|
230
|
+
const fd = fs.openSync(path, 'r');
|
|
231
|
+
try {
|
|
232
|
+
const buffer = Buffer.alloc(st.size - start);
|
|
233
|
+
return buffer.subarray(0, fs.readSync(fd, buffer, 0, buffer.length, start));
|
|
234
|
+
} finally {
|
|
235
|
+
fs.closeSync(fd);
|
|
236
|
+
}
|
|
237
|
+
} catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
return asAgentSh('[ -f "$1" ] && tail -c "$2" -- "$1"', [path, String(maxBytes)]);
|
|
243
|
+
} catch {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { chmodSync, lstatSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { isAbsolute, join, relative, sep } from 'node:path';
|
|
4
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
5
|
+
|
|
6
|
+
const CLI_PREFIX = '.npm-global';
|
|
7
|
+
const OPEN_BITS = 0o077;
|
|
8
|
+
|
|
9
|
+
function entryHolding(home: string, path: string): string | null {
|
|
10
|
+
const rel = relative(home, path);
|
|
11
|
+
if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) return null;
|
|
12
|
+
return rel.split(sep)[0] ?? null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function sharedEntries(home: string, env: NodeJS.ProcessEnv = process.env): Set<string> {
|
|
16
|
+
const cli = env.METRO_CLI_BIN?.trim() ?? '';
|
|
17
|
+
const holding = cli === '' ? null : entryHolding(home, cli);
|
|
18
|
+
return new Set(holding === null ? [CLI_PREFIX] : [CLI_PREFIX, holding]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function closeEntry(path: string): boolean {
|
|
22
|
+
const stat = lstatSync(path);
|
|
23
|
+
if (stat.isSymbolicLink() || (stat.mode & OPEN_BITS) === 0) return false;
|
|
24
|
+
chmodSync(path, stat.mode & 0o700);
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function closeMetroHome(home = homedir(), env: NodeJS.ProcessEnv = process.env): string[] {
|
|
29
|
+
chmodSync(home, 0o711);
|
|
30
|
+
const shared = sharedEntries(home, env);
|
|
31
|
+
const closed: string[] = [];
|
|
32
|
+
for (const name of readdirSync(home)) {
|
|
33
|
+
if (shared.has(name)) continue;
|
|
34
|
+
try {
|
|
35
|
+
if (closeEntry(join(home, name))) closed.push(name);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
log.warn({ entry: name, err: errMsg(err) }, 'agent-user: could not close an entry of the metro home');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (closed.length > 0) log.info({ closed }, 'agent-user: closed entries of the metro home so the agent user cannot read them');
|
|
41
|
+
return closed;
|
|
42
|
+
}
|
|
@@ -7,6 +7,7 @@ import { errMsg, log } from '@metro-labs/core/log';
|
|
|
7
7
|
import { copyIntoHome } from './home-fs.js';
|
|
8
8
|
import { AGENT_NAME, agentMarketplaceDir, agentUser, agentUserExpected, asUser, claudeBin, forgetAgentUser, type AgentUser } from './user.js';
|
|
9
9
|
import { watchAgentView } from './view.js';
|
|
10
|
+
import { closeMetroHome } from './metro-home.js';
|
|
10
11
|
import { mustHelper } from '../metro-user/privilege.js';
|
|
11
12
|
import { stagedPluginVersion } from '../claude/plugin-install.js';
|
|
12
13
|
|
|
@@ -22,7 +23,7 @@ function run(file: string, args: string[]): void {
|
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
function openMetroHome(): void {
|
|
25
|
-
|
|
26
|
+
closeMetroHome();
|
|
26
27
|
chmodSync(join(homedir(), '.metro'), 0o700);
|
|
27
28
|
}
|
|
28
29
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import {
|
|
2
|
+
import { readAsAgent } from './agent-fs.js';
|
|
3
3
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
4
4
|
import { log } from '@metro-labs/core/log';
|
|
5
5
|
import { listSchedules, realRunner, showProps, type Runner, type ScheduledJob } from './schedules.js';
|
|
@@ -18,14 +18,10 @@ export interface JobDetail extends ScheduledJob {
|
|
|
18
18
|
const SCRIPT_MAX = 64 * 1024;
|
|
19
19
|
|
|
20
20
|
function textFile(path: string): string | null {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
return text.includes('\u0000') ? null : text;
|
|
26
|
-
} catch {
|
|
27
|
-
return null;
|
|
28
|
-
}
|
|
21
|
+
const bytes = readAsAgent(path, SCRIPT_MAX + 1);
|
|
22
|
+
if (bytes === null || bytes.length > SCRIPT_MAX) return null;
|
|
23
|
+
const text = bytes.toString('utf8');
|
|
24
|
+
return text.includes('\u0000') ? null : text;
|
|
29
25
|
}
|
|
30
26
|
|
|
31
27
|
export function scriptOf(command: string): { path: string; text: string } | null {
|
|
@@ -50,10 +46,9 @@ export function logFileOf(command: string): string | null {
|
|
|
50
46
|
return m?.[1] ?? null;
|
|
51
47
|
}
|
|
52
48
|
|
|
53
|
-
function tail(path: string): string {
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
return readRange(path, start, size - start).toString('utf8').split('\n').slice(-LOG_LINES).join('\n');
|
|
49
|
+
function tail(path: string | null): string {
|
|
50
|
+
const bytes = path === null ? null : readAsAgent(path, TAIL_BYTES);
|
|
51
|
+
return bytes === null ? '' : bytes.toString('utf8').split('\n').slice(-LOG_LINES).join('\n');
|
|
57
52
|
}
|
|
58
53
|
|
|
59
54
|
function timerDetail(job: ScheduledJob, runner: Runner): JobDetail {
|
|
@@ -70,11 +65,10 @@ function timerDetail(job: ScheduledJob, runner: Runner): JobDetail {
|
|
|
70
65
|
|
|
71
66
|
function cronDetail(job: ScheduledJob): JobDetail {
|
|
72
67
|
const file = logFileOf(job.command);
|
|
73
|
-
const readable = file !== null && existsSync(file) && statSync(file).isFile();
|
|
74
68
|
return {
|
|
75
69
|
...job,
|
|
76
70
|
definition: `${job.schedule} ${job.command}`,
|
|
77
|
-
logs:
|
|
71
|
+
logs: tail(file),
|
|
78
72
|
logSource: file,
|
|
79
73
|
script: scriptOf(job.command),
|
|
80
74
|
};
|
|
@@ -4,6 +4,7 @@ import { errMsg, log } from '@metro-labs/core/log';
|
|
|
4
4
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
5
5
|
import { agentsDir } from '../agents/files.js';
|
|
6
6
|
import { removeHome, writeHomeText } from './home-fs.js';
|
|
7
|
+
import { closeMetroHome } from './metro-home.js';
|
|
7
8
|
import { agentUser, agentViewDir, type AgentUser } from './user.js';
|
|
8
9
|
|
|
9
10
|
const COPIED = ['policy.json', 'claude-setup.json', 'system-prompt.md'] as const;
|
|
@@ -80,6 +81,11 @@ export function watchAgentView(everyMs = SYNC_MS): void {
|
|
|
80
81
|
} catch (err) {
|
|
81
82
|
log.warn({ err: errMsg(err) }, 'agent-user: could not refresh the Metro files the agent may read');
|
|
82
83
|
}
|
|
84
|
+
try {
|
|
85
|
+
closeMetroHome();
|
|
86
|
+
} catch (err) {
|
|
87
|
+
log.warn({ err: errMsg(err) }, 'agent-user: could not close the metro home');
|
|
88
|
+
}
|
|
83
89
|
};
|
|
84
90
|
tick();
|
|
85
91
|
timer = setInterval(tick, everyMs);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
|
+
import { cameThroughProxy } from '@metro-labs/http/api-http';
|
|
3
4
|
import { agentIdForKey } from '../agents/keys.js';
|
|
4
5
|
import {
|
|
5
6
|
assertBedrockReady,
|
|
@@ -301,7 +302,8 @@ export function handleGatewayRequest(req: IncomingMessage, res: ServerResponse,
|
|
|
301
302
|
res.writeHead(200).end();
|
|
302
303
|
return true;
|
|
303
304
|
}
|
|
304
|
-
if ((
|
|
305
|
+
if (cameThroughProxy(req)) sendError(res, 401, 'authentication_error', 'metro gateway: the agent key works only on this machine, not through its public address');
|
|
306
|
+
else if ((deps.identify ?? defaultIdentify)(keyOf(req))) answer(req, res, path, deps);
|
|
305
307
|
else sendError(res, 401, 'authentication_error', 'metro gateway: no agent key (x-metro-key); start Claude Code with metro claude');
|
|
306
308
|
return true;
|
|
307
309
|
}
|
|
@@ -138,11 +138,20 @@ async function loadStations(): Promise<ScopedAccounts> {
|
|
|
138
138
|
return { accounts, unavailable };
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
const URL_SECRETS = new Set(['endpoint', 'callback']);
|
|
142
|
+
|
|
143
|
+
function withoutUrlSecrets(acc: unknown): unknown {
|
|
144
|
+
const rec = asRecord(acc);
|
|
145
|
+
if (rec === undefined) return acc;
|
|
146
|
+
return Object.fromEntries(Object.entries(rec).filter(([key]) => !URL_SECRETS.has(key)));
|
|
147
|
+
}
|
|
148
|
+
|
|
141
149
|
export async function gatherAccounts(
|
|
142
150
|
allowedAgents?: Set<string>,
|
|
143
151
|
): Promise<Record<string, unknown[]>> {
|
|
144
152
|
const { accounts } = await loadStations();
|
|
145
|
-
|
|
153
|
+
const scoped = allowedAgents ? scopeAccountsByAgent(accounts, allowedAgents) : accounts;
|
|
154
|
+
return Object.fromEntries(Object.entries(scoped).map(([station, rows]) => [station, rows.map(withoutUrlSecrets)]));
|
|
146
155
|
}
|
|
147
156
|
|
|
148
157
|
export async function gatherAccountsForAgents(
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
2
|
import type { IncomingMessage } from 'node:http';
|
|
3
|
+
import { cameThroughProxy } from '@metro-labs/http/api-http';
|
|
3
4
|
import { agentIdForKey } from '../agents/keys.js';
|
|
4
5
|
|
|
5
6
|
export interface RequestIdentity {
|
|
@@ -33,6 +34,7 @@ export function extractToken(req: IncomingMessage): string | undefined {
|
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
export function authenticate(req: IncomingMessage): RequestIdentity | null {
|
|
37
|
+
if (cameThroughProxy(req)) return null;
|
|
36
38
|
const token = extractToken(req);
|
|
37
39
|
if (!token) return null;
|
|
38
40
|
const agentId = agentIdForKey(token);
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
assertAttachmentSize,
|
|
3
3
|
assertContentLength,
|
|
4
4
|
guessMime,
|
|
5
|
+
MAX_ATTACHMENT_BYTES,
|
|
5
6
|
} from '@metro-labs/core/stations/attachments';
|
|
6
7
|
import {
|
|
7
8
|
assertInlineTotal,
|
|
@@ -105,6 +106,49 @@ async function fromPath(
|
|
|
105
106
|
};
|
|
106
107
|
}
|
|
107
108
|
|
|
109
|
+
export const curlArgs = (url: string): string[] => [
|
|
110
|
+
'curl', '-sS', '-f', '-L', '--max-redirs', '5', '--proto', '=http,https', '--proto-redir', '=http,https',
|
|
111
|
+
'--max-time', String(FETCH_TIMEOUT_MS / 1000), '--max-filesize', String(MAX_ATTACHMENT_BYTES),
|
|
112
|
+
'-w', '%{stderr}\n%{content_type}', '--', url,
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
interface CurlSaid {
|
|
116
|
+
type: string;
|
|
117
|
+
why: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function curlSaid(text: string): CurlSaid {
|
|
121
|
+
const lines = text.trimEnd().split('\n');
|
|
122
|
+
const type = lines.pop()?.split(';')[0]?.trim() ?? '';
|
|
123
|
+
return { type, why: lines.join(' ').replace(/^curl: \(\d+\)\s*/, '').trim() };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function fromUrlAs(
|
|
127
|
+
user: AgentUser,
|
|
128
|
+
a: CanonicalAttachment,
|
|
129
|
+
url: string,
|
|
130
|
+
): Promise<ResolvedAttachment> {
|
|
131
|
+
const proc = Bun.spawn(agentCommand(curlArgs(url)), { stdout: 'pipe', stderr: 'pipe' });
|
|
132
|
+
const name = a.name ?? basenameOf(url) ?? 'attachment';
|
|
133
|
+
const said = new Response(proc.stderr).text().catch((err: unknown) => {
|
|
134
|
+
log.debug({ err: String(err) }, 'send: could not read what curl said');
|
|
135
|
+
return '';
|
|
136
|
+
});
|
|
137
|
+
let saved: Awaited<ReturnType<typeof streamToTemp>>;
|
|
138
|
+
try {
|
|
139
|
+
saved = await streamToTemp(proc.stdout, name);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
proc.kill();
|
|
142
|
+
throw err;
|
|
143
|
+
}
|
|
144
|
+
const { type, why } = curlSaid(await said);
|
|
145
|
+
if ((await proc.exited) !== 0) {
|
|
146
|
+
await removeInlineTemp(saved.dir);
|
|
147
|
+
throw new Error(`attachment fetch failed for '${url}' (fetched as the ${user.name} user): ${why || 'no answer'}`);
|
|
148
|
+
}
|
|
149
|
+
return { path: saved.path, mime: a.mime ?? (type || guessMime(url)), name, bytes: saved.bytes, temp: saved.dir };
|
|
150
|
+
}
|
|
151
|
+
|
|
108
152
|
async function fromUrl(
|
|
109
153
|
a: CanonicalAttachment,
|
|
110
154
|
url: string,
|
|
@@ -113,6 +157,8 @@ async function fromUrl(
|
|
|
113
157
|
throw new Error(
|
|
114
158
|
`attachment url '${url}' is not an http(s) url; use \`path\` for a local file`,
|
|
115
159
|
);
|
|
160
|
+
const user = agentUser();
|
|
161
|
+
if (user !== null) return fromUrlAs(user, a, url);
|
|
116
162
|
const res = await fetch(url, {
|
|
117
163
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
118
164
|
});
|
|
@@ -19,6 +19,11 @@ export function setBearerSessions(fn: BearerSessions | null): void {
|
|
|
19
19
|
bearerSessions = fn;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
const PROXY_HEADERS = ['x-forwarded-for', 'x-forwarded-host', 'tailscale-funnel-request'] as const;
|
|
23
|
+
|
|
24
|
+
export const cameThroughProxy = (req: IncomingMessage): boolean =>
|
|
25
|
+
PROXY_HEADERS.some((name) => req.headers[name] !== undefined);
|
|
26
|
+
|
|
22
27
|
export function requireAdmin(session: ApiSession): void {
|
|
23
28
|
if (session.role !== 'admin') throw new ApiError('this needs the admin role in your organization', 403);
|
|
24
29
|
}
|
package/runtime/runtime.json
CHANGED