@piagent/platform 1.24.0 → 1.26.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 (88) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +1 -1
  3. package/README.vi.md +1 -1
  4. package/docs/command-reference-vietnamese.md +2 -2
  5. package/docs/company-dev-commands.md +68 -0
  6. package/docs/distribution-standard.md +6 -6
  7. package/docs/harness-conformance.md +154 -0
  8. package/docs/languages.json +2 -2
  9. package/docs/managed-local-acceptance.md +38 -0
  10. package/docs/operator-manual-vietnamese.md +7 -7
  11. package/docs/publishing-for-teams.md +2 -0
  12. package/docs/release-install-policy.md +2 -2
  13. package/docs/team-onboarding.md +2 -2
  14. package/docs-site/architecture.html +1 -1
  15. package/docs-site/benchmark.html +1 -1
  16. package/docs-site/commands.html +1 -1
  17. package/docs-site/content/en/index.html +4 -4
  18. package/docs-site/content/en/whats-new.html +12 -0
  19. package/docs-site/content/index.html +4 -4
  20. package/docs-site/content/whats-new.html +12 -0
  21. package/docs-site/context-engine.html +1 -1
  22. package/docs-site/ecosystem.html +1 -1
  23. package/docs-site/en/architecture.html +1 -1
  24. package/docs-site/en/benchmark.html +1 -1
  25. package/docs-site/en/commands.html +1 -1
  26. package/docs-site/en/context-engine.html +1 -1
  27. package/docs-site/en/ecosystem.html +1 -1
  28. package/docs-site/en/index.html +5 -5
  29. package/docs-site/en/mcp-approval.html +1 -1
  30. package/docs-site/en/mcp-auth.html +1 -1
  31. package/docs-site/en/mcp-servers.html +1 -1
  32. package/docs-site/en/mcp.html +1 -1
  33. package/docs-site/en/permissions.html +1 -1
  34. package/docs-site/en/profiles.html +1 -1
  35. package/docs-site/en/quickstart.html +1 -1
  36. package/docs-site/en/runtime.html +1 -1
  37. package/docs-site/en/scope.html +1 -1
  38. package/docs-site/en/security.html +1 -1
  39. package/docs-site/en/team.html +1 -1
  40. package/docs-site/en/whats-new.html +13 -1
  41. package/docs-site/en/windows.html +1 -1
  42. package/docs-site/en/workflows.html +1 -1
  43. package/docs-site/index.html +5 -5
  44. package/docs-site/mcp-approval.html +1 -1
  45. package/docs-site/mcp-auth.html +1 -1
  46. package/docs-site/mcp-servers.html +1 -1
  47. package/docs-site/mcp.html +1 -1
  48. package/docs-site/permissions.html +1 -1
  49. package/docs-site/profiles.html +1 -1
  50. package/docs-site/quickstart.html +1 -1
  51. package/docs-site/runtime.html +1 -1
  52. package/docs-site/scope.html +1 -1
  53. package/docs-site/security.html +1 -1
  54. package/docs-site/team.html +1 -1
  55. package/docs-site/whats-new.html +13 -1
  56. package/docs-site/windows.html +1 -1
  57. package/docs-site/workflows.html +1 -1
  58. package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
  59. package/package.json +1 -1
  60. package/packages/piagent-core/managed/background-process.mjs +101 -0
  61. package/packages/piagent-core/managed/checkpoints.mjs +69 -0
  62. package/packages/piagent-core/managed/command-tools.mjs +92 -21
  63. package/packages/piagent-core/managed/container-engine.mjs +40 -0
  64. package/packages/piagent-core/managed/continuation.mjs +8 -0
  65. package/packages/piagent-core/managed/doctor.mjs +4 -4
  66. package/packages/piagent-core/managed/host-command.mjs +67 -0
  67. package/packages/piagent-core/managed/language-environment.mjs +8 -2
  68. package/packages/piagent-core/managed/pi-host.mjs +19 -0
  69. package/packages/piagent-core/managed/repository-operation.mjs +39 -17
  70. package/packages/piagent-core/managed/sandbox-diagnostic.mjs +22 -9
  71. package/packages/piagent-core/managed/session.mjs +3 -3
  72. package/packages/piagent-core/managed/tool-boundary.mjs +102 -59
  73. package/packages/piagent-core/managed/tool-worker.mjs +3 -1
  74. package/packages/piagent-core/managed/ts-diagnostics-server.mjs +102 -0
  75. package/packages/piagent-core/managed/ts-diagnostics.mjs +59 -0
  76. package/packages/piagent-core/managed/workflow.mjs +18 -6
  77. package/packages/piagent-core/package.json +1 -1
  78. package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-GDGT_YLS.js → MarkdownMessage-DcFq8SRy.js} +1 -1
  79. package/packages/piagent-webui/dist/client/assets/index-B4PFrM4y.js +232 -0
  80. package/packages/piagent-webui/dist/client/index.html +1 -1
  81. package/packages/piagent-webui/gateway/managed-gateway.mjs +2 -1
  82. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +2 -2
  83. package/packages/piagent-webui/server/transcript-projection.ts +9 -3
  84. package/packages/piagent-webui/shared/process-copy.ts +10 -0
  85. package/schemas/piagent-webui/transcript-v1.schema.json +13 -1
  86. package/scripts/company-terminal.mjs +12 -0
  87. package/scripts/piagent-studio.mjs +20 -7
  88. package/packages/piagent-webui/dist/client/assets/index-BmXL8XrW.js +0 -231
@@ -16,28 +16,50 @@ export function githubRemote(value) {
16
16
  return {repository:`${match[1]}/${match[2]}`,url:`https://github.com/${match[1]}/${match[2]}.git`};
17
17
  }
18
18
 
19
- export async function repositoryFetchPlan(boundary) {
20
- const result=await boundary.invoke('bash',{command:'git config --local --get remote.origin.url',timeout:5});
21
- const remote=githubRemote(text(result));
22
- return Object.freeze({...remote,cwd:boundary.cwd});
19
+ // The repository is the conversation folder, or a folder inside it (a
20
+ // workspace folder holding several repositories).
21
+ export function repositoryFolder(cwd, folder) {
22
+ if (folder === undefined || folder === null || folder === '' || folder === '.') return cwd;
23
+ if (typeof folder !== 'string' || path.isAbsolute(folder) || folder.length > 500) throw Error('managed-repository-path-invalid: path is a folder inside the conversation folder, such as "api".');
24
+ const dir = path.resolve(cwd, folder);
25
+ let real;
26
+ try { real = fs.realpathSync(dir); } catch { throw Error(`managed-repository-not-found: ${folder} does not exist.`); }
27
+ const top = fs.realpathSync(cwd);
28
+ if (real !== top && !real.startsWith(top + path.sep)) throw Error('managed-repository-path-invalid: path is a folder inside the conversation folder.');
29
+ return real;
23
30
  }
24
31
 
25
- function command(binary,args,{cwd,env,signal,secret='',limit=1024*1024}={}) {
32
+ export async function repositoryFetchPlan(boundary, folder) {
33
+ const cwd=repositoryFolder(boundary.cwd,folder);
34
+ let url='';
35
+ try { url=text(await boundary.invoke('bash',{command:`git -C ${quote(cwd)} config --local --get remote.origin.url`,timeout:5})); } catch { url=''; }
36
+ if (!url || /^fatal:|Command exited with code/m.test(url)) throw Error(cwd===boundary.cwd
37
+ ? 'managed-repository-not-found: the conversation folder is not a Git repository. Pass path: the repository folder inside it, such as "api".'
38
+ : `managed-repository-not-found: ${folder} is not a Git repository with an origin remote.`);
39
+ return Object.freeze({...githubRemote(url),cwd});
40
+ }
41
+
42
+ // Git's own error stays out of model context; the way on does not.
43
+ const FETCH_FAILED='managed-repository-fetch-failed: fetch_origin không tải được nhánh từ GitHub (repo cần quyền mà tài khoản gh trên máy không có, gh chưa đăng nhập, mạng lỗi hoặc tải quá lâu). Dùng run_on_host để member duyệt chạy git fetch trên máy bằng tài khoản của họ (ví dụ git -C <thư mục repo> fetch origin <nhánh>).';
44
+ function command(binary,args,{cwd,env,signal,secret='',limit=1024*1024,timeoutMs=60000}={}) {
26
45
  return new Promise((resolve,reject)=>{
27
46
  if(signal?.aborted) return reject(Error('managed-operation-cancelled'));
28
47
  const child=spawn(binary,args,{cwd,env,stdio:['ignore','pipe','pipe']});let output='',size=0,failed=false;
29
48
  const abort=()=>{failed=true;child.kill('SIGKILL')};signal?.addEventListener('abort',abort,{once:true});
30
- const timer=setTimeout(abort,60000);timer.unref();
49
+ const timer=setTimeout(abort,timeoutMs);timer.unref();
31
50
  child.stdout.on('data',chunk=>{size+=chunk.length;if(size>limit)abort();else output+=chunk});
32
51
  // Credential helpers and Git diagnostics are never relayed to model context.
33
52
  child.stderr.on('data',()=>{});child.on('error',()=>{failed=true});
34
53
  child.on('close',code=>{clearTimeout(timer);signal?.removeEventListener('abort',abort);
35
- if(failed||code!==0) reject(Error(signal?.aborted?'managed-operation-cancelled':'managed-repository-fetch-failed'));
54
+ if(failed||code!==0) reject(Error(signal?.aborted?'managed-operation-cancelled':FETCH_FAILED));
36
55
  else resolve(secret?output.replaceAll(secret,'[REDACTED]'):output);
37
56
  });
38
57
  });
39
58
  }
40
59
 
60
+ // Every branch of a repository with many (a team's 90 branches took two
61
+ // minutes): ten minutes, as long as a command may run.
62
+ const FETCH_MS=10*60*1000;
41
63
  export async function fetchRepositoryBundle(plan, destination, signal) {
42
64
  // This private process sees only fixed arguments and an empty bare repo. It
43
65
  // never executes a command, hook, helper, filter or config from the project.
@@ -60,7 +82,7 @@ export async function fetchRepositoryBundle(plan, destination, signal) {
60
82
  await command(git,['init','--bare','--quiet',temporary],{cwd:temporary,env,signal});
61
83
  if(hash(git)!==gitHash)throw Error('managed-runtime-changed');
62
84
  const authentication=token?{GIT_CONFIG_COUNT:'1',GIT_CONFIG_KEY_0:'http.https://github.com/.extraheader',GIT_CONFIG_VALUE_0:'Authorization: Basic '+Buffer.from('x-access-token:'+token).toString('base64')}:{};
63
- await command(git,['-c','credential.helper=','-c','core.hooksPath=/dev/null','-c','http.followRedirects=false','-c','protocol.allow=never','-c','protocol.https.allow=always','fetch','--quiet','--no-tags','--no-recurse-submodules','--depth=100',plan.url,'+refs/heads/*:refs/heads/*'],{cwd:temporary,env:{...env,...authentication},signal,secret:token});
85
+ await command(git,['-c','credential.helper=','-c','core.hooksPath=/dev/null','-c','http.followRedirects=false','-c','protocol.allow=never','-c','protocol.https.allow=always','fetch','--quiet','--no-tags','--no-recurse-submodules','--depth=100',plan.url,'+refs/heads/*:refs/heads/*'],{cwd:temporary,env:{...env,...authentication},signal,secret:token,timeoutMs:FETCH_MS});
64
86
  // A self-contained bundle cannot be shallow. Do not silently fetch an
65
87
  // unbounded history; export fetched objects as a local bare repository.
66
88
  fs.cpSync(temporary,destination,{recursive:true,errorOnExist:true,force:false});
@@ -70,15 +92,15 @@ export async function fetchRepositoryBundle(plan, destination, signal) {
70
92
  }
71
93
 
72
94
  export async function executeRepositoryFetch(boundary,plan,signal,{transport=fetchRepositoryBundle}={}) {
73
- const current=await repositoryFetchPlan(boundary);
95
+ const current=await repositoryFetchPlan(boundary,path.relative(boundary.cwd,plan.cwd));
74
96
  if(current.url!==plan.url||current.cwd!==plan.cwd)throw Error('managed-operation-scope-changed');
75
97
  const staging=path.join(boundary.temporary,'approved-origin-'+Date.now());
76
98
  try {
77
99
  await transport(plan,staging,signal);
78
- const rechecked=await repositoryFetchPlan(boundary);
100
+ const rechecked=await repositoryFetchPlan(boundary,path.relative(boundary.cwd,plan.cwd));
79
101
  if(rechecked.url!==plan.url)throw Error('managed-operation-scope-changed');
80
102
  // Import is sandboxed. Malicious repo hooks/config cannot reach credentials.
81
- await boundary.invoke('bash',{command:`git -c core.hooksPath=/dev/null -c protocol.allow=never -c protocol.file.allow=always fetch --no-tags --no-recurse-submodules --no-write-fetch-head --update-shallow ${quote(staging)} '+refs/heads/*:refs/remotes/origin/*'`,timeout:30},signal);
103
+ await boundary.invoke('bash',{command:`git -C ${quote(plan.cwd)} -c core.hooksPath=/dev/null -c protocol.allow=never -c protocol.file.allow=always fetch --no-tags --no-recurse-submodules --no-write-fetch-head --update-shallow ${quote(staging)} '+refs/heads/*:refs/remotes/origin/*'`,timeout:120},signal);
82
104
  return {content:[{type:'text',text:`Fetched origin branches for ${plan.repository}. Working files and current branch were not changed.`}],details:{operation:'git.fetch',repository:plan.repository}};
83
105
  } finally {fs.rmSync(staging,{recursive:true,force:true});}
84
106
  }
@@ -86,17 +108,17 @@ export async function executeRepositoryFetch(boundary,plan,signal,{transport=fet
86
108
  // The fetch_origin tool of a company conversation: one approved fetch of the
87
109
  // GitHub origin (Bypass fetches without asking).
88
110
  export function fetchOriginTool(self) {
89
- return {name:'fetch_origin',label:'Fetch origin',description:'Request one approved GitHub origin fetch. Refreshes origin branches without changing working files. Does not expose credentials or allow arbitrary network commands.',
90
- parameters:{type:'object',properties:{},additionalProperties:false},
91
- execute:async(id,_args,signal,_update,ctx)=>{
92
- const plan=await repositoryFetchPlan(self.boundary);
111
+ return {name:'fetch_origin',label:'Fetch origin',description:'Request one approved GitHub origin fetch. Refreshes origin branches without changing working files. Does not expose credentials or allow arbitrary network commands. When the conversation folder holds several repositories, pass path: the repository folder inside it. Use this instead of git fetch, which has no GitHub sign-in here.',
112
+ parameters:{type:'object',properties:{path:{type:'string',maxLength:500,description:'Repository folder inside the conversation folder, such as "api"; omit when the conversation folder is the repository.'}},additionalProperties:false},
113
+ execute:async(id,args,signal,_update,ctx)=>{
114
+ const plan=await repositoryFetchPlan(self.boundary,args?.path);
93
115
  if(self.permission==='trusted-full-access'&&!signal?.aborted)return executeRepositoryFetch(self.boundary,plan,signal);
94
116
  const {piApprovalBroker}=await import('../runtime/inspection/approval-broker.ts');
95
117
  const decision=await piApprovalBroker.request({cwd:self.cwd,rawSessionId:self.session.sessionManager.getSessionId(),toolCallId:id,
96
118
  action:{kind:'external-provider-action',preconditionClass:'runtime-only',toolName:'fetch_origin',rawAction:plan,
97
- targetPaths:[self.cwd],provider:'github',urlOrigin:'https://github.com',requestedScope:'fetch-origin-once',
119
+ targetPaths:[plan.cwd],provider:'github',urlOrigin:'https://github.com',requestedScope:'fetch-origin-once',
98
120
  reason:`Fetch origin branches from ${plan.repository}`,riskClass:'low',allowConsequence:'Download origin branches once; keep the current branch and working files unchanged.',denyConsequence:'No network request or credential read.'},
99
- terminalConfirm:()=>ctx?.ui?.confirm?.('Tải nhánh từ origin',`Tải các nhánh của ${plan.repository} về ${self.cwd} một lần.\n\nĐồng ý: chỉ tải nhánh; nhánh hiện tại và file đang làm không đổi.\nTừ chối: không gửi request mạng, không đọc thông tin đăng nhập.`)??Promise.resolve(false),
121
+ terminalConfirm:()=>ctx?.ui?.confirm?.('Tải nhánh từ origin',`Tải các nhánh của ${plan.repository} về ${plan.cwd} một lần.\n\nĐồng ý: chỉ tải nhánh; nhánh hiện tại và file đang làm không đổi.\nTừ chối: không gửi request mạng, không đọc thông tin đăng nhập.`)??Promise.resolve(false),
100
122
  unavailableFallback:'terminal-confirm',recheck:()=>!signal?.aborted&&Boolean(self.grant)});
101
123
  if(!decision.allowed||!decision.consume()||signal?.aborted)throw Error('managed-operation-denied');
102
124
  return executeRepositoryFetch(self.boundary,plan,signal);
@@ -1,26 +1,39 @@
1
+ // What a failed command tells the agent. Every environment failure names the
2
+ // way on (owner, 2026-10-13): a tool that asks the member's approval
3
+ // (run_with_network, run_with_docker, start_docker, fetch_origin, run_on_host)
4
+ // or a question to the member (ask_user), never "tell the user and stop".
1
5
  const DENIAL = /\b(?:EPERM|EACCES)\b|operation not permitted|permission denied/i;
2
6
  // A download that never left the machine: resolvers, npm/pip, and the curl or
3
7
  // wget a build wrapper runs (mvnw/gradlew fetch their distribution: "curl:
4
- // Failed to fetch https://repo.maven.apache.org/…").
5
- const OFFLINE = /ENOTFOUND|EAI_AGAIN|Could not resolve host|getaddrinfo|npm error network|Failed to establish a new connection|\bcurl: (?:\(\d+\) )?Failed to (?:fetch|connect)|\bwget: unable to resolve|Unknown host|UnknownHostException/i;
8
+ // Failed to fetch https://repo.maven.apache.org/…", "wget: Failed to fetch …").
9
+ const OFFLINE = /ENOTFOUND|EAI_AGAIN|Could not resolve host|getaddrinfo|npm error network|Failed to establish a new connection|\bcurl: (?:\(\d+\) )?Failed to (?:fetch|connect)|\bwget: (?:unable to resolve|Failed to fetch)|Unknown host|UnknownHostException/i;
6
10
  // `network`: the member approved this command and it ran with network, so a
7
11
  // name that does not resolve is the machine's network, not the sandbox.
8
12
  // `isolated`: a proxy listens on this Mac, so without approval a command does
9
13
  // not reach this Mac's own servers either (tool-boundary.mjs).
14
+ // Node's fetch reports that refusal as a bare "fetch failed" when a test
15
+ // runner prints the error without its cause (node --test on Node 22).
10
16
  // `docker`: the command ran with the Docker engine (run_with_docker);
11
17
  // `engine`: one is there for run_with_docker, but this command ran without it.
12
18
  const DOCKER_DAEMON = /Cannot connect to the Docker daemon|connect to the docker API|docker\.sock|Is the docker daemon running|Could not find a valid Docker environment/i;
19
+ // A runtime or tool the command needs is not on this machine (or not the
20
+ // version the project asks for).
21
+ const TOOLCHAIN = /Unable to locate a Java Runtime|No Java runtime present|JAVA_HOME is not (?:set|defined correctly)|(?:^|\n)\S*?(?:ba|z)?sh: (?:line \d+: )?[\w.+-]+: command not found|zsh: command not found: [\w.+-]+|xcrun: error: (?:unable to find utility|invalid active developer path)|env: '?[\w.+-]+'?: No such file or directory|Unsupported class file major version|invalid target release: \d+|release version \d+ not supported|requires? (?:at least )?Java \d+|The engine "node" is incompatible|Unsupported engine/i;
22
+ // The member's sign-in, which the sandbox does not have.
23
+ const SIGN_IN = /could not read (?:Username|Password) for|terminal prompts disabled|Authentication failed for|Permission denied \(publickey|Host key verification failed|not logged into any GitHub hosts|please run:\s+gh auth login|Could not open a connection to your authentication agent|\bENEEDAUTH\b|\bE401\b|\b401 Unauthorized|requires authentication|denied: requested access to the resource is denied|pull access denied/i;
24
+
13
25
  export function sandboxDiagnostic(message, { network = false, isolated = false, docker = false, engine = false } = {}) {
14
26
  const text=String(message);
15
- if(docker&&DOCKER_DAEMON.test(text))return 'managed-docker-unreachable: Lệnh chạy với Docker nhưng Docker engine không trả lời (Docker Desktop/Colima chưa chạy hoặc đang khởi động). Không xin duyệt lại cùng lệnh; báo người dùng mở Docker rồi thử lại.\n'+text;
16
- if(!docker&&DOCKER_DAEMON.test(text))return (engine?'managed-docker-blocked: Lệnh bash thường không dùng được Docker trong chế độ công ty. Lệnh docker, docker compose, hoặc test cần Docker (Testcontainers) chạy qua run_with_docker để người dùng duyệt đúng lệnh đó.\n':'managed-docker-unavailable: Máy chưa có Docker engine đang chạy (Docker Desktop, Colima, OrbStack…). Báo người dùng mở Docker; sau đó cuộc trò chuyện mới có run_with_docker.\n')+text;
17
- if(isolated&&!/CONNECT tunnel failed|tunneling socket|statusCode=403|response 403/i.test(text)&&/ECONNREFUSED|EPERM|connect|Connection refused|could not connect/i.test(text))return 'managed-loopback-blocked: Máy đang chạy một proxy cục bộ, nên lệnh không có mạng cũng không được kết nối tới server trên localhost (proxy có thể đưa lệnh ra internet). Test cần server hoặc database trên localhost: dùng run_with_network để người dùng duyệt đúng lệnh đó.\n'+text;
18
- if(network&&!DENIAL.test(text)&&OFFLINE.test(text))return 'managed-network-unreachable: Lệnh đã được duyệt và chạy có mạng nhưng không tới được host (máy đang offline, cần VPN/proxy công ty, hoặc sai tên host). Không xin duyệt lại cùng lệnh; báo người dùng lỗi mạng này.\n'+text;
27
+ if(docker&&DOCKER_DAEMON.test(text))return 'managed-docker-unreachable: Lệnh chạy với Docker nhưng Docker engine không trả lời (Docker Desktop/Colima/OrbStack chưa chạy hoặc đang khởi động). Gọi start_docker để xin member duyệt mở Docker, rồi chạy lại đúng lệnh này bằng run_with_docker.\n'+text;
28
+ if(!docker&&DOCKER_DAEMON.test(text))return (engine?'managed-docker-blocked: Lệnh bash thường không dùng được Docker trong chế độ công ty. Lệnh docker, docker compose, hoặc test cần Docker (Testcontainers) chạy qua run_with_docker để người dùng duyệt đúng lệnh đó.\n':'managed-docker-unavailable: Docker engine trên máy chưa chạy. Gọi start_docker để xin member duyệt mở Docker, rồi chạy lệnh bằng run_with_docker.\n')+text;
29
+ if(TOOLCHAIN.test(text))return 'managed-toolchain-missing: Máy thiếu runtime/công cụ mà lệnh cần, hoặc sai version project yêu cầu. Đừng dừng ở đây: xem project cần gì (pom.xml, build.gradle, .nvmrc, .tool-versions, package.json engines…) rồi dùng run_on_host để xin member duyệt cài trên máy (ví dụ brew install openjdk@21, brew install node@22, sdk install java 21-tem), sau đó chạy lại lệnh. Nếu project build được trong Docker thì có thể đề xuất run_with_docker. Trình cài cần mật khẩu (sudo, file .pkg) thì hỏi member bằng ask_user.\n'+text;
30
+ if(SIGN_IN.test(text))return 'managed-credentials-unavailable: Lệnh cần tài khoản đăng nhập của member (git qua HTTPS/SSH, registry riêng, image riêng), sandbox không có. git fetch dùng fetch_origin; git pull, git push, cài từ registry riêng hoặc pull image riêng thì dùng run_on_host để member duyệt chạy đúng lệnh đó trên máy bằng tài khoản của họ.\n'+text;
31
+ if(isolated&&!/CONNECT tunnel failed|tunneling socket|statusCode=403|response 403/i.test(text)&&/ECONNREFUSED|EPERM|connect|Connection refused|could not connect|fetch failed/i.test(text))return 'managed-loopback-blocked: Máy đang chạy một proxy cục bộ, nên lệnh không có mạng cũng không được kết nối tới server trên localhost (proxy có thể đưa lệnh ra internet). Test cần server hoặc database trên localhost: dùng run_with_network để người dùng duyệt đúng lệnh đó.\n'+text;
32
+ if(network&&!DENIAL.test(text)&&OFFLINE.test(text))return 'managed-network-unreachable: Lệnh đã được duyệt và chạy có mạng nhưng không tới được host (máy offline, cần VPN/proxy công ty, hoặc sai tên host). Hỏi member bằng ask_user, kèm lựa chọn "Đã kết nối mạng/VPN, chạy lại" và "Bỏ qua bước này"; member trả lời xong thì làm theo.\n'+text;
19
33
  if(!DENIAL.test(text)&&OFFLINE.test(text))return 'managed-network-blocked: Lệnh bash thường không có mạng trong chế độ công ty. Nếu lệnh thật sự cần mạng (cài package, tải dữ liệu), dùng run_with_network để người dùng duyệt đúng lệnh đó.\n'+text;
20
34
  if(!DENIAL.test(text))return text;
21
35
  // /tmp is shared by every process on the Mac, so it stays read-only; each
22
36
  // command has its own temporary folder in $TMPDIR (Linux: a private /tmp).
23
- if(/(?:^|[\s'"=:(])\/(?:private\/)?tmp\//.test(text))return 'managed-sandbox-denied: /tmp chỉ được đọc trong chế độ công ty. Ghi file tạm (log, kết quả test) vào "$TMPDIR" (thư mục tạm riêng của lệnh, ví dụ "$TMPDIR/test.log") hoặc trong project. Không tự chạy lại bên ngoài sandbox.\n'+text;
24
- return 'managed-sandbox-denied: Công cụ bị giới hạn quyền trong chế độ công ty (hoặc quyền file của hệ điều hành). Keychain và file chứa credential như .env/.npmrc bị chặn. Lệnh cần mạng (cài package, tải dữ liệu) dùng run_with_network để xin duyệt; git fetch origin dùng fetch_origin. Không tự chạy lại bên ngoài sandbox.\n'+text;
37
+ if(/(?:^|[\s'"=:(])\/(?:private\/)?tmp\//.test(text))return 'managed-sandbox-denied: /tmp chỉ được đọc trong chế độ công ty. Ghi file tạm (log, kết quả test) vào "$TMPDIR" (thư mục tạm riêng của lệnh, ví dụ "$TMPDIR/test.log") hoặc trong project.\n'+text;
38
+ return 'managed-sandbox-denied: Sandbox chặn thao tác này (ghi ngoài project, Keychain, file credential như .env/.npmrc, thư mục hệ thống). Nếu task thật sự cần nó, dùng run_on_host để member duyệt chạy đúng lệnh đó trên máy, ngoài sandbox. Lệnh chỉ cần mạng thì dùng run_with_network; git fetch dùng fetch_origin.\n'+text;
25
39
  }
26
-
@@ -3,7 +3,7 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { randomUUID, createHash } from 'node:crypto';
5
5
  import { pathToFileURL, fileURLToPath } from 'node:url';
6
- import { ManagedToolBoundary } from './tool-boundary.mjs';
6
+ import { ManagedToolBoundary, PI_HOST_VERSION, hostPackage } from './tool-boundary.mjs';
7
7
  import { ensureSearchTools, trustSystemCertificates } from './toolchain.mjs';
8
8
  import { inlineExtensions, managedResourceLoader, projectInstructions } from './resource-loader.mjs';
9
9
  import { fetchPublicPage, fetchLimits } from './web-fetch.mjs';
@@ -91,10 +91,10 @@ export class ManagedSession {
91
91
  // the conversation enrolls again before its next run.
92
92
  static async create({ sdkRoot, cwd, origin, broker, protectedRoots = [], agentDir, sessionManager, renewBroker = null, bindingRevision = null, terminalExtensions = [] }) {
93
93
  const sdk = fs.realpathSync(sdkRoot);
94
- if (JSON.parse(fs.readFileSync(path.join(sdk, 'package.json'))).version !== '0.87.1') throw Error('managed-sdk-version-unqualified');
94
+ if (JSON.parse(fs.readFileSync(path.join(sdk, 'package.json'))).version !== PI_HOST_VERSION) throw Error('managed-sdk-version-unqualified');
95
95
  await trustSystemCertificates();
96
96
  const api = await import(pathToFileURL(path.join(sdk, 'dist/index.js')));
97
- const ai = await import(pathToFileURL(path.join(sdk, 'node_modules/@earendil-works/pi-ai/dist/index.js')));
97
+ const ai = await import(pathToFileURL(path.join(hostPackage(sdk, '@earendil-works/pi-ai'), 'dist/index.js')));
98
98
  const manifest = usable(await patiently(() => broker.request('config'), 3));
99
99
  const self = new ManagedSession(); Object.assign(self, { sdk, cwd: fs.realpathSync(cwd), origin: safeOrigin(origin), broker, api, ai, manifest, protectedRoots, helpers: new Map(), helperCalls: new Map(), ownPaths: new Set(), listeners: new Set(), capacityWaits: new Set(), routes: new WeakMap(), grant: null,
100
100
  renewBroker, bindingRevision, bindingSeen: bindingRevision?.() ?? null, preflight: null, blocked: null });
@@ -8,7 +8,9 @@ import { createHash, randomUUID } from 'node:crypto';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { developerLicense, developerTools, managedGit, searchToolPath, userToolchains } from './toolchain.mjs';
10
10
  import { languageEnvironment } from './language-environment.mjs';
11
- import { containerEngine, engineEnvironment } from './container-engine.mjs';
11
+ import { PI_HOST_VERSION, hostPackage } from './pi-host.mjs';
12
+ export { PI_HOST_VERSION, hostPackage };
13
+ import { containerEngine, dockerCli, engineEnvironment } from './container-engine.mjs';
12
14
  import { DEFAULT_DOMAINS, PackageProxy, mavenSettings, memberDomains, proxyEnvironment } from './package-proxy.mjs';
13
15
  import { BWRAP, bubblewrapArgs, credentialFiles, referenceFolders } from './linux-sandbox.mjs';
14
16
  import { agentWatchDataDirectory } from './store.mjs';
@@ -205,8 +207,8 @@ export class ManagedToolBoundary {
205
207
  this.runtimeRoot = fs.realpathSync(path.dirname(workerPath));
206
208
  this.sdkPath = path.join(this.sdkRoot, 'dist/index.js');
207
209
  this.git = managedGit();
208
- if (JSON.parse(fs.readFileSync(path.join(this.sdkRoot, 'package.json'), 'utf8')).version !== '0.87.1') throw new Error('managed-sdk-version-unqualified');
209
- const workerModules = ['search-fallback.mjs', 'sandbox-diagnostic.mjs'].map(name => path.join(this.runtimeRoot, name));
210
+ if (JSON.parse(fs.readFileSync(path.join(this.sdkRoot, 'package.json'), 'utf8')).version !== PI_HOST_VERSION) throw new Error('managed-sdk-version-unqualified');
211
+ const workerModules = ['search-fallback.mjs', 'sandbox-diagnostic.mjs', 'ts-diagnostics-server.mjs'].map(name => path.join(this.runtimeRoot, name));
210
212
  this.pins = new Map([this.node, this.git, workerPath, ...workerModules, this.sdkPath].map(file => [file, hash(file)]));
211
213
  const userHome = fs.realpathSync(home ?? os.homedir());
212
214
  if ([userHome, '/', this.sdkRoot, this.runtimeRoot].includes(this.cwd)
@@ -229,11 +231,6 @@ export class ManagedToolBoundary {
229
231
  // A repository above the home folder (a dotfiles repo) is never opened:
230
232
  // neither read as a monorepo nor writable through its git directory.
231
233
  const gitDirs = repo && (repo.top === this.cwd || allowedRoot(repo.top)) ? [...new Set([repo.gitDir, repo.commonDir])].filter(dir => !inside(dir, this.cwd) && allowedRoot(dir)) : [];
232
- const toolchains = userToolchains(userHome, this.node), developer = developerTools(), browsers = playwrightBrowsers(userHome);
233
- const languages = languageEnvironment({ userHome, repositoryTop: this.repositoryTop, home: this.home });
234
- this.packageCache = languages.cache;
235
- // The Docker engine, for commands the member approves (run_with_docker).
236
- this.engine = readOnly ? null : containerEngine({ userHome });
237
234
  // A skill folder opens for reading only inside the agent folders (.claude,
238
235
  // .codex, .pi, .agents) or outside the home folder's hidden entries, and
239
236
  // never when it holds a closed folder (a link to ~/.ssh, the home folder).
@@ -241,42 +238,77 @@ export class ManagedToolBoundary {
241
238
  const skills = [...new Set(skillFolders.flatMap(dir => { try { return [fs.realpathSync(dir)]; } catch { return []; } }))]
242
239
  .filter(dir => dir !== '/' && !/[\x00-\x1f\x7f]/.test(dir) && !narrowed.some(root => inside(root, dir)) && !inside(userHome, dir)
243
240
  && (!inside(dir, userHome) || agentFolders.some(root => inside(dir, root)) || !path.relative(userHome, dir).startsWith('.')));
244
- const base = { cwd: this.cwd, sdkRoot: this.sdkRoot, runtimeRoot: this.runtimeRoot, temporary: this.temporary, node: this.node,
245
- denied: narrowed, readOnly, repository, gitDirs, toolchains: [...toolchains.roots, ...languages.readRoots], developer: developer.roots,
246
- browsers: browsers ? [browsers] : [], skills, caches: languages.writeRoots, license: developerLicense() };
247
241
  this.userHome = userHome;
248
242
  // Only for commands the user approved (run_with_network); credentials stay
249
243
  // denied, and so do reference folders: what a command with network can
250
244
  // read is the project, not the member's other files.
251
245
  this.networkAllowed = !readOnly;
252
- if (this.linux) {
253
- const references = referenceFolders(userHome).filter(dir => !narrowed.some(root => inside(dir, root) || inside(root, dir)));
254
- const empty = path.join(this.temporary, 'empty'); fs.writeFileSync(empty, '', { mode: 0o400 });
255
- this.linuxBase = { ...base, toolchains: [...base.toolchains, ...base.browsers], references, empty, engineRoots: this.engine?.readRoots ?? [] };
256
- this.referenceCredentials = credentialFiles(references, { limit: 30_000 });
257
- this.projectCredentials = { at: 0, files: [] };
258
- } else {
259
- this.profile = managedSeatbelt({ ...base, references: userHome });
260
- // The same without loopback, while a local proxy listens (localProxyListening).
261
- this.isolatedProfile = managedSeatbelt({ ...base, references: userHome, loopback: false });
262
- this.proxyPorts = proxyPorts ?? localProxyPorts(); this.proxyCheck = { at: 0, listening: Promise.resolve(false) };
263
- this.networkProfile = readOnly ? null : managedSeatbelt({ ...base, network: true });
264
- // An approved network command that also reaches the Docker engine's socket.
265
- this.dockerProfile = this.engine ? managedSeatbelt({ ...base, network: true, docker: this.engine.socket, toolchains: [...base.toolchains, ...this.engine.readRoots] }) : null;
266
- }
267
246
  // Every process a command starts carries this marker, also one it detaches.
268
247
  this.scope = randomUUID();
269
- this.environment = toolEnvironment({ git: this.git, node: this.node, home: this.home, temporary: this.temporary, toolchains, developer, scope: this.scope, browsers, languages,
270
- identity: identity !== undefined ? identity : gitIdentity(this.git, this.cwd) });
271
- if (this.engine) this.environment.docker = { ...this.environment.network, ...engineEnvironment(this.engine, this.home) };
248
+ this.setup = { narrowed, readOnly, repository, gitDirs, skills, identity: identity !== undefined ? identity : gitIdentity(this.git, this.cwd) };
249
+ if (this.linux) {
250
+ this.references = referenceFolders(userHome).filter(dir => !narrowed.some(root => inside(dir, root) || inside(root, dir)));
251
+ this.empty = path.join(this.temporary, 'empty'); fs.writeFileSync(this.empty, '', { mode: 0o400 });
252
+ this.referenceCredentials = credentialFiles(this.references, { limit: 30_000 });
253
+ this.projectCredentials = { at: 0, files: [] };
254
+ } else { this.proxyPorts = proxyPorts ?? localProxyPorts(); this.proxyCheck = { at: 0, listening: Promise.resolve(false) }; }
255
+ this.#configure();
272
256
  // Package registries without asking (package-proxy.mjs): commands that
273
257
  // write the project reach them through the runtime's proxy; read-only
274
258
  // helpers keep no network at all.
275
- if (!readOnly) this.proxyReady = this.startProxy(base, languages);
259
+ if (!readOnly) this.proxyReady = this.startProxy();
276
260
  if (searchTools) provisionSearchTools(this.home, userHome);
277
261
  else fs.writeFileSync(path.join(this.home, '.ripgreprc'), '', { mode: 0o600 });
278
262
  this.allowed = readOnly ? TOOL_NAMES.filter(name => !['write', 'edit'].includes(name) && (name !== 'bash' || commands)) : [...TOOL_NAMES];
279
263
  }
264
+ // What the machine offers commands: toolchains, the JDK, the Docker engine.
265
+ // Looked up again after the member installs or starts something
266
+ // (run_on_host, start_docker), so the conversation sees it without a restart.
267
+ #configure() {
268
+ const { narrowed, readOnly, repository, gitDirs, skills, identity } = this.setup, userHome = this.userHome;
269
+ const toolchains = userToolchains(userHome, this.node), developer = developerTools(), browsers = playwrightBrowsers(userHome);
270
+ const languages = languageEnvironment({ userHome, repositoryTop: this.repositoryTop, home: this.home });
271
+ this.packageCache = languages.cache; this.languages = languages;
272
+ // The Docker engine, for commands the member approves (run_with_docker).
273
+ this.engine = readOnly ? null : containerEngine({ userHome });
274
+ this.dockerCli = readOnly ? null : this.engine?.cli ?? dockerCli();
275
+ // What the host loads: its own folder, and the node_modules its
276
+ // dependencies were hoisted to when they are not nested in it (Pi 1.x).
277
+ const ai = hostPackage(this.sdkRoot, '@earendil-works/pi-ai'), hostRoot = inside(fs.realpathSync(ai), this.sdkRoot) ? this.sdkRoot : fs.realpathSync(path.dirname(path.dirname(ai)));
278
+ const base = this.base = { cwd: this.cwd, sdkRoot: hostRoot, runtimeRoot: this.runtimeRoot, temporary: this.temporary, node: this.node,
279
+ denied: narrowed, readOnly, repository, gitDirs, toolchains: [...toolchains.roots, ...languages.readRoots], developer: developer.roots,
280
+ browsers: browsers ? [browsers] : [], skills, caches: languages.writeRoots, license: developerLicense() };
281
+ if (this.linux) this.linuxBase = { ...base, toolchains: [...base.toolchains, ...base.browsers], references: this.references, empty: this.empty, engineRoots: this.engine?.readRoots ?? [] };
282
+ else {
283
+ this.networkProfile = readOnly ? null : managedSeatbelt({ ...base, network: true });
284
+ // An approved network command that also reaches the Docker engine's socket.
285
+ this.dockerProfile = this.engine ? managedSeatbelt({ ...base, network: true, docker: this.engine.socket, toolchains: [...base.toolchains, ...this.engine.readRoots] }) : null;
286
+ }
287
+ this.environment = toolEnvironment({ git: this.git, node: this.node, home: this.home, temporary: this.temporary, toolchains, developer, scope: this.scope, browsers, languages, identity });
288
+ if (this.engine) this.environment.docker = { ...this.environment.network, ...engineEnvironment(this.engine, this.home) };
289
+ this.#applyProxy();
290
+ }
291
+ // Plain commands' profile and environment, with the package proxy once it listens.
292
+ #applyProxy() {
293
+ const port = this.proxyUrl && !this.linux ? Number(new URL(this.proxyUrl).port) : null;
294
+ if (!this.linux) {
295
+ // TLS through the macOS Security framework (Go, Swift, .NET) verifies
296
+ // the registries' certificates with the system's trust services.
297
+ this.profile = managedSeatbelt({ ...this.base, references: this.userHome, ...(port ? { proxyPort: port } : {}) });
298
+ // The same without loopback, while a local proxy listens (localProxyListening).
299
+ this.isolatedProfile = managedSeatbelt({ ...this.base, references: this.userHome, loopback: false, ...(port ? { proxyPort: port } : {}) });
300
+ }
301
+ if (!this.proxyUrl) return;
302
+ const offline = this.environment.offline;
303
+ for (const key of ['npm_config_fetch_retries', 'npm_config_fetch_timeout', 'PIP_RETRIES', 'PIP_TIMEOUT']) delete offline[key];
304
+ Object.assign(offline, proxyEnvironment(this.proxyUrl, { mavenOpts: offline.MAVEN_OPTS, gradleOpts: offline.GRADLE_OPTS, mavenHome: this.home }), { GOPROXY: this.languages.env.network.GOPROXY },
305
+ this.linux ? { PIAGENT_PROXY_SOCKET: '/run/piagent-proxy.sock' } : {});
306
+ }
307
+ // Looks the machine up again; true when the Docker engine is there now.
308
+ refreshEnvironment() {
309
+ if (!this.#closed) this.#configure();
310
+ return Boolean(this.engine);
311
+ }
280
312
  tools(api) {
281
313
  const factories = { read: api.createReadToolDefinition, write: api.createWriteToolDefinition,
282
314
  edit: api.createEditToolDefinition, bash: api.createBashToolDefinition,
@@ -291,7 +323,7 @@ export class ManagedToolBoundary {
291
323
  // The proxy listens on loopback (macOS: Seatbelt opens its one port even
292
324
  // while a local proxy closes the rest of loopback) or on a Unix socket that
293
325
  // the worker forwards from inside the command's network namespace (Linux).
294
- async startProxy(base, languages) {
326
+ async startProxy() {
295
327
  this.proxy = new PackageProxy({ domains: [...DEFAULT_DOMAINS, ...memberDomains(this.userHome)] });
296
328
  try {
297
329
  let url;
@@ -299,21 +331,10 @@ export class ManagedToolBoundary {
299
331
  this.proxySocket = path.join(os.tmpdir(), `piagent-proxy-${randomUUID().slice(0, 8)}.sock`);
300
332
  await this.proxy.listen(this.proxySocket);
301
333
  url = 'http://127.0.0.1:3128';
302
- this.environment.offline.PIAGENT_PROXY_SOCKET = '/run/piagent-proxy.sock';
303
- } else {
304
- const { port } = await this.proxy.listen();
305
- url = `http://127.0.0.1:${port}`;
306
- // TLS through the macOS Security framework (Go, Swift, .NET) verifies
307
- // the registries' certificates with the system's trust services.
308
- this.profile = managedSeatbelt({ ...base, references: this.userHome, proxyPort: port });
309
- this.isolatedProfile = managedSeatbelt({ ...base, references: this.userHome, loopback: false, proxyPort: port });
310
- }
311
- const offline = this.environment.offline;
312
- for (const key of ['npm_config_fetch_retries', 'npm_config_fetch_timeout', 'PIP_RETRIES', 'PIP_TIMEOUT']) delete offline[key];
334
+ } else url = `http://127.0.0.1:${(await this.proxy.listen()).port}`;
313
335
  const settings = path.join(this.home, '.m2/settings.xml');
314
336
  fs.mkdirSync(path.dirname(settings), { recursive: true, mode: 0o700 }); fs.writeFileSync(settings, mavenSettings(url), { mode: 0o600 });
315
- Object.assign(offline, proxyEnvironment(url, { mavenOpts: offline.MAVEN_OPTS, gradleOpts: offline.GRADLE_OPTS, mavenHome: this.home }), { GOPROXY: languages.env.network.GOPROXY });
316
- this.proxyUrl = url;
337
+ this.proxyUrl = url; this.#applyProxy();
317
338
  } catch { await this.proxy.close().catch(() => {}); this.proxy = null; }
318
339
  }
319
340
  async invoke(name, args, signal, onUpdate, model, { network = false, docker = false } = {}) {
@@ -420,28 +441,50 @@ export class ManagedToolBoundary {
420
441
  async stopStrays() {
421
442
  // On Linux a command's processes end with it (its own PID namespace).
422
443
  if (this.#children.size || this.linux) return 0;
423
- const marker = `PIAGENT_TOOL_SCOPE=${this.scope}`, find = () => {
424
- try {
425
- return execFileSync('/bin/ps', ['-axwwE', '-o', 'pid=,command='], { encoding: 'utf8', maxBuffer: 64 << 20, timeout: 5000 }).split('\n')
426
- .filter(row => row.includes(marker)).map(row => Number(row.trim().split(/\s+/)[0])).filter(pid => Number.isSafeInteger(pid) && pid > 1 && pid !== process.pid);
427
- } catch { return []; }
428
- };
429
- const strays = find();
430
- for (const pid of strays) try { process.kill(pid, 'SIGTERM'); } catch {}
431
- for (let waited = 0; strays.length && waited < 2000; waited += 100) {
432
- await new Promise(resolve => setTimeout(resolve, 100));
433
- if (!find().length) break;
434
- }
435
- for (const pid of find()) try { process.kill(pid, 'SIGKILL'); } catch {}
436
- return strays.length;
444
+ return stopMarked(`PIAGENT_TOOL_SCOPE=${this.scope}`);
445
+ }
446
+ // A process the agent keeps running across turns (background-process.mjs):
447
+ // the plain command sandbox and environment, marked as this conversation's
448
+ // background process instead of the turn's, so stopStrays leaves it.
449
+ async spawnBackground(command, key) { return this.spawnSandboxed(['/bin/bash', '-c', command], key); }
450
+ // `argv` in the plain command sandbox; stdin 'pipe' for a process that
451
+ // answers requests (ts-diagnostics.mjs).
452
+ async spawnSandboxed(argv, key, { stdin = 'ignore' } = {}) {
453
+ if (this.#closed || this.linux || !this.allowed.includes('bash') || !/^[0-9a-f-]{36}$/.test(key)) throw new Error('managed-tool-unavailable');
454
+ for (const [file, digest] of this.pins) if (hash(file) !== digest) throw new Error('managed-runtime-changed');
455
+ if (Date.now() - this.proxyCheck.at > 5000) this.proxyCheck = { at: Date.now(), listening: localProxyListening(this.proxyPorts) };
456
+ const isolated = await this.proxyCheck.listening, { PIAGENT_TOOL_SCOPE: _turn, ...env } = this.environment.offline;
457
+ const child = spawn('/usr/bin/sandbox-exec', ['-p', isolated ? this.isolatedProfile : this.profile, ...argv],
458
+ { cwd: this.cwd, detached: true, stdio: [stdin, 'pipe', 'pipe'], env: { ...env, PIAGENT_PROCESS_SCOPE: `${this.scope}:${key}` } });
459
+ return { child, isolated };
437
460
  }
461
+ stopBackground(key) { return stopMarked(`PIAGENT_PROCESS_SCOPE=${this.scope}:${key}`); }
438
462
  async dispose() {
439
463
  this.#closed = true;
440
464
  const stopping = [...this.#children].map(child => new Promise(resolve => { child.once('close', resolve); child.managedCancel(); }));
441
465
  await Promise.all(stopping); await this.#tail;
442
- await this.stopStrays();
466
+ await this.background?.dispose(); await this.stopStrays(); if (!this.linux) await stopMarked(`PIAGENT_PROCESS_SCOPE=${this.scope}:`);
443
467
  await this.proxyReady; await this.proxy?.close().catch(() => {});
444
468
  if (this.proxySocket) fs.rmSync(this.proxySocket, { force: true });
445
469
  fs.rmSync(this.temporary, { recursive: true, force: true });
446
470
  }
447
471
  }
472
+
473
+ // Stops every process whose environment carries `marker` (ps -E), then
474
+ // whatever is left after two seconds.
475
+ async function stopMarked(marker) {
476
+ const find = () => {
477
+ try {
478
+ return execFileSync('/bin/ps', ['-axwwE', '-o', 'pid=,command='], { encoding: 'utf8', maxBuffer: 64 << 20, timeout: 5000 }).split('\n')
479
+ .filter(row => row.includes(marker)).map(row => Number(row.trim().split(/\s+/)[0])).filter(pid => Number.isSafeInteger(pid) && pid > 1 && pid !== process.pid);
480
+ } catch { return []; }
481
+ };
482
+ const found = find();
483
+ for (const pid of found) try { process.kill(pid, 'SIGTERM'); } catch {}
484
+ for (let waited = 0; found.length && waited < 2000; waited += 100) {
485
+ await new Promise(resolve => setTimeout(resolve, 100));
486
+ if (!find().length) break;
487
+ }
488
+ for (const pid of find()) try { process.kill(pid, 'SIGKILL'); } catch {}
489
+ return found.length;
490
+ }
@@ -43,7 +43,9 @@ input.on('line', async line => {
43
43
  : await factories[message.name](cwd, options).execute('managed-tool', message.args, controller.signal,
44
44
  update => emit({ update }), { cwd, model: message.model });
45
45
  // Successful output is returned verbatim; the sandbox note is only added to
46
- // failures (Pi tools throw on denial and on a non-zero shell exit).
46
+ // failures. Pi 0.87 throws on denial and on a non-zero shell exit; Pi 1.x
47
+ // returns an isError result with the same text instead: read as the throw.
48
+ if (result?.isError === true) throw new Error((result.content ?? []).filter(part => part.type === 'text').map(part => part.text).join('\n') || 'managed-worker-failed');
47
49
  emit({ result });
48
50
  } catch (error) { emit({ error: sandboxDiagnostic(error instanceof Error ? error.message : 'managed-worker-failed', { network, isolated, docker, engine }) }); }
49
51
  finally { if (active) { input.close(); process.stdin.destroy(); } }
@@ -0,0 +1,102 @@
1
+ // Runs inside the command sandbox (ts-diagnostics.mjs starts it): the
2
+ // project's own TypeScript reports the errors of one file, one request per
3
+ // line on stdin ({id, file}), one answer per line on stdout. TypeScript 5 and
4
+ // 6 keep a language service per tsconfig/jsconfig; TypeScript 7 (the native
5
+ // compiler, which has no such JavaScript API) checks the project with its own
6
+ // tsc, fast enough to run on each change.
7
+ import fs from 'node:fs';
8
+ import { execFileSync } from 'node:child_process';
9
+ import path from 'node:path';
10
+ import { createRequire } from 'node:module';
11
+ import { createInterface } from 'node:readline';
12
+
13
+ const [cwd] = process.argv.slice(2);
14
+ const services = new Map();
15
+ const inside = (root, file) => { const relative = path.relative(root, file); return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); };
16
+ const version = file => { try { const stat = fs.statSync(file); return `${stat.mtimeMs}:${stat.size}`; } catch { return '0'; } };
17
+
18
+ // The nearest tsconfig.json, else jsconfig.json, inside the project.
19
+ function configFor(file) {
20
+ for (const name of ['tsconfig.json', 'jsconfig.json']) {
21
+ for (let dir = path.dirname(file); inside(cwd, dir); dir = path.dirname(dir)) {
22
+ if (fs.existsSync(path.join(dir, name))) return path.join(dir, name);
23
+ if (dir === cwd) break;
24
+ }
25
+ }
26
+ return null;
27
+ }
28
+
29
+ function serviceFor(file) {
30
+ let manifest;
31
+ try { manifest = createRequire(file).resolve('typescript/package.json'); } catch { return { reason: 'no-typescript' }; }
32
+ const config = configFor(file);
33
+ if (!config) return { reason: 'no-config' };
34
+ if (services.has(config)) return services.get(config);
35
+ const major = Number.parseInt(JSON.parse(fs.readFileSync(manifest, 'utf8')).version, 10);
36
+ if (major >= 7) {
37
+ const entry = { native: path.join(path.dirname(manifest), 'bin/tsc'), config };
38
+ services.set(config, entry); return entry;
39
+ }
40
+ const ts = createRequire(file)(path.dirname(manifest));
41
+ const parsed = ts.getParsedCommandLineOfConfigFile(config, {}, { ...ts.sys, onUnRecoverableConfigFileDiagnostic: () => {} });
42
+ if (!parsed) return { reason: 'no-config' };
43
+ const extra = new Set();
44
+ const host = {
45
+ getScriptFileNames: () => [...new Set([...parsed.fileNames, ...extra])],
46
+ getScriptVersion: version,
47
+ getScriptSnapshot: name => fs.existsSync(name) ? ts.ScriptSnapshot.fromString(fs.readFileSync(name, 'utf8')) : undefined,
48
+ getCurrentDirectory: () => path.dirname(config),
49
+ getCompilationSettings: () => parsed.options,
50
+ getDefaultLibFileName: options => ts.getDefaultLibFilePath(options),
51
+ fileExists: ts.sys.fileExists, readFile: ts.sys.readFile, readDirectory: ts.sys.readDirectory,
52
+ directoryExists: ts.sys.directoryExists, getDirectories: ts.sys.getDirectories,
53
+ };
54
+ const entry = { ts, config, extra, service: ts.createLanguageService(host, ts.createDocumentRegistry()) };
55
+ services.set(config, entry);
56
+ return entry;
57
+ }
58
+
59
+ // tsc --pretty false prints "file(line,col): error TS1234: message".
60
+ function nativeDiagnostics({ native, config }, file) {
61
+ let output;
62
+ try { output = execFileSync(process.execPath, [native, '--noEmit', '--pretty', 'false', '-p', config], { cwd: path.dirname(config), encoding: 'utf8', timeout: 30000, maxBuffer: 16 << 20, stdio: ['ignore', 'pipe', 'pipe'] }); }
63
+ catch (error) { output = String(error.stdout ?? ''); }
64
+ const found = [], elsewhere = [];
65
+ for (const line of output.split('\n')) {
66
+ const match = line.match(/^(.+?)\((\d+),(\d+)\): error TS(\d+): (.*)$/);
67
+ if (!match) continue;
68
+ const at = path.resolve(path.dirname(config), match[1]), item = { line: Number(match[2]), column: Number(match[3]), code: Number(match[4]), message: match[5].slice(0, 400) };
69
+ if (at === file) found.push(item); else elsewhere.push({ ...item, file: path.relative(cwd, at) });
70
+ }
71
+ // A syntax error anywhere stops tsc before it checks types: say so rather
72
+ // than let silence read as a clean file.
73
+ const syntax = elsewhere.filter(item => item.code >= 1000 && item.code < 2000);
74
+ return found.length || !syntax.length ? { diagnostics: found } : { diagnostics: [], uncheckedBecause: syntax.slice(0, 3) };
75
+ }
76
+
77
+ function diagnostics(file) {
78
+ const entry = serviceFor(file);
79
+ if (entry.reason) return { reason: entry.reason };
80
+ if (entry.native) return { config: path.relative(cwd, entry.config), ...nativeDiagnostics(entry, file) };
81
+ const { ts, service, extra, config } = entry;
82
+ extra.add(file);
83
+ const found = [...service.getSyntacticDiagnostics(file), ...service.getSemanticDiagnostics(file)]
84
+ .filter(d => d.category === ts.DiagnosticCategory.Error)
85
+ .map(d => {
86
+ const at = d.file && d.start !== undefined ? d.file.getLineAndCharacterOfPosition(d.start) : { line: 0, character: 0 };
87
+ return { line: at.line + 1, column: at.character + 1, code: d.code, message: ts.flattenDiagnosticMessageText(d.messageText, ' ').slice(0, 400) };
88
+ });
89
+ return { config: path.relative(cwd, config), diagnostics: found };
90
+ }
91
+
92
+ const input = createInterface({ input: process.stdin });
93
+ input.on('line', line => {
94
+ let id = null;
95
+ try {
96
+ const request = JSON.parse(line); id = request.id;
97
+ const file = path.resolve(cwd, String(request.file));
98
+ if (!inside(cwd, file)) throw new Error('outside the project');
99
+ process.stdout.write(JSON.stringify({ id, ...diagnostics(file) }) + '\n');
100
+ } catch (error) { process.stdout.write(JSON.stringify({ id, error: String(error?.message ?? error).slice(0, 300) }) + '\n'); }
101
+ });
102
+ input.on('close', () => process.exit(0));