@piagent/platform 1.7.1 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/AGENTS.md +7 -0
  2. package/CHANGELOG.md +113 -1
  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 +69 -91
  10. package/docs/context-window-policy.md +5 -5
  11. package/docs/distribution-standard.md +16 -19
  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 +22 -17
  19. package/docs/oauth-providers.md +1 -1
  20. package/docs/operator-manual-vietnamese.md +85 -130
  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 +24 -25
  30. package/docs/release-install-policy.md +16 -11
  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 +33 -64
  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/runtime.html +1 -1
  58. package/docs-site/content/en/security.html +1 -1
  59. package/docs-site/content/en/team.html +2 -2
  60. package/docs-site/content/en/whats-new.html +89 -1
  61. package/docs-site/content/en/workflows.html +29 -31
  62. package/docs-site/content/index.html +24 -9
  63. package/docs-site/content/quickstart.html +10 -12
  64. package/docs-site/content/runtime.html +2 -2
  65. package/docs-site/content/security.html +2 -2
  66. package/docs-site/content/team.html +2 -2
  67. package/docs-site/content/whats-new.html +89 -1
  68. package/docs-site/content/workflows.html +36 -38
  69. package/docs-site/context-engine.html +2 -1
  70. package/docs-site/ecosystem.html +283 -0
  71. package/docs-site/en/architecture.html +3 -2
  72. package/docs-site/en/benchmark.html +7 -1
  73. package/docs-site/en/commands.html +32 -14
  74. package/docs-site/en/context-engine.html +2 -1
  75. package/docs-site/en/ecosystem.html +283 -0
  76. package/docs-site/en/index.html +30 -14
  77. package/docs-site/en/mcp-approval.html +2 -1
  78. package/docs-site/en/mcp-auth.html +2 -1
  79. package/docs-site/en/mcp-servers.html +2 -1
  80. package/docs-site/en/mcp.html +2 -1
  81. package/docs-site/en/permissions.html +2 -1
  82. package/docs-site/en/profiles.html +2 -1
  83. package/docs-site/en/quickstart.html +17 -18
  84. package/docs-site/en/runtime.html +3 -2
  85. package/docs-site/en/scope.html +2 -1
  86. package/docs-site/en/security.html +6 -5
  87. package/docs-site/en/team.html +4 -3
  88. package/docs-site/en/whats-new.html +97 -8
  89. package/docs-site/en/workflows.html +34 -35
  90. package/docs-site/index.html +30 -14
  91. package/docs-site/mcp-approval.html +2 -1
  92. package/docs-site/mcp-auth.html +2 -1
  93. package/docs-site/mcp-servers.html +2 -1
  94. package/docs-site/mcp.html +2 -1
  95. package/docs-site/permissions.html +2 -1
  96. package/docs-site/profiles.html +2 -1
  97. package/docs-site/quickstart.html +17 -18
  98. package/docs-site/runtime.html +4 -3
  99. package/docs-site/scope.html +2 -1
  100. package/docs-site/security.html +7 -6
  101. package/docs-site/team.html +4 -3
  102. package/docs-site/whats-new.html +97 -8
  103. package/docs-site/workflows.html +41 -42
  104. package/evals/fs5-causal-arm.v1.json +2 -2
  105. package/evals/private-holdout-v1/CURRENT_BOUNDARY.md +17 -0
  106. package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
  107. package/package.json +10 -9
  108. package/packages/piagent-core/README.md +15 -23
  109. package/packages/piagent-core/docs/context-efficiency-improvement-plan.md +2 -0
  110. package/packages/piagent-core/extensions/experiment-loop-policy.ts +75 -0
  111. package/packages/piagent-core/extensions/local-state-path.js +15 -0
  112. package/packages/piagent-core/extensions/piagent-guard.ts +108 -960
  113. package/packages/piagent-core/extensions/policy-core.js +2 -2
  114. package/packages/piagent-core/extensions/source-checkout-paths.ts +54 -0
  115. package/packages/piagent-core/managed/broker-client.mjs +49 -0
  116. package/packages/piagent-core/managed/capabilities.mjs +28 -0
  117. package/packages/piagent-core/managed/compaction.mjs +63 -0
  118. package/packages/piagent-core/managed/doctor.mjs +107 -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 +129 -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 +472 -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 +243 -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 +96 -0
  135. package/packages/piagent-core/managed/workflow.mjs +299 -0
  136. package/packages/piagent-core/package.json +3 -6
  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/inspection/same-process-bridge-proof.ts +1 -1
  143. package/packages/piagent-core/runtime/inspection/webui-snapshot.ts +1 -1
  144. package/packages/piagent-core/runtime/managed-failure.d.mts +10 -0
  145. package/packages/piagent-core/runtime/managed-failure.mjs +164 -0
  146. package/packages/piagent-core/runtime/model/model-route-policy.ts +33 -13
  147. package/packages/piagent-core/runtime/model/model-route-types.ts +10 -6
  148. package/packages/piagent-core/runtime/orchestration/minimal-delegation-policy.ts +8 -29
  149. package/packages/piagent-core/runtime/orchestration/owned-work-budget.ts +1 -1
  150. package/packages/piagent-core/runtime/policy/block-remedy.ts +5 -3
  151. package/packages/piagent-core/runtime/registration/context-commands.ts +1 -1
  152. package/packages/piagent-core/runtime/registration/knowledge-tools.ts +7 -7
  153. package/packages/piagent-core/runtime/registration/policy-tools.ts +2 -2
  154. package/packages/piagent-core/runtime/registration/session-commands.ts +11 -26
  155. package/packages/piagent-core/runtime/registration/task-evidence-tools.ts +1 -1
  156. package/packages/piagent-core/runtime/session/system-prompt.ts +6 -7
  157. package/packages/piagent-core/runtime/workflows/webui-runtime-command.ts +6 -6
  158. package/packages/piagent-core/runtime/workflows/webui-workflow.ts +6 -11
  159. package/packages/piagent-core/security/sensitive-text.js +9 -0
  160. package/packages/piagent-core/skills/piagent-ops/SKILL.md +8 -10
  161. package/packages/piagent-core/skills/test-audit/SKILL.md +101 -0
  162. package/packages/piagent-core/subagents/piagent-planner.md +1 -1
  163. package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-DGk34zJT.js → MarkdownMessage-Dd9wPk-v.js} +1 -1
  164. package/packages/piagent-webui/dist/client/assets/index-7I-50Q2U.js +228 -0
  165. package/packages/piagent-webui/dist/client/assets/{index-DVahUVWy.css → index-DgSmcnP8.css} +1 -1
  166. package/packages/piagent-webui/dist/client/index.html +2 -2
  167. package/packages/piagent-webui/extension/piagent-webui.ts +11 -11
  168. package/packages/piagent-webui/extension/session-stream-adapter.ts +3 -1
  169. package/packages/piagent-webui/extension/terminal-session-adapter.ts +6 -1
  170. package/packages/piagent-webui/gateway/company-relay.ts +308 -0
  171. package/packages/piagent-webui/gateway/control-socket.ts +39 -12
  172. package/packages/piagent-webui/gateway/gateway-service.ts +109 -28
  173. package/packages/piagent-webui/gateway/gateway-session-stream.ts +58 -2
  174. package/packages/piagent-webui/gateway/managed-gateway.mjs +41 -0
  175. package/packages/piagent-webui/gateway/managed-launch.ts +145 -0
  176. package/packages/piagent-webui/gateway/managed-projection.ts +20 -0
  177. package/packages/piagent-webui/gateway/pi-host.ts +22 -4
  178. package/packages/piagent-webui/gateway/runtime-session-controls.ts +5 -2
  179. package/packages/piagent-webui/gateway/session-approval-authority.ts +28 -0
  180. package/packages/piagent-webui/gateway/session-catalog.ts +11 -3
  181. package/packages/piagent-webui/gateway/session-command-controller.ts +8 -2
  182. package/packages/piagent-webui/gateway/session-file-facts.ts +25 -0
  183. package/packages/piagent-webui/gateway/session-inspection-registry.ts +42 -12
  184. package/packages/piagent-webui/gateway/session-list-cache.ts +68 -0
  185. package/packages/piagent-webui/gateway/session-runtime-ownership.ts +8 -1
  186. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +20 -27
  187. package/packages/piagent-webui/ownership/managed-terminal-lease.ts +27 -0
  188. package/packages/piagent-webui/ownership/profile-state.ts +2 -0
  189. package/packages/piagent-webui/ownership/session-lease-store.ts +22 -1
  190. package/packages/piagent-webui/server/approval-projection.ts +9 -0
  191. package/packages/piagent-webui/server/core-inspection-provider.ts +4 -9
  192. package/packages/piagent-webui/server/loopback-server.ts +37 -1
  193. package/packages/piagent-webui/server/message-stop-reason.ts +11 -0
  194. package/packages/piagent-webui/server/tool-call-summary.ts +185 -0
  195. package/packages/piagent-webui/server/transcript-projection.ts +86 -3
  196. package/packs/engineering-base/pack.json +4 -0
  197. package/schemas/piagent-webui/runtime-command-v1.schema.json +1 -2
  198. package/schemas/piagent-webui/session-catalog-v1.schema.json +8 -0
  199. package/schemas/piagent-webui/transcript-v1.schema.json +94 -1
  200. package/scripts/build-docs-site.mjs +23 -12
  201. package/scripts/check-public-wording.mjs +5 -6
  202. package/scripts/check-runtime-advisories.mjs +23 -6
  203. package/scripts/claude-model-additions.mjs +33 -0
  204. package/scripts/configure-model-scope.sh +36 -8
  205. package/scripts/configure-subagents.sh +1 -1
  206. package/scripts/explain-command.mjs +5 -6
  207. package/scripts/install-global.sh +2 -2
  208. package/scripts/model-route-evaluation-core.ts +1 -0
  209. package/scripts/model-scope-prune.mjs +75 -0
  210. package/scripts/phase-tools-evaluation.mjs +9 -4
  211. package/scripts/pi-model-route.mjs +1 -1
  212. package/scripts/piagent-cli.mjs +51 -2
  213. package/scripts/piagent-dashboard.mjs +27 -4
  214. package/scripts/piagent-installations.mjs +26 -0
  215. package/scripts/piagent-studio.mjs +155 -0
  216. package/scripts/runtime-advisory-exceptions.json +29 -0
  217. package/scripts/setup.sh +2 -2
  218. package/scripts/team-doctor.sh +20 -1
  219. package/scripts/typescript-loader.mjs +6 -0
  220. package/scripts/verify-local.sh +5 -5
  221. package/scripts/verify-release-identity.mjs +1 -1
  222. package/templates/global/settings.json +8 -2
  223. package/templates/project/.pi/project-context.md +3 -3
  224. package/templates/project/AGENTS.md +4 -5
  225. package/docs/piagent-core-reasoning-research-2026-09-06.md +0 -225
  226. package/docs/piagent-core-research-sources-2026-09-06.json +0 -416
  227. package/packages/piagent-webui/dist/client/assets/index-DiKRmCEP.js +0 -176
@@ -0,0 +1,243 @@
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 = [], 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
+ const readRoots = ['/System', '/usr', '/bin', '/sbin', '/Library/Developer', '/Applications/Xcode.app', '/opt/homebrew', ...system, ...developer, sdkRoot, runtimeRoot, cwd, temporary, ...repository, ...gitDirs, ...toolchains];
30
+ const filters = readRoots.map(root => `(subpath ${literal(root)})`).join(' ');
31
+ const writeRoots = [temporary, ...(!readOnly ? [cwd, ...gitDirs] : [])];
32
+ // Signals reach only processes of this sandbox: a search stops ripgrep at
33
+ // its limit and a cancelled command stops its children, while nothing
34
+ // outside (Agent Watch, the member's apps) can be signalled.
35
+ return `(version 1)
36
+ (deny default)
37
+ (allow process-fork process-exec)
38
+ (allow signal (target same-sandbox))
39
+ (allow sysctl-read)
40
+ (allow mach-lookup (global-name "com.apple.system.logger") (global-name "com.apple.system.opendirectoryd"))
41
+ (allow file-read-metadata)
42
+ (allow file-read* (literal "/") ${filters} (literal ${literal(node)}) (subpath "/private/etc") (subpath "/dev"))
43
+ (allow file-write* ${writeRoots.map(root => `(subpath ${literal(root)})`).join(' ')} (literal "/dev/null") (literal "/dev/tty"))
44
+ ${network ? `(allow network-outbound (remote ip))
45
+ (allow network-outbound (literal "/private/var/run/mDNSResponder"))
46
+ (allow system-socket)
47
+ (allow mach-lookup (global-name "com.apple.dnssd.service") (global-name "com.apple.trustd.agent") (global-name "com.apple.SystemConfiguration.configd"))` : ''}
48
+ ${denied.map(root => `(deny file-read* file-write* (subpath ${literal(root)}))`).join('\n')}
49
+ (deny file-read* file-write* (regex #"(^|/)(\\.env([./]|$)|auth\\.json$|credentials([./]|$)|\\.npmrc$|\\.netrc$)"))
50
+ `;
51
+ }
52
+
53
+ // Toolchains from the pinned node/git and Homebrew, compilers and SDK; the
54
+ // git identity this project gets on the user's machine (name and email only)
55
+ // so commits work. Without network, package managers fail fast instead of
56
+ // retrying for minutes.
57
+ function toolEnvironment({ git, node, home, temporary, toolchains, developer, identity, scope }) {
58
+ 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(':');
59
+ const network = { ...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'),
60
+ ...(identity ? { GIT_AUTHOR_NAME: identity.name, GIT_AUTHOR_EMAIL: identity.email, GIT_COMMITTER_NAME: identity.name, GIT_COMMITTER_EMAIL: identity.email } : {}) };
61
+ return { network, offline: { ...network, npm_config_fetch_retries: '0', npm_config_fetch_timeout: '5000', PIP_RETRIES: '0', PIP_TIMEOUT: '5' } };
62
+ }
63
+ // Effective config for this project, as the user's own git would see it:
64
+ // global and XDG files, includeIf (e.g. a work email for ~/work) and the
65
+ // repository's own settings. `git config --get` runs no hooks or helpers.
66
+ function gitIdentity(git, cwd) {
67
+ 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 ''; } };
68
+ const name = read('user.name'), email = read('user.email');
69
+ return name && email && name.length <= 200 && email.length <= 200 && !/[\x00-\x1f]/.test(name + email) ? { name, email } : null;
70
+ }
71
+ // The trusted runtime resolves the repository before any tool runs.
72
+ function repositoryRoots(git, cwd, temporary) {
73
+ try {
74
+ const [top, gitDir, commonDir] = execFileSync(git, ['-C', cwd, 'rev-parse', '--show-toplevel', '--absolute-git-dir', '--git-common-dir'],
75
+ { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'], env: { HOME: temporary, PATH: '/usr/bin:/bin', GIT_CONFIG_NOSYSTEM: '1' } }).trim().split('\n');
76
+ return { top: fs.realpathSync(top), gitDir: fs.realpathSync(gitDir), commonDir: fs.realpathSync(path.resolve(cwd, commonDir)) };
77
+ } catch { return null; }
78
+ }
79
+ // Pi's grep/find need ripgrep and fd and would download them, which the
80
+ // sandbox forbids. Copy installed binaries into the sandbox home instead.
81
+ function provisionSearchTools(home, userHome) {
82
+ // Credential files are unreadable here; ripgrep skips them instead of
83
+ // failing the whole search with a permission error.
84
+ fs.writeFileSync(path.join(home, '.ripgreprc'), ['.env', '.env.*', '.env/**', 'auth.json', 'credentials', 'credentials.*', 'credentials/**', '.npmrc', '.netrc']
85
+ .map(glob => `--glob=!${glob}`).join('\n') + '\n', { mode: 0o600 });
86
+ const bin = path.join(home, '.pi/agent/bin');
87
+ fs.mkdirSync(bin, { recursive: true, mode: 0o700 });
88
+ // Without a copy here the worker uses the built-in search (search-fallback.mjs).
89
+ for (const name of ['rg', 'fd']) {
90
+ const source = searchToolPath(name, userHome);
91
+ if (source) { fs.copyFileSync(source, path.join(bin, name)); fs.chmodSync(path.join(bin, name), 0o755); }
92
+ }
93
+ }
94
+
95
+ export class ManagedToolBoundary {
96
+ #tail = Promise.resolve();
97
+ #children = new Set();
98
+ #closed = false;
99
+ constructor({ cwd, sdkRoot, protectedRoots = [], readOnly = false, node = process.execPath, identity, userHome: home, searchTools = true }) {
100
+ if (process.platform !== 'darwin') throw new Error('managed-sandbox-unavailable');
101
+ this.cwd = fs.realpathSync(cwd); this.sdkRoot = fs.realpathSync(sdkRoot);
102
+ this.node = fs.realpathSync(node);
103
+ this.runtimeRoot = fs.realpathSync(path.dirname(workerPath));
104
+ this.sdkPath = path.join(this.sdkRoot, 'dist/index.js');
105
+ this.git = managedGit();
106
+ if (JSON.parse(fs.readFileSync(path.join(this.sdkRoot, 'package.json'), 'utf8')).version !== '0.87.1') throw new Error('managed-sdk-version-unqualified');
107
+ const workerModules = ['search-fallback.mjs', 'sandbox-diagnostic.mjs'].map(name => path.join(this.runtimeRoot, name));
108
+ this.pins = new Map([this.node, this.git, workerPath, ...workerModules, this.sdkPath].map(file => [file, hash(file)]));
109
+ const userHome = fs.realpathSync(home ?? os.homedir());
110
+ if ([userHome, '/', this.sdkRoot, this.runtimeRoot].includes(this.cwd)
111
+ || [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');
112
+ this.temporary = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'piagent-tools-')));
113
+ fs.chmodSync(this.temporary, 0o700);
114
+ this.home = path.join(this.temporary, 'home'); fs.mkdirSync(this.home, { mode: 0o700 });
115
+ const denied = [path.join(userHome, '.ssh'), path.join(userHome, '.aws'), path.join(userHome, '.config'),
116
+ path.join(userHome, '.pi'), path.join(userHome, '.codex'), path.join(userHome, '.claude'),
117
+ path.join(userHome, 'Library/Keychains'), path.join(userHome, 'Library/Application Support/AgentWatch'),
118
+ ...protectedRoots.map(p => path.resolve(p))];
119
+ // A pinned SDK may itself be installed under ~/.pi; grant that code only,
120
+ // while keeping the adjacent personal auth/config subtree inaccessible.
121
+ const narrowed = denied.filter(root => !inside(this.sdkRoot, root));
122
+ if (inside(this.sdkRoot, path.join(userHome, '.pi'))) narrowed.push(path.join(userHome, '.pi/agent'));
123
+ const repo = repositoryRoots(this.git, this.cwd, this.temporary);
124
+ const allowedRoot = root => root !== '/' && !inside(userHome, root) && !inside(this.sdkRoot, root) && !inside(this.runtimeRoot, root);
125
+ const repository = repo && repo.top !== this.cwd && allowedRoot(repo.top) ? [repo.top] : [];
126
+ this.repositoryTop = repository[0] ?? this.cwd;
127
+ // A repository above the home folder (a dotfiles repo) is never opened:
128
+ // neither read as a monorepo nor writable through its git directory.
129
+ const gitDirs = repo && (repo.top === this.cwd || allowedRoot(repo.top)) ? [...new Set([repo.gitDir, repo.commonDir])].filter(dir => !inside(dir, this.cwd) && allowedRoot(dir)) : [];
130
+ const toolchains = userToolchains(userHome, this.node), developer = developerTools();
131
+ const base = { cwd: this.cwd, sdkRoot: this.sdkRoot, runtimeRoot: this.runtimeRoot, temporary: this.temporary, node: this.node,
132
+ denied: narrowed, readOnly, repository, gitDirs, toolchains: toolchains.roots, developer: developer.roots };
133
+ this.profile = managedSeatbelt(base);
134
+ // Only for commands the user approved (run_with_network); credentials stay denied.
135
+ this.networkProfile = readOnly ? null : managedSeatbelt({ ...base, network: true });
136
+ // Every process a command starts carries this marker, also one it detaches.
137
+ this.scope = randomUUID();
138
+ this.environment = toolEnvironment({ git: this.git, node: this.node, home: this.home, temporary: this.temporary, toolchains, developer, scope: this.scope,
139
+ identity: identity !== undefined ? identity : gitIdentity(this.git, this.cwd) });
140
+ if (searchTools) provisionSearchTools(this.home, userHome);
141
+ else fs.writeFileSync(path.join(this.home, '.ripgreprc'), '', { mode: 0o600 });
142
+ this.allowed = readOnly ? TOOL_NAMES.filter(name => !['write', 'edit', 'bash'].includes(name)) : [...TOOL_NAMES];
143
+ }
144
+ tools(api) {
145
+ const factories = { read: api.createReadToolDefinition, write: api.createWriteToolDefinition,
146
+ edit: api.createEditToolDefinition, bash: api.createBashToolDefinition,
147
+ grep: api.createGrepToolDefinition, find: api.createFindToolDefinition, ls: api.createLsToolDefinition };
148
+ return this.allowed.map(name => ({ ...factories[name](this.cwd), execute: (_id, args, signal, onUpdate, context) => {
149
+ // One queue spans read and all mutations so two concurrent edit requests
150
+ // observe a deterministic order. Helpers use separate read-only workers.
151
+ const run = this.#tail.then(() => this.invoke(name, args, signal, onUpdate, context?.model));
152
+ this.#tail = run.catch(() => {}); return run;
153
+ } }));
154
+ }
155
+ async invoke(name, args, signal, onUpdate, model, { network = false } = {}) {
156
+ if (this.#closed || !this.allowed.includes(name) || network && (name !== 'bash' || !this.networkProfile)) throw new Error('managed-tool-unavailable');
157
+ if (signal?.aborted) throw new Error('managed-tool-cancelled');
158
+ for (const [file, digest] of this.pins) if (hash(file) !== digest) throw new Error('managed-runtime-changed');
159
+ const message = JSON.stringify({ name, args, ...(network ? { network: true } : {}), model: model ? { input: model.input, inputLimits: model.inputLimits } : undefined }) + '\n';
160
+ if (Buffer.byteLength(message) > MAX_WIRE_BYTES) throw new Error('managed-tool-input-too-large');
161
+ return await new Promise((resolve, reject) => {
162
+ const child = spawn('/usr/bin/sandbox-exec', ['-p', network ? this.networkProfile : this.profile, this.node, workerPath, this.sdkPath, this.cwd], {
163
+ cwd: this.cwd, detached: true, stdio: ['pipe', 'pipe', 'pipe'],
164
+ env: network ? this.environment.network : this.environment.offline,
165
+ });
166
+ this.#children.add(child);
167
+ const decoder = new StringDecoder('utf8');
168
+ let pending = '', bytes = 0, result, failure, terminated = false, hardStop;
169
+ const terminate = () => {
170
+ if (terminated) return; terminated = true;
171
+ child.stdin.write('{"cancel":true}\n');
172
+ // bash descendants may use separate process groups. Native bash abort
173
+ // handles those first; a hard stop is the last resort, never a refund.
174
+ hardStop = setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} }, 1000);
175
+ hardStop.unref();
176
+ };
177
+ const abort = () => { failure = new Error('managed-tool-cancelled'); terminate(); };
178
+ child.managedCancel = abort;
179
+ const deadline = setTimeout(() => { failure = new Error('managed-tool-timeout'); terminate(); }, name === 'bash' ? 600000 : 30000);
180
+ deadline.unref();
181
+ signal?.addEventListener('abort', abort, { once: true });
182
+ child.stdin.on('error', () => {});
183
+ child.stdout.on('data', chunk => {
184
+ bytes += chunk.length;
185
+ if (bytes > MAX_WIRE_BYTES) { failure = new Error('managed-tool-output-too-large'); terminate(); return; }
186
+ pending += decoder.write(chunk);
187
+ for (;;) {
188
+ const newline = pending.indexOf('\n'); if (newline < 0) break;
189
+ const line = pending.slice(0, newline); pending = pending.slice(newline + 1);
190
+ try {
191
+ const value = JSON.parse(line);
192
+ if (value.update) onUpdate?.(value.update);
193
+ // A cancel or timeout asked for first stays the reason: the worker
194
+ // then reports how its command ended ("Command aborted").
195
+ else if (value.error) failure ??= new Error(String(value.error).slice(0, 1000));
196
+ else if (value.result) result = value.result;
197
+ else throw new Error('managed-worker-invalid-response');
198
+ } catch { failure = new Error('managed-worker-invalid-response'); terminate(); }
199
+ }
200
+ });
201
+ // Do not relay raw startup diagnostics into model context (paths/env may
202
+ // contain sensitive material). The UI can show this stable failure code.
203
+ child.stderr.on('data', () => {});
204
+ child.on('error', () => { failure = new Error('managed-worker-start-failed'); });
205
+ child.on('close', code => {
206
+ clearTimeout(deadline); if (hardStop) clearTimeout(hardStop);
207
+ this.#children.delete(child); signal?.removeEventListener('abort', abort);
208
+ if (failure) reject(failure);
209
+ else if (code !== 0 || !result || pending) reject(new Error('managed-worker-failed'));
210
+ else resolve(result);
211
+ });
212
+ child.stdin.write(message);
213
+ if (signal?.aborted) abort();
214
+ });
215
+ }
216
+ // Processes a command left running (`nohup … &`, a detached child) outlive
217
+ // it with the marker in their environment. They are stopped when a turn ends
218
+ // and when the boundary closes; nothing outside this boundary has the marker.
219
+ async stopStrays() {
220
+ if (this.#children.size) return 0;
221
+ const marker = `PIAGENT_TOOL_SCOPE=${this.scope}`, find = () => {
222
+ try {
223
+ return execFileSync('/bin/ps', ['-axwwE', '-o', 'pid=,command='], { encoding: 'utf8', maxBuffer: 64 << 20, timeout: 5000 }).split('\n')
224
+ .filter(row => row.includes(marker)).map(row => Number(row.trim().split(/\s+/)[0])).filter(pid => Number.isSafeInteger(pid) && pid > 1 && pid !== process.pid);
225
+ } catch { return []; }
226
+ };
227
+ const strays = find();
228
+ for (const pid of strays) try { process.kill(pid, 'SIGTERM'); } catch {}
229
+ for (let waited = 0; strays.length && waited < 2000; waited += 100) {
230
+ await new Promise(resolve => setTimeout(resolve, 100));
231
+ if (!find().length) break;
232
+ }
233
+ for (const pid of find()) try { process.kill(pid, 'SIGKILL'); } catch {}
234
+ return strays.length;
235
+ }
236
+ async dispose() {
237
+ this.#closed = true;
238
+ const stopping = [...this.#children].map(child => new Promise(resolve => { child.once('close', resolve); child.managedCancel(); }));
239
+ await Promise.all(stopping); await this.#tail;
240
+ await this.stopStrays();
241
+ fs.rmSync(this.temporary, { recursive: true, force: true });
242
+ }
243
+ }
@@ -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
+ }
@@ -0,0 +1,96 @@
1
+ // Web search for company sessions: the provider's own search tool (Codex
2
+ // `web_search`, Claude `web_search_20250305`) through Studio with the current
3
+ // run token, so queries stay with the company accounts and are metered like
4
+ // any other request. The request carries the grant's model and effort, which
5
+ // Studio requires to match.
6
+ const INSTRUCTIONS = 'Search the web for the request. Answer concisely with the facts found and cite every source URL. Prefer official documentation and primary sources.';
7
+
8
+ function sourceList(sources) {
9
+ const seen = new Set(), out = [];
10
+ for (const source of sources) {
11
+ if (typeof source?.url !== 'string' || !/^https?:\/\//.test(source.url)) continue;
12
+ const url = source.url.replace(/[?&]utm_source=openai$/, '');
13
+ if (seen.has(url)) continue;
14
+ seen.add(url); out.push({ title: typeof source.title === 'string' && source.title.trim() ? source.title.trim() : url, url });
15
+ }
16
+ return out.slice(0, 20);
17
+ }
18
+
19
+ function* events(text) {
20
+ for (const line of text.split('\n')) {
21
+ if (!line.startsWith('data:')) continue;
22
+ const data = line.slice(5).trim();
23
+ if (!data || data === '[DONE]') continue;
24
+ try { yield JSON.parse(data); } catch { /* skip keep-alives */ }
25
+ }
26
+ }
27
+
28
+ function parseCodex(text) {
29
+ let output = [];
30
+ const items = [];
31
+ for (const event of events(text)) {
32
+ if (event.type === 'response.output_item.done' && event.item) items.push(event.item);
33
+ if ((event.type === 'response.completed' || event.type === 'response.done') && Array.isArray(event.response?.output)) output = event.response.output;
34
+ }
35
+ if (!output.length) output = items;
36
+ // Sources the answer cites come first, then the other pages searched.
37
+ const answer = [], cited = [], searched = [];
38
+ for (const item of output) {
39
+ if (item?.type === 'message') for (const part of item.content ?? []) {
40
+ if (typeof part?.text === 'string') answer.push(part.text);
41
+ for (const note of part?.annotations ?? []) if (note?.type === 'url_citation') cited.push(note);
42
+ }
43
+ if (item?.type === 'web_search_call') for (const group of [item.action?.sources, item.sources, item.results]) {
44
+ for (const source of Array.isArray(group) ? group : []) searched.push({ url: source?.url, title: source?.title });
45
+ }
46
+ }
47
+ return { answer: answer.join('\n').trim(), sources: sourceList([...cited, ...searched]) };
48
+ }
49
+
50
+ function parseClaude(text) {
51
+ const blocks = [], sources = [];
52
+ let current = null;
53
+ for (const event of events(text)) {
54
+ if (event.type === 'content_block_start') {
55
+ current = event.content_block ?? null;
56
+ if (current?.type === 'text') blocks.push(current.text ?? '');
57
+ if (current?.type === 'web_search_tool_result' && Array.isArray(current.content)) for (const result of current.content) sources.push(result);
58
+ }
59
+ if (event.type === 'content_block_delta') {
60
+ if (event.delta?.type === 'text_delta' && current?.type === 'text') blocks[blocks.length - 1] += event.delta.text ?? '';
61
+ if (event.delta?.type === 'citations_delta') sources.push(event.delta.citation);
62
+ }
63
+ }
64
+ return { answer: blocks.join('').trim(), sources: sourceList(sources) };
65
+ }
66
+
67
+ export async function searchThroughStudio({ provider, origin, token, roleId, model, effort, query, domains, signal, fetchImpl = fetch }) {
68
+ if (typeof query !== 'string' || !query.trim() || query.length > 2000) throw new Error('web-search-query-invalid');
69
+ const allowed = Array.isArray(domains) ? domains.filter((domain) => typeof domain === 'string' && /^[a-z0-9.-]{1,253}$/i.test(domain)).slice(0, 20) : [];
70
+ const claude = provider === 'claude';
71
+ const body = claude
72
+ ? { model, max_tokens: 4096, system: INSTRUCTIONS, stream: true, messages: [{ role: 'user', content: query }],
73
+ tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: 5, ...(allowed.length ? { allowed_domains: allowed } : {}) }],
74
+ ...(effort ? { thinking: { type: 'adaptive' }, output_config: { effort } } : {}) }
75
+ : { model, instructions: INSTRUCTIONS, stream: true, store: false, input: [{ role: 'user', content: [{ type: 'input_text', text: query }] }],
76
+ tools: [{ type: 'web_search', ...(allowed.length ? { filters: { allowed_domains: allowed } } : {}) }],
77
+ include: ['web_search_call.action.sources'], tool_choice: 'required', ...(effort ? { reasoning: { effort } } : {}) };
78
+ const response = await fetchImpl(`${origin}${claude ? '/claude/v1/messages' : '/v1/responses'}`, {
79
+ method: 'POST', signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(120_000)]) : AbortSignal.timeout(120_000),
80
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'text/event-stream', 'X-Session-Id': roleId,
81
+ ...(claude ? { 'anthropic-version': '2023-06-01' } : {}) },
82
+ body: JSON.stringify(body) });
83
+ const text = await response.text();
84
+ if (!response.ok) {
85
+ let code = `http-${response.status}`;
86
+ try { code = JSON.parse(text)?.error?.code ?? code; } catch { /* not JSON */ }
87
+ throw new Error(`web-search-failed: ${String(code).slice(0, 80)}`);
88
+ }
89
+ const result = claude ? parseClaude(text) : parseCodex(text);
90
+ if (!result.answer && !result.sources.length) throw new Error('web-search-empty');
91
+ return result;
92
+ }
93
+
94
+ export function searchResultText({ answer, sources }) {
95
+ return [answer || 'No summary returned.', sources.length ? 'Sources:\n' + sources.map((source, index) => `${index + 1}. ${source.title} — ${source.url}`).join('\n') : ''].filter(Boolean).join('\n\n');
96
+ }