@piagent/platform 1.17.0 → 1.19.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 +33 -0
- package/README.md +1 -1
- package/README.vi.md +1 -1
- package/catalog/claude-model-additions.json +49 -2
- 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 +13 -0
- package/docs-site/content/index.html +4 -4
- package/docs-site/content/whats-new.html +13 -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 +14 -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 +14 -1
- package/docs-site/windows.html +1 -1
- package/docs-site/workflows.html +1 -1
- package/evals/private-holdout-v1/public-exposure.v2.json +3 -3
- package/evals/runtime-conformance-v1/runner.mjs +13 -17
- package/package.json +1 -1
- package/packages/piagent-core/managed/call-purpose.mjs +3 -2
- package/packages/piagent-core/managed/command-tools.mjs +59 -0
- package/packages/piagent-core/managed/container-engine.mjs +102 -0
- package/packages/piagent-core/managed/continuation.mjs +131 -0
- package/packages/piagent-core/managed/doctor.mjs +7 -0
- package/packages/piagent-core/managed/language-environment.mjs +57 -0
- package/packages/piagent-core/managed/linux-sandbox.mjs +3 -1
- package/packages/piagent-core/managed/native-catalog.mjs +14 -1
- package/packages/piagent-core/managed/patch-snapshot.mjs +25 -9
- package/packages/piagent-core/managed/repository-operation.mjs +20 -0
- package/packages/piagent-core/managed/request-stream.mjs +28 -0
- package/packages/piagent-core/managed/review-snapshot-worker.mjs +11 -4
- package/packages/piagent-core/managed/sandbox-diagnostic.mjs +6 -1
- package/packages/piagent-core/managed/session.mjs +52 -63
- package/packages/piagent-core/managed/tool-boundary.mjs +21 -10
- package/packages/piagent-core/managed/tool-worker.mjs +3 -3
- package/packages/piagent-core/managed/workflow.mjs +20 -20
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-CeQ79h30.js → MarkdownMessage-nggiOt0N.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{index-BNQK7rHr.js → index-CM9cs-gP.js} +16 -14
- package/packages/piagent-webui/dist/client/index.html +1 -1
- package/packages/piagent-webui/gateway/company-relay.ts +14 -2
- package/packages/piagent-webui/gateway/gateway-service.ts +38 -0
- package/packages/piagent-webui/gateway/git-branches.ts +94 -0
- package/packages/piagent-webui/gateway/git-repositories.ts +46 -0
- package/packages/piagent-webui/gateway/managed-projection.ts +9 -2
- package/packages/piagent-webui/gateway/project-registry.ts +10 -6
- package/packages/piagent-webui/gateway/session-catalog.ts +13 -6
- package/packages/piagent-webui/gateway/session-operation-lifecycle.ts +10 -3
- package/packages/piagent-webui/server/loopback-server.ts +9 -2
- package/packages/piagent-webui/server/member-routes.ts +41 -0
- package/packages/piagent-webui/server/tool-call-summary.ts +3 -1
- package/packages/piagent-webui/server/transcript-projection.ts +20 -4
- package/packages/piagent-webui/shared/process-copy.ts +3 -1
- package/packages/piagent-webui/shared/turn-end.ts +68 -0
- package/schemas/piagent-webui/session-catalog-v1.schema.json +11 -1
- package/schemas/piagent-webui/transcript-v1.schema.json +23 -2
- package/scripts/company-terminal.mjs +15 -0
- package/scripts/configure-model-scope.sh +1 -0
- package/templates/global/settings.json +2 -1
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { unfinished, planText, currentPlan, workflowPolicy } from './workflow.mjs';
|
|
2
|
+
import { failureCode } from '../runtime/managed-failure.mjs';
|
|
3
|
+
|
|
4
|
+
// A main agent often ends its turn between the steps of its own checklist
|
|
5
|
+
// ("STEP06 done, STEP07 next"), though the member asked for all of them.
|
|
6
|
+
// Under plan "require" the harness sends it back to the next open step, as a
|
|
7
|
+
// new run (as if the member had sent "continue": each round gets its own
|
|
8
|
+
// helpers, checks and review), until the checklist is done, the member stops
|
|
9
|
+
// it, a round fails, or IDLE_ROUNDS rounds in a row change nothing.
|
|
10
|
+
export const IDLE_ROUNDS = 2;
|
|
11
|
+
// A checklist holds at most 30 steps: one round per step at most.
|
|
12
|
+
export const MAX_ROUNDS = 30;
|
|
13
|
+
// How the member's message ended, shown under the last answer.
|
|
14
|
+
export const TURN_END_ENTRY = 'agent-watch-turn-end';
|
|
15
|
+
export const TURN_END_STATES = ['done', 'midway', 'failed', 'cancelled'];
|
|
16
|
+
|
|
17
|
+
const completed = plan => plan?.plan?.filter(p => p.status === 'completed').length ?? 0;
|
|
18
|
+
const deferred = plan => plan?.plan?.filter(p => p.status === 'deferred').length ?? 0;
|
|
19
|
+
|
|
20
|
+
export class Continuation {
|
|
21
|
+
constructor(policy) {
|
|
22
|
+
Object.assign(this, { policy, rounds: 0, idle: 0, touched: false, reason: null, startDone: 0 });
|
|
23
|
+
}
|
|
24
|
+
// A round is about to start: what its progress is measured from.
|
|
25
|
+
begin(plan) { this.startDone = completed(plan); }
|
|
26
|
+
// A round ended (its run closed). The message that starts the next round,
|
|
27
|
+
// or null: then `reason` says why the harness stopped sending it back.
|
|
28
|
+
next({ plan, planUpdated, changed, stopped, failed }) {
|
|
29
|
+
if (planUpdated) this.touched = true;
|
|
30
|
+
if (stopped) { this.reason = 'stopped'; return null; }
|
|
31
|
+
if (failed) { this.reason = 'failed'; return null; }
|
|
32
|
+
if (!this.touched || !unfinished(plan)) { this.reason = null; return null; }
|
|
33
|
+
if (this.policy.plan !== 'require') { this.reason = 'policy'; return null; }
|
|
34
|
+
// The member's own round always gets one round back; a harness round
|
|
35
|
+
// that neither completed a step nor changed code counts as idle.
|
|
36
|
+
if (this.rounds > 0) this.idle = completed(plan) > this.startDone || changed ? 0 : this.idle + 1;
|
|
37
|
+
if (this.idle >= IDLE_ROUNDS) { this.reason = 'idle'; return null; }
|
|
38
|
+
if (this.rounds >= MAX_ROUNDS) { this.reason = 'limit'; return null; }
|
|
39
|
+
this.rounds += 1;
|
|
40
|
+
const steps = plan.plan.length, done = completed(plan), open = steps - done - deferred(plan);
|
|
41
|
+
return { customType: 'agent-watch-process', display: true,
|
|
42
|
+
content: `Harness: your checklist still has ${open} open step(s) (${done}/${steps} completed):\n${planText(plan)}\nThe member asked for all of it, unless they told you to stop at this point. Continue with the next open step now and keep going until every step is completed; update the checklist with update_plan as you go. Do not stop to report progress between steps. If the member asked you to stop here (to review a plan before any change, for example) or you cannot go on without them (a decision, access, a service that is not running), ask with ask_user instead of going on. If a step is no longer needed, take it out of the checklist and say why in your final answer.`,
|
|
43
|
+
details: { phase: 'continue', round: this.rounds, maxRounds: MAX_ROUNDS, planOpen: open, planDone: done, planSteps: steps, ...(this.idle ? { idle: this.idle } : {}) } };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// How a member's message ended: every step done (or answered without a
|
|
48
|
+
// checklist), stopped with steps open (and why the harness did not go on),
|
|
49
|
+
// failed (which role, which kind) or stopped by the member.
|
|
50
|
+
export function turnEnd(continuation, plan, { stopped, failure }) {
|
|
51
|
+
const steps = continuation.touched ? plan?.plan?.length ?? 0 : 0, done = continuation.touched ? completed(plan) : 0;
|
|
52
|
+
const state = stopped ? 'cancelled' : failure ? 'failed' : continuation.touched && unfinished(plan) ? 'midway' : 'done';
|
|
53
|
+
const later = continuation.touched ? deferred(plan) : 0;
|
|
54
|
+
return { state, planSteps: steps, planDone: done, ...(later ? { planDeferred: later } : {}), rounds: continuation.rounds,
|
|
55
|
+
...(state === 'midway' && continuation.reason ? { reason: continuation.reason } : {}),
|
|
56
|
+
...(state === 'failed' ? { role: failure.role ?? 'main', code: String(failure.code || 'unknown').toLowerCase().replace(/[^a-z0-9_]+/g, '_').slice(0, 64) } : {}),
|
|
57
|
+
at: new Date().toISOString() };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// One member message: its round, then the rounds the harness adds while the
|
|
61
|
+
// checklist has open steps. `runRound(text, deliver)` runs one Studio run and
|
|
62
|
+
// returns { result, preflight, planUpdated, changed, closeFailed }. A message the member
|
|
63
|
+
// sends while a round closes (no model runs then) becomes the next round;
|
|
64
|
+
// Stop ends the turn. Listeners hear "settled" once, after the last round.
|
|
65
|
+
// "tiếp tục", "continue", the dashboard's Continue button: the member asks
|
|
66
|
+
// for the open checklist, so it is this message's work even before the agent
|
|
67
|
+
// touches it again.
|
|
68
|
+
const CONTINUE = /^(tiep tuc|lam tiep|lam not|continue|go on|keep going|resume)\b/;
|
|
69
|
+
export function asksToContinue(text) {
|
|
70
|
+
const normal = String(text ?? '').normalize('NFD').replace(/\p{M}/gu, '').replace(/[đĐ]/g, 'd').toLowerCase().trim();
|
|
71
|
+
return normal.length <= 60 && CONTINUE.test(normal);
|
|
72
|
+
}
|
|
73
|
+
const fresh = (self, manager, text) => {
|
|
74
|
+
const continuation = new Continuation(workflowPolicy(self.manifest));
|
|
75
|
+
if (asksToContinue(text) && unfinished(currentPlan(manager))) continuation.touched = true;
|
|
76
|
+
return continuation;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export async function memberTurn(self, { manager, prompt, runRound }, text, deliver) {
|
|
80
|
+
let continuation = fresh(self, manager, text), result, waiting = null, ended = null;
|
|
81
|
+
self.stopRequested = false; self.queued = [];
|
|
82
|
+
try {
|
|
83
|
+
for (let first = true; ; first = false) {
|
|
84
|
+
continuation.begin(currentPlan(manager));
|
|
85
|
+
self.between = false;
|
|
86
|
+
let round;
|
|
87
|
+
try { round = await runRound(text, deliver); }
|
|
88
|
+
catch (error) { waiting?.reject(error); waiting = null; throw error; }
|
|
89
|
+
if (first) result = round.result; else waiting?.resolve(round.result);
|
|
90
|
+
waiting = null;
|
|
91
|
+
const last = self.session.messages.findLast(m => m.role === 'assistant');
|
|
92
|
+
const stopped = self.stopRequested || last?.stopReason === 'aborted';
|
|
93
|
+
const failure = round.preflight || last?.stopReason === 'error' ? { role: 'main', code: failureCode(last?.errorMessage ?? '') }
|
|
94
|
+
: round.closeFailed ? { role: 'main', code: round.closeFailed } : null;
|
|
95
|
+
const plan = currentPlan(manager), message = continuation.next({ plan, planUpdated: round.planUpdated, changed: round.changed, stopped, failed: !!failure });
|
|
96
|
+
if (self.queued.length) {
|
|
97
|
+
// The member wrote while the round closed: theirs is the next round,
|
|
98
|
+
// a new request (Stop before it applied to the round before).
|
|
99
|
+
waiting = self.queued.shift(); self.stopRequested = false;
|
|
100
|
+
const next = waiting; text = next.text;
|
|
101
|
+
deliver = () => prompt(next.text, { ...next.options, expandPromptTemplates: false });
|
|
102
|
+
continuation = fresh(self, manager, next.text);
|
|
103
|
+
} else if (message) {
|
|
104
|
+
text = message.content;
|
|
105
|
+
deliver = () => { self.nextPurpose = 'continue'; return self.session.sendCustomMessage(message, { triggerTurn: true }); };
|
|
106
|
+
} else { ended = turnEnd(continuation, plan, { stopped, failure }); break; }
|
|
107
|
+
}
|
|
108
|
+
} finally {
|
|
109
|
+
self.between = false;
|
|
110
|
+
for (const queued of self.queued.splice(0)) queued.reject(Error('managed-turn-stopped'));
|
|
111
|
+
// How the member's message ended: an entry of the conversation (not a
|
|
112
|
+
// message: the model never reads it), shown under the last answer, and
|
|
113
|
+
// told to listeners (the company Terminal).
|
|
114
|
+
if (ended) {
|
|
115
|
+
try { manager.appendCustomEntry(TURN_END_ENTRY, ended); } catch { /* the answer stands */ }
|
|
116
|
+
self.notify({ type: 'managed_turn_end', end: ended, text: turnEndText(ended) });
|
|
117
|
+
}
|
|
118
|
+
self.activePrompt = false;
|
|
119
|
+
self.notify({ type: 'agent_settled' });
|
|
120
|
+
}
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The plain text of a turn's end (the Terminal shows it; the WebUI reads details).
|
|
125
|
+
export function turnEndText(end) {
|
|
126
|
+
if (end.state === 'cancelled') return 'Turn stopped by the member.';
|
|
127
|
+
if (end.state === 'failed') return `Turn failed: ${end.role} agent, ${end.code}. Send "continue" to go on.`;
|
|
128
|
+
if (end.state === 'midway') return `Turn ended with ${end.planSteps - end.planDone - (end.planDeferred ?? 0)} of ${end.planSteps} checklist step(s) open${end.reason === 'idle' ? `: the agent stopped ${IDLE_ROUNDS} times in a row without progress` : end.reason === 'limit' ? `: ${MAX_ROUNDS} rounds reached` : ''}. Send "continue" to go on.`;
|
|
129
|
+
if (end.planDeferred) return `Done: ${end.planDone} of ${end.planSteps} checklist steps completed, ${end.planDeferred} left for later.`;
|
|
130
|
+
return end.planSteps ? `Done: all ${end.planSteps} checklist steps completed.` : 'Done.';
|
|
131
|
+
}
|
|
@@ -86,6 +86,13 @@ export async function runDoctor({ sdkRoot, origin, broker, network = true }) {
|
|
|
86
86
|
try { return await bash('node loopback.mjs'); }
|
|
87
87
|
catch (error) { throw Error(/loopback-blocked/.test(String(error?.message)) ? 'a local proxy listens on this Mac: tests that use localhost need run_with_network' : first(String(error?.message))); }
|
|
88
88
|
});
|
|
89
|
+
// run_with_docker: the engine answers through its socket, and only there.
|
|
90
|
+
await check('Docker for approved commands (run_with_docker)', false, async () => {
|
|
91
|
+
if (!boundary.engine) throw Error('no Docker engine running (Docker Desktop, Colima, OrbStack…): start it, then open a new conversation');
|
|
92
|
+
let refused = false; try { await bash('docker version --format "{{.Server.Version}}"'); } catch { refused = true; }
|
|
93
|
+
if (!refused) throw Error('a plain command reached the Docker engine');
|
|
94
|
+
return `engine ${await bash('docker version --format "{{.Server.Version}}"', { docker: true })}`;
|
|
95
|
+
});
|
|
89
96
|
await check('package caches kept for the project', false, async () => { await bash('mkdir -p "$npm_config_cache" && touch "$npm_config_cache/.piagent-doctor"'); return boundary.packageCache; });
|
|
90
97
|
// The languages installed on this Mac, each started once in the sandbox.
|
|
91
98
|
// (macOS ships stubs for some, such as javac without a JDK: a probe decides.)
|
|
@@ -10,6 +10,10 @@ import { execFileSync } from 'node:child_process';
|
|
|
10
10
|
// network (run_with_network), later runs work offline;
|
|
11
11
|
// - the member's own dependency caches as read-only seeds, where the tool can
|
|
12
12
|
// layer one (Go, Gradle, Maven), so what is already downloaded is reused;
|
|
13
|
+
// - the distributions the Maven Wrapper (./mvnw) and the Gradle Wrapper
|
|
14
|
+
// (./gradlew) already unpacked on this Mac, linked read-only into the
|
|
15
|
+
// project's cache, so they run without asking for network; one they have
|
|
16
|
+
// to download stays in the cache;
|
|
13
17
|
// - runtimes installed in places the sandbox otherwise keeps closed (JDKs
|
|
14
18
|
// under ~/Library/Java, the Android SDK, the system gem folder, MacPorts);
|
|
15
19
|
// - SwiftPM and xcodebuild, which cannot start their own sandbox inside this
|
|
@@ -62,6 +66,50 @@ case "$(basename "$0"):$1" in
|
|
|
62
66
|
esac
|
|
63
67
|
`;
|
|
64
68
|
|
|
69
|
+
// Each <name>/<hash> distribution folder of the member's Maven Wrapper as a
|
|
70
|
+
// link in the project's cache; one already there (linked, or downloaded by an
|
|
71
|
+
// approved command) is kept.
|
|
72
|
+
function linkDistributions(source, target) {
|
|
73
|
+
let names = [];
|
|
74
|
+
try { names = fs.readdirSync(source, { withFileTypes: true }).filter(entry => entry.isDirectory()); } catch { return; }
|
|
75
|
+
for (const name of names) {
|
|
76
|
+
let hashes = [];
|
|
77
|
+
try { hashes = fs.readdirSync(path.join(source, name.name), { withFileTypes: true }).filter(entry => entry.isDirectory()); } catch { continue; }
|
|
78
|
+
for (const hash of hashes) {
|
|
79
|
+
const link = path.join(target, name.name, hash.name);
|
|
80
|
+
try { fs.lstatSync(link); continue; } catch { /* not there yet */ }
|
|
81
|
+
try { fs.mkdirSync(path.dirname(link), { recursive: true, mode: 0o700 }); fs.symlinkSync(path.join(source, name.name, hash.name), link); } catch { /* best effort */ }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The Gradle Wrapper locks <hash>/<zip>.lck and checks <zip>.ok before it
|
|
87
|
+
// uses a distribution, so each <name>/<hash> is a folder of the cache with
|
|
88
|
+
// the marker copied and the unpacked distribution linked.
|
|
89
|
+
function linkGradleDistributions(source, target) {
|
|
90
|
+
let names = [];
|
|
91
|
+
try { names = fs.readdirSync(source, { withFileTypes: true }).filter(entry => entry.isDirectory()); } catch { return; }
|
|
92
|
+
for (const name of names) {
|
|
93
|
+
let hashes = [];
|
|
94
|
+
try { hashes = fs.readdirSync(path.join(source, name.name), { withFileTypes: true }).filter(entry => entry.isDirectory()); } catch { continue; }
|
|
95
|
+
for (const hash of hashes) {
|
|
96
|
+
const from = path.join(source, name.name, hash.name), to = path.join(target, name.name, hash.name);
|
|
97
|
+
let entries = [];
|
|
98
|
+
try { entries = fs.readdirSync(from, { withFileTypes: true }); } catch { continue; }
|
|
99
|
+
if (!entries.some(entry => entry.name.endsWith('.ok'))) continue; // not fully unpacked
|
|
100
|
+
try { fs.mkdirSync(to, { recursive: true, mode: 0o700 }); } catch { continue; }
|
|
101
|
+
for (const entry of entries) {
|
|
102
|
+
const link = path.join(to, entry.name);
|
|
103
|
+
try { fs.lstatSync(link); continue; } catch { /* not there yet */ }
|
|
104
|
+
try {
|
|
105
|
+
if (entry.isDirectory()) fs.symlinkSync(path.join(from, entry.name), link);
|
|
106
|
+
else if (entry.name.endsWith('.ok')) fs.writeFileSync(link, '', { mode: 0o600 });
|
|
107
|
+
} catch { /* best effort */ }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
65
113
|
export function languageEnvironment({ userHome, repositoryTop, home }) {
|
|
66
114
|
const linux = process.platform === 'linux';
|
|
67
115
|
const root = linux ? path.join(process.env.XDG_CACHE_HOME || path.join(userHome, '.cache'), 'piagent/sandbox') : path.join(userHome, 'Library/Caches/Piagent/sandbox');
|
|
@@ -80,11 +128,19 @@ export function languageEnvironment({ userHome, repositoryTop, home }) {
|
|
|
80
128
|
// .NET restores over TLS through the macOS Security framework, closed
|
|
81
129
|
// here: packages the member restored are read from their own folder.
|
|
82
130
|
nuget: existing(path.join(userHome, '.nuget/packages')),
|
|
131
|
+
mavenWrapper: existing(path.join(userHome, '.m2/wrapper/dists')),
|
|
132
|
+
gradleWrapper: existing(path.join(userHome, '.gradle/wrapper/dists')),
|
|
83
133
|
};
|
|
84
134
|
const java = javaHome(userHome), android = existing(path.join(userHome, linux ? 'Android/Sdk' : 'Library/Android/sdk'));
|
|
85
135
|
const readRoots = [...new Set([...Object.values(seeds), java, android, existing(path.join(userHome, 'Library/Java/JavaVirtualMachines')),
|
|
86
136
|
existing('/Library/Ruby'), existing('/opt/local')].filter(Boolean))];
|
|
87
137
|
|
|
138
|
+
// ./mvnw looks for its distribution in MAVEN_USER_HOME/wrapper/dists/<name>/<hash>
|
|
139
|
+
// (the sandbox's HOME is an empty folder per conversation).
|
|
140
|
+
if (seeds.mavenWrapper) linkDistributions(seeds.mavenWrapper, at('m2/wrapper/dists'));
|
|
141
|
+
// ./gradlew: GRADLE_USER_HOME/wrapper/dists/<name>/<hash>.
|
|
142
|
+
if (seeds.gradleWrapper) linkGradleDistributions(seeds.gradleWrapper, at('gradle/wrapper/dists'));
|
|
143
|
+
|
|
88
144
|
const bin = path.join(home, '.piagent/bin');
|
|
89
145
|
fs.mkdirSync(bin, { recursive: true, mode: 0o700 });
|
|
90
146
|
// (Only macOS: SwiftPM on Linux starts no sandbox of its own.)
|
|
@@ -102,6 +158,7 @@ export function languageEnvironment({ userHome, repositoryTop, home }) {
|
|
|
102
158
|
CARGO_HOME: at('cargo'), GRADLE_USER_HOME: at('gradle'), NUGET_PACKAGES: at('nuget'), COMPOSER_CACHE_DIR: at('composer'),
|
|
103
159
|
PUB_CACHE: at('pub'), GEM_HOME: at('gem'), BUNDLE_PATH: at('bundle'), CP_HOME_DIR: at('cocoapods'), PIAGENT_DERIVED_DATA: at('DerivedData'),
|
|
104
160
|
DOTNET_CLI_TELEMETRY_OPTOUT: '1', DOTNET_NOLOGO: '1', DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '1',
|
|
161
|
+
MAVEN_USER_HOME: at('m2'),
|
|
105
162
|
MAVEN_OPTS: [`-Dmaven.repo.local=${at('m2/repository')}`, ...(seeds.maven ? [`-Dmaven.repo.local.tail=${seeds.maven}`] : [])].join(' '),
|
|
106
163
|
...(seeds.gradle ? { GRADLE_RO_DEP_CACHE: seeds.gradle } : {}),
|
|
107
164
|
...(seeds.nuget ? { NUGET_FALLBACK_PACKAGES: seeds.nuget } : {}),
|
|
@@ -52,7 +52,7 @@ export function credentialFiles(roots, { limit = SCAN_ENTRIES } = {}) {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
const depth = file => file.split('/').length;
|
|
55
|
-
export function bubblewrapArgs({ cwd, sdkRoot, runtimeRoot, temporary, empty, node, readOnly = false, repository = [], gitDirs = [], toolchains = [], skills = [], caches = [], references = [], network = false, credentials = [] }) {
|
|
55
|
+
export function bubblewrapArgs({ cwd, sdkRoot, runtimeRoot, temporary, empty, node, readOnly = false, repository = [], gitDirs = [], toolchains = [], skills = [], caches = [], references = [], network = false, credentials = [], docker = null }) {
|
|
56
56
|
const exists = file => { try { fs.statSync(file); return true; } catch { return false; } };
|
|
57
57
|
const readable = [...new Set([sdkRoot, runtimeRoot, path.dirname(node), ...repository, ...toolchains, ...skills, ...references, ...(readOnly ? [cwd, ...gitDirs] : [])])].filter(exists);
|
|
58
58
|
const writable = [...new Set([...caches, ...(readOnly ? [] : [cwd, ...gitDirs])])].filter(exists);
|
|
@@ -66,6 +66,8 @@ export function bubblewrapArgs({ cwd, sdkRoot, runtimeRoot, temporary, empty, no
|
|
|
66
66
|
'--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp', '--bind', temporary, temporary,
|
|
67
67
|
...mounts.flatMap(([kind, dir]) => [kind, dir, dir]),
|
|
68
68
|
...credentials.filter(item => exists(item.path)).flatMap(item => item.directory ? ['--tmpfs', item.path] : ['--ro-bind', empty, item.path]),
|
|
69
|
+
// An approved Docker command reaches the engine's socket (run_with_docker).
|
|
70
|
+
...(docker && exists(docker) ? ['--bind', docker, docker] : []),
|
|
69
71
|
'--remount-ro', '/', '--chdir', cwd,
|
|
70
72
|
];
|
|
71
73
|
}
|
|
@@ -12,6 +12,19 @@ const sonnet55 = {
|
|
|
12
12
|
compat: { forceAdaptiveThinking: true, supportsTemperature: false, supportsStrictTools: true, supportsMidConvoEffort: true },
|
|
13
13
|
promptCache: { short: 300, long: 3600 },
|
|
14
14
|
};
|
|
15
|
+
// Claude Haiku 5.5, released 2026-10-07, checked 2026-10-08: 1M context,
|
|
16
|
+
// 128k output, adaptive thinking with effort; $0.10/$0.50 per 1M, cache read
|
|
17
|
+
// $0.01, 5m cache write $0.125, all five times above 100k input tokens.
|
|
18
|
+
// https://platform.claude.com/docs/en/models/haiku-5-5/overview
|
|
19
|
+
const haiku55 = {
|
|
20
|
+
id: 'claude-haiku-5-5', provider: 'anthropic', api: 'anthropic-messages',
|
|
21
|
+
name: 'Claude Haiku 5.5', reasoning: true, input: ['text', 'image'],
|
|
22
|
+
contextWindow: 1000000, maxTokens: 128000,
|
|
23
|
+
cost: { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125, tiers: [{ inputTokensAbove: 100000, input: 0.5, output: 2.5, cacheRead: 0.05, cacheWrite: 0.625 }] },
|
|
24
|
+
thinkingLevelMap: { off: null, minimal: null, low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'max' },
|
|
25
|
+
compat: { forceAdaptiveThinking: true, supportsTemperature: false, supportsStrictTools: true, supportsMidConvoEffort: true },
|
|
26
|
+
promptCache: { short: 300, long: 3600 },
|
|
27
|
+
};
|
|
15
28
|
// GPT-6.1 Sol through Codex, checked 2026-10-01: Codex keeps the 272k window
|
|
16
29
|
// of its pricing tier (the API model has 1.05M); 128k output; efforts low to
|
|
17
30
|
// max, no none/minimal; $2/$10 per 1M, cached $0.10, doubled above 272k.
|
|
@@ -27,7 +40,7 @@ const sol61 = {
|
|
|
27
40
|
compat: { supportsOpenAIGrammarTools: true, supportsAdditionalTools: true, supportsToolSearch: true, supportsMidConvoSystemMessages: true },
|
|
28
41
|
inputLimits: { images: { resize: { maxWidth: 2000, maxHeight: 2000, maxBytes: 4718592, jpegQuality: 80 } } },
|
|
29
42
|
};
|
|
30
|
-
const GAPS = { anthropic: [sonnet55], 'openai-codex': [sol61] };
|
|
43
|
+
const GAPS = { anthropic: [sonnet55, haiku55], 'openai-codex': [sol61] };
|
|
31
44
|
// Studio's providers: the Claude and Codex subscriptions, or an API-key
|
|
32
45
|
// vendor (DeepSeek, Kimi, GLM, MiMo, Qwen, OpenCode, Grok) whose id is Pi's
|
|
33
46
|
// own provider id; Studio runs vendors as OpenAI Chat Completions.
|
|
@@ -10,14 +10,18 @@ const MAX_SNAPSHOT = 16 * 1024 * 1024;
|
|
|
10
10
|
|
|
11
11
|
// The worker runs inside the tool boundary; what it prints is one short line.
|
|
12
12
|
// `scope`: only these paths (relative to the project) make the patch.
|
|
13
|
-
|
|
13
|
+
// `base`: the commit the patch starts from (the turn's starting HEAD), so a
|
|
14
|
+
// commit made during the turn stays part of its change; none: HEAD.
|
|
15
|
+
const BASE = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
|
16
|
+
async function worker(boundary, mode, scope = null, base = null) {
|
|
14
17
|
let scopeName = null;
|
|
15
18
|
if (scope) {
|
|
16
19
|
scopeName = `scope-${randomUUID()}.json`;
|
|
17
20
|
fs.writeFileSync(path.join(boundary.temporary, scopeName), JSON.stringify([...scope]), { mode: 0o600, flag: 'wx' });
|
|
18
21
|
}
|
|
19
22
|
let result;
|
|
20
|
-
|
|
23
|
+
const from = typeof base === 'string' && BASE.test(base) ? ' ' + base : '';
|
|
24
|
+
try { result = await boundary.invoke('bash', { command: `${quote(process.execPath)} ${quote(WORKER)} ${mode}${scopeName ? ' ' + scopeName : ''}${from}`, timeout: 60 }); }
|
|
21
25
|
finally { if (scopeName) fs.rmSync(path.join(boundary.temporary, scopeName), { force: true }); }
|
|
22
26
|
const text = textContent(result).trim();
|
|
23
27
|
if (result.details?.truncation?.truncated || text.includes('\n')) throw Error(/review-(patch|file|untracked)-limit/.exec(text)?.[0] ?? 'managed-review-patch-unreadable');
|
|
@@ -28,14 +32,23 @@ async function worker(boundary, mode, scope = null) {
|
|
|
28
32
|
|
|
29
33
|
// The digest of the patch a reviewer would see (diff against HEAD plus
|
|
30
34
|
// untracked files): whether a review is still current. Nothing else moves.
|
|
31
|
-
export async function readPatchDigest(boundary, scope = null) { return (await worker(boundary, 'digest', scope)).digest; }
|
|
35
|
+
export async function readPatchDigest(boundary, scope = null, base = null) { return (await worker(boundary, 'digest', scope, base)).digest; }
|
|
36
|
+
|
|
37
|
+
// The commit HEAD names now, or null (no repository, no commit yet).
|
|
38
|
+
export async function readHead(boundary) {
|
|
39
|
+
try {
|
|
40
|
+
const result = await boundary.invoke('bash', { command: `${quote(process.execPath)} ${quote(WORKER)} head`, timeout: 60 });
|
|
41
|
+
const text = textContent(result).trim();
|
|
42
|
+
return BASE.test(text) ? text : null;
|
|
43
|
+
} catch { return null; }
|
|
44
|
+
}
|
|
32
45
|
|
|
33
46
|
// The patch a reviewer sees, read inside the tool boundary. The worker writes
|
|
34
47
|
// it to the boundary's temporary directory and names it, with its digest, on
|
|
35
48
|
// its output; the file is read once, must match that digest (a file changed
|
|
36
49
|
// meanwhile never passes) and is removed.
|
|
37
|
-
export async function readPatchSnapshot(boundary, scope = null) {
|
|
38
|
-
const { digest, bytes, name } = await worker(boundary, 'file', scope);
|
|
50
|
+
export async function readPatchSnapshot(boundary, scope = null, base = null) {
|
|
51
|
+
const { digest, bytes, name } = await worker(boundary, 'file', scope, base);
|
|
39
52
|
if (!/^review-[0-9a-f-]{36}\.json$/.test(name ?? '')) throw Error('managed-review-patch-unreadable');
|
|
40
53
|
return { patch: readOnce(boundary, name, bytes, digest).toString('utf8'), digest };
|
|
41
54
|
}
|
|
@@ -44,8 +57,8 @@ export async function readPatchSnapshot(boundary, scope = null) {
|
|
|
44
57
|
// Compared before and after a command, it names the files that command
|
|
45
58
|
// changed, so a conversation answers for its own changes, not for another
|
|
46
59
|
// conversation's in the same folder.
|
|
47
|
-
export async function readChangedFiles(boundary) {
|
|
48
|
-
const { digest, bytes, name } = await worker(boundary, 'files');
|
|
60
|
+
export async function readChangedFiles(boundary, base = null) {
|
|
61
|
+
const { digest, bytes, name } = await worker(boundary, 'files', null, base);
|
|
49
62
|
if (!/^files-[0-9a-f-]{36}\.json$/.test(name ?? '')) throw Error('managed-review-patch-unreadable');
|
|
50
63
|
return new Map(Object.entries(JSON.parse(readOnce(boundary, name, bytes, digest).toString('utf8'))));
|
|
51
64
|
}
|
|
@@ -74,6 +87,9 @@ export function reviewUnavailableReason(message) {
|
|
|
74
87
|
return 'failed';
|
|
75
88
|
}
|
|
76
89
|
|
|
90
|
+
// What the diff is taken against: HEAD, or the commit the turn started from
|
|
91
|
+
// (commits made during the turn are part of the change).
|
|
92
|
+
const against = value => value?.base ? `diff against the commit the turn started from (${String(value.base).slice(0, 12)}), commits made since included` : 'diff against HEAD';
|
|
77
93
|
// The patch as a reviewer reads it: the diff, then each new file in full.
|
|
78
94
|
export function reviewText(snapshot) {
|
|
79
95
|
let value;
|
|
@@ -82,7 +98,7 @@ export function reviewText(snapshot) {
|
|
|
82
98
|
: f.mode === 'directory' ? `=== new folder ${f.path} (a repository of its own, not reviewed)`
|
|
83
99
|
: f.encoding === 'omitted' ? `=== new file ${f.path} (${f.bytes} bytes, too large to review)`
|
|
84
100
|
: f.encoding === 'utf8' ? `=== new file ${f.path}\n${f.contents}` : `=== new binary file ${f.path} (${Math.round(f.contents.length * 0.75)} bytes)`);
|
|
85
|
-
return `Patch snapshot ${snapshot.digest}:\n---
|
|
101
|
+
return `Patch snapshot ${snapshot.digest}:\n--- ${against(value)} ---\n${value.patch || '(no changes to tracked files)'}\n${files.length ? `--- new files ---\n${files.join('\n\n')}` : ''}`;
|
|
86
102
|
}
|
|
87
103
|
|
|
88
104
|
// A patch larger than one reviewer reads at once (REVIEW_PART_BYTES, about
|
|
@@ -125,7 +141,7 @@ export function reviewParts(snapshot, size = REVIEW_PART_BYTES) {
|
|
|
125
141
|
const parts = groups.map((group, i) => {
|
|
126
142
|
const files = group.sections.map(s => s.file), diff = group.sections.filter(s => s.kind === 'diff').map(s => s.text).join(''), news = group.sections.filter(s => s.kind === 'new').map(s => s.text);
|
|
127
143
|
const head = groups.length === 1 ? `Patch snapshot ${snapshot.digest}.` : `Patch snapshot ${snapshot.digest}, part ${i + 1} of ${groups.length}. This part's files: ${files.join(', ')}. The other parts are reviewed separately: judge this part, and read other files only for context.`;
|
|
128
|
-
return { files, text: `${head}${skipped}\n---
|
|
144
|
+
return { files, text: `${head}${skipped}\n--- ${against(value)} ---\n${diff || '(no changes to tracked files in this part)'}\n${news.length ? `--- new files ---\n${news.join('\n\n')}` : ''}` };
|
|
129
145
|
});
|
|
130
146
|
return { parts: parts.length ? parts : [{ files: [], text: `Patch snapshot ${snapshot.digest}: every changed file is too large to review.${skipped}` }], omitted };
|
|
131
147
|
}
|
|
@@ -82,3 +82,23 @@ export async function executeRepositoryFetch(boundary,plan,signal,{transport=fet
|
|
|
82
82
|
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
83
|
} finally {fs.rmSync(staging,{recursive:true,force:true});}
|
|
84
84
|
}
|
|
85
|
+
|
|
86
|
+
// The fetch_origin tool of a company conversation: one approved fetch of the
|
|
87
|
+
// GitHub origin (Bypass fetches without asking).
|
|
88
|
+
export function fetchOriginTool(self) {
|
|
89
|
+
return {name:'fetch_origin',label:'Fetch origin',description:'Request one approved GitHub origin fetch. Refreshes origin branches without changing working files. Does not expose credentials or allow arbitrary network commands.',
|
|
90
|
+
parameters:{type:'object',properties:{},additionalProperties:false},
|
|
91
|
+
execute:async(id,_args,signal,_update,ctx)=>{
|
|
92
|
+
const plan=await repositoryFetchPlan(self.boundary);
|
|
93
|
+
if(self.permission==='trusted-full-access'&&!signal?.aborted)return executeRepositoryFetch(self.boundary,plan,signal);
|
|
94
|
+
const {piApprovalBroker}=await import('../runtime/inspection/approval-broker.ts');
|
|
95
|
+
const decision=await piApprovalBroker.request({cwd:self.cwd,rawSessionId:self.session.sessionManager.getSessionId(),toolCallId:id,
|
|
96
|
+
action:{kind:'external-provider-action',preconditionClass:'runtime-only',toolName:'fetch_origin',rawAction:plan,
|
|
97
|
+
targetPaths:[self.cwd],provider:'github',urlOrigin:'https://github.com',requestedScope:'fetch-origin-once',
|
|
98
|
+
reason:`Fetch origin branches from ${plan.repository}`,riskClass:'low',allowConsequence:'Download origin branches once; keep the current branch and working files unchanged.',denyConsequence:'No network request or credential read.'},
|
|
99
|
+
terminalConfirm:()=>ctx?.ui?.confirm?.('Tải nhánh từ origin',`Tải các nhánh của ${plan.repository} về ${self.cwd} một lần.\n\nĐồng ý: chỉ tải nhánh; nhánh hiện tại và file đang làm không đổi.\nTừ chối: không gửi request mạng, không đọc thông tin đăng nhập.`)??Promise.resolve(false),
|
|
100
|
+
unavailableFallback:'terminal-confirm',recheck:()=>!signal?.aborted&&Boolean(self.grant)});
|
|
101
|
+
if(!decision.allowed||!decision.consume()||signal?.aborted)throw Error('managed-operation-denied');
|
|
102
|
+
return executeRepositoryFetch(self.boundary,plan,signal);
|
|
103
|
+
}};
|
|
104
|
+
}
|
|
@@ -186,3 +186,31 @@ function capacityStatus(owner) {
|
|
|
186
186
|
try { owner.session?.extensionRunner?.getUIContext?.()?.setWorkingMessage?.(wait ? `Agent Watch ${who}: waiting for a free company model account (${failureReason(wait.code)}). Esc stops.` : undefined); }
|
|
187
187
|
catch { /* no Terminal */ }
|
|
188
188
|
}
|
|
189
|
+
|
|
190
|
+
// A company answer that failed for a passing reason after Studio admitted it
|
|
191
|
+
// (the model's stream was cut or came back empty, the model or Studio's
|
|
192
|
+
// gateway did not answer, Studio could not be reached) is asked again by Pi's
|
|
193
|
+
// own turn retry: up to twice, 2 then 4 s apart, with the failed attempt left
|
|
194
|
+
// out of what the model sees. Tools run only after a whole answer, so nothing
|
|
195
|
+
// the agent did is repeated; the price is the request's input, billed again.
|
|
196
|
+
// Refusals before admission are asked again earlier, in wrapRoleStreams;
|
|
197
|
+
// everything else (quota, key, policy, a rejected or refused request, a closed
|
|
198
|
+
// run) fails at once, as before. Pi decides by the words of an error, which
|
|
199
|
+
// for a company failure would match "rate limited" or "timeout" in sentences
|
|
200
|
+
// that must not be retried; here it decides by Studio's code only.
|
|
201
|
+
export const TURN_RETRY_CODES = new Set(['upstream_interrupted', 'upstream_incomplete', 'upstream_timeout', 'upstream_unavailable',
|
|
202
|
+
'connector_unavailable', 'connector_execution_failed', 'inference_unavailable', 'studio-unreachable']);
|
|
203
|
+
export const TURN_RETRY = { maxRetries: 2, baseDelayMs: 2_000 };
|
|
204
|
+
|
|
205
|
+
export function companyTurnRetryable(message) {
|
|
206
|
+
return message?.stopReason === 'error' && TURN_RETRY_CODES.has(failureCode(message.errorMessage));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// True when the session retries company failures by code. A Pi whose session
|
|
210
|
+
// no longer has the hook keeps retry off rather than retrying by words.
|
|
211
|
+
export function enableCompanyTurnRetry(session, settings) {
|
|
212
|
+
if (typeof session?._isRetryableError !== 'function') return false;
|
|
213
|
+
session._isRetryableError = companyTurnRetryable;
|
|
214
|
+
settings.applyOverrides({ retry: { enabled: true, ...TURN_RETRY } });
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
@@ -13,10 +13,17 @@ const MAX_SNAPSHOT = 16 * 1024 * 1024, MAX_UNTRACKED = 2000, READ_FILE = 400_000
|
|
|
13
13
|
const git = args => execFileSync(managedGit(), ['--no-pager', ...args], { encoding: 'utf8', maxBuffer: MAX_SNAPSHOT + 1, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_NOSYSTEM: '1' } });
|
|
14
14
|
const sha256 = data => createHash('sha256').update(data).digest('hex');
|
|
15
15
|
const mode = process.argv[2];
|
|
16
|
+
// The arguments after the mode: a scope file, then the commit the patch starts
|
|
17
|
+
// from (the turn's starting HEAD; commits made since are part of the change).
|
|
18
|
+
// While HEAD has not moved, the patch is the plain one against HEAD.
|
|
19
|
+
const head = () => { try { return git(['rev-parse', '--verify', '-q', 'HEAD^{commit}']).trim(); } catch { return ''; } };
|
|
20
|
+
if (mode === 'head') { process.stdout.write(head() || 'none'); process.exit(0); }
|
|
21
|
+
const extra = process.argv.slice(3), given = extra.find(a => /^[0-9a-f]{40}([0-9a-f]{24})?$/.test(a));
|
|
22
|
+
const base = given && given !== head() ? given : 'HEAD';
|
|
16
23
|
// "files": what each changed or new file holds now (a hash, "deleted"), so
|
|
17
24
|
// the runtime can tell which files one conversation's command changed.
|
|
18
25
|
if (mode === 'files') {
|
|
19
|
-
const changed = git(['diff', '--name-only', '--no-renames', '--relative', '-z',
|
|
26
|
+
const changed = git(['diff', '--name-only', '--no-renames', '--relative', '-z', base, '--', '.']).split('\0').filter(Boolean);
|
|
20
27
|
const fresh = git(['ls-files', '--others', '--exclude-standard', '-z']).split('\0').filter(Boolean);
|
|
21
28
|
if (changed.length + fresh.length > MAX_UNTRACKED * 4) throw Error('review-untracked-limit');
|
|
22
29
|
const print = name => {
|
|
@@ -33,11 +40,11 @@ if (mode === 'files') {
|
|
|
33
40
|
}
|
|
34
41
|
// A scope (a file the runtime wrote in this boundary's temporary directory):
|
|
35
42
|
// only these paths, the ones this conversation changed, make the patch.
|
|
36
|
-
const scopeName =
|
|
43
|
+
const scopeName = extra.find(a => a !== given);
|
|
37
44
|
if (scopeName !== undefined && !/^scope-[0-9a-f-]{36}\.json$/.test(scopeName)) throw Error('review-scope-invalid');
|
|
38
45
|
const scope = scopeName ? new Set(JSON.parse(fs.readFileSync(path.join(os.tmpdir(), scopeName), 'utf8'))) : null;
|
|
39
46
|
if (scope && [...scope].some(p => typeof p !== 'string' || !p || path.isAbsolute(p) || p.split('/').includes('..'))) throw Error('review-scope-invalid');
|
|
40
|
-
const patch = scope && !scope.size ? '' : git(['diff', '--binary', '--no-ext-diff', '--no-textconv', '--no-renames',
|
|
47
|
+
const patch = scope && !scope.size ? '' : git(['diff', '--binary', '--no-ext-diff', '--no-textconv', '--no-renames', base, '--', ...(scope ? [...scope].sort().map(p => ':(literal)' + p) : ['.'])]);
|
|
41
48
|
const paths = git(['ls-files', '--others', '--exclude-standard', '-z']).split('\0').filter(Boolean).filter(p => !scope || scope.has(p)).sort();
|
|
42
49
|
if (paths.length > MAX_UNTRACKED) throw Error('review-untracked-limit');
|
|
43
50
|
const untracked = paths.map(name => {
|
|
@@ -55,7 +62,7 @@ const untracked = paths.map(name => {
|
|
|
55
62
|
if (text !== null) return { path: name, mode, contents: text, encoding: 'utf8' };
|
|
56
63
|
return stat.size > BINARY_INLINE ? { path: name, mode, encoding: 'omitted', bytes: stat.size, sha256: sha256(data) } : { path: name, mode, contents: data.toString('base64'), encoding: 'base64' };
|
|
57
64
|
});
|
|
58
|
-
const snapshot = Buffer.from(JSON.stringify({ patch, untracked }));
|
|
65
|
+
const snapshot = Buffer.from(JSON.stringify(base === 'HEAD' ? { patch, untracked } : { base, patch, untracked }));
|
|
59
66
|
if (snapshot.length > MAX_SNAPSHOT) throw Error('review-patch-limit');
|
|
60
67
|
// "digest": the digest and size only (is a review still current?). "file":
|
|
61
68
|
// the snapshot goes to a new file in this boundary's temporary directory,
|
|
@@ -7,8 +7,13 @@ const OFFLINE = /ENOTFOUND|EAI_AGAIN|Could not resolve host|getaddrinfo|npm erro
|
|
|
7
7
|
// name that does not resolve is the machine's network, not the sandbox.
|
|
8
8
|
// `isolated`: a proxy listens on this Mac, so without approval a command does
|
|
9
9
|
// not reach this Mac's own servers either (tool-boundary.mjs).
|
|
10
|
-
|
|
10
|
+
// `docker`: the command ran with the Docker engine (run_with_docker);
|
|
11
|
+
// `engine`: one is there for run_with_docker, but this command ran without it.
|
|
12
|
+
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;
|
|
13
|
+
export function sandboxDiagnostic(message, { network = false, isolated = false, docker = false, engine = false } = {}) {
|
|
11
14
|
const text=String(message);
|
|
15
|
+
if(docker&&DOCKER_DAEMON.test(text))return 'managed-docker-unreachable: Lệnh chạy với Docker nhưng Docker engine không trả lời (Docker Desktop/Colima chưa chạy hoặc đang khởi động). Không xin duyệt lại cùng lệnh; báo người dùng mở Docker rồi thử lại.\n'+text;
|
|
16
|
+
if(!docker&&DOCKER_DAEMON.test(text))return (engine?'managed-docker-blocked: Lệnh bash thường không dùng được Docker trong chế độ công ty. Lệnh docker, docker compose, hoặc test cần Docker (Testcontainers) chạy qua run_with_docker để người dùng duyệt đúng lệnh đó.\n':'managed-docker-unavailable: Máy chưa có Docker engine đang chạy (Docker Desktop, Colima, OrbStack…). Báo người dùng mở Docker; sau đó cuộc trò chuyện mới có run_with_docker.\n')+text;
|
|
12
17
|
if(isolated&&/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;
|
|
13
18
|
if(network&&!DENIAL.test(text)&&OFFLINE.test(text))return 'managed-network-unreachable: Lệnh đã được duyệt và chạy có mạng nhưng không tới được host (máy đang offline, cần VPN/proxy công ty, hoặc sai tên host). Không xin duyệt lại cùng lệnh; báo người dùng lỗi mạng này.\n'+text;
|
|
14
19
|
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;
|