@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.
Files changed (91) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +3 -3
  3. package/README.vi.md +5 -1
  4. package/docs/command-reference-vietnamese.md +2 -2
  5. package/docs/distribution-standard.md +6 -6
  6. package/docs/en/README.md +1 -0
  7. package/docs/en/windows.md +66 -0
  8. package/docs/languages.json +13 -4
  9. package/docs/operator-manual-vietnamese.md +7 -7
  10. package/docs/publishing-for-teams.md +2 -0
  11. package/docs/release-install-policy.md +2 -2
  12. package/docs/team-onboarding.md +2 -2
  13. package/docs/vi/README.md +1 -0
  14. package/docs/vi/windows.md +66 -0
  15. package/docs-site/architecture.html +1 -1
  16. package/docs-site/benchmark.html +1 -1
  17. package/docs-site/commands.html +1 -1
  18. package/docs-site/content/en/index.html +4 -4
  19. package/docs-site/content/en/whats-new.html +19 -0
  20. package/docs-site/content/index.html +4 -4
  21. package/docs-site/content/whats-new.html +19 -0
  22. package/docs-site/context-engine.html +1 -1
  23. package/docs-site/ecosystem.html +1 -1
  24. package/docs-site/en/architecture.html +1 -1
  25. package/docs-site/en/benchmark.html +1 -1
  26. package/docs-site/en/commands.html +1 -1
  27. package/docs-site/en/context-engine.html +1 -1
  28. package/docs-site/en/ecosystem.html +1 -1
  29. package/docs-site/en/index.html +5 -5
  30. package/docs-site/en/mcp-approval.html +1 -1
  31. package/docs-site/en/mcp-auth.html +1 -1
  32. package/docs-site/en/mcp-servers.html +1 -1
  33. package/docs-site/en/mcp.html +1 -1
  34. package/docs-site/en/permissions.html +1 -1
  35. package/docs-site/en/profiles.html +1 -1
  36. package/docs-site/en/quickstart.html +1 -1
  37. package/docs-site/en/runtime.html +1 -1
  38. package/docs-site/en/scope.html +1 -1
  39. package/docs-site/en/security.html +1 -1
  40. package/docs-site/en/team.html +1 -1
  41. package/docs-site/en/whats-new.html +20 -1
  42. package/docs-site/en/workflows.html +1 -1
  43. package/docs-site/index.html +5 -5
  44. package/docs-site/mcp-approval.html +1 -1
  45. package/docs-site/mcp-auth.html +1 -1
  46. package/docs-site/mcp-servers.html +1 -1
  47. package/docs-site/mcp.html +1 -1
  48. package/docs-site/permissions.html +1 -1
  49. package/docs-site/profiles.html +1 -1
  50. package/docs-site/quickstart.html +1 -1
  51. package/docs-site/runtime.html +1 -1
  52. package/docs-site/scope.html +1 -1
  53. package/docs-site/security.html +1 -1
  54. package/docs-site/team.html +1 -1
  55. package/docs-site/whats-new.html +20 -1
  56. package/docs-site/workflows.html +1 -1
  57. package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
  58. package/package.json +1 -1
  59. package/packages/piagent-core/extensions/piagent-guard.ts +3 -1
  60. package/packages/piagent-core/extensions/shell-write-targets.js +2 -1
  61. package/packages/piagent-core/extensions/source-checkout-paths.ts +4 -3
  62. package/packages/piagent-core/extensions/task-journal.js +2 -1
  63. package/packages/piagent-core/extensions/task-state.js +3 -3
  64. package/packages/piagent-core/managed/broker-client.mjs +2 -1
  65. package/packages/piagent-core/managed/doctor.mjs +5 -3
  66. package/packages/piagent-core/managed/language-environment.mjs +16 -6
  67. package/packages/piagent-core/managed/linux-sandbox.mjs +81 -0
  68. package/packages/piagent-core/managed/session.mjs +4 -1
  69. package/packages/piagent-core/managed/store.d.mts +2 -0
  70. package/packages/piagent-core/managed/store.mjs +12 -0
  71. package/packages/piagent-core/managed/tool-boundary.mjs +41 -14
  72. package/packages/piagent-core/managed/toolchain.mjs +5 -2
  73. package/packages/piagent-core/package.json +1 -1
  74. package/packages/piagent-core/runtime/host/browser-opener.d.mts +2 -0
  75. package/packages/piagent-core/runtime/host/browser-opener.mjs +20 -0
  76. package/packages/piagent-core/runtime/input/chat-images.ts +11 -4
  77. package/packages/piagent-core/runtime/resources/agent-resources.mjs +4 -4
  78. package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-FWfOWZjV.js → MarkdownMessage-CyT2G1H2.js} +1 -1
  79. package/packages/piagent-webui/dist/client/assets/{index-DFKHLVYk.js → index-BBEa7Ger.js} +12 -12
  80. package/packages/piagent-webui/dist/client/index.html +1 -1
  81. package/packages/piagent-webui/extension/piagent-webui.ts +6 -5
  82. package/packages/piagent-webui/gateway/control-socket.ts +14 -2
  83. package/packages/piagent-webui/gateway/managed-launch.ts +8 -6
  84. package/packages/piagent-webui/gateway/native-project-picker.ts +28 -3
  85. package/packages/piagent-webui/gateway/path-suggestions.ts +9 -3
  86. package/packages/piagent-webui/gateway/pi-host.ts +11 -3
  87. package/scripts/dashboard-update-job.mjs +3 -2
  88. package/scripts/piagent-cli.mjs +2 -2
  89. package/scripts/piagent-dashboard.mjs +7 -7
  90. package/scripts/piagent-studio.mjs +4 -4
  91. 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
- let refused = false; try { await boundary.invoke('write', { path: '../piagent-doctor-outside.txt', content: 'x' }); } catch { refused = true; }
58
- if (!refused) { fs.rmSync(path.join(path.dirname(project), 'piagent-doctor-outside.txt'), { force: true }); throw Error('a write outside the project was allowed'); }
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 servers on this Mac (localhost)', false, async () => {
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 root = path.join(userHome, 'Library/Caches/Piagent/sandbox');
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
- for (const name of ['swift', 'xcodebuild']) fs.writeFileSync(path.join(bin, name), SWIFT_WRAPPER, { mode: 0o755 });
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
- if (dotnet) try { fs.mkdirSync('/private/tmp/.dotnet', { recursive: true, mode: 0o777 }); } catch { /* another user's */ }
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. 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.';
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
- const common = { ...toolchains.env, ...developer.env, PATH, HOME: home, TMPDIR: temporary, PIAGENT_TOOL_SCOPE: scope, LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8', RIPGREP_CONFIG_PATH: path.join(home, '.ripgreprc'),
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
- try { const dir = fs.realpathSync(path.join(userHome, 'Library/Caches/ms-playwright')); return fs.statSync(dir).isDirectory() ? dir : null; } catch { return null; }
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
- if (process.platform !== 'darwin') throw new Error('managed-sandbox-unavailable');
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'), path.join(userHome, 'Library/Application Support/AgentWatch'),
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.networkProfile = readOnly ? null : managedSeatbelt({ ...base, network: true });
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.networkProfile)) throw new Error('managed-tool-unavailable');
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 child = spawn('/usr/bin/sandbox-exec', ['-p', network ? this.networkProfile : isolated ? this.isolatedProfile : this.profile, this.node, workerPath, this.sdkPath, this.cwd], {
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
- if (this.#children.size) return 0;
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
- for (const candidate of [downloaded.get(name), `/opt/homebrew/bin/${name}`, `/usr/local/bin/${name}`, path.join(userHome, '.pi/agent/bin', name)]) {
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;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piagent/core",
3
- "version": "1.12.1",
3
+ "version": "1.13.1",
4
4
  "type": "module",
5
5
  "private": true,
6
6
  "license": "MIT",
@@ -0,0 +1,2 @@
1
+ export function browserCommand(url: string, options?: { platform?: NodeJS.Platform; environment?: Readonly<Record<string, string | undefined>> }):
2
+ { command: string; args: string[]; options?: { cwd?: string } } | 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://") || raw.startsWith("~/") || path.isAbsolute(raw) || raw.startsWith("./") || raw.startsWith("../");
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 (raw.startsWith("~/")) {
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 linePathPattern = '\\s((?:/|~\\/|\\.\\.?/)[^\\n\\r"\\\'<>]*?\\.' + imageExt + ')(?=$|\\s|["\\\'`)>])';
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 = []) {