@piagent/platform 1.18.0 → 1.20.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 +37 -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 +15 -0
- package/docs-site/content/index.html +4 -4
- package/docs-site/content/whats-new.html +15 -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 +16 -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 +16 -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/call-purpose.mjs +3 -2
- package/packages/piagent-core/managed/command-tools.mjs +56 -0
- package/packages/piagent-core/managed/container-engine.mjs +68 -0
- package/packages/piagent-core/managed/continuation.mjs +131 -0
- package/packages/piagent-core/managed/doctor.mjs +12 -0
- package/packages/piagent-core/managed/helper-roles.mjs +1 -1
- package/packages/piagent-core/managed/helper-run.mjs +24 -6
- package/packages/piagent-core/managed/language-environment.mjs +57 -0
- package/packages/piagent-core/managed/linux-sandbox.mjs +6 -1
- package/packages/piagent-core/managed/package-proxy.mjs +181 -0
- package/packages/piagent-core/managed/patch-snapshot.mjs +53 -9
- package/packages/piagent-core/managed/repository-operation.mjs +20 -0
- package/packages/piagent-core/managed/review-snapshot-worker.mjs +29 -4
- package/packages/piagent-core/managed/sandbox-diagnostic.mjs +7 -2
- package/packages/piagent-core/managed/session.mjs +60 -67
- package/packages/piagent-core/managed/tool-boundary.mjs +70 -11
- package/packages/piagent-core/managed/tool-worker.mjs +18 -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-DlbuyiQ3.js → MarkdownMessage-nggiOt0N.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{index-BGxsZHB5.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 +20 -8
- 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/server/loopback-server.ts +1 -1
- package/packages/piagent-webui/server/member-routes.ts +6 -3
- 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
|
@@ -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
|
+
}
|
|
@@ -66,6 +66,11 @@ export async function runDoctor({ sdkRoot, origin, broker, network = true }) {
|
|
|
66
66
|
let refused = false; try { await bash('curl -sS -m 5 https://example.com/ >/dev/null'); } catch { refused = true; }
|
|
67
67
|
if (!refused) throw Error('a command reached the internet without approval'); return 'blocked';
|
|
68
68
|
});
|
|
69
|
+
// Package registries through the runtime's proxy (package-proxy.mjs).
|
|
70
|
+
if (network) await check('package registries without approval (npm through the proxy)', false, async () => {
|
|
71
|
+
const status = await bash('curl -sS -m 15 -o /dev/null -w "%{http_code}" https://registry.npmjs.org/');
|
|
72
|
+
if (status !== '200') throw Error(`HTTP ${status}`); return 'registry.npmjs.org';
|
|
73
|
+
});
|
|
69
74
|
await check('git', true, () => bash('git --version'));
|
|
70
75
|
await check('git commit with the member\'s identity', false, async () => {
|
|
71
76
|
await bash('git init -q . && git add -A');
|
|
@@ -86,6 +91,13 @@ export async function runDoctor({ sdkRoot, origin, broker, network = true }) {
|
|
|
86
91
|
try { return await bash('node loopback.mjs'); }
|
|
87
92
|
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
93
|
});
|
|
94
|
+
// run_with_docker: the engine answers through its socket, and only there.
|
|
95
|
+
await check('Docker for approved commands (run_with_docker)', false, async () => {
|
|
96
|
+
if (!boundary.engine) throw Error('no Docker engine running (Docker Desktop, Colima, OrbStack…): start it, then open a new conversation');
|
|
97
|
+
let refused = false; try { await bash('docker version --format "{{.Server.Version}}"'); } catch { refused = true; }
|
|
98
|
+
if (!refused) throw Error('a plain command reached the Docker engine');
|
|
99
|
+
return `engine ${await bash('docker version --format "{{.Server.Version}}"', { docker: true })}`;
|
|
100
|
+
});
|
|
89
101
|
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
102
|
// The languages installed on this Mac, each started once in the sandbox.
|
|
91
103
|
// (macOS ships stubs for some, such as javac without a JDK: a probe decides.)
|
|
@@ -34,7 +34,7 @@ const HELPER_USES = {
|
|
|
34
34
|
research: roles => roles.includes('scout') ? 'research: anything on the web (current information, news, library or API documentation, releases, error messages), with the code when needed. It searches and reads the pages; you get its summary with sources.'
|
|
35
35
|
: 'research: anything on the web (current information, news, documentation; it searches and reads the pages, you get its summary with sources), and before changing an area you do not know that spans more files than you can read in a few batches (where something is computed and every caller, how a flow crosses modules); your own context stays free for the change.',
|
|
36
36
|
verify: () => 'verify: an independent check of a result: it runs the repository checks on the current code (offline, and the project is read-only for its commands) and confirms claims, yours or a helper\'s, against the code and primary sources. It answers pass, fail or unverifiable per claim, with evidence.',
|
|
37
|
-
review: () => 'review: an independent review of the current patch.',
|
|
37
|
+
review: () => 'review: an independent review of the current patch (this turn\'s changes, commits made in the turn included). To review work committed in earlier turns, pass since: the commit, branch or HEAD~N it started from; when this turn changed nothing, it reviews the branch\'s commits since it left the default branch.',
|
|
38
38
|
};
|
|
39
39
|
export function delegateDescription(roles) {
|
|
40
40
|
return `Hand work to a helper with its own context. Helpers read the project but cannot change files or call helpers. ${roles.map(role => HELPER_USES[role](roles)).join(' ')} A lookup that one grep or read answers stays with you. Write the task as a brief the helper can act on alone: the goal, what to find or check, where to start and what you already know or ruled out, and the answer you need (file:line facts). One call per helper runs at a time; each can be called again, up to ${HELPER_CALLS} times per user message, for example a second review after fixing what the first one found.`;
|
|
@@ -8,7 +8,7 @@ import { ManagedToolBoundary } from './tool-boundary.mjs';
|
|
|
8
8
|
import { managedResourceLoader } from './resource-loader.mjs';
|
|
9
9
|
import { shareGrant, releaseGrant } from './grant-share.mjs';
|
|
10
10
|
import { describeFailure, failureCode, failureText } from '../runtime/managed-failure.mjs';
|
|
11
|
-
import { reviewParts, mergeReviews, REVIEW_PARALLEL } from './patch-snapshot.mjs';
|
|
11
|
+
import { reviewParts, mergeReviews, REVIEW_PARALLEL, emptySnapshot, readBranchBase, readPatchDigest, readPatchSnapshot, resolveReviewBase } from './patch-snapshot.mjs';
|
|
12
12
|
import { reviewFindings, briefIssues } from './workflow.mjs';
|
|
13
13
|
import { anchorText, relayObjection, recordVerdicts } from './objections.mjs';
|
|
14
14
|
import { HELPER_CALLS, HELPER_SETUP, READ_TOOLS, countedCheck } from './helper-roles.mjs';
|
|
@@ -21,8 +21,24 @@ const textContent = result => result.content?.filter(p => p.type === 'text').map
|
|
|
21
21
|
const HELPER_SWITCH = new Set(['upstream_rate_limited', 'session_account_not_ready_retry_later', 'session_account_unavailable_start_new_session', 'account_capacity_unavailable', 'execution_capability_unavailable']);
|
|
22
22
|
const HELPER_ATTEMPTS = 2;
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
// The patch a review reads: this turn's change; the commits since `since`
|
|
25
|
+
// (a commit before HEAD the main agent names); or, when the turn changed
|
|
26
|
+
// nothing and the main agent asked (a review of work committed in earlier
|
|
27
|
+
// turns), the branch's commits since it left the default branch.
|
|
28
|
+
async function reviewSnapshot(managed, harness, since) {
|
|
29
|
+
if (since) {
|
|
30
|
+
const base = await resolveReviewBase(managed.boundary, since);
|
|
31
|
+
if (!base) throw Error(`managed-review-base-unknown: "${String(since).slice(0, 80)}" is not a commit HEAD descends from in this repository; name a commit, a branch or HEAD~N`);
|
|
32
|
+
return Object.assign(await readPatchSnapshot(managed.boundary, null, base), { from: { kind: 'since', ref: since, base } });
|
|
33
|
+
}
|
|
34
|
+
const snapshot = await managed.patchSnapshot();
|
|
35
|
+
if (harness || !emptySnapshot(snapshot)) return snapshot;
|
|
36
|
+
const branch = await readBranchBase(managed.boundary);
|
|
37
|
+
return branch ? Object.assign(await readPatchSnapshot(managed.boundary, null, branch.base), { from: { kind: 'branch', ref: branch.ref, base: branch.base } }) : snapshot;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function runHelper(managed, role, task, signal, harness, { verifyGrant, patiently, title, since }) {
|
|
41
|
+
const snapshot = role === 'review' ? await reviewSnapshot(managed, harness, since) : null, parts = snapshot ? reviewParts(snapshot) : null;
|
|
26
42
|
// A helper whose company model account is out of usage, or resting after
|
|
27
43
|
// a refusal, starts once more: Studio then gives the new helper the next
|
|
28
44
|
// model of its role that serves (an auto role's list). Helpers read and
|
|
@@ -98,15 +114,17 @@ async function helperAttempt(managed, role, task, signal, harness, snapshot, par
|
|
|
98
114
|
review = mergeReviews(parts, answers, { findingsOf: reviewFindings, issuesOf: briefIssues });
|
|
99
115
|
({ reply, usage } = review);
|
|
100
116
|
}
|
|
101
|
-
const
|
|
102
|
-
const
|
|
117
|
+
const from = snapshot?.from ?? null;
|
|
118
|
+
const stale = snapshot ? (await (from ? readPatchDigest(managed.boundary, null, from.base) : managed.patchDigest())) !== snapshot.digest : false;
|
|
119
|
+
const details = { role, runID: grant.run_id, tokens: usage, patchDigest: snapshot?.digest, stale, ...(from ? { since: { kind: from.kind, ref: from.ref, base: from.base } } : {}) };
|
|
103
120
|
if (snapshot) {
|
|
104
121
|
// Severity-graded findings decide whether the harness sends the agent back.
|
|
105
122
|
const findings = review ? review.findings : reviewFindings(reply);
|
|
106
123
|
Object.assign(details, { parsed: review ? review.parsed : !!findings, findings: (findings ?? []).slice(0, 40), blocking: findings?.filter(f => f.severity === 'blocking').length ?? 0 });
|
|
107
124
|
if (review) Object.assign(details, { parts: parts.parts.length, unread: parts.omitted.map(o => o.path).slice(0, 50) });
|
|
108
125
|
if (managed.run) { managed.run.reviews += 1; managed.run.blocking += details.blocking; }
|
|
109
|
-
|
|
126
|
+
// A review of earlier commits is not the review of this turn's change.
|
|
127
|
+
if (!from) managed.review = details;
|
|
110
128
|
managed.session.sessionManager.appendCustomEntry('agent-watch-review', details);
|
|
111
129
|
}
|
|
112
130
|
const verdicts = role === 'verify' ? recordVerdicts(managed.run, reply, harness, details) : '';
|
|
@@ -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, proxySocket = 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,11 @@ 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] : []),
|
|
71
|
+
// A command without network reaches the package proxy (package-proxy.mjs)
|
|
72
|
+
// through this socket; the worker forwards 127.0.0.1:3128 to it.
|
|
73
|
+
...(proxySocket && exists(proxySocket) ? ['--bind', proxySocket, '/run/piagent-proxy.sock'] : []),
|
|
69
74
|
'--remount-ro', '/', '--chdir', cwd,
|
|
70
75
|
];
|
|
71
76
|
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import net from 'node:net';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { lookup } from 'node:dns/promises';
|
|
6
|
+
|
|
7
|
+
// The package registries a company command reaches without asking: plain
|
|
8
|
+
// bash has no network, except through this proxy, which the trusted runtime
|
|
9
|
+
// runs outside the sandbox. It tunnels HTTPS (CONNECT) and forwards plain
|
|
10
|
+
// HTTP to the hosts below and the member's own additions, on ports 443 and
|
|
11
|
+
// 80 only, never to an IP literal or a name that resolves to this machine or
|
|
12
|
+
// a private network (DNS rebinding), and connects to the address it checked.
|
|
13
|
+
// Everything else is refused with 403 and remembered, so the command's
|
|
14
|
+
// failure names the host and run_with_network (the member approves it).
|
|
15
|
+
// Registries (and GitHub) take no upload without a token, and tokens stay
|
|
16
|
+
// out of the sandbox (.npmrc, ~/.m2/settings.xml, git credentials).
|
|
17
|
+
export const DEFAULT_DOMAINS = Object.freeze([
|
|
18
|
+
// JavaScript
|
|
19
|
+
'registry.npmjs.org', 'registry.yarnpkg.com', 'repo.yarnpkg.com', 'registry.npmmirror.com', 'jsr.io', 'npm.jsr.io', 'deno.land',
|
|
20
|
+
// Python
|
|
21
|
+
'pypi.org', 'files.pythonhosted.org',
|
|
22
|
+
// Go
|
|
23
|
+
'proxy.golang.org', 'sum.golang.org', 'index.golang.org',
|
|
24
|
+
// Rust
|
|
25
|
+
'crates.io', 'index.crates.io', 'static.crates.io', 'static.rust-lang.org',
|
|
26
|
+
// JVM
|
|
27
|
+
'repo.maven.apache.org', 'repo1.maven.org', 'maven.google.com', 'dl.google.com', 'plugins.gradle.org', 'plugins-artifacts.gradle.org',
|
|
28
|
+
'services.gradle.org', 'downloads.gradle.org', 'repo.spring.io', 'jitpack.io',
|
|
29
|
+
// .NET, Ruby, PHP, Dart, Swift/CocoaPods, Elixir
|
|
30
|
+
'api.nuget.org', 'globalcdn.nuget.org', 'rubygems.org', 'index.rubygems.org', 'packagist.org', 'repo.packagist.org',
|
|
31
|
+
'pub.dev', 'cdn.cocoapods.org', 'repo.hex.pm', 'builds.hex.pm',
|
|
32
|
+
// Public GitHub downloads: release assets (the Gradle Wrapper's
|
|
33
|
+
// distributions redirect there), git dependencies and archives. Nothing
|
|
34
|
+
// is pushed or uploaded without a token, and none reaches the sandbox.
|
|
35
|
+
'github.com', 'codeload.github.com', 'objects.githubusercontent.com', 'release-assets.githubusercontent.com',
|
|
36
|
+
]);
|
|
37
|
+
const PORTS = new Set([80, 443]);
|
|
38
|
+
|
|
39
|
+
// One host name per line in the member's file (# comments); "*.example.com"
|
|
40
|
+
// covers its subdomains. Read by the trusted runtime: the sandbox cannot.
|
|
41
|
+
export function memberDomains(userHome, file = path.join(userHome, '.piagent/sandbox-domains')) {
|
|
42
|
+
let text = '';
|
|
43
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
|
|
44
|
+
return text.split('\n').map(line => line.replace(/#.*/, '').trim().toLowerCase()).filter(name => /^(\*\.)?[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(name)).slice(0, 200);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function domainAllowed(host, domains) {
|
|
48
|
+
const name = String(host).toLowerCase().replace(/\.$/, '');
|
|
49
|
+
if (!/^[a-z0-9.-]+$/.test(name) || net.isIP(name)) return false;
|
|
50
|
+
return domains.some(rule => rule.startsWith('*.') ? name.endsWith(rule.slice(1)) && name.length > rule.length - 1 : name === rule);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Addresses that are this machine, a private network or not routable.
|
|
54
|
+
export function privateAddress(address) {
|
|
55
|
+
if (net.isIPv4(address)) {
|
|
56
|
+
const [a, b] = address.split('.').map(Number);
|
|
57
|
+
return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b < 128) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b < 32)
|
|
58
|
+
|| (a === 192 && b === 168) || (a === 198 && (b === 18 || b === 19)) || a >= 224;
|
|
59
|
+
}
|
|
60
|
+
const v6 = address.toLowerCase();
|
|
61
|
+
if (v6.startsWith('::ffff:')) return privateAddress(v6.slice(7));
|
|
62
|
+
return v6 === '::' || v6 === '::1' || /^f[cd]/.test(v6) || /^fe[89ab]/.test(v6) || v6.startsWith('ff');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function upstreamProxy(env) {
|
|
66
|
+
for (const name of ['HTTPS_PROXY', 'https_proxy']) {
|
|
67
|
+
try { const url = new URL(env[name]); if (url.protocol === 'http:' && url.hostname && !url.username) return { host: url.hostname, port: Number(url.port) || 80 }; } catch { /* unset */ }
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class PackageProxy {
|
|
73
|
+
#server; #sockets = new Set();
|
|
74
|
+
// (`ports` and `isPrivate` are for tests.)
|
|
75
|
+
constructor({ domains = DEFAULT_DOMAINS, resolve = name => lookup(name, { all: true }), env = process.env, maxDenied = 200, ports = PORTS, isPrivate = privateAddress } = {}) {
|
|
76
|
+
this.domains = [...new Set(domains.map(name => name.toLowerCase()))];
|
|
77
|
+
this.resolve = resolve; this.upstream = upstreamProxy(env); this.maxDenied = maxDenied; this.ports = new Set(ports); this.isPrivate = isPrivate;
|
|
78
|
+
this.log = []; this.deniedCount = 0;
|
|
79
|
+
this.#server = http.createServer((request, response) => this.#forward(request, response));
|
|
80
|
+
this.#server.on('connect', (request, socket, head) => this.#tunnel(request, socket, head));
|
|
81
|
+
this.#server.on('connection', socket => { this.#sockets.add(socket); socket.once('close', () => this.#sockets.delete(socket)); });
|
|
82
|
+
this.#server.on('clientError', (_error, socket) => socket.destroy());
|
|
83
|
+
}
|
|
84
|
+
// A TCP port on loopback (macOS) or a Unix socket (Linux).
|
|
85
|
+
listen(where) {
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
this.#server.once('error', reject);
|
|
88
|
+
const done = () => { this.#server.off('error', reject); resolve(this.#server.address()); };
|
|
89
|
+
if (typeof where === 'string') this.#server.listen(where, done); else this.#server.listen(0, '127.0.0.1', done);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
#deny(host) {
|
|
93
|
+
this.deniedCount += 1;
|
|
94
|
+
this.log.push(String(host).slice(0, 253)); if (this.log.length > this.maxDenied) this.log.shift();
|
|
95
|
+
}
|
|
96
|
+
// The hosts refused since the count was `before` (at most the last few).
|
|
97
|
+
deniedSince(before) {
|
|
98
|
+
const count = Math.min(this.deniedCount - before, this.log.length);
|
|
99
|
+
return count > 0 ? [...new Set(this.log.slice(-count))].slice(-5) : [];
|
|
100
|
+
}
|
|
101
|
+
// The checked public address of an allowed host, or null.
|
|
102
|
+
async #target(host, port) {
|
|
103
|
+
if (!this.ports.has(port) || !domainAllowed(host, this.domains)) { this.#deny(host); return null; }
|
|
104
|
+
if (this.upstream) return { host, port };
|
|
105
|
+
try {
|
|
106
|
+
const addresses = await this.resolve(host);
|
|
107
|
+
if (!addresses.length || addresses.some(entry => this.isPrivate(entry.address))) { this.#deny(host); return null; }
|
|
108
|
+
return { host: addresses[0].address, port };
|
|
109
|
+
} catch { this.#deny(host); return null; }
|
|
110
|
+
}
|
|
111
|
+
async #tunnel(request, socket, head) {
|
|
112
|
+
socket.on('error', () => {});
|
|
113
|
+
const match = /^([^:[\]]+|\[[^\]]+\]):(\d+)$/.exec(request.url ?? '');
|
|
114
|
+
const target = match ? await this.#target(match[1], Number(match[2])) : (this.#deny(request.url ?? ''), null);
|
|
115
|
+
if (!target) { socket.end('HTTP/1.1 403 Forbidden\r\nX-Piagent-Proxy: domain-not-allowed\r\nContent-Length: 0\r\n\r\n'); return; }
|
|
116
|
+
const upstream = this.upstream ? net.connect(this.upstream.port, this.upstream.host) : net.connect(target.port, target.host);
|
|
117
|
+
upstream.setTimeout(10 * 60_000, () => upstream.destroy());
|
|
118
|
+
upstream.once('connect', () => {
|
|
119
|
+
if (this.upstream) {
|
|
120
|
+
upstream.write(`CONNECT ${match[1]}:${match[2]} HTTP/1.1\r\nHost: ${match[1]}:${match[2]}\r\n\r\n`);
|
|
121
|
+
let reply = Buffer.alloc(0);
|
|
122
|
+
const onData = chunk => {
|
|
123
|
+
reply = Buffer.concat([reply, chunk]);
|
|
124
|
+
const end = reply.indexOf('\r\n\r\n'); if (end < 0) { if (reply.length > 16384) upstream.destroy(); return; }
|
|
125
|
+
upstream.off('data', onData);
|
|
126
|
+
if (!/^HTTP\/1\.[01] 2\d\d/.test(reply.subarray(0, end).toString('latin1'))) { socket.end('HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n'); upstream.destroy(); return; }
|
|
127
|
+
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); if (reply.length > end + 4) socket.write(reply.subarray(end + 4)); if (head?.length) upstream.write(head);
|
|
128
|
+
upstream.pipe(socket); socket.pipe(upstream);
|
|
129
|
+
};
|
|
130
|
+
upstream.on('data', onData);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
134
|
+
if (head?.length) upstream.write(head);
|
|
135
|
+
upstream.pipe(socket); socket.pipe(upstream);
|
|
136
|
+
});
|
|
137
|
+
upstream.on('error', () => { if (socket.writable) socket.end('HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n'); });
|
|
138
|
+
socket.on('close', () => upstream.destroy());
|
|
139
|
+
}
|
|
140
|
+
async #forward(request, response) {
|
|
141
|
+
let url;
|
|
142
|
+
try { url = new URL(request.url); } catch { response.writeHead(400).end(); return; }
|
|
143
|
+
if (url.protocol !== 'http:') { response.writeHead(400).end(); return; }
|
|
144
|
+
const target = await this.#target(url.hostname, Number(url.port) || 80);
|
|
145
|
+
if (!target) { response.writeHead(403, { 'X-Piagent-Proxy': 'domain-not-allowed', 'Content-Type': 'text/plain; charset=utf-8' }).end(`piagent: ${url.hostname} is not an allowed package registry\n`); return; }
|
|
146
|
+
const headers = { ...request.headers, host: url.host }; delete headers['proxy-connection']; delete headers['proxy-authorization'];
|
|
147
|
+
const outgoing = http.request(this.upstream
|
|
148
|
+
? { host: this.upstream.host, port: this.upstream.port, path: url.href, method: request.method, headers }
|
|
149
|
+
: { host: target.host, port: target.port, path: url.pathname + url.search, method: request.method, headers, servername: url.hostname }, incoming => {
|
|
150
|
+
response.writeHead(incoming.statusCode ?? 502, incoming.headers); incoming.pipe(response);
|
|
151
|
+
});
|
|
152
|
+
outgoing.on('error', () => { if (!response.headersSent) response.writeHead(502); response.end(); });
|
|
153
|
+
request.pipe(outgoing);
|
|
154
|
+
}
|
|
155
|
+
async close() {
|
|
156
|
+
for (const socket of this.#sockets) socket.destroy();
|
|
157
|
+
await new Promise(resolve => this.#server.close(() => resolve()));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Maven reads its proxy only from settings.xml (not the JVM's properties):
|
|
162
|
+
// a settings file in the sandbox's own home, which Maven reads as the user's
|
|
163
|
+
// once MAVEN_OPTS names that home (Java takes user.home from the account,
|
|
164
|
+
// not from HOME, and the member's ~/.m2 is closed here).
|
|
165
|
+
export function mavenSettings(url) {
|
|
166
|
+
const { hostname, port } = new URL(url);
|
|
167
|
+
const proxy = protocol => `<proxy><id>piagent-${protocol}</id><active>true</active><protocol>${protocol}</protocol><host>${hostname}</host><port>${port}</port><nonProxyHosts>localhost|127.0.0.1</nonProxyHosts></proxy>`;
|
|
168
|
+
return `<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"><proxies>${proxy('https')}${proxy('http')}</proxies></settings>\n`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Environment a command gets for the proxy at this URL: the variables
|
|
172
|
+
// package managers read, and the JVM's (Gradle), which ignores them.
|
|
173
|
+
export function proxyEnvironment(url, { mavenOpts = '', gradleOpts = '', mavenHome = null } = {}) {
|
|
174
|
+
const { hostname, port } = new URL(url);
|
|
175
|
+
const jvm = `-Dhttp.proxyHost=${hostname} -Dhttp.proxyPort=${port} -Dhttps.proxyHost=${hostname} -Dhttps.proxyPort=${port} -Dhttp.nonProxyHosts=localhost|127.0.0.1|::1`;
|
|
176
|
+
return {
|
|
177
|
+
HTTP_PROXY: url, HTTPS_PROXY: url, http_proxy: url, https_proxy: url, NO_PROXY: 'localhost,127.0.0.1,::1', no_proxy: 'localhost,127.0.0.1,::1',
|
|
178
|
+
npm_config_proxy: url, npm_config_https_proxy: url,
|
|
179
|
+
MAVEN_OPTS: [mavenOpts, jvm, mavenHome && `-Duser.home=${mavenHome}`].filter(Boolean).join(' '), GRADLE_OPTS: [gradleOpts, jvm].filter(Boolean).join(' '),
|
|
180
|
+
};
|
|
181
|
+
}
|