@piagent/platform 1.8.0 → 1.9.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 (221) hide show
  1. package/AGENTS.md +7 -0
  2. package/CHANGELOG.md +132 -2
  3. package/README.md +45 -70
  4. package/README.vi.md +34 -61
  5. package/architecture/layers.json +16 -3
  6. package/catalog/capabilities.json +14 -7
  7. package/catalog/claude-model-additions.json +42 -0
  8. package/docs/auto-delegation-policy.md +2 -0
  9. package/docs/command-reference-vietnamese.md +68 -90
  10. package/docs/context-window-policy.md +5 -5
  11. package/docs/distribution-standard.md +13 -16
  12. package/docs/en/architecture.md +9 -1
  13. package/docs/herdr-workflow.md +2 -3
  14. package/docs/languages.json +4 -4
  15. package/docs/managed-local-acceptance.md +403 -0
  16. package/docs/mcp-and-tools.md +5 -7
  17. package/docs/memory-policy.md +3 -3
  18. package/docs/model-options.md +2 -2
  19. package/docs/oauth-providers.md +1 -1
  20. package/docs/operator-manual-vietnamese.md +82 -127
  21. package/docs/package-architecture-notes.md +4 -4
  22. package/docs/piagent-core-improvement-plan.md +3 -5
  23. package/docs/piagent-core-plan-checkpoint-2026-09-06.json +2 -13
  24. package/docs/piagent-runtime-benchmark-execution-plan.md +10 -8
  25. package/docs/project-adapters.md +3 -3
  26. package/docs/project-onboarding.md +15 -16
  27. package/docs/publishing-for-teams.md +3 -1
  28. package/docs/quality-benchmark.md +2 -0
  29. package/docs/quickstart-vietnamese.md +23 -24
  30. package/docs/release-install-policy.md +3 -2
  31. package/docs/research/harness-next-evidence-review.md +7 -7
  32. package/docs/runtime-harness-standard.md +2 -0
  33. package/docs/runtime-policy-design.md +2 -0
  34. package/docs/runtime-quality-baseline.md +2 -0
  35. package/docs/subagent-orchestration-capabilities.md +1 -1
  36. package/docs/subagents-and-multiagent.md +10 -8
  37. package/docs/task-implementation-contract.md +2 -0
  38. package/docs/task-lifecycle-vietnamese.md +2 -0
  39. package/docs/team-onboarding.md +28 -59
  40. package/docs/usage-observability.md +2 -2
  41. package/docs/vi/architecture.md +9 -1
  42. package/docs/workflow-recipes.md +6 -10
  43. package/docs-site/architecture.html +4 -3
  44. package/docs-site/assets/docs.css +80 -0
  45. package/docs-site/benchmark.html +7 -1
  46. package/docs-site/commands.html +37 -23
  47. package/docs-site/content/architecture.html +2 -2
  48. package/docs-site/content/benchmark.html +5 -0
  49. package/docs-site/content/commands.html +35 -22
  50. package/docs-site/content/ecosystem.html +144 -0
  51. package/docs-site/content/en/architecture.html +1 -1
  52. package/docs-site/content/en/benchmark.html +5 -0
  53. package/docs-site/content/en/commands.html +30 -13
  54. package/docs-site/content/en/ecosystem.html +144 -0
  55. package/docs-site/content/en/index.html +24 -9
  56. package/docs-site/content/en/quickstart.html +10 -12
  57. package/docs-site/content/en/security.html +1 -1
  58. package/docs-site/content/en/team.html +2 -2
  59. package/docs-site/content/en/whats-new.html +92 -1
  60. package/docs-site/content/en/workflows.html +29 -31
  61. package/docs-site/content/index.html +24 -9
  62. package/docs-site/content/quickstart.html +10 -12
  63. package/docs-site/content/security.html +2 -2
  64. package/docs-site/content/team.html +2 -2
  65. package/docs-site/content/whats-new.html +92 -1
  66. package/docs-site/content/workflows.html +36 -38
  67. package/docs-site/context-engine.html +2 -1
  68. package/docs-site/ecosystem.html +283 -0
  69. package/docs-site/en/architecture.html +3 -2
  70. package/docs-site/en/benchmark.html +7 -1
  71. package/docs-site/en/commands.html +32 -14
  72. package/docs-site/en/context-engine.html +2 -1
  73. package/docs-site/en/ecosystem.html +283 -0
  74. package/docs-site/en/index.html +30 -14
  75. package/docs-site/en/mcp-approval.html +2 -1
  76. package/docs-site/en/mcp-auth.html +2 -1
  77. package/docs-site/en/mcp-servers.html +2 -1
  78. package/docs-site/en/mcp.html +2 -1
  79. package/docs-site/en/permissions.html +2 -1
  80. package/docs-site/en/profiles.html +2 -1
  81. package/docs-site/en/quickstart.html +17 -18
  82. package/docs-site/en/runtime.html +2 -1
  83. package/docs-site/en/scope.html +2 -1
  84. package/docs-site/en/security.html +6 -5
  85. package/docs-site/en/team.html +4 -3
  86. package/docs-site/en/whats-new.html +100 -8
  87. package/docs-site/en/workflows.html +34 -35
  88. package/docs-site/index.html +30 -14
  89. package/docs-site/mcp-approval.html +2 -1
  90. package/docs-site/mcp-auth.html +2 -1
  91. package/docs-site/mcp-servers.html +2 -1
  92. package/docs-site/mcp.html +2 -1
  93. package/docs-site/permissions.html +2 -1
  94. package/docs-site/profiles.html +2 -1
  95. package/docs-site/quickstart.html +17 -18
  96. package/docs-site/runtime.html +2 -1
  97. package/docs-site/scope.html +2 -1
  98. package/docs-site/security.html +7 -6
  99. package/docs-site/team.html +4 -3
  100. package/docs-site/whats-new.html +100 -8
  101. package/docs-site/workflows.html +41 -42
  102. package/evals/fs5-causal-arm.v1.json +2 -2
  103. package/evals/private-holdout-v1/CURRENT_BOUNDARY.md +17 -0
  104. package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
  105. package/package.json +8 -7
  106. package/packages/piagent-core/README.md +15 -23
  107. package/packages/piagent-core/docs/context-efficiency-improvement-plan.md +2 -0
  108. package/packages/piagent-core/extensions/experiment-loop-policy.ts +75 -0
  109. package/packages/piagent-core/extensions/local-state-path.js +15 -0
  110. package/packages/piagent-core/extensions/piagent-guard.ts +108 -960
  111. package/packages/piagent-core/extensions/policy-core.js +2 -2
  112. package/packages/piagent-core/extensions/source-checkout-paths.ts +54 -0
  113. package/packages/piagent-core/managed/broker-client.mjs +49 -0
  114. package/packages/piagent-core/managed/capabilities.mjs +28 -0
  115. package/packages/piagent-core/managed/client-agent.mjs +6 -0
  116. package/packages/piagent-core/managed/compaction.mjs +63 -0
  117. package/packages/piagent-core/managed/doctor.mjs +107 -0
  118. package/packages/piagent-core/managed/helper-roles.mjs +57 -0
  119. package/packages/piagent-core/managed/native-catalog.mjs +42 -0
  120. package/packages/piagent-core/managed/patch-snapshot.mjs +48 -0
  121. package/packages/piagent-core/managed/repository-operation.mjs +84 -0
  122. package/packages/piagent-core/managed/request-stream.mjs +130 -0
  123. package/packages/piagent-core/managed/resource-loader.mjs +42 -0
  124. package/packages/piagent-core/managed/review-snapshot-worker.mjs +27 -0
  125. package/packages/piagent-core/managed/sandbox-diagnostic.mjs +12 -0
  126. package/packages/piagent-core/managed/search-fallback.mjs +93 -0
  127. package/packages/piagent-core/managed/session.mjs +493 -0
  128. package/packages/piagent-core/managed/store.d.mts +3 -0
  129. package/packages/piagent-core/managed/store.mjs +64 -0
  130. package/packages/piagent-core/managed/tool-boundary.mjs +275 -0
  131. package/packages/piagent-core/managed/tool-worker.mjs +36 -0
  132. package/packages/piagent-core/managed/toolchain.mjs +120 -0
  133. package/packages/piagent-core/managed/web-fetch.mjs +105 -0
  134. package/packages/piagent-core/managed/web-search.mjs +131 -0
  135. package/packages/piagent-core/managed/workflow.mjs +326 -0
  136. package/packages/piagent-core/package.json +2 -5
  137. package/packages/piagent-core/policies/base-policy.json +80 -26
  138. package/packages/piagent-core/runtime/hooks/agent-start-hook.ts +3 -3
  139. package/packages/piagent-core/runtime/hooks/input-hook.ts +35 -196
  140. package/packages/piagent-core/runtime/hooks/session-start-hook.ts +2 -0
  141. package/packages/piagent-core/runtime/inspection/approval-broker.ts +9 -1
  142. package/packages/piagent-core/runtime/managed-failure.d.mts +10 -0
  143. package/packages/piagent-core/runtime/managed-failure.mjs +166 -0
  144. package/packages/piagent-core/runtime/orchestration/minimal-delegation-policy.ts +8 -29
  145. package/packages/piagent-core/runtime/orchestration/owned-work-budget.ts +1 -1
  146. package/packages/piagent-core/runtime/policy/block-remedy.ts +5 -3
  147. package/packages/piagent-core/runtime/registration/context-commands.ts +1 -1
  148. package/packages/piagent-core/runtime/registration/knowledge-tools.ts +7 -7
  149. package/packages/piagent-core/runtime/registration/policy-tools.ts +2 -2
  150. package/packages/piagent-core/runtime/registration/session-commands.ts +11 -26
  151. package/packages/piagent-core/runtime/registration/task-evidence-tools.ts +1 -1
  152. package/packages/piagent-core/runtime/session/system-prompt.ts +6 -7
  153. package/packages/piagent-core/runtime/workflows/webui-runtime-command.ts +6 -6
  154. package/packages/piagent-core/runtime/workflows/webui-workflow.ts +6 -11
  155. package/packages/piagent-core/security/sensitive-text.js +9 -0
  156. package/packages/piagent-core/skills/piagent-ops/SKILL.md +8 -10
  157. package/packages/piagent-core/skills/test-audit/SKILL.md +101 -0
  158. package/packages/piagent-core/subagents/piagent-planner.md +1 -1
  159. package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-DMU7Xe_O.js → MarkdownMessage-BRPIMCRh.js} +1 -1
  160. package/packages/piagent-webui/dist/client/assets/index-DNJ1vH5d.js +228 -0
  161. package/packages/piagent-webui/dist/client/assets/{index-DVahUVWy.css → index-DgSmcnP8.css} +1 -1
  162. package/packages/piagent-webui/dist/client/index.html +2 -2
  163. package/packages/piagent-webui/extension/piagent-webui.ts +11 -11
  164. package/packages/piagent-webui/extension/session-stream-adapter.ts +3 -1
  165. package/packages/piagent-webui/extension/terminal-session-adapter.ts +6 -1
  166. package/packages/piagent-webui/gateway/company-relay.ts +308 -0
  167. package/packages/piagent-webui/gateway/control-socket.ts +39 -12
  168. package/packages/piagent-webui/gateway/gateway-service.ts +109 -28
  169. package/packages/piagent-webui/gateway/gateway-session-stream.ts +58 -2
  170. package/packages/piagent-webui/gateway/managed-gateway.mjs +41 -0
  171. package/packages/piagent-webui/gateway/managed-launch.ts +145 -0
  172. package/packages/piagent-webui/gateway/managed-projection.ts +20 -0
  173. package/packages/piagent-webui/gateway/pi-host.ts +22 -4
  174. package/packages/piagent-webui/gateway/runtime-session-controls.ts +5 -2
  175. package/packages/piagent-webui/gateway/session-approval-authority.ts +28 -0
  176. package/packages/piagent-webui/gateway/session-catalog.ts +11 -3
  177. package/packages/piagent-webui/gateway/session-command-controller.ts +8 -2
  178. package/packages/piagent-webui/gateway/session-file-facts.ts +25 -0
  179. package/packages/piagent-webui/gateway/session-inspection-registry.ts +40 -9
  180. package/packages/piagent-webui/gateway/session-list-cache.ts +68 -0
  181. package/packages/piagent-webui/gateway/session-runtime-ownership.ts +8 -1
  182. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +20 -27
  183. package/packages/piagent-webui/ownership/managed-terminal-lease.ts +27 -0
  184. package/packages/piagent-webui/ownership/profile-state.ts +2 -0
  185. package/packages/piagent-webui/ownership/session-lease-store.ts +22 -1
  186. package/packages/piagent-webui/server/approval-projection.ts +9 -0
  187. package/packages/piagent-webui/server/core-inspection-provider.ts +4 -9
  188. package/packages/piagent-webui/server/loopback-server.ts +37 -1
  189. package/packages/piagent-webui/server/message-stop-reason.ts +11 -0
  190. package/packages/piagent-webui/server/tool-call-summary.ts +185 -0
  191. package/packages/piagent-webui/server/transcript-projection.ts +86 -3
  192. package/packs/engineering-base/pack.json +4 -0
  193. package/schemas/piagent-webui/runtime-command-v1.schema.json +1 -2
  194. package/schemas/piagent-webui/session-catalog-v1.schema.json +8 -0
  195. package/schemas/piagent-webui/transcript-v1.schema.json +94 -1
  196. package/scripts/build-docs-site.mjs +23 -12
  197. package/scripts/check-public-wording.mjs +5 -6
  198. package/scripts/check-runtime-advisories.mjs +23 -6
  199. package/scripts/claude-model-additions.mjs +33 -0
  200. package/scripts/configure-model-scope.sh +30 -6
  201. package/scripts/configure-subagents.sh +1 -1
  202. package/scripts/explain-command.mjs +5 -6
  203. package/scripts/init-project.sh +0 -2
  204. package/scripts/install-global.sh +3 -2
  205. package/scripts/model-scope-prune.mjs +75 -0
  206. package/scripts/phase-tools-evaluation.mjs +9 -4
  207. package/scripts/piagent-cli.mjs +51 -2
  208. package/scripts/piagent-dashboard.mjs +27 -4
  209. package/scripts/piagent-installations.mjs +26 -0
  210. package/scripts/piagent-studio.mjs +155 -0
  211. package/scripts/runtime-advisory-exceptions.json +29 -0
  212. package/scripts/setup.sh +10 -6
  213. package/scripts/team-doctor.sh +20 -1
  214. package/scripts/typescript-loader.mjs +6 -0
  215. package/scripts/verify-local.sh +2 -2
  216. package/templates/global/settings.json +3 -1
  217. package/templates/project/.pi/project-context.md +3 -3
  218. package/templates/project/AGENTS.md +4 -5
  219. package/docs/piagent-core-reasoning-research-2026-09-06.md +0 -225
  220. package/docs/piagent-core-research-sources-2026-09-06.json +0 -416
  221. package/packages/piagent-webui/dist/client/assets/index-Cui9AAV_.js +0 -176
@@ -0,0 +1,275 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { execFileSync, spawn } from 'node:child_process';
5
+ import { StringDecoder } from 'node:string_decoder';
6
+ import { createHash, randomUUID } from 'node:crypto';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { developerTools, managedGit, searchToolPath, userToolchains } from './toolchain.mjs';
9
+
10
+ const TOOL_NAMES = Object.freeze(['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls']);
11
+ const MAX_WIRE_BYTES = 12 * 1024 * 1024;
12
+ const workerPath = fileURLToPath(new URL('./tool-worker.mjs', import.meta.url));
13
+ const hash = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
14
+ function literal(value) {
15
+ if (typeof value !== 'string' || !path.isAbsolute(value) || /[\x00-\x1f\x7f]/.test(value)) throw new Error('managed-path-invalid');
16
+ return JSON.stringify(value);
17
+ }
18
+ function inside(file, root) { return file === root || file.startsWith(root + path.sep); }
19
+
20
+ export function managedSeatbelt({ cwd, sdkRoot, runtimeRoot, temporary, node, denied = [], readOnly = false, repository = [], gitDirs = [], toolchains = [], developer = [], browsers = [], network = false }) {
21
+ // Default-deny protects Keychain/SSH agent/Watch IPC and process inspection.
22
+ // System libraries/toolchains are read-only. Writes stay in this project and
23
+ // a fresh temporary directory. No network exception or unsandboxed fallback.
24
+ // A project inside a larger repository reads that repository (monorepo)
25
+ // and, when writable, its git directories so commits work. Also readable,
26
+ // as on any Mac: installer-based Python/JDKs, the Rosetta runtime (x86_64
27
+ // tools on Apple Silicon) and the time zone database.
28
+ const system = ['/Library/Frameworks', '/Library/Java', '/Library/Apple', '/private/var/db/oah', '/private/var/db/timezone'];
29
+ // An approved network command may also drive the member's Playwright
30
+ // browsers against a server it starts on this Mac (E2E tests).
31
+ const readRoots = ['/System', '/usr', '/bin', '/sbin', '/Library/Developer', '/Applications/Xcode.app', '/opt/homebrew', ...system, ...developer, sdkRoot, runtimeRoot, cwd, temporary, ...repository, ...gitDirs, ...toolchains, ...(network ? browsers : [])];
32
+ const filters = readRoots.map(root => `(subpath ${literal(root)})`).join(' ');
33
+ const writeRoots = [temporary, ...(!readOnly ? [cwd, ...gitDirs] : [])];
34
+ // Credential files are unreadable, unwritable and hidden: a stat fails, so a
35
+ // tool that loads them when present (Vite and Next read .env.local) carries
36
+ // on without them instead of failing on a file it was shown. auth.json holds
37
+ // credentials at a project's root (Composer) or in a dot folder (.codex,
38
+ // .composer); one deeper in the source (a translation file) is ordinary, and
39
+ // so are committed templates (.env.example, .env.sample…).
40
+ const projectRoots = [...new Set([cwd, ...repository])];
41
+ // An approved network command may also start a server and run Playwright's
42
+ // Chromium against it (E2E tests). The rule names loopback, but the sandbox
43
+ // cannot tell it from all interfaces (0.0.0.0): like the internet access the
44
+ // member approved, a server it starts is reachable while it runs. Chromium
45
+ // needs only its own rendezvous services (org.chromium.*) and the
46
+ // power-management client; other system services, Keychain among them,
47
+ // stay closed.
48
+ // Signals reach only processes of this sandbox: a search stops ripgrep at
49
+ // its limit and a cancelled command stops its children, while nothing
50
+ // outside (Agent Watch, the member's apps) can be signalled.
51
+ return `(version 1)
52
+ (deny default)
53
+ (allow process-fork process-exec)
54
+ (allow signal (target same-sandbox))
55
+ (allow sysctl-read)
56
+ (allow mach-lookup (global-name "com.apple.system.logger") (global-name "com.apple.system.opendirectoryd"))
57
+ (allow file-read-metadata)
58
+ (allow file-read* (literal "/") ${filters} (literal ${literal(node)}) (subpath "/private/etc") (subpath "/dev"))
59
+ (allow file-write* ${writeRoots.map(root => `(subpath ${literal(root)})`).join(' ')} (literal "/dev/null") (literal "/dev/tty"))
60
+ ${network ? `(allow network-outbound (remote ip))
61
+ (allow network-outbound (literal "/private/var/run/mDNSResponder"))
62
+ (allow network-bind network-inbound (local ip "localhost:*"))
63
+ (allow system-socket)
64
+ (allow iokit-open (iokit-user-client-class "RootDomainUserClient"))
65
+ (allow mach-register mach-lookup (global-name-regex #"^org\\.chromium\\."))
66
+ (allow mach-lookup (global-name "com.apple.dnssd.service") (global-name "com.apple.trustd.agent") (global-name "com.apple.SystemConfiguration.configd"))` : ''}
67
+ ${denied.map(root => `(deny file-read* file-write* (subpath ${literal(root)}))`).join('\n')}
68
+ (deny file-read* file-read-metadata file-write* (require-all (require-any (regex #"(^|/)(\\.env([./]|$)|credentials([./]|$)|\\.npmrc$|\\.netrc$)") (regex #"/\\.[^/]+/auth\\.json$") ${projectRoots.map(root => `(literal ${literal(path.join(root, 'auth.json'))})`).join(' ')})
69
+ (require-not (regex #"(^|/)\\.env\\.(example|sample|template|dist|defaults)$"))))
70
+ `;
71
+ }
72
+
73
+ // Toolchains from the pinned node/git and Homebrew, compilers and SDK; the
74
+ // git identity this project gets on the user's machine (name and email only)
75
+ // so commits work. Without network, package managers fail fast instead of
76
+ // retrying for minutes.
77
+ function toolEnvironment({ git, node, home, temporary, toolchains, developer, identity, scope, browsers }) {
78
+ const PATH = [...new Set([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(':');
79
+ 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'),
80
+ ...(identity ? { GIT_AUTHOR_NAME: identity.name, GIT_AUTHOR_EMAIL: identity.email, GIT_COMMITTER_NAME: identity.name, GIT_COMMITTER_EMAIL: identity.email } : {}) };
81
+ // The sandbox home is empty: Playwright finds the member's browsers by path.
82
+ return { network: { ...common, ...(browsers ? { PLAYWRIGHT_BROWSERS_PATH: browsers } : {}) },
83
+ offline: { ...common, npm_config_fetch_retries: '0', npm_config_fetch_timeout: '5000', PIP_RETRIES: '0', PIP_TIMEOUT: '5' } };
84
+ }
85
+ // The browsers `npx playwright install` put in the member's cache.
86
+ function playwrightBrowsers(userHome) {
87
+ try { const dir = fs.realpathSync(path.join(userHome, 'Library/Caches/ms-playwright')); return fs.statSync(dir).isDirectory() ? dir : null; } catch { return null; }
88
+ }
89
+ // Effective config for this project, as the user's own git would see it:
90
+ // global and XDG files, includeIf (e.g. a work email for ~/work) and the
91
+ // repository's own settings. `git config --get` runs no hooks or helpers.
92
+ function gitIdentity(git, cwd) {
93
+ const read = key => { try { return execFileSync(git, ['-C', cwd, 'config', '--get', key], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'], env: { HOME: os.homedir(), PATH: '/usr/bin:/bin' } }).trim(); } catch { return ''; } };
94
+ const name = read('user.name'), email = read('user.email');
95
+ return name && email && name.length <= 200 && email.length <= 200 && !/[\x00-\x1f]/.test(name + email) ? { name, email } : null;
96
+ }
97
+ // The trusted runtime resolves the repository before any tool runs.
98
+ function repositoryRoots(git, cwd, temporary) {
99
+ try {
100
+ const [top, gitDir, commonDir] = execFileSync(git, ['-C', cwd, 'rev-parse', '--show-toplevel', '--absolute-git-dir', '--git-common-dir'],
101
+ { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'], env: { HOME: temporary, PATH: '/usr/bin:/bin', GIT_CONFIG_NOSYSTEM: '1' } }).trim().split('\n');
102
+ return { top: fs.realpathSync(top), gitDir: fs.realpathSync(gitDir), commonDir: fs.realpathSync(path.resolve(cwd, commonDir)) };
103
+ } catch { return null; }
104
+ }
105
+ // Pi's grep/find need ripgrep and fd and would download them, which the
106
+ // sandbox forbids. Copy installed binaries into the sandbox home instead.
107
+ function provisionSearchTools(home, userHome) {
108
+ // Credential files are unreadable here; ripgrep skips them instead of
109
+ // failing the whole search with a permission error.
110
+ fs.writeFileSync(path.join(home, '.ripgreprc'), ['.env', '.env.*', '.env/**', 'auth.json', 'credentials', 'credentials.*', 'credentials/**', '.npmrc', '.netrc']
111
+ .map(glob => `--glob=!${glob}`).join('\n') + '\n', { mode: 0o600 });
112
+ const bin = path.join(home, '.pi/agent/bin');
113
+ fs.mkdirSync(bin, { recursive: true, mode: 0o700 });
114
+ // Without a copy here the worker uses the built-in search (search-fallback.mjs).
115
+ for (const name of ['rg', 'fd']) {
116
+ const source = searchToolPath(name, userHome);
117
+ if (source) { fs.copyFileSync(source, path.join(bin, name)); fs.chmodSync(path.join(bin, name), 0o755); }
118
+ }
119
+ }
120
+
121
+ export class ManagedToolBoundary {
122
+ #tail = Promise.resolve();
123
+ #children = new Set();
124
+ #closed = false;
125
+ // readOnly: the project cannot be written. commands: bash is offered all
126
+ // the same (a helper that runs checks); it writes only to its temporary
127
+ // directory, and has no network.
128
+ constructor({ cwd, sdkRoot, protectedRoots = [], readOnly = false, commands = false, node = process.execPath, identity, userHome: home, searchTools = true }) {
129
+ if (process.platform !== 'darwin') throw new Error('managed-sandbox-unavailable');
130
+ this.cwd = fs.realpathSync(cwd); this.sdkRoot = fs.realpathSync(sdkRoot);
131
+ this.node = fs.realpathSync(node);
132
+ this.runtimeRoot = fs.realpathSync(path.dirname(workerPath));
133
+ this.sdkPath = path.join(this.sdkRoot, 'dist/index.js');
134
+ this.git = managedGit();
135
+ if (JSON.parse(fs.readFileSync(path.join(this.sdkRoot, 'package.json'), 'utf8')).version !== '0.87.1') throw new Error('managed-sdk-version-unqualified');
136
+ const workerModules = ['search-fallback.mjs', 'sandbox-diagnostic.mjs'].map(name => path.join(this.runtimeRoot, name));
137
+ this.pins = new Map([this.node, this.git, workerPath, ...workerModules, this.sdkPath].map(file => [file, hash(file)]));
138
+ const userHome = fs.realpathSync(home ?? os.homedir());
139
+ if ([userHome, '/', this.sdkRoot, this.runtimeRoot].includes(this.cwd)
140
+ || [this.sdkRoot,this.runtimeRoot,this.node,this.git,...['/opt/homebrew/bin/gh','/usr/local/bin/gh'].filter(p=>fs.existsSync(p)).map(p=>fs.realpathSync(p))].some(p=>inside(p,this.cwd))) throw new Error('managed-project-overlaps-runtime');
141
+ this.temporary = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'piagent-tools-')));
142
+ fs.chmodSync(this.temporary, 0o700);
143
+ this.home = path.join(this.temporary, 'home'); fs.mkdirSync(this.home, { mode: 0o700 });
144
+ const denied = [path.join(userHome, '.ssh'), path.join(userHome, '.aws'), path.join(userHome, '.config'),
145
+ path.join(userHome, '.pi'), path.join(userHome, '.codex'), path.join(userHome, '.claude'),
146
+ path.join(userHome, 'Library/Keychains'), path.join(userHome, 'Library/Application Support/AgentWatch'),
147
+ ...protectedRoots.map(p => path.resolve(p))];
148
+ // A pinned SDK may itself be installed under ~/.pi; grant that code only,
149
+ // while keeping the adjacent personal auth/config subtree inaccessible.
150
+ const narrowed = denied.filter(root => !inside(this.sdkRoot, root));
151
+ if (inside(this.sdkRoot, path.join(userHome, '.pi'))) narrowed.push(path.join(userHome, '.pi/agent'));
152
+ const repo = repositoryRoots(this.git, this.cwd, this.temporary);
153
+ const allowedRoot = root => root !== '/' && !inside(userHome, root) && !inside(this.sdkRoot, root) && !inside(this.runtimeRoot, root);
154
+ const repository = repo && repo.top !== this.cwd && allowedRoot(repo.top) ? [repo.top] : [];
155
+ this.repositoryTop = repository[0] ?? this.cwd;
156
+ // A repository above the home folder (a dotfiles repo) is never opened:
157
+ // neither read as a monorepo nor writable through its git directory.
158
+ const gitDirs = repo && (repo.top === this.cwd || allowedRoot(repo.top)) ? [...new Set([repo.gitDir, repo.commonDir])].filter(dir => !inside(dir, this.cwd) && allowedRoot(dir)) : [];
159
+ const toolchains = userToolchains(userHome, this.node), developer = developerTools(), browsers = playwrightBrowsers(userHome);
160
+ const base = { cwd: this.cwd, sdkRoot: this.sdkRoot, runtimeRoot: this.runtimeRoot, temporary: this.temporary, node: this.node,
161
+ denied: narrowed, readOnly, repository, gitDirs, toolchains: toolchains.roots, developer: developer.roots, browsers: browsers ? [browsers] : [] };
162
+ this.profile = managedSeatbelt(base);
163
+ // Only for commands the user approved (run_with_network); credentials stay denied.
164
+ this.networkProfile = readOnly ? null : managedSeatbelt({ ...base, network: true });
165
+ // Every process a command starts carries this marker, also one it detaches.
166
+ this.scope = randomUUID();
167
+ this.environment = toolEnvironment({ git: this.git, node: this.node, home: this.home, temporary: this.temporary, toolchains, developer, scope: this.scope, browsers,
168
+ identity: identity !== undefined ? identity : gitIdentity(this.git, this.cwd) });
169
+ if (searchTools) provisionSearchTools(this.home, userHome);
170
+ else fs.writeFileSync(path.join(this.home, '.ripgreprc'), '', { mode: 0o600 });
171
+ this.allowed = readOnly ? TOOL_NAMES.filter(name => !['write', 'edit'].includes(name) && (name !== 'bash' || commands)) : [...TOOL_NAMES];
172
+ }
173
+ tools(api) {
174
+ const factories = { read: api.createReadToolDefinition, write: api.createWriteToolDefinition,
175
+ edit: api.createEditToolDefinition, bash: api.createBashToolDefinition,
176
+ grep: api.createGrepToolDefinition, find: api.createFindToolDefinition, ls: api.createLsToolDefinition };
177
+ return this.allowed.map(name => ({ ...factories[name](this.cwd), execute: (_id, args, signal, onUpdate, context) => {
178
+ // One queue spans read and all mutations so two concurrent edit requests
179
+ // observe a deterministic order. Helpers use separate read-only workers.
180
+ const run = this.#tail.then(() => this.invoke(name, args, signal, onUpdate, context?.model));
181
+ this.#tail = run.catch(() => {}); return run;
182
+ } }));
183
+ }
184
+ async invoke(name, args, signal, onUpdate, model, { network = false } = {}) {
185
+ if (this.#closed || !this.allowed.includes(name) || network && (name !== 'bash' || !this.networkProfile)) throw new Error('managed-tool-unavailable');
186
+ if (signal?.aborted) throw new Error('managed-tool-cancelled');
187
+ for (const [file, digest] of this.pins) if (hash(file) !== digest) throw new Error('managed-runtime-changed');
188
+ const message = JSON.stringify({ name, args, ...(network ? { network: true } : {}), model: model ? { input: model.input, inputLimits: model.inputLimits } : undefined }) + '\n';
189
+ if (Buffer.byteLength(message) > MAX_WIRE_BYTES) throw new Error('managed-tool-input-too-large');
190
+ return await new Promise((resolve, reject) => {
191
+ const child = spawn('/usr/bin/sandbox-exec', ['-p', network ? this.networkProfile : this.profile, this.node, workerPath, this.sdkPath, this.cwd], {
192
+ cwd: this.cwd, detached: true, stdio: ['pipe', 'pipe', 'pipe'],
193
+ env: network ? this.environment.network : this.environment.offline,
194
+ });
195
+ this.#children.add(child);
196
+ const decoder = new StringDecoder('utf8');
197
+ let pending = '', bytes = 0, result, failure, terminated = false, hardStop;
198
+ const terminate = () => {
199
+ if (terminated) return; terminated = true;
200
+ child.stdin.write('{"cancel":true}\n');
201
+ // bash descendants may use separate process groups. Native bash abort
202
+ // handles those first; a hard stop is the last resort, never a refund.
203
+ hardStop = setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} }, 1000);
204
+ hardStop.unref();
205
+ };
206
+ const abort = () => { failure = new Error('managed-tool-cancelled'); terminate(); };
207
+ child.managedCancel = abort;
208
+ // A command stops at its own timeout (at most 30 minutes, for a build
209
+ // and end-to-end suite), else after 10 minutes; other tools after 30 s.
210
+ const seconds = Math.min(Math.max(Number(args?.timeout) || 600, 1), 1800);
211
+ const deadline = setTimeout(() => { failure = new Error('managed-tool-timeout'); terminate(); }, name === 'bash' ? seconds * 1000 + 5000 : 30000);
212
+ deadline.unref();
213
+ signal?.addEventListener('abort', abort, { once: true });
214
+ child.stdin.on('error', () => {});
215
+ child.stdout.on('data', chunk => {
216
+ bytes += chunk.length;
217
+ if (bytes > MAX_WIRE_BYTES) { failure = new Error('managed-tool-output-too-large'); terminate(); return; }
218
+ pending += decoder.write(chunk);
219
+ for (;;) {
220
+ const newline = pending.indexOf('\n'); if (newline < 0) break;
221
+ const line = pending.slice(0, newline); pending = pending.slice(newline + 1);
222
+ try {
223
+ const value = JSON.parse(line);
224
+ if (value.update) onUpdate?.(value.update);
225
+ // A cancel or timeout asked for first stays the reason: the worker
226
+ // then reports how its command ended ("Command aborted").
227
+ else if (value.error) failure ??= new Error(String(value.error).slice(0, 1000));
228
+ else if (value.result) result = value.result;
229
+ else throw new Error('managed-worker-invalid-response');
230
+ } catch { failure = new Error('managed-worker-invalid-response'); terminate(); }
231
+ }
232
+ });
233
+ // Do not relay raw startup diagnostics into model context (paths/env may
234
+ // contain sensitive material). The UI can show this stable failure code.
235
+ child.stderr.on('data', () => {});
236
+ child.on('error', () => { failure = new Error('managed-worker-start-failed'); });
237
+ child.on('close', code => {
238
+ clearTimeout(deadline); if (hardStop) clearTimeout(hardStop);
239
+ this.#children.delete(child); signal?.removeEventListener('abort', abort);
240
+ if (failure) reject(failure);
241
+ else if (code !== 0 || !result || pending) reject(new Error('managed-worker-failed'));
242
+ else resolve(result);
243
+ });
244
+ child.stdin.write(message);
245
+ if (signal?.aborted) abort();
246
+ });
247
+ }
248
+ // Processes a command left running (`nohup … &`, a detached child) outlive
249
+ // it with the marker in their environment. They are stopped when a turn ends
250
+ // and when the boundary closes; nothing outside this boundary has the marker.
251
+ async stopStrays() {
252
+ if (this.#children.size) return 0;
253
+ const marker = `PIAGENT_TOOL_SCOPE=${this.scope}`, find = () => {
254
+ try {
255
+ return execFileSync('/bin/ps', ['-axwwE', '-o', 'pid=,command='], { encoding: 'utf8', maxBuffer: 64 << 20, timeout: 5000 }).split('\n')
256
+ .filter(row => row.includes(marker)).map(row => Number(row.trim().split(/\s+/)[0])).filter(pid => Number.isSafeInteger(pid) && pid > 1 && pid !== process.pid);
257
+ } catch { return []; }
258
+ };
259
+ const strays = find();
260
+ for (const pid of strays) try { process.kill(pid, 'SIGTERM'); } catch {}
261
+ for (let waited = 0; strays.length && waited < 2000; waited += 100) {
262
+ await new Promise(resolve => setTimeout(resolve, 100));
263
+ if (!find().length) break;
264
+ }
265
+ for (const pid of find()) try { process.kill(pid, 'SIGKILL'); } catch {}
266
+ return strays.length;
267
+ }
268
+ async dispose() {
269
+ this.#closed = true;
270
+ const stopping = [...this.#children].map(child => new Promise(resolve => { child.once('close', resolve); child.managedCancel(); }));
271
+ await Promise.all(stopping); await this.#tail;
272
+ await this.stopStrays();
273
+ fs.rmSync(this.temporary, { recursive: true, force: true });
274
+ }
275
+ }
@@ -0,0 +1,36 @@
1
+ import { sandboxDiagnostic } from './sandbox-diagnostic.mjs';
2
+ import { fallbackFind, fallbackGrep } from './search-fallback.mjs';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { createInterface } from 'node:readline';
6
+ import { pathToFileURL } from 'node:url';
7
+ // A dedicated, credential-free process already inside Seatbelt. The launch
8
+ // profile and environment are supplied by the trusted runtime, never the LLM.
9
+ const [sdkPath, cwd] = process.argv.slice(2);
10
+ const api = await import(pathToFileURL(sdkPath).href);
11
+ const factories = { read: api.createReadToolDefinition, write: api.createWriteToolDefinition,
12
+ edit: api.createEditToolDefinition, bash: api.createBashToolDefinition,
13
+ grep: api.createGrepToolDefinition, find: api.createFindToolDefinition, ls: api.createLsToolDefinition };
14
+ let active = false, controller, network = false;
15
+ const emit = value => process.stdout.write(JSON.stringify(value) + '\n');
16
+ const input = createInterface({ input: process.stdin });
17
+ input.on('line', async line => {
18
+ try {
19
+ const message = JSON.parse(line);
20
+ if (message.cancel === true) { controller?.abort(); return; }
21
+ if (active || !Object.hasOwn(factories, message.name)) throw new Error('managed-worker-request-invalid');
22
+ active = true; controller = new AbortController(); network = message.network === true;
23
+ const options = message.name === 'bash' ? { shellPath: '/bin/bash', exposeSessionEnvironment: false } : undefined;
24
+ // The runtime copies ripgrep/fd here when the machine has or can fetch them.
25
+ const provided = name => fs.existsSync(path.join(process.env.HOME ?? '', '.pi/agent/bin', name));
26
+ const result = message.name === 'grep' && !provided('rg') ? fallbackGrep(cwd, message.args)
27
+ : message.name === 'find' && !provided('fd') ? fallbackFind(cwd, message.args)
28
+ : await factories[message.name](cwd, options).execute('managed-tool', message.args, controller.signal,
29
+ update => emit({ update }), { cwd, model: message.model });
30
+ // Successful output is returned verbatim; the sandbox note is only added to
31
+ // failures (Pi tools throw on denial and on a non-zero shell exit).
32
+ emit({ result });
33
+ } catch (error) { emit({ error: sandboxDiagnostic(error instanceof Error ? error.message : 'managed-worker-failed', { network }) }); }
34
+ finally { if (active) { input.close(); process.stdin.destroy(); } }
35
+ });
36
+ process.stdin.on('end', () => controller?.abort());
@@ -0,0 +1,120 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { pathToFileURL } from 'node:url';
6
+
7
+ const CLT = '/Library/Developer/CommandLineTools';
8
+ const executable = file => { try { fs.accessSync(file, fs.constants.X_OK); return fs.statSync(file).isFile(); } catch { return false; } };
9
+ // The developer directory chosen by xcode-select (any Xcode name or location).
10
+ function selectedDeveloperDir() {
11
+ try { return fs.realpathSync('/private/var/db/xcode_select_link'); } catch { return null; }
12
+ }
13
+
14
+ // /usr/bin/git is a macOS developer-tools launcher. Inside Seatbelt it may
15
+ // misreport an unaccepted Xcode license when its preference lookup is denied.
16
+ // Pin the actual installed binary; never widen access to user preferences.
17
+ export function managedGit() {
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']) {
20
+ if (executable(candidate)) return fs.realpathSync(candidate);
21
+ }
22
+ throw Error('managed-git-unavailable');
23
+ }
24
+
25
+ // Compilers without the /usr/bin shims: inside Seatbelt, xcrun cannot read
26
+ // the Xcode license record and xcodebuild aborts, so cc/c++/make (and so Rust,
27
+ // cgo and node-gyp builds) would fail on machines that select Xcode.app while
28
+ // working on machines with only the Command Line Tools. Resolve the tools and
29
+ // SDK here, outside the sandbox, the same way on every machine: the Command
30
+ // Line Tools when installed, otherwise the selected Xcode.
31
+ let developer;
32
+ export function developerTools() {
33
+ if (developer) return developer;
34
+ const selected = selectedDeveloperDir();
35
+ const dir = executable(`${CLT}/usr/bin/clang`) ? CLT
36
+ : [selected, '/Applications/Xcode.app/Contents/Developer'].find(dev => dev && executable(path.join(dev, 'Toolchains/XcodeDefault.xctoolchain/usr/bin/clang')));
37
+ if (!dir) return (developer = { roots: [], bins: [], env: {} });
38
+ let sdk = '';
39
+ try {
40
+ sdk = execFileSync('/usr/bin/xcrun', ['--sdk', 'macosx', '--show-sdk-path'], { encoding: 'utf8', timeout: 15_000, stdio: ['ignore', 'pipe', 'ignore'], env: { PATH: '/usr/bin:/bin', DEVELOPER_DIR: dir } }).trim();
41
+ sdk = fs.realpathSync(sdk);
42
+ } catch { sdk = dir === CLT && fs.existsSync(`${CLT}/SDKs/MacOSX.sdk`) ? fs.realpathSync(`${CLT}/SDKs/MacOSX.sdk`) : ''; }
43
+ const bins = dir === CLT ? [`${CLT}/usr/bin`] : [path.join(dir, 'Toolchains/XcodeDefault.xctoolchain/usr/bin'), path.join(dir, 'usr/bin')];
44
+ const app = dir.match(/^(.+?\.app)\/Contents\/Developer$/)?.[1];
45
+ return (developer = { roots: [...new Set([app ?? dir, ...(sdk ? [sdk] : [])])], bins, env: { DEVELOPER_DIR: dir, ...(sdk ? { SDKROOT: sdk } : {}) } });
46
+ }
47
+
48
+ // Certificates the Mac trusts beyond Node's bundled roots (a company CA, a
49
+ // TLS-inspecting network) are trusted here too, so Studio and web pages load
50
+ // wherever Agent Watch (which uses the system trust store) can connect.
51
+ let systemTrust = false;
52
+ export async function trustSystemCertificates() {
53
+ if (systemTrust) return; systemTrust = true;
54
+ try {
55
+ const tls = await import('node:tls');
56
+ const system = tls.getCACertificates('system');
57
+ if (system.length) tls.setDefaultCACertificates([...new Set([...tls.getCACertificates('default'), ...system])]);
58
+ } catch { /* older Node: bundled roots only */ }
59
+ }
60
+
61
+ const downloaded = new Map();
62
+
63
+ // ripgrep and fd for Pi's grep/find, on any member machine: an installed copy,
64
+ // else Pi's own downloader run here (outside the sandbox, like personal Pi).
65
+ export function searchToolPath(name, userHome = os.homedir()) {
66
+ for (const candidate of [downloaded.get(name), `/opt/homebrew/bin/${name}`, `/usr/local/bin/${name}`, path.join(userHome, '.pi/agent/bin', name)]) {
67
+ if (candidate && executable(candidate)) return fs.realpathSync(candidate);
68
+ }
69
+ return null;
70
+ }
71
+ export async function ensureSearchTools(sdkRoot, userHome = os.homedir(), timeoutMs = 60_000) {
72
+ const missing = ['rg', 'fd'].filter(name => !searchToolPath(name, userHome));
73
+ if (!missing.length) return;
74
+ let manager;
75
+ try { manager = await import(pathToFileURL(path.join(sdkRoot, 'dist/utils/tools-manager.js')).href); } catch { return; }
76
+ await Promise.all(missing.map(async name => {
77
+ try {
78
+ const found = await Promise.race([manager.ensureTool(name, true), new Promise(resolve => setTimeout(resolve, timeoutMs).unref())]);
79
+ if (typeof found === 'string' && path.isAbsolute(found) && executable(found)) downloaded.set(name, found);
80
+ } catch { /* grep/find fall back to the built-in search */ }
81
+ }));
82
+ }
83
+
84
+ // Version managers and user-level toolchains the sandbox may read (never
85
+ // write): only folders that exist on this machine, with their bin folders on
86
+ // PATH and the variables their shims need. Credentials inside stay denied by
87
+ // the profile's credential rules.
88
+ const TOOLCHAINS = [
89
+ { dir: '.nvm' }, { dir: '.fnm' }, { dir: '.local/share/fnm' },
90
+ { dir: '.volta', bin: ['bin'], env: 'VOLTA_HOME' },
91
+ { dir: 'Library/pnpm', bin: [''], env: 'PNPM_HOME' }, { dir: '.local/share/pnpm', bin: [''] },
92
+ { dir: '.bun', bin: ['bin'], env: 'BUN_INSTALL' }, { dir: '.deno', bin: ['bin'] },
93
+ { dir: '.cargo', bin: ['bin'], env: 'CARGO_HOME' }, { dir: '.rustup', env: 'RUSTUP_HOME' },
94
+ { dir: '.pyenv', bin: ['shims', 'bin'], env: 'PYENV_ROOT' }, { dir: '.rbenv', bin: ['shims', 'bin'], env: 'RBENV_ROOT' },
95
+ { dir: 'go', bin: ['bin'], env: 'GOPATH' }, { dir: '.sdkman/candidates', bin: ['java/current/bin', 'gradle/current/bin', 'maven/current/bin'] },
96
+ { dir: '.local/share/mise', bin: ['shims'], env: 'MISE_DATA_DIR' }, { dir: '.asdf', bin: ['shims', 'bin'], env: 'ASDF_DATA_DIR' },
97
+ { dir: '.nodenv', bin: ['shims', 'bin'], env: 'NODENV_ROOT' }, { dir: '.goenv', bin: ['shims', 'bin'], env: 'GOENV_ROOT' },
98
+ { dir: '.jenv', bin: ['shims', 'bin'] }, { dir: '.rvm', bin: ['bin'] },
99
+ { dir: 'miniconda3', bin: ['bin', 'condabin'] }, { dir: 'miniforge3', bin: ['bin', 'condabin'] }, { dir: 'anaconda3', bin: ['bin', 'condabin'] },
100
+ { dir: '.dotnet', bin: [''], env: 'DOTNET_ROOT' }, { dir: '.yarn', bin: ['bin'] },
101
+ { dir: '.npm-global', bin: ['bin'] }, { dir: '.npm-packages', bin: ['bin'] },
102
+ { dir: '.local/bin', bin: [''] },
103
+ ];
104
+ export function userToolchains(userHome = os.homedir(), node = process.execPath) {
105
+ const roots = [], bins = [], env = {};
106
+ for (const tool of TOOLCHAINS) {
107
+ const root = path.join(userHome, tool.dir);
108
+ let real;
109
+ try { real = fs.realpathSync(root); if (!fs.statSync(real).isDirectory()) continue; } catch { continue; }
110
+ roots.push(real);
111
+ for (const bin of tool.bin ?? []) { const dir = path.join(real, bin); if (fs.existsSync(dir)) bins.push(dir); }
112
+ if (tool.env) env[tool.env] = real;
113
+ }
114
+ if (env.GOPATH) env.GOMODCACHE = path.join(env.GOPATH, 'pkg/mod');
115
+ // npm/npx of the pinned node live in its install prefix (nvm, volta, fnm, a
116
+ // tarball install), which may sit outside the system folders.
117
+ const prefix = path.dirname(path.dirname(fs.realpathSync(node)));
118
+ if (prefix !== userHome && prefix !== '/' && fs.existsSync(path.join(prefix, 'lib/node_modules'))) roots.push(prefix);
119
+ return { roots: [...new Set(roots)], bins, env };
120
+ }
@@ -0,0 +1,105 @@
1
+ import dns from 'node:dns/promises';
2
+ import https from 'node:https';
3
+ import net from 'node:net';
4
+ import zlib from 'node:zlib';
5
+
6
+ // Reads one public https page for the company session, from the runtime
7
+ // process (tools in the sandbox have no network). GET only, no cookies or
8
+ // credentials, public addresses only (checked per redirect and pinned for the
9
+ // connection so DNS cannot rebind to a private address), bounded size/time.
10
+ const MAX_BYTES = 5 * 1024 * 1024;
11
+ const MAX_REDIRECTS = 5;
12
+ const TEXT = /^(text\/|application\/(json|xml|xhtml\+xml|javascript|x-yaml|yaml|ld\+json|rss\+xml|atom\+xml))/i;
13
+
14
+ function v4Private(address) {
15
+ const [a, b] = address.split('.').map(Number);
16
+ return a === 0 || a === 10 || a === 127 || a >= 224 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254)
17
+ || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 192 && b === 0) || (a === 198 && (b === 18 || b === 19));
18
+ }
19
+ export function privateAddress(address) {
20
+ if (net.isIPv4(address)) return v4Private(address);
21
+ if (!net.isIPv6(address)) return true;
22
+ const value = address.toLowerCase();
23
+ const mapped = value.match(/^(?:0*:)*:ffff:(\d+\.\d+\.\d+\.\d+)$/) ?? value.match(/^64:ff9b::(\d+\.\d+\.\d+\.\d+)$/);
24
+ if (mapped) return v4Private(mapped[1]);
25
+ return value === '::' || value === '::1' || /^f[cd]/.test(value) || /^fe[89ab]/.test(value) || /^ff/.test(value) || value.startsWith('2001:db8');
26
+ }
27
+
28
+ async function publicTarget(url, resolve) {
29
+ if (url.protocol !== 'https:' || url.username || url.password) throw new Error('web-fetch-url-not-allowed');
30
+ const host = url.hostname.replace(/^\[|\]$/g, '');
31
+ if (/^(localhost|.*\.local|.*\.internal|.*\.localhost)$/i.test(host)) throw new Error('web-fetch-private-address');
32
+ const addresses = net.isIP(host) ? [{ address: host, family: net.isIP(host) }] : await resolve(host);
33
+ if (!addresses.length || addresses.some((entry) => privateAddress(entry.address))) throw new Error('web-fetch-private-address');
34
+ return addresses[0];
35
+ }
36
+
37
+ export function htmlToText(html) {
38
+ const title = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] ?? '';
39
+ const body = html.replace(/<(script|style|noscript|svg|template|iframe|title)[\s\S]*?<\/\1>/gi, ' ').replace(/<!--[\s\S]*?-->/g, ' ')
40
+ .replace(/<(br|hr)\b[^>]*>/gi, '\n').replace(/<\/(p|div|section|article|li|tr|h[1-6]|pre|blockquote|table|ul|ol)>/gi, '\n')
41
+ .replace(/<h([1-6])[^>]*>/gi, (_, level) => '\n' + '#'.repeat(Number(level)) + ' ').replace(/<li[^>]*>/gi, '- ').replace(/<[^>]+>/g, ' ');
42
+ const decode = (text) => text.replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos|nbsp|#39);/gi, (entity, code) => {
43
+ const lower = code.toLowerCase();
44
+ if (lower.startsWith('#x')) return String.fromCodePoint(parseInt(lower.slice(2), 16) || 32);
45
+ if (lower.startsWith('#')) return String.fromCodePoint(Number(lower.slice(1)) || 32);
46
+ return { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }[lower] ?? entity;
47
+ });
48
+ const text = decode(body).split('\n').map((line) => line.replace(/[ \t\f\v ]+/g, ' ').trim()).filter(Boolean).join('\n');
49
+ return { title: decode(title).replace(/\s+/g, ' ').trim(), text };
50
+ }
51
+
52
+ // Connect only to the checked address. Node (autoSelectFamily, default since
53
+ // Node 20) asks for every address at once ({all: true}) and rejects a single
54
+ // address with "Invalid IP address", so answer in the shape requested.
55
+ export function pinnedLookup(target) {
56
+ return (_host, options, callback) => options?.all
57
+ ? callback(null, [{ address: target.address, family: target.family }])
58
+ : callback(null, target.address, target.family);
59
+ }
60
+
61
+ function request(url, target, signal) {
62
+ return new Promise((resolve, reject) => {
63
+ const req = https.request(url, { method: 'GET', signal, servername: net.isIP(url.hostname) ? undefined : url.hostname,
64
+ lookup: pinnedLookup(target),
65
+ headers: { 'User-Agent': 'Piagent-company-web-fetch/1', Accept: 'text/html,text/plain,application/json;q=0.9,*/*;q=0.1', 'Accept-Encoding': 'gzip, deflate, br' } }, resolve);
66
+ req.once('error', reject); req.end();
67
+ });
68
+ }
69
+
70
+ async function body(response) {
71
+ const encoding = String(response.headers['content-encoding'] ?? '').toLowerCase();
72
+ const stream = encoding === 'gzip' ? response.pipe(zlib.createGunzip()) : encoding === 'deflate' ? response.pipe(zlib.createInflate())
73
+ : encoding === 'br' ? response.pipe(zlib.createBrotliDecompress()) : response;
74
+ const chunks = []; let size = 0;
75
+ for await (const chunk of stream) {
76
+ size += chunk.length;
77
+ if (size > MAX_BYTES) { response.destroy(); throw new Error('web-fetch-too-large'); }
78
+ chunks.push(chunk);
79
+ }
80
+ return Buffer.concat(chunks).toString('utf8');
81
+ }
82
+
83
+ // `requestForTest` replaces only the connection; address checks still apply.
84
+ export async function fetchPublicPage(input, { maxChars = 30_000, signal, resolve = (host) => dns.lookup(host, { all: true, verbatim: true }), requestForTest } = {}) {
85
+ let url;
86
+ try { url = new URL(String(input)); } catch { throw new Error('web-fetch-url-invalid'); }
87
+ const limit = Math.max(1_000, Math.min(100_000, Number(maxChars) || 30_000));
88
+ const timeout = AbortSignal.timeout(20_000), combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
89
+ for (let hop = 0; ; hop += 1) {
90
+ const target = await publicTarget(url, resolve);
91
+ const response = requestForTest ? await requestForTest(url) : await request(url, target, combined);
92
+ const status = response.statusCode ?? 0;
93
+ if (status >= 300 && status < 400 && response.headers.location) {
94
+ response.resume();
95
+ if (hop >= MAX_REDIRECTS) throw new Error('web-fetch-too-many-redirects');
96
+ url = new URL(response.headers.location, url); continue;
97
+ }
98
+ const type = String(response.headers['content-type'] ?? '');
99
+ if (type && !TEXT.test(type)) { response.destroy(); return { url: url.href, status, text: `Unsupported content type: ${type.split(';')[0]}` }; }
100
+ const raw = await body(response);
101
+ const page = /html/i.test(type) || /^\s*<(!doctype html|html)/i.test(raw) ? htmlToText(raw) : { title: '', text: raw };
102
+ const truncated = page.text.length > limit;
103
+ return { url: url.href, status, title: page.title, text: truncated ? page.text.slice(0, limit) + `\n[Truncated at ${limit} characters.]` : page.text };
104
+ }
105
+ }