@piagent/platform 1.12.1 → 1.13.1
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 +42 -0
- package/README.md +3 -3
- package/README.vi.md +5 -1
- package/docs/command-reference-vietnamese.md +2 -2
- package/docs/distribution-standard.md +6 -6
- package/docs/en/README.md +1 -0
- package/docs/en/windows.md +66 -0
- package/docs/languages.json +13 -4
- package/docs/operator-manual-vietnamese.md +7 -7
- package/docs/publishing-for-teams.md +2 -0
- package/docs/release-install-policy.md +2 -2
- package/docs/team-onboarding.md +2 -2
- package/docs/vi/README.md +1 -0
- package/docs/vi/windows.md +66 -0
- package/docs-site/architecture.html +1 -1
- package/docs-site/benchmark.html +1 -1
- package/docs-site/commands.html +1 -1
- package/docs-site/content/en/index.html +4 -4
- package/docs-site/content/en/whats-new.html +19 -0
- package/docs-site/content/index.html +4 -4
- package/docs-site/content/whats-new.html +19 -0
- package/docs-site/context-engine.html +1 -1
- package/docs-site/ecosystem.html +1 -1
- package/docs-site/en/architecture.html +1 -1
- package/docs-site/en/benchmark.html +1 -1
- package/docs-site/en/commands.html +1 -1
- package/docs-site/en/context-engine.html +1 -1
- package/docs-site/en/ecosystem.html +1 -1
- package/docs-site/en/index.html +5 -5
- package/docs-site/en/mcp-approval.html +1 -1
- package/docs-site/en/mcp-auth.html +1 -1
- package/docs-site/en/mcp-servers.html +1 -1
- package/docs-site/en/mcp.html +1 -1
- package/docs-site/en/permissions.html +1 -1
- package/docs-site/en/profiles.html +1 -1
- package/docs-site/en/quickstart.html +1 -1
- package/docs-site/en/runtime.html +1 -1
- package/docs-site/en/scope.html +1 -1
- package/docs-site/en/security.html +1 -1
- package/docs-site/en/team.html +1 -1
- package/docs-site/en/whats-new.html +20 -1
- package/docs-site/en/workflows.html +1 -1
- package/docs-site/index.html +5 -5
- package/docs-site/mcp-approval.html +1 -1
- package/docs-site/mcp-auth.html +1 -1
- package/docs-site/mcp-servers.html +1 -1
- package/docs-site/mcp.html +1 -1
- package/docs-site/permissions.html +1 -1
- package/docs-site/profiles.html +1 -1
- package/docs-site/quickstart.html +1 -1
- package/docs-site/runtime.html +1 -1
- package/docs-site/scope.html +1 -1
- package/docs-site/security.html +1 -1
- package/docs-site/team.html +1 -1
- package/docs-site/whats-new.html +20 -1
- package/docs-site/workflows.html +1 -1
- package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
- package/package.json +1 -1
- package/packages/piagent-core/extensions/piagent-guard.ts +3 -1
- package/packages/piagent-core/extensions/shell-write-targets.js +2 -1
- package/packages/piagent-core/extensions/source-checkout-paths.ts +4 -3
- package/packages/piagent-core/extensions/task-journal.js +2 -1
- package/packages/piagent-core/extensions/task-state.js +3 -3
- package/packages/piagent-core/managed/broker-client.mjs +2 -1
- package/packages/piagent-core/managed/doctor.mjs +5 -3
- package/packages/piagent-core/managed/language-environment.mjs +16 -6
- package/packages/piagent-core/managed/linux-sandbox.mjs +81 -0
- package/packages/piagent-core/managed/session.mjs +4 -1
- package/packages/piagent-core/managed/store.d.mts +2 -0
- package/packages/piagent-core/managed/store.mjs +12 -0
- package/packages/piagent-core/managed/tool-boundary.mjs +41 -14
- package/packages/piagent-core/managed/toolchain.mjs +5 -2
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-core/runtime/host/browser-opener.d.mts +2 -0
- package/packages/piagent-core/runtime/host/browser-opener.mjs +20 -0
- package/packages/piagent-core/runtime/input/chat-images.ts +11 -4
- package/packages/piagent-core/runtime/resources/agent-resources.mjs +4 -4
- package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-FWfOWZjV.js → MarkdownMessage-CyT2G1H2.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{index-DFKHLVYk.js → index-BBEa7Ger.js} +12 -12
- package/packages/piagent-webui/dist/client/index.html +1 -1
- package/packages/piagent-webui/extension/piagent-webui.ts +6 -5
- package/packages/piagent-webui/gateway/control-socket.ts +14 -2
- package/packages/piagent-webui/gateway/managed-launch.ts +8 -6
- package/packages/piagent-webui/gateway/native-project-picker.ts +28 -3
- package/packages/piagent-webui/gateway/path-suggestions.ts +9 -3
- package/packages/piagent-webui/gateway/pi-host.ts +11 -3
- package/scripts/dashboard-update-job.mjs +3 -2
- package/scripts/piagent-cli.mjs +2 -2
- package/scripts/piagent-dashboard.mjs +7 -7
- package/scripts/piagent-studio.mjs +4 -4
- package/scripts/piagent-webui-launcher.mjs +4 -3
|
@@ -54,8 +54,10 @@ export async function runDoctor({ sdkRoot, origin, broker, network = true }) {
|
|
|
54
54
|
if (await bash("cat 'Dự án mới/ghi chú.md'") !== 'ổn') throw Error('content changed'); return 'ok';
|
|
55
55
|
});
|
|
56
56
|
await check('folders outside the project are read-only', true, async () => {
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
// Refused, or (Linux) kept in the sandbox's own /tmp: never on this machine.
|
|
58
|
+
const outside = path.join(path.dirname(project), 'piagent-doctor-outside.txt');
|
|
59
|
+
try { await boundary.invoke('write', { path: '../piagent-doctor-outside.txt', content: 'x' }); } catch { /* refused */ }
|
|
60
|
+
if (fs.existsSync(outside)) { fs.rmSync(outside, { force: true }); throw Error('a write outside the project was allowed'); }
|
|
59
61
|
// A folder the member points to for reference (@~/…) is readable.
|
|
60
62
|
await boundary.invoke('ls', { path: '~' });
|
|
61
63
|
return 'read, write refused';
|
|
@@ -79,7 +81,7 @@ export async function runDoctor({ sdkRoot, origin, broker, network = true }) {
|
|
|
79
81
|
});
|
|
80
82
|
await check('make', false, () => bash('make --version'));
|
|
81
83
|
// Tests that start a server or use a local database (tool-boundary.mjs).
|
|
82
|
-
await check('tests reach
|
|
84
|
+
await check('tests reach their own servers (localhost)', false, async () => {
|
|
83
85
|
await boundary.invoke('write', { path: 'loopback.mjs', content: "import http from 'node:http'; const s = http.createServer((q, r) => r.end('ok')); s.listen(0, '127.0.0.1', async () => { console.log(await (await fetch(`http://127.0.0.1:${s.address().port}/`)).text()); s.close(); });\n" });
|
|
84
86
|
try { return await bash('node loopback.mjs'); }
|
|
85
87
|
catch (error) { throw Error(/loopback-blocked/.test(String(error?.message)) ? 'a local proxy listens on this Mac: tests that use localhost need run_with_network' : first(String(error?.message))); }
|
|
@@ -30,6 +30,13 @@ function prune(root, keep) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
function javaHome(userHome) {
|
|
33
|
+
if (process.platform === 'linux') {
|
|
34
|
+
if (process.env.JAVA_HOME) return existing(process.env.JAVA_HOME);
|
|
35
|
+
for (const dir of (process.env.PATH ?? '/usr/bin').split(':')) {
|
|
36
|
+
try { return existing(path.dirname(path.dirname(fs.realpathSync(path.join(dir, 'javac'))))); } catch { /* next */ }
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
33
40
|
try {
|
|
34
41
|
const home = execFileSync('/usr/libexec/java_home', [], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'], env: { HOME: userHome, PATH: '/usr/bin:/bin' } }).trim();
|
|
35
42
|
return existing(home);
|
|
@@ -56,7 +63,8 @@ esac
|
|
|
56
63
|
`;
|
|
57
64
|
|
|
58
65
|
export function languageEnvironment({ userHome, repositoryTop, home }) {
|
|
59
|
-
const
|
|
66
|
+
const linux = process.platform === 'linux';
|
|
67
|
+
const root = linux ? path.join(process.env.XDG_CACHE_HOME || path.join(userHome, '.cache'), 'piagent/sandbox') : path.join(userHome, 'Library/Caches/Piagent/sandbox');
|
|
60
68
|
const cache = path.join(root, createHash('sha256').update(repositoryTop).digest('hex').slice(0, 24));
|
|
61
69
|
fs.mkdirSync(cache, { recursive: true, mode: 0o700 });
|
|
62
70
|
fs.chmodSync(cache, 0o700);
|
|
@@ -73,16 +81,17 @@ export function languageEnvironment({ userHome, repositoryTop, home }) {
|
|
|
73
81
|
// here: packages the member restored are read from their own folder.
|
|
74
82
|
nuget: existing(path.join(userHome, '.nuget/packages')),
|
|
75
83
|
};
|
|
76
|
-
const java = javaHome(userHome), android = existing(path.join(userHome, 'Library/Android/sdk'));
|
|
84
|
+
const java = javaHome(userHome), android = existing(path.join(userHome, linux ? 'Android/Sdk' : 'Library/Android/sdk'));
|
|
77
85
|
const readRoots = [...new Set([...Object.values(seeds), java, android, existing(path.join(userHome, 'Library/Java/JavaVirtualMachines')),
|
|
78
86
|
existing('/Library/Ruby'), existing('/opt/local')].filter(Boolean))];
|
|
79
87
|
|
|
80
88
|
const bin = path.join(home, '.piagent/bin');
|
|
81
89
|
fs.mkdirSync(bin, { recursive: true, mode: 0o700 });
|
|
82
|
-
|
|
90
|
+
// (Only macOS: SwiftPM on Linux starts no sandbox of its own.)
|
|
91
|
+
if (!linux) for (const name of ['swift', 'xcodebuild']) fs.writeFileSync(path.join(bin, name), SWIFT_WRAPPER, { mode: 0o755 });
|
|
83
92
|
// pnpm's store: its own settings file, which npm does not read (an npm_config_
|
|
84
93
|
// variable would make every npm command warn about an unknown setting).
|
|
85
|
-
const pnpm = path.join(home, 'Library/Preferences/pnpm');
|
|
94
|
+
const pnpm = path.join(home, linux ? '.config/pnpm' : 'Library/Preferences/pnpm');
|
|
86
95
|
fs.mkdirSync(pnpm, { recursive: true, mode: 0o700 });
|
|
87
96
|
fs.writeFileSync(path.join(pnpm, 'rc'), `store-dir=${at('pnpm-store')}\n`, { mode: 0o600 });
|
|
88
97
|
|
|
@@ -105,9 +114,10 @@ export function languageEnvironment({ userHome, repositoryTop, home }) {
|
|
|
105
114
|
// .NET keeps its named mutexes (NuGet's among them) as files in
|
|
106
115
|
// /tmp/.dotnet, whatever TMPDIR says.
|
|
107
116
|
const dotnet = [path.join(userHome, '.dotnet/dotnet'), '/usr/local/share/dotnet/dotnet', '/opt/homebrew/bin/dotnet'].some(file => fs.existsSync(file));
|
|
108
|
-
|
|
117
|
+
// (On Linux the sandbox has its own /tmp.)
|
|
118
|
+
if (dotnet && !linux) try { fs.mkdirSync('/private/tmp/.dotnet', { recursive: true, mode: 0o777 }); } catch { /* another user's */ }
|
|
109
119
|
return {
|
|
110
|
-
cache, readRoots, writeRoots: [cache, ...(dotnet && existing('/private/tmp/.dotnet') ? ['/private/tmp/.dotnet'] : [])], bin,
|
|
120
|
+
cache, readRoots, writeRoots: [cache, ...(dotnet && !linux && existing('/private/tmp/.dotnet') ? ['/private/tmp/.dotnet'] : [])], bin,
|
|
111
121
|
env: {
|
|
112
122
|
// .NET's dual-stack sockets reach 127.0.0.1 as ::ffff:127.0.0.1, which
|
|
113
123
|
// the loopback rule does not name (the test host would not connect).
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
// The company sandbox on Linux, and so on Windows through WSL2: what Seatbelt
|
|
5
|
+
// does on macOS (tool-boundary.mjs), built from bubblewrap mounts. Nothing is
|
|
6
|
+
// there unless mounted:
|
|
7
|
+
// - system folders, toolchains, the SDK and skill folders read-only;
|
|
8
|
+
// - the project (read-only for helpers), its package cache and a private
|
|
9
|
+
// temporary folder writable;
|
|
10
|
+
// - the member's own folders read-only for reference, without the home
|
|
11
|
+
// folder's hidden entries (shell files, histories, tool settings, keys);
|
|
12
|
+
// - credential files found in what is mounted read as empty (an empty file
|
|
13
|
+
// mounted over them: /dev/null cannot be opened in a user namespace);
|
|
14
|
+
// - everything else, the root included, read-only: a write elsewhere fails
|
|
15
|
+
// as it does on macOS instead of landing in a throwaway folder.
|
|
16
|
+
// A command without approval runs in its own network namespace: it reaches
|
|
17
|
+
// the servers it starts itself, nothing else (no internet, no proxy, no
|
|
18
|
+
// service of this machine). Its processes end with it. Windows programs
|
|
19
|
+
// cannot be started from inside: WSL's interop socket is not mounted.
|
|
20
|
+
export const BWRAP = ['/usr/bin/bwrap', '/bin/bwrap', '/usr/local/bin/bwrap'].find(file => { try { fs.accessSync(file, fs.constants.X_OK); return true; } catch { return false; } }) ?? null;
|
|
21
|
+
|
|
22
|
+
const SYSTEM = ['/usr', '/bin', '/sbin', '/lib', '/lib32', '/lib64', '/libx32', '/etc', '/opt', '/snap', '/sys', '/run/systemd/resolve'];
|
|
23
|
+
const SKIP = new Set(['node_modules', '.git', '.hg', '.svn', 'dist', 'build', 'target', 'vendor', '.venv', 'venv', '__pycache__', '.cache', '.next', '.gradle']);
|
|
24
|
+
const SCAN_ENTRIES = 50_000;
|
|
25
|
+
|
|
26
|
+
// Names the macOS profile closes (tool-boundary.mjs): .env and .env.* but not
|
|
27
|
+
// committed templates, credentials and credentials.*, .npmrc, .netrc, and
|
|
28
|
+
// auth.json at a project's root or in a dot folder.
|
|
29
|
+
export function credentialName(name, parent, roots) {
|
|
30
|
+
if (/^\.env(\.|$)/.test(name)) return !/^\.env\.(example|sample|template|dist|defaults)$/.test(name);
|
|
31
|
+
if (/^credentials(\.|$)/.test(name) || name === '.npmrc' || name === '.netrc') return true;
|
|
32
|
+
return name === 'auth.json' && (roots.includes(parent) || path.basename(parent).startsWith('.'));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Credential files and folders under these roots, walking at most
|
|
36
|
+
// SCAN_ENTRIES entries and never into dependency or build folders or links.
|
|
37
|
+
export function credentialFiles(roots, { limit = SCAN_ENTRIES } = {}) {
|
|
38
|
+
const found = [], queue = [...roots];
|
|
39
|
+
let seen = 0;
|
|
40
|
+
while (queue.length && seen < limit) {
|
|
41
|
+
const dir = queue.shift();
|
|
42
|
+
let entries;
|
|
43
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
44
|
+
for (const entry of entries) {
|
|
45
|
+
seen += 1;
|
|
46
|
+
const full = path.join(dir, entry.name);
|
|
47
|
+
if (credentialName(entry.name, dir, roots)) { found.push({ path: full, directory: entry.isDirectory() }); continue; }
|
|
48
|
+
if (entry.isDirectory() && !entry.isSymbolicLink() && !SKIP.has(entry.name)) queue.push(full);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return found;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const depth = file => file.split('/').length;
|
|
55
|
+
export function bubblewrapArgs({ cwd, sdkRoot, runtimeRoot, temporary, empty, node, readOnly = false, repository = [], gitDirs = [], toolchains = [], skills = [], caches = [], references = [], network = false, credentials = [] }) {
|
|
56
|
+
const exists = file => { try { fs.statSync(file); return true; } catch { return false; } };
|
|
57
|
+
const readable = [...new Set([sdkRoot, runtimeRoot, path.dirname(node), ...repository, ...toolchains, ...skills, ...references, ...(readOnly ? [cwd, ...gitDirs] : [])])].filter(exists);
|
|
58
|
+
const writable = [...new Set([...caches, ...(readOnly ? [] : [cwd, ...gitDirs])])].filter(exists);
|
|
59
|
+
// A parent is mounted before what lies under it (a later mount covers).
|
|
60
|
+
const mounts = [...readable.map(dir => ['--ro-bind', dir]), ...writable.map(dir => ['--bind', dir])]
|
|
61
|
+
.sort((a, b) => depth(a[1]) - depth(b[1]) || (a[0] === '--ro-bind' ? -1 : 1));
|
|
62
|
+
return [
|
|
63
|
+
'--die-with-parent', '--new-session', '--unshare-pid', '--unshare-ipc', '--unshare-uts', '--unshare-cgroup-try',
|
|
64
|
+
...(network ? [] : ['--unshare-net']),
|
|
65
|
+
...SYSTEM.filter(exists).flatMap(dir => ['--ro-bind', dir, dir]),
|
|
66
|
+
'--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp', '--bind', temporary, temporary,
|
|
67
|
+
...mounts.flatMap(([kind, dir]) => [kind, dir, dir]),
|
|
68
|
+
...credentials.filter(item => exists(item.path)).flatMap(item => item.directory ? ['--tmpfs', item.path] : ['--ro-bind', empty, item.path]),
|
|
69
|
+
'--remount-ro', '/', '--chdir', cwd,
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The member's own folders for reference: the home folder's entries except
|
|
74
|
+
// hidden ones (and the Windows profile's, from WSL, except AppData).
|
|
75
|
+
export function referenceFolders(userHome, windowsHome = null) {
|
|
76
|
+
const list = (home, skip = () => false) => {
|
|
77
|
+
try { return fs.readdirSync(home, { withFileTypes: true }).filter(entry => !entry.name.startsWith('.') && !skip(entry.name)).map(entry => path.join(home, entry.name)); }
|
|
78
|
+
catch { return []; }
|
|
79
|
+
};
|
|
80
|
+
return [...list(userHome), ...(windowsHome ? list(windowsHome, name => /^(AppData|NTUSER|ntuser)/i.test(name)) : [])];
|
|
81
|
+
}
|
|
@@ -31,7 +31,10 @@ const BASE_PROMPT = 'You are Piagent, the company coding assistant. If an earlie
|
|
|
31
31
|
// they name) are readable for reference; the sandbox keeps them read-only.
|
|
32
32
|
const referencePrompt = home => ` The member may point you to folders outside the project for reference (an @path mention or a folder they name): read them with read, ls, grep and find. They are read-only: changes go in the project only. In those tools ~ is the member's home folder (${home}); in bash ~ is a private empty folder, so use absolute paths there.`;
|
|
33
33
|
// Tests in the sandbox (tool-boundary.mjs, language-environment.mjs).
|
|
34
|
-
const TEST_PROMPT = ' To run unit and integration tests, use bash. Installing packages (npm, pnpm, pip, uv, go, cargo, gradle, maven…) needs run_with_network once; the project\'s package caches are kept, so later runs work offline.
|
|
34
|
+
const TEST_PROMPT = ' To run unit and integration tests, use bash. Installing packages (npm, pnpm, pip, uv, go, cargo, gradle, maven…) needs run_with_network once; the project\'s package caches are kept, so later runs work offline. '
|
|
35
|
+
+ (process.platform === 'linux'
|
|
36
|
+
? 'A command without network reaches only the servers it starts itself, which end with it: start a test server and run the tests in one command; a database or service already running on this machine needs run_with_network. Docker is not available in the sandbox: ask the member to start those services.'
|
|
37
|
+
: 'Servers on localhost (a test\'s own server, a local database) are reachable, except while a local proxy runs (the error says so: then use run_with_network). Docker and the iOS Simulator are not available in the sandbox: ask the member to start those services or run those tests.');
|
|
35
38
|
const textContent = result => result.content?.filter(p => p.type === 'text').map(p => p.text).join('\n') ?? '';
|
|
36
39
|
export function taskClass(text) {
|
|
37
40
|
// Advisory hint only. Studio owns allowed models, budgets and run authority.
|
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export function memberId(origin: unknown, userId: unknown): string | null;
|
|
2
2
|
export function storedSlot(root: string, slot: string): string | null;
|
|
3
3
|
export function resolveStore(root: string, slot: string, identity: { origin: unknown; userId: unknown }): string;
|
|
4
|
+
export function agentWatchDataDirectory(home: string, environment?: Readonly<Record<string, string | undefined>>): string;
|
|
5
|
+
export function interopEnvironment(environment?: Readonly<Record<string, string | undefined>>): Record<string, string>;
|
|
@@ -9,6 +9,18 @@ import { createHash } from 'node:crypto';
|
|
|
9
9
|
// points at it (`<slot>/store`), so a new key lists and continues the same
|
|
10
10
|
// conversations. Another member, or another Studio, gets another store.
|
|
11
11
|
const SLOT = /^[a-f0-9]{64}$/;
|
|
12
|
+
|
|
13
|
+
// Where Agent Watch keeps a member's company data on this machine: the macOS
|
|
14
|
+
// app's support folder; on Linux, and so in WSL, the XDG data folder.
|
|
15
|
+
export function agentWatchDataDirectory(home, environment = process.env) {
|
|
16
|
+
return process.platform === 'linux' ? path.join(environment.XDG_DATA_HOME || path.join(home, '.local/share'), 'agentwatch')
|
|
17
|
+
: path.join(home, 'Library/Application Support/AgentWatch');
|
|
18
|
+
}
|
|
19
|
+
// What a Windows program (Agent Watch's broker) needs to start from WSL; a
|
|
20
|
+
// clean environment otherwise drops it.
|
|
21
|
+
export function interopEnvironment(environment = process.env) {
|
|
22
|
+
return Object.fromEntries(['WSL_INTEROP', 'WSL_DISTRO_NAME'].filter(name => environment[name]).map(name => [name, environment[name]]));
|
|
23
|
+
}
|
|
12
24
|
const read = file => { try { return fs.readFileSync(file, 'utf8').trim(); } catch { return null; } };
|
|
13
25
|
const write = (file, value) => { fs.writeFileSync(file, value + '\n', { mode: 0o600 }); };
|
|
14
26
|
const sessionFiles = dir => { try { return fs.readdirSync(path.join(dir, 'sessions')).filter(name => name.endsWith('.jsonl')); } catch { return []; } };
|
|
@@ -8,6 +8,8 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import { developerLicense, developerTools, managedGit, searchToolPath, userToolchains } from './toolchain.mjs';
|
|
10
10
|
import { languageEnvironment } from './language-environment.mjs';
|
|
11
|
+
import { BWRAP, bubblewrapArgs, credentialFiles, referenceFolders } from './linux-sandbox.mjs';
|
|
12
|
+
import { agentWatchDataDirectory } from './store.mjs';
|
|
11
13
|
|
|
12
14
|
const TOOL_NAMES = Object.freeze(['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls']);
|
|
13
15
|
const MAX_WIRE_BYTES = 12 * 1024 * 1024;
|
|
@@ -108,7 +110,9 @@ ${denied.map(root => `(deny file-read* file-write* (require-all (subpath ${liter
|
|
|
108
110
|
// retrying for minutes.
|
|
109
111
|
function toolEnvironment({ git, node, home, temporary, toolchains, developer, identity, scope, browsers, languages }) {
|
|
110
112
|
const PATH = [...new Set([languages.bin, path.dirname(node), ...toolchains.bins, '/opt/homebrew/bin', '/opt/homebrew/sbin', '/usr/local/bin', ...developer.bins, path.dirname(git), '/usr/bin', '/bin', '/usr/sbin', '/sbin'])].join(':');
|
|
111
|
-
|
|
113
|
+
// C.UTF-8 is on every Linux; en_US.UTF-8 may not be generated there.
|
|
114
|
+
const locale = process.platform === 'linux' ? 'C.UTF-8' : 'en_US.UTF-8';
|
|
115
|
+
const common = { ...toolchains.env, ...developer.env, PATH, HOME: home, TMPDIR: temporary, PIAGENT_TOOL_SCOPE: scope, LANG: locale, LC_ALL: locale, RIPGREP_CONFIG_PATH: path.join(home, '.ripgreprc'),
|
|
112
116
|
...(identity ? { GIT_AUTHOR_NAME: identity.name, GIT_AUTHOR_EMAIL: identity.email, GIT_COMMITTER_NAME: identity.name, GIT_COMMITTER_EMAIL: identity.email } : {}) };
|
|
113
117
|
// The sandbox home is empty: Playwright finds the member's browsers by path.
|
|
114
118
|
const playwright = browsers ? { PLAYWRIGHT_BROWSERS_PATH: browsers } : {};
|
|
@@ -117,7 +121,8 @@ function toolEnvironment({ git, node, home, temporary, toolchains, developer, id
|
|
|
117
121
|
}
|
|
118
122
|
// The browsers `npx playwright install` put in the member's cache.
|
|
119
123
|
function playwrightBrowsers(userHome) {
|
|
120
|
-
|
|
124
|
+
const cache = process.platform === 'darwin' ? 'Library/Caches/ms-playwright' : '.cache/ms-playwright';
|
|
125
|
+
try { const dir = fs.realpathSync(path.join(userHome, cache)); return fs.statSync(dir).isDirectory() ? dir : null; } catch { return null; }
|
|
121
126
|
}
|
|
122
127
|
// Without network a command still reaches servers on this Mac (a test's own
|
|
123
128
|
// server, a database for integration tests), unless a proxy listens here:
|
|
@@ -187,7 +192,9 @@ export class ManagedToolBoundary {
|
|
|
187
192
|
// the same (a helper that runs checks); it writes only to its temporary
|
|
188
193
|
// directory, and has no network.
|
|
189
194
|
constructor({ cwd, sdkRoot, protectedRoots = [], readOnly = false, commands = false, node = process.execPath, identity, userHome: home, searchTools = true, skills: skillFolders = [], proxyPorts }) {
|
|
190
|
-
|
|
195
|
+
// macOS: Seatbelt. Linux, and Windows through WSL2: bubblewrap (linux-sandbox.mjs).
|
|
196
|
+
this.linux = process.platform === 'linux';
|
|
197
|
+
if (process.platform !== 'darwin' && !(this.linux && BWRAP)) throw new Error('managed-sandbox-unavailable');
|
|
191
198
|
this.cwd = fs.realpathSync(cwd); this.sdkRoot = fs.realpathSync(sdkRoot);
|
|
192
199
|
this.node = fs.realpathSync(node);
|
|
193
200
|
this.runtimeRoot = fs.realpathSync(path.dirname(workerPath));
|
|
@@ -204,7 +211,7 @@ export class ManagedToolBoundary {
|
|
|
204
211
|
this.home = path.join(this.temporary, 'home'); fs.mkdirSync(this.home, { mode: 0o700 });
|
|
205
212
|
const denied = [path.join(userHome, '.ssh'), path.join(userHome, '.aws'), path.join(userHome, '.config'),
|
|
206
213
|
path.join(userHome, '.pi'), path.join(userHome, '.codex'), path.join(userHome, '.claude'),
|
|
207
|
-
path.join(userHome, 'Library/Keychains'),
|
|
214
|
+
path.join(userHome, 'Library/Keychains'), agentWatchDataDirectory(userHome),
|
|
208
215
|
...protectedRoots.map(p => path.resolve(p))];
|
|
209
216
|
// A pinned SDK may itself be installed under ~/.pi; grant that code only,
|
|
210
217
|
// while keeping the adjacent personal auth/config subtree inaccessible.
|
|
@@ -231,14 +238,23 @@ export class ManagedToolBoundary {
|
|
|
231
238
|
denied: narrowed, readOnly, repository, gitDirs, toolchains: [...toolchains.roots, ...languages.readRoots], developer: developer.roots,
|
|
232
239
|
browsers: browsers ? [browsers] : [], skills, caches: languages.writeRoots, license: developerLicense() };
|
|
233
240
|
this.userHome = userHome;
|
|
234
|
-
this.profile = managedSeatbelt({ ...base, references: userHome });
|
|
235
|
-
// The same without loopback, while a local proxy listens (localProxyListening).
|
|
236
|
-
this.isolatedProfile = managedSeatbelt({ ...base, references: userHome, loopback: false });
|
|
237
|
-
this.proxyPorts = proxyPorts ?? localProxyPorts(); this.proxyCheck = { at: 0, listening: Promise.resolve(false) };
|
|
238
241
|
// Only for commands the user approved (run_with_network); credentials stay
|
|
239
242
|
// denied, and so do reference folders: what a command with network can
|
|
240
243
|
// read is the project, not the member's other files.
|
|
241
|
-
this.
|
|
244
|
+
this.networkAllowed = !readOnly;
|
|
245
|
+
if (this.linux) {
|
|
246
|
+
const references = referenceFolders(userHome).filter(dir => !narrowed.some(root => inside(dir, root) || inside(root, dir)));
|
|
247
|
+
const empty = path.join(this.temporary, 'empty'); fs.writeFileSync(empty, '', { mode: 0o400 });
|
|
248
|
+
this.linuxBase = { ...base, toolchains: [...base.toolchains, ...base.browsers], references, empty };
|
|
249
|
+
this.referenceCredentials = credentialFiles(references, { limit: 30_000 });
|
|
250
|
+
this.projectCredentials = { at: 0, files: [] };
|
|
251
|
+
} else {
|
|
252
|
+
this.profile = managedSeatbelt({ ...base, references: userHome });
|
|
253
|
+
// The same without loopback, while a local proxy listens (localProxyListening).
|
|
254
|
+
this.isolatedProfile = managedSeatbelt({ ...base, references: userHome, loopback: false });
|
|
255
|
+
this.proxyPorts = proxyPorts ?? localProxyPorts(); this.proxyCheck = { at: 0, listening: Promise.resolve(false) };
|
|
256
|
+
this.networkProfile = readOnly ? null : managedSeatbelt({ ...base, network: true });
|
|
257
|
+
}
|
|
242
258
|
// Every process a command starts carries this marker, also one it detaches.
|
|
243
259
|
this.scope = randomUUID();
|
|
244
260
|
this.environment = toolEnvironment({ git: this.git, node: this.node, home: this.home, temporary: this.temporary, toolchains, developer, scope: this.scope, browsers, languages,
|
|
@@ -259,18 +275,20 @@ export class ManagedToolBoundary {
|
|
|
259
275
|
} }));
|
|
260
276
|
}
|
|
261
277
|
async invoke(name, args, signal, onUpdate, model, { network = false } = {}) {
|
|
262
|
-
if (this.#closed || !this.allowed.includes(name) || network && (name !== 'bash' || !this.
|
|
278
|
+
if (this.#closed || !this.allowed.includes(name) || network && (name !== 'bash' || !this.networkAllowed)) throw new Error('managed-tool-unavailable');
|
|
263
279
|
if (signal?.aborted) throw new Error('managed-tool-cancelled');
|
|
264
280
|
for (const [file, digest] of this.pins) if (hash(file) !== digest) throw new Error('managed-runtime-changed');
|
|
265
281
|
// `~` (as in an @~/… mention) is the member's home folder: the sandbox's
|
|
266
282
|
// own HOME is an empty private folder.
|
|
267
283
|
if (name !== 'bash' && typeof args?.path === 'string' && /^@?~(\/|$)/.test(args.path)) args = { ...args, path: this.userHome + args.path.replace(/^@?~/, '') };
|
|
268
|
-
if (!network && Date.now() - this.proxyCheck.at > 5000) this.proxyCheck = { at: Date.now(), listening: localProxyListening(this.proxyPorts) };
|
|
269
|
-
const isolated = !network && await this.proxyCheck.listening;
|
|
284
|
+
if (!this.linux && !network && Date.now() - this.proxyCheck.at > 5000) this.proxyCheck = { at: Date.now(), listening: localProxyListening(this.proxyPorts) };
|
|
285
|
+
const isolated = !this.linux && !network && await this.proxyCheck.listening;
|
|
270
286
|
const message = JSON.stringify({ name, args, ...(network ? { network: true } : {}), ...(isolated ? { isolated: true } : {}), model: model ? { input: model.input, inputLimits: model.inputLimits } : undefined }) + '\n';
|
|
271
287
|
if (Buffer.byteLength(message) > MAX_WIRE_BYTES) throw new Error('managed-tool-input-too-large');
|
|
272
288
|
return await new Promise((resolve, reject) => {
|
|
273
|
-
const
|
|
289
|
+
const [sandbox, sandboxArgs] = this.linux ? [BWRAP, this.bubblewrap(network)]
|
|
290
|
+
: ['/usr/bin/sandbox-exec', ['-p', network ? this.networkProfile : isolated ? this.isolatedProfile : this.profile]];
|
|
291
|
+
const child = spawn(sandbox, [...sandboxArgs, this.node, workerPath, this.sdkPath, this.cwd], {
|
|
274
292
|
cwd: this.cwd, detached: true, stdio: ['pipe', 'pipe', 'pipe'],
|
|
275
293
|
env: network ? this.environment.network : this.environment.offline,
|
|
276
294
|
});
|
|
@@ -327,11 +345,20 @@ export class ManagedToolBoundary {
|
|
|
327
345
|
if (signal?.aborted) abort();
|
|
328
346
|
});
|
|
329
347
|
}
|
|
348
|
+
// bubblewrap arguments for one command: credential files of the project are
|
|
349
|
+
// looked for again every 15 seconds (a file the member just added).
|
|
350
|
+
bubblewrap(network) {
|
|
351
|
+
if (Date.now() - this.projectCredentials.at > 15_000)
|
|
352
|
+
this.projectCredentials = { at: Date.now(), files: credentialFiles([this.cwd, ...this.linuxBase.repository]) };
|
|
353
|
+
const credentials = [...this.projectCredentials.files, ...(network ? [] : this.referenceCredentials)];
|
|
354
|
+
return bubblewrapArgs({ ...this.linuxBase, references: network ? [] : this.linuxBase.references, network, credentials });
|
|
355
|
+
}
|
|
330
356
|
// Processes a command left running (`nohup … &`, a detached child) outlive
|
|
331
357
|
// it with the marker in their environment. They are stopped when a turn ends
|
|
332
358
|
// and when the boundary closes; nothing outside this boundary has the marker.
|
|
333
359
|
async stopStrays() {
|
|
334
|
-
|
|
360
|
+
// On Linux a command's processes end with it (its own PID namespace).
|
|
361
|
+
if (this.#children.size || this.linux) return 0;
|
|
335
362
|
const marker = `PIAGENT_TOOL_SCOPE=${this.scope}`, find = () => {
|
|
336
363
|
try {
|
|
337
364
|
return execFileSync('/bin/ps', ['-axwwE', '-o', 'pid=,command='], { encoding: 'utf8', maxBuffer: 64 << 20, timeout: 5000 }).split('\n')
|
|
@@ -16,7 +16,8 @@ function selectedDeveloperDir() {
|
|
|
16
16
|
// Pin the actual installed binary; never widen access to user preferences.
|
|
17
17
|
export function managedGit() {
|
|
18
18
|
const selected = selectedDeveloperDir();
|
|
19
|
-
for (const candidate of [`${CLT}/usr/bin/git`, ...(selected ? [path.join(selected, 'usr/bin/git')] : []), '/Applications/Xcode.app/Contents/Developer/usr/bin/git', '/opt/homebrew/bin/git', '/usr/local/bin/git'
|
|
19
|
+
for (const candidate of [`${CLT}/usr/bin/git`, ...(selected ? [path.join(selected, 'usr/bin/git')] : []), '/Applications/Xcode.app/Contents/Developer/usr/bin/git', '/opt/homebrew/bin/git', '/usr/local/bin/git',
|
|
20
|
+
...(process.platform === 'linux' ? ['/usr/bin/git'] : [])]) {
|
|
20
21
|
if (executable(candidate)) return fs.realpathSync(candidate);
|
|
21
22
|
}
|
|
22
23
|
throw Error('managed-git-unavailable');
|
|
@@ -65,7 +66,9 @@ const downloaded = new Map();
|
|
|
65
66
|
// ripgrep and fd for Pi's grep/find, on any member machine: an installed copy,
|
|
66
67
|
// else Pi's own downloader run here (outside the sandbox, like personal Pi).
|
|
67
68
|
export function searchToolPath(name, userHome = os.homedir()) {
|
|
68
|
-
|
|
69
|
+
// Debian and Ubuntu install fd as fdfind.
|
|
70
|
+
const system = process.platform === 'linux' ? [`/usr/bin/${name}`, ...(name === 'fd' ? ['/usr/bin/fdfind'] : [])] : [];
|
|
71
|
+
for (const candidate of [downloaded.get(name), `/opt/homebrew/bin/${name}`, `/usr/local/bin/${name}`, path.join(userHome, '.pi/agent/bin', name), ...system]) {
|
|
69
72
|
if (candidate && executable(candidate)) return fs.realpathSync(candidate);
|
|
70
73
|
}
|
|
71
74
|
return null;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
// How this machine opens a link in the member's browser: `open` on macOS,
|
|
5
|
+
// `start` on Windows, and from WSL the Windows browser (wslview when
|
|
6
|
+
// installed, else explorer.exe, which takes a link as it is: cmd's start
|
|
7
|
+
// would split one at &). Elsewhere xdg-open. null: no way here.
|
|
8
|
+
export function browserCommand(url, { platform = process.platform, environment = process.env } = {}) {
|
|
9
|
+
if (platform === 'darwin') return { command: 'open', args: [url] };
|
|
10
|
+
if (platform === 'win32') return { command: 'cmd', args: ['/c', 'start', '', url] };
|
|
11
|
+
if (platform !== 'linux') return null;
|
|
12
|
+
const onPath = name => String(environment.PATH ?? '').split(':').filter(Boolean).map(dir => path.join(dir, name)).find(file => { try { fs.accessSync(file, fs.constants.X_OK); return true; } catch { return false; } });
|
|
13
|
+
if (environment.WSL_DISTRO_NAME) {
|
|
14
|
+
const wslview = onPath('wslview');
|
|
15
|
+
if (wslview) return { command: wslview, args: [url] };
|
|
16
|
+
const explorer = onPath('explorer.exe');
|
|
17
|
+
if (explorer) return { command: explorer, args: [url], options: { cwd: '/mnt/c' } };
|
|
18
|
+
}
|
|
19
|
+
return { command: 'xdg-open', args: [url] };
|
|
20
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { fileURLToPath } from "node:url";
|
|
4
5
|
|
|
@@ -84,19 +85,24 @@ export function supportedChatImageMimeType(bytes: Buffer): string | undefined {
|
|
|
84
85
|
return undefined;
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
// On Windows a path may also start with a drive (C:\) and use backslashes.
|
|
89
|
+
const WINDOWS = process.platform === "win32";
|
|
90
|
+
const HOME_PREFIX = WINDOWS ? /^~[\\/]/ : /^~\//;
|
|
91
|
+
const RELATIVE_PREFIX = WINDOWS ? /^\.\.?[\\/]/ : /^\.\.?\//;
|
|
92
|
+
|
|
87
93
|
function normalizeImagePathCandidate(candidate: string, cwd: string, options: { allowBareRelative?: boolean } = {}): string | undefined {
|
|
88
94
|
let raw = candidate.trim().replace(/^['"`<]+|['"`>,.;:!?]+$/g, "");
|
|
89
95
|
if (!raw) return undefined;
|
|
90
96
|
const allowBareRelative = options.allowBareRelative !== false;
|
|
91
|
-
const hasExplicitPathPrefix = raw.startsWith("file://") ||
|
|
97
|
+
const hasExplicitPathPrefix = raw.startsWith("file://") || HOME_PREFIX.test(raw) || path.isAbsolute(raw) || RELATIVE_PREFIX.test(raw);
|
|
92
98
|
if (!allowBareRelative && !hasExplicitPathPrefix) return undefined;
|
|
93
99
|
try {
|
|
94
100
|
if (raw.startsWith("file://")) raw = fileURLToPath(raw);
|
|
95
101
|
} catch {
|
|
96
102
|
return undefined;
|
|
97
103
|
}
|
|
98
|
-
if (
|
|
99
|
-
const home = process.env.HOME;
|
|
104
|
+
if (HOME_PREFIX.test(raw)) {
|
|
105
|
+
const home = process.env.HOME || (WINDOWS ? os.homedir() : undefined);
|
|
100
106
|
if (!home) return undefined;
|
|
101
107
|
raw = path.join(home, raw.slice(2));
|
|
102
108
|
}
|
|
@@ -123,7 +129,8 @@ export function extractLocalImagePathCandidates(text: string, cwd: string): stri
|
|
|
123
129
|
if (normalized) candidates.add(normalized);
|
|
124
130
|
}
|
|
125
131
|
|
|
126
|
-
const
|
|
132
|
+
const start = WINDOWS ? "(?:/|[A-Za-z]:[\\\\/]|~[\\\\/]|\\.\\.?[\\\\/])" : "(?:/|~\\/|\\.\\.?/)";
|
|
133
|
+
const linePathPattern = '\\s(' + start + '[^\\n\\r"\\\'<>]*?\\.' + imageExt + ')(?=$|\\s|["\\\'`)>])';
|
|
127
134
|
const linePath = new RegExp(linePathPattern, "gi");
|
|
128
135
|
for (const match of text.matchAll(linePath)) {
|
|
129
136
|
const normalized = normalizeImagePathCandidate(match[1], cwd);
|
|
@@ -17,14 +17,14 @@ const MAX_FILE_BYTES = 256 * 1024;
|
|
|
17
17
|
const MAX_ENTRIES = 400;
|
|
18
18
|
|
|
19
19
|
function folder(file) {
|
|
20
|
-
try { return fs.statSync(file).isDirectory() ? fs.realpathSync(file) : null; } catch { return null; }
|
|
20
|
+
try { return fs.statSync(file).isDirectory() ? fs.realpathSync.native(file) : null; } catch { return null; }
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
// The project folder and its parents up to the repository root (the first
|
|
24
24
|
// folder holding .git), at most 12 levels, never the home folder or above it.
|
|
25
25
|
export function projectFolders(cwd, home = os.homedir()) {
|
|
26
|
-
const folders = [], stop = fs.realpathSync(home);
|
|
27
|
-
for (let dir = fs.realpathSync(cwd), depth = 0; depth < 12; depth += 1) {
|
|
26
|
+
const folders = [], stop = fs.realpathSync.native(home);
|
|
27
|
+
for (let dir = fs.realpathSync.native(cwd), depth = 0; depth < 12; depth += 1) {
|
|
28
28
|
if (dir === stop || dir === path.dirname(dir) || (stop + path.sep).startsWith(dir + path.sep)) break;
|
|
29
29
|
folders.push(dir);
|
|
30
30
|
if (fs.existsSync(path.join(dir, '.git'))) break;
|
|
@@ -89,7 +89,7 @@ function readSmall(file) {
|
|
|
89
89
|
|
|
90
90
|
function origin(file, home) {
|
|
91
91
|
const name = /(?:^|\/)\.(claude|codex|agents|pi)\//.exec(file)?.[1] ?? 'pi';
|
|
92
|
-
return { origin: name, scope: file.startsWith(`${fs.realpathSync(home)}${path.sep}.`) ? 'user' : 'project' };
|
|
92
|
+
return { origin: name, scope: file.startsWith(`${fs.realpathSync.native(home)}${path.sep}.`) ? 'user' : 'project' };
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
function skillFiles(root, depth = 0, files = []) {
|