@piagent/platform 1.23.1 → 1.25.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 +28 -0
- package/README.md +1 -1
- package/README.vi.md +1 -1
- package/docs/command-reference-vietnamese.md +2 -2
- package/docs/distribution-standard.md +6 -6
- package/docs/languages.json +2 -2
- 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/command-tools.mjs +88 -20
- package/packages/piagent-core/managed/container-engine.mjs +40 -0
- 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/repository-operation.mjs +39 -17
- package/packages/piagent-core/managed/sandbox-diagnostic.mjs +19 -8
- package/packages/piagent-core/managed/session.mjs +1 -1
- package/packages/piagent-core/managed/tool-boundary.mjs +58 -42
- package/packages/piagent-core/managed/workflow.mjs +10 -6
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-BdMpMzXP.js → MarkdownMessage-B8CsDh3B.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/index-CiLLJu4R.js +231 -0
- package/packages/piagent-webui/dist/client/index.html +1 -1
- package/packages/piagent-webui/gateway/managed-gateway.mjs +7 -1
- package/packages/piagent-webui/gateway/session-command-controller.ts +5 -1
- package/packages/piagent-webui/gateway/session-inspection-registry.ts +2 -1
- package/schemas/piagent-webui/session-command-v1.schema.json +4 -0
- package/packages/piagent-webui/dist/client/assets/index-F_KXNLyp.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,8 +1,12 @@
|
|
|
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
|
|
@@ -10,17 +14,24 @@ const OFFLINE = /ENOTFOUND|EAI_AGAIN|Could not resolve host|getaddrinfo|npm erro
|
|
|
10
14
|
// `docker`: the command ran with the Docker engine (run_with_docker);
|
|
11
15
|
// `engine`: one is there for run_with_docker, but this command ran without it.
|
|
12
16
|
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;
|
|
17
|
+
// A runtime or tool the command needs is not on this machine (or not the
|
|
18
|
+
// version the project asks for).
|
|
19
|
+
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;
|
|
20
|
+
// The member's sign-in, which the sandbox does not have.
|
|
21
|
+
const SIGN_IN = /could not read (?:Username|Password) for|terminal prompts disabled|Authentication failed for|Permission denied \(publickey|Host key verification failed|\bENEEDAUTH\b|\bE401\b|\b401 Unauthorized|requires authentication|denied: requested access to the resource is denied|pull access denied/i;
|
|
22
|
+
|
|
13
23
|
export function sandboxDiagnostic(message, { network = false, isolated = false, docker = false, engine = false } = {}) {
|
|
14
24
|
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:
|
|
25
|
+
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;
|
|
26
|
+
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;
|
|
27
|
+
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;
|
|
28
|
+
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;
|
|
17
29
|
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
|
|
30
|
+
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
31
|
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
32
|
if(!DENIAL.test(text))return text;
|
|
21
33
|
// /tmp is shared by every process on the Mac, so it stays read-only; each
|
|
22
34
|
// 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:
|
|
35
|
+
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;
|
|
36
|
+
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
37
|
}
|
|
26
|
-
|
|
@@ -171,7 +171,7 @@ export class ManagedSession {
|
|
|
171
171
|
// The member's access for this conversation: ask first, or Bypass (permission.mjs).
|
|
172
172
|
self.permission = restorePermission(manager); self.session.managedSetPermission = permissionSetter(self, manager);
|
|
173
173
|
// Fast mode, where the Harness allows it (fast-mode.mjs): off until the member turns it on.
|
|
174
|
-
self.fast = restoreFast(manager); recordFast(self, manager); self.session.managedSetFast = fastSetter(self, manager); self.session.managedFastState = () => fastState(self); // null
|
|
174
|
+
self.fast = restoreFast(manager); recordFast(self, manager); self.session.managedSetFast = fastSetter(self, manager); self.session.managedFastState = () => fastState(self); // null where the Harness does not allow it
|
|
175
175
|
self.session.managedFastServed = () => { const route = self.routes.get(self.modelRuntime); return route ? fastServed(route) : null; };
|
|
176
176
|
// A helper's receipt (its tokens, the patch it read) is for the member's
|
|
177
177
|
// eyes: the agent has the helper's answer as the tool result, and a
|
|
@@ -8,7 +8,7 @@ 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 { containerEngine, dockerCli, engineEnvironment } from './container-engine.mjs';
|
|
12
12
|
import { DEFAULT_DOMAINS, PackageProxy, mavenSettings, memberDomains, proxyEnvironment } from './package-proxy.mjs';
|
|
13
13
|
import { BWRAP, bubblewrapArgs, credentialFiles, referenceFolders } from './linux-sandbox.mjs';
|
|
14
14
|
import { agentWatchDataDirectory } from './store.mjs';
|
|
@@ -229,11 +229,6 @@ export class ManagedToolBoundary {
|
|
|
229
229
|
// A repository above the home folder (a dotfiles repo) is never opened:
|
|
230
230
|
// neither read as a monorepo nor writable through its git directory.
|
|
231
231
|
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
232
|
// A skill folder opens for reading only inside the agent folders (.claude,
|
|
238
233
|
// .codex, .pi, .agents) or outside the home folder's hidden entries, and
|
|
239
234
|
// never when it holds a closed folder (a link to ~/.ssh, the home folder).
|
|
@@ -241,42 +236,74 @@ export class ManagedToolBoundary {
|
|
|
241
236
|
const skills = [...new Set(skillFolders.flatMap(dir => { try { return [fs.realpathSync(dir)]; } catch { return []; } }))]
|
|
242
237
|
.filter(dir => dir !== '/' && !/[\x00-\x1f\x7f]/.test(dir) && !narrowed.some(root => inside(root, dir)) && !inside(userHome, dir)
|
|
243
238
|
&& (!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
239
|
this.userHome = userHome;
|
|
248
240
|
// Only for commands the user approved (run_with_network); credentials stay
|
|
249
241
|
// denied, and so do reference folders: what a command with network can
|
|
250
242
|
// read is the project, not the member's other files.
|
|
251
243
|
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
244
|
// Every process a command starts carries this marker, also one it detaches.
|
|
268
245
|
this.scope = randomUUID();
|
|
269
|
-
this.
|
|
270
|
-
|
|
271
|
-
|
|
246
|
+
this.setup = { narrowed, readOnly, repository, gitDirs, skills, identity: identity !== undefined ? identity : gitIdentity(this.git, this.cwd) };
|
|
247
|
+
if (this.linux) {
|
|
248
|
+
this.references = referenceFolders(userHome).filter(dir => !narrowed.some(root => inside(dir, root) || inside(root, dir)));
|
|
249
|
+
this.empty = path.join(this.temporary, 'empty'); fs.writeFileSync(this.empty, '', { mode: 0o400 });
|
|
250
|
+
this.referenceCredentials = credentialFiles(this.references, { limit: 30_000 });
|
|
251
|
+
this.projectCredentials = { at: 0, files: [] };
|
|
252
|
+
} else { this.proxyPorts = proxyPorts ?? localProxyPorts(); this.proxyCheck = { at: 0, listening: Promise.resolve(false) }; }
|
|
253
|
+
this.#configure();
|
|
272
254
|
// Package registries without asking (package-proxy.mjs): commands that
|
|
273
255
|
// write the project reach them through the runtime's proxy; read-only
|
|
274
256
|
// helpers keep no network at all.
|
|
275
|
-
if (!readOnly) this.proxyReady = this.startProxy(
|
|
257
|
+
if (!readOnly) this.proxyReady = this.startProxy();
|
|
276
258
|
if (searchTools) provisionSearchTools(this.home, userHome);
|
|
277
259
|
else fs.writeFileSync(path.join(this.home, '.ripgreprc'), '', { mode: 0o600 });
|
|
278
260
|
this.allowed = readOnly ? TOOL_NAMES.filter(name => !['write', 'edit'].includes(name) && (name !== 'bash' || commands)) : [...TOOL_NAMES];
|
|
279
261
|
}
|
|
262
|
+
// What the machine offers commands: toolchains, the JDK, the Docker engine.
|
|
263
|
+
// Looked up again after the member installs or starts something
|
|
264
|
+
// (run_on_host, start_docker), so the conversation sees it without a restart.
|
|
265
|
+
#configure() {
|
|
266
|
+
const { narrowed, readOnly, repository, gitDirs, skills, identity } = this.setup, userHome = this.userHome;
|
|
267
|
+
const toolchains = userToolchains(userHome, this.node), developer = developerTools(), browsers = playwrightBrowsers(userHome);
|
|
268
|
+
const languages = languageEnvironment({ userHome, repositoryTop: this.repositoryTop, home: this.home });
|
|
269
|
+
this.packageCache = languages.cache; this.languages = languages;
|
|
270
|
+
// The Docker engine, for commands the member approves (run_with_docker).
|
|
271
|
+
this.engine = readOnly ? null : containerEngine({ userHome });
|
|
272
|
+
this.dockerCli = readOnly ? null : this.engine?.cli ?? dockerCli();
|
|
273
|
+
const base = this.base = { cwd: this.cwd, sdkRoot: this.sdkRoot, runtimeRoot: this.runtimeRoot, temporary: this.temporary, node: this.node,
|
|
274
|
+
denied: narrowed, readOnly, repository, gitDirs, toolchains: [...toolchains.roots, ...languages.readRoots], developer: developer.roots,
|
|
275
|
+
browsers: browsers ? [browsers] : [], skills, caches: languages.writeRoots, license: developerLicense() };
|
|
276
|
+
if (this.linux) this.linuxBase = { ...base, toolchains: [...base.toolchains, ...base.browsers], references: this.references, empty: this.empty, engineRoots: this.engine?.readRoots ?? [] };
|
|
277
|
+
else {
|
|
278
|
+
this.networkProfile = readOnly ? null : managedSeatbelt({ ...base, network: true });
|
|
279
|
+
// An approved network command that also reaches the Docker engine's socket.
|
|
280
|
+
this.dockerProfile = this.engine ? managedSeatbelt({ ...base, network: true, docker: this.engine.socket, toolchains: [...base.toolchains, ...this.engine.readRoots] }) : null;
|
|
281
|
+
}
|
|
282
|
+
this.environment = toolEnvironment({ git: this.git, node: this.node, home: this.home, temporary: this.temporary, toolchains, developer, scope: this.scope, browsers, languages, identity });
|
|
283
|
+
if (this.engine) this.environment.docker = { ...this.environment.network, ...engineEnvironment(this.engine, this.home) };
|
|
284
|
+
this.#applyProxy();
|
|
285
|
+
}
|
|
286
|
+
// Plain commands' profile and environment, with the package proxy once it listens.
|
|
287
|
+
#applyProxy() {
|
|
288
|
+
const port = this.proxyUrl && !this.linux ? Number(new URL(this.proxyUrl).port) : null;
|
|
289
|
+
if (!this.linux) {
|
|
290
|
+
// TLS through the macOS Security framework (Go, Swift, .NET) verifies
|
|
291
|
+
// the registries' certificates with the system's trust services.
|
|
292
|
+
this.profile = managedSeatbelt({ ...this.base, references: this.userHome, ...(port ? { proxyPort: port } : {}) });
|
|
293
|
+
// The same without loopback, while a local proxy listens (localProxyListening).
|
|
294
|
+
this.isolatedProfile = managedSeatbelt({ ...this.base, references: this.userHome, loopback: false, ...(port ? { proxyPort: port } : {}) });
|
|
295
|
+
}
|
|
296
|
+
if (!this.proxyUrl) return;
|
|
297
|
+
const offline = this.environment.offline;
|
|
298
|
+
for (const key of ['npm_config_fetch_retries', 'npm_config_fetch_timeout', 'PIP_RETRIES', 'PIP_TIMEOUT']) delete offline[key];
|
|
299
|
+
Object.assign(offline, proxyEnvironment(this.proxyUrl, { mavenOpts: offline.MAVEN_OPTS, gradleOpts: offline.GRADLE_OPTS, mavenHome: this.home }), { GOPROXY: this.languages.env.network.GOPROXY },
|
|
300
|
+
this.linux ? { PIAGENT_PROXY_SOCKET: '/run/piagent-proxy.sock' } : {});
|
|
301
|
+
}
|
|
302
|
+
// Looks the machine up again; true when the Docker engine is there now.
|
|
303
|
+
refreshEnvironment() {
|
|
304
|
+
if (!this.#closed) this.#configure();
|
|
305
|
+
return Boolean(this.engine);
|
|
306
|
+
}
|
|
280
307
|
tools(api) {
|
|
281
308
|
const factories = { read: api.createReadToolDefinition, write: api.createWriteToolDefinition,
|
|
282
309
|
edit: api.createEditToolDefinition, bash: api.createBashToolDefinition,
|
|
@@ -291,7 +318,7 @@ export class ManagedToolBoundary {
|
|
|
291
318
|
// The proxy listens on loopback (macOS: Seatbelt opens its one port even
|
|
292
319
|
// while a local proxy closes the rest of loopback) or on a Unix socket that
|
|
293
320
|
// the worker forwards from inside the command's network namespace (Linux).
|
|
294
|
-
async startProxy(
|
|
321
|
+
async startProxy() {
|
|
295
322
|
this.proxy = new PackageProxy({ domains: [...DEFAULT_DOMAINS, ...memberDomains(this.userHome)] });
|
|
296
323
|
try {
|
|
297
324
|
let url;
|
|
@@ -299,21 +326,10 @@ export class ManagedToolBoundary {
|
|
|
299
326
|
this.proxySocket = path.join(os.tmpdir(), `piagent-proxy-${randomUUID().slice(0, 8)}.sock`);
|
|
300
327
|
await this.proxy.listen(this.proxySocket);
|
|
301
328
|
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];
|
|
329
|
+
} else url = `http://127.0.0.1:${(await this.proxy.listen()).port}`;
|
|
313
330
|
const settings = path.join(this.home, '.m2/settings.xml');
|
|
314
331
|
fs.mkdirSync(path.dirname(settings), { recursive: true, mode: 0o700 }); fs.writeFileSync(settings, mavenSettings(url), { mode: 0o600 });
|
|
315
|
-
|
|
316
|
-
this.proxyUrl = url;
|
|
332
|
+
this.proxyUrl = url; this.#applyProxy();
|
|
317
333
|
} catch { await this.proxy.close().catch(() => {}); this.proxy = null; }
|
|
318
334
|
}
|
|
319
335
|
async invoke(name, args, signal, onUpdate, model, { network = false, docker = false } = {}) {
|
|
@@ -58,10 +58,13 @@ export function repositoryChecks(agentsFiles, cwd, top = cwd) {
|
|
|
58
58
|
}
|
|
59
59
|
// A command that verifies code: a declared check, or a known test, type,
|
|
60
60
|
// lint or build runner. Text tools that merely mention one do not count.
|
|
61
|
-
const RUNNER = /\b(npm|pnpm|yarn|bun)\s+(run\s+)?(test|lint|typecheck|type-check|check|build|verify)\b|\bnode\s+--test\b|\b(tsc|vitest|jest|eslint|biome|ruff|mypy|pyright|pytest|rspec|phpunit)\b|\bplaywright\s+test\b|\bpython3?\s+-m\s+(pytest|unittest|mypy)\b|\bgo\s+(test|vet|build)\b|\bcargo\s+(test|check|clippy|build)\b|\bswift\s+(test|build)\b|\bxcodebuild\b.*\b(test|build)\b|\bmake\s+(test|check|lint)\b|\b(gradle|gradlew|mvn)\b.*\b(test|check|verify)\b|\bdotnet\s+(test|build)\b|\bdeno\s+(test|check|lint)\b/;
|
|
61
|
+
const RUNNER = /\b(npm|pnpm|yarn|bun)\s+(run\s+)?(test|lint|typecheck|type-check|check|build|verify)\b|\bnode\s+--test\b|\b(tsc|vitest|jest|eslint|biome|ruff|mypy|pyright|pytest|rspec|phpunit)\b|\bplaywright\s+test\b|\bpython3?\s+-m\s+(pytest|unittest|mypy)\b|\bgo\s+(test|vet|build)\b|\bcargo\s+(test|check|clippy|build)\b|\bswift\s+(test|build)\b|\bxcodebuild\b.*\b(test|build)\b|\bmake\s+(test|check|lint)\b|\b(gradle|gradlew|mvn|mvnw)\b.*\b(test|check|verify|compile|package|build)\b|\bdotnet\s+(test|build)\b|\bdeno\s+(test|check|lint)\b/;
|
|
62
62
|
// A command that only reads: each part starts with a reading tool and nothing
|
|
63
|
-
// is written to a file. Anything else may change files.
|
|
64
|
-
|
|
63
|
+
// is written to a file. Anything else may change files. Git may name the
|
|
64
|
+
// repository first (git -C <folder>): a workspace folder holding several
|
|
65
|
+
// repositories is read that way.
|
|
66
|
+
const GIT_READ = String.raw`git(\s+(-C\s+('[^']*'|"[^"]*"|\S+)|-c\s+\S+|--no-pager))*\s+(status|diff|log|show|branch|ls-files|rev-parse|rev-list|blame|describe|shortlog|cat-file|for-each-ref|reflog(?!\s+(expire|delete))|remote(\s+(-v|--verbose|show|get-url)\b|$))`;
|
|
67
|
+
const READER = new RegExp(String.raw`^(ls|cat|head|tail|wc|grep|egrep|rg|find|fd|sed|awk|cut|sort|uniq|tr|echo|printf|pwd|which|stat|file|du|df|tree|jq|diff|cmp|basename|dirname|realpath|date|true|cd|${GIT_READ})(\b|$)`);
|
|
65
68
|
export function isReadOnlyCommand(command) {
|
|
66
69
|
const text = String(command ?? '').trim();
|
|
67
70
|
if (!text || />(?!>|\s*&\d|\s*\/dev\/null)|\btee\b|\bsed\s+(-[a-zA-Z]*i|--in-place)|\s-(delete|exec|execdir|fprint|ok)\b|\bxargs\b|\$\(|`/.test(text)) return false;
|
|
@@ -117,7 +120,7 @@ export function workflowPrompt(policy, checks) {
|
|
|
117
120
|
}
|
|
118
121
|
if (policy.review !== 'off') lines.push(policy.review === 'require' ? '- Review: the harness reviews the final patch with an independent reviewer; blocking findings come back to you to fix, or to answer when a finding is wrong: the reviewer then judges your answer, and a disagreement that remains goes to the member.'
|
|
119
122
|
: '- Review: before you finish a code change, ask the review helper (delegate role "review") to review the patch, then fix blocking findings.');
|
|
120
|
-
return lines.length ? `\n\nProcess for code changes (company Harness):\n${lines.join('\n')}\nEnd-to-end tests that start a local server and a browser run through run_with_network, which the user approves. If a
|
|
123
|
+
return lines.length ? `\n\nProcess for code changes (company Harness):\n${lines.join('\n')}\nEnd-to-end tests that start a local server and a browser run through run_with_network, which the user approves. If a check cannot run because the machine lacks something (a runtime such as a JDK, Docker, a database or another service, the network), do not stop there: request the approval that makes it run (run_with_network, run_with_docker, start_docker, or run_on_host to install what is missing) or ask the member with ask_user; never install or start anything without that approval.\nA command stops after 10 minutes and anything left running in the background stops when the turn ends: run the checks that cover your change first and a long suite in parts, not in the background.` : '';
|
|
121
124
|
}
|
|
122
125
|
|
|
123
126
|
// Every helper checks the brief it was given: it may object with evidence, it
|
|
@@ -280,9 +283,10 @@ export async function completionGate(managed, run, signal) {
|
|
|
280
283
|
for (let step = 0; step < 16; step += 1) {
|
|
281
284
|
digest = await digestOf();
|
|
282
285
|
run.changed = run.startDigest !== null && digest !== null ? digest !== run.startDigest : run.mutations > 0;
|
|
286
|
+
// A check that passed still counts when the turn was cut off after it.
|
|
287
|
+
if (run.changed) run.verified = run.verifiedOn(digest);
|
|
283
288
|
if (stopped()) { interrupted = run.changed; break; }
|
|
284
289
|
if (run.changed) {
|
|
285
|
-
run.verified = run.verifiedOn(digest);
|
|
286
290
|
const reviewDigest = managed.review ? await reviewDigestOf() : null;
|
|
287
291
|
const review = managed.review, fresh = !!review && !review.stale && reviewDigest !== null && review.patchDigest === reviewDigest;
|
|
288
292
|
run.reviewed = fresh;
|
|
@@ -292,7 +296,7 @@ export async function completionGate(managed, run, signal) {
|
|
|
292
296
|
if (policy.verify === 'require' && !run.verified && !verifyGaveUp) {
|
|
293
297
|
run.fixLoops += 1;
|
|
294
298
|
const list = run.checks.commands.length ? run.checks.commands.map(c => '`' + c + '`').join(', ') : 'the tests, type check or build that cover the change';
|
|
295
|
-
await send(`Harness process check: code changed in this turn, but no check has passed on the current code. Run the checks that cover your change (${list}) and fix what fails. If a check cannot run
|
|
299
|
+
await send(`Harness process check: code changed in this turn, but no check has passed on the current code. Run the checks that cover your change (${list}) and fix what fails. If a check cannot run because the machine lacks something (a runtime such as a JDK, Docker, a database or another service, the network), request the approval that makes it run (run_with_network, run_with_docker, start_docker, run_on_host) or ask the member with ask_user, then run it; install or start nothing without that approval. If it fails for a reason unrelated to your change, say so plainly.`,
|
|
296
300
|
{ phase: 'verify', loop: run.fixLoops, maxLoops: policy.maxFixLoops, lastCheckFailed: run.lastCheck?.ok === false });
|
|
297
301
|
continue;
|
|
298
302
|
}
|