@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.
- package/CHANGELOG.md +44 -0
- package/README.md +1 -1
- package/README.vi.md +1 -1
- package/docs/command-reference-vietnamese.md +2 -2
- package/docs/company-dev-commands.md +68 -0
- package/docs/distribution-standard.md +6 -6
- package/docs/harness-conformance.md +154 -0
- package/docs/languages.json +2 -2
- package/docs/managed-local-acceptance.md +38 -0
- package/docs/operator-manual-vietnamese.md +7 -7
- package/docs/publishing-for-teams.md +2 -0
- package/docs/release-install-policy.md +2 -2
- package/docs/team-onboarding.md +2 -2
- package/docs-site/architecture.html +1 -1
- package/docs-site/benchmark.html +1 -1
- package/docs-site/commands.html +1 -1
- package/docs-site/content/en/index.html +4 -4
- package/docs-site/content/en/whats-new.html +12 -0
- package/docs-site/content/index.html +4 -4
- package/docs-site/content/whats-new.html +12 -0
- package/docs-site/context-engine.html +1 -1
- package/docs-site/ecosystem.html +1 -1
- package/docs-site/en/architecture.html +1 -1
- package/docs-site/en/benchmark.html +1 -1
- package/docs-site/en/commands.html +1 -1
- package/docs-site/en/context-engine.html +1 -1
- package/docs-site/en/ecosystem.html +1 -1
- package/docs-site/en/index.html +5 -5
- package/docs-site/en/mcp-approval.html +1 -1
- package/docs-site/en/mcp-auth.html +1 -1
- package/docs-site/en/mcp-servers.html +1 -1
- package/docs-site/en/mcp.html +1 -1
- package/docs-site/en/permissions.html +1 -1
- package/docs-site/en/profiles.html +1 -1
- package/docs-site/en/quickstart.html +1 -1
- package/docs-site/en/runtime.html +1 -1
- package/docs-site/en/scope.html +1 -1
- package/docs-site/en/security.html +1 -1
- package/docs-site/en/team.html +1 -1
- package/docs-site/en/whats-new.html +13 -1
- package/docs-site/en/windows.html +1 -1
- package/docs-site/en/workflows.html +1 -1
- package/docs-site/index.html +5 -5
- package/docs-site/mcp-approval.html +1 -1
- package/docs-site/mcp-auth.html +1 -1
- package/docs-site/mcp-servers.html +1 -1
- package/docs-site/mcp.html +1 -1
- package/docs-site/permissions.html +1 -1
- package/docs-site/profiles.html +1 -1
- package/docs-site/quickstart.html +1 -1
- package/docs-site/runtime.html +1 -1
- package/docs-site/scope.html +1 -1
- package/docs-site/security.html +1 -1
- package/docs-site/team.html +1 -1
- package/docs-site/whats-new.html +13 -1
- package/docs-site/windows.html +1 -1
- package/docs-site/workflows.html +1 -1
- package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
- package/package.json +1 -1
- package/packages/piagent-core/managed/background-process.mjs +101 -0
- package/packages/piagent-core/managed/checkpoints.mjs +69 -0
- package/packages/piagent-core/managed/command-tools.mjs +92 -21
- package/packages/piagent-core/managed/container-engine.mjs +40 -0
- package/packages/piagent-core/managed/continuation.mjs +8 -0
- package/packages/piagent-core/managed/doctor.mjs +4 -4
- package/packages/piagent-core/managed/host-command.mjs +67 -0
- package/packages/piagent-core/managed/language-environment.mjs +8 -2
- package/packages/piagent-core/managed/pi-host.mjs +19 -0
- package/packages/piagent-core/managed/repository-operation.mjs +39 -17
- package/packages/piagent-core/managed/sandbox-diagnostic.mjs +22 -9
- package/packages/piagent-core/managed/session.mjs +3 -3
- package/packages/piagent-core/managed/tool-boundary.mjs +102 -59
- package/packages/piagent-core/managed/tool-worker.mjs +3 -1
- package/packages/piagent-core/managed/ts-diagnostics-server.mjs +102 -0
- package/packages/piagent-core/managed/ts-diagnostics.mjs +59 -0
- package/packages/piagent-core/managed/workflow.mjs +18 -6
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-GDGT_YLS.js → MarkdownMessage-DcFq8SRy.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/index-B4PFrM4y.js +232 -0
- package/packages/piagent-webui/dist/client/index.html +1 -1
- package/packages/piagent-webui/gateway/managed-gateway.mjs +2 -1
- package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +2 -2
- package/packages/piagent-webui/server/transcript-projection.ts +9 -3
- package/packages/piagent-webui/shared/process-copy.ts +10 -0
- package/schemas/piagent-webui/transcript-v1.schema.json +13 -1
- package/scripts/company-terminal.mjs +12 -0
- package/scripts/piagent-studio.mjs +20 -7
- 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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
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,
|
|
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':
|
|
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:
|
|
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,
|
|
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:[
|
|
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ề ${
|
|
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).
|
|
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:
|
|
17
|
-
if(
|
|
18
|
-
if(
|
|
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
|
|
24
|
-
return 'managed-sandbox-denied:
|
|
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 !==
|
|
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, '
|
|
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 {
|
|
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 !==
|
|
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.
|
|
270
|
-
|
|
271
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
|
|
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
|
|
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));
|