@piagent/platform 1.7.1 → 1.9.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/AGENTS.md +7 -0
- package/CHANGELOG.md +113 -1
- package/README.md +45 -70
- package/README.vi.md +34 -61
- package/architecture/layers.json +16 -3
- package/catalog/capabilities.json +14 -7
- package/catalog/claude-model-additions.json +42 -0
- package/docs/auto-delegation-policy.md +2 -0
- package/docs/command-reference-vietnamese.md +69 -91
- package/docs/context-window-policy.md +5 -5
- package/docs/distribution-standard.md +16 -19
- package/docs/en/architecture.md +9 -1
- package/docs/herdr-workflow.md +2 -3
- package/docs/languages.json +4 -4
- package/docs/managed-local-acceptance.md +403 -0
- package/docs/mcp-and-tools.md +5 -7
- package/docs/memory-policy.md +3 -3
- package/docs/model-options.md +22 -17
- package/docs/oauth-providers.md +1 -1
- package/docs/operator-manual-vietnamese.md +85 -130
- package/docs/package-architecture-notes.md +4 -4
- package/docs/piagent-core-improvement-plan.md +3 -5
- package/docs/piagent-core-plan-checkpoint-2026-09-06.json +2 -13
- package/docs/piagent-runtime-benchmark-execution-plan.md +10 -8
- package/docs/project-adapters.md +3 -3
- package/docs/project-onboarding.md +15 -16
- package/docs/publishing-for-teams.md +3 -1
- package/docs/quality-benchmark.md +2 -0
- package/docs/quickstart-vietnamese.md +24 -25
- package/docs/release-install-policy.md +16 -11
- package/docs/research/harness-next-evidence-review.md +7 -7
- package/docs/runtime-harness-standard.md +2 -0
- package/docs/runtime-policy-design.md +2 -0
- package/docs/runtime-quality-baseline.md +2 -0
- package/docs/subagent-orchestration-capabilities.md +1 -1
- package/docs/subagents-and-multiagent.md +10 -8
- package/docs/task-implementation-contract.md +2 -0
- package/docs/task-lifecycle-vietnamese.md +2 -0
- package/docs/team-onboarding.md +33 -64
- package/docs/usage-observability.md +2 -2
- package/docs/vi/architecture.md +9 -1
- package/docs/workflow-recipes.md +6 -10
- package/docs-site/architecture.html +4 -3
- package/docs-site/assets/docs.css +80 -0
- package/docs-site/benchmark.html +7 -1
- package/docs-site/commands.html +37 -23
- package/docs-site/content/architecture.html +2 -2
- package/docs-site/content/benchmark.html +5 -0
- package/docs-site/content/commands.html +35 -22
- package/docs-site/content/ecosystem.html +144 -0
- package/docs-site/content/en/architecture.html +1 -1
- package/docs-site/content/en/benchmark.html +5 -0
- package/docs-site/content/en/commands.html +30 -13
- package/docs-site/content/en/ecosystem.html +144 -0
- package/docs-site/content/en/index.html +24 -9
- package/docs-site/content/en/quickstart.html +10 -12
- package/docs-site/content/en/runtime.html +1 -1
- package/docs-site/content/en/security.html +1 -1
- package/docs-site/content/en/team.html +2 -2
- package/docs-site/content/en/whats-new.html +89 -1
- package/docs-site/content/en/workflows.html +29 -31
- package/docs-site/content/index.html +24 -9
- package/docs-site/content/quickstart.html +10 -12
- package/docs-site/content/runtime.html +2 -2
- package/docs-site/content/security.html +2 -2
- package/docs-site/content/team.html +2 -2
- package/docs-site/content/whats-new.html +89 -1
- package/docs-site/content/workflows.html +36 -38
- package/docs-site/context-engine.html +2 -1
- package/docs-site/ecosystem.html +283 -0
- package/docs-site/en/architecture.html +3 -2
- package/docs-site/en/benchmark.html +7 -1
- package/docs-site/en/commands.html +32 -14
- package/docs-site/en/context-engine.html +2 -1
- package/docs-site/en/ecosystem.html +283 -0
- package/docs-site/en/index.html +30 -14
- package/docs-site/en/mcp-approval.html +2 -1
- package/docs-site/en/mcp-auth.html +2 -1
- package/docs-site/en/mcp-servers.html +2 -1
- package/docs-site/en/mcp.html +2 -1
- package/docs-site/en/permissions.html +2 -1
- package/docs-site/en/profiles.html +2 -1
- package/docs-site/en/quickstart.html +17 -18
- package/docs-site/en/runtime.html +3 -2
- package/docs-site/en/scope.html +2 -1
- package/docs-site/en/security.html +6 -5
- package/docs-site/en/team.html +4 -3
- package/docs-site/en/whats-new.html +97 -8
- package/docs-site/en/workflows.html +34 -35
- package/docs-site/index.html +30 -14
- package/docs-site/mcp-approval.html +2 -1
- package/docs-site/mcp-auth.html +2 -1
- package/docs-site/mcp-servers.html +2 -1
- package/docs-site/mcp.html +2 -1
- package/docs-site/permissions.html +2 -1
- package/docs-site/profiles.html +2 -1
- package/docs-site/quickstart.html +17 -18
- package/docs-site/runtime.html +4 -3
- package/docs-site/scope.html +2 -1
- package/docs-site/security.html +7 -6
- package/docs-site/team.html +4 -3
- package/docs-site/whats-new.html +97 -8
- package/docs-site/workflows.html +41 -42
- package/evals/fs5-causal-arm.v1.json +2 -2
- package/evals/private-holdout-v1/CURRENT_BOUNDARY.md +17 -0
- package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
- package/package.json +10 -9
- package/packages/piagent-core/README.md +15 -23
- package/packages/piagent-core/docs/context-efficiency-improvement-plan.md +2 -0
- package/packages/piagent-core/extensions/experiment-loop-policy.ts +75 -0
- package/packages/piagent-core/extensions/local-state-path.js +15 -0
- package/packages/piagent-core/extensions/piagent-guard.ts +108 -960
- package/packages/piagent-core/extensions/policy-core.js +2 -2
- package/packages/piagent-core/extensions/source-checkout-paths.ts +54 -0
- package/packages/piagent-core/managed/broker-client.mjs +49 -0
- package/packages/piagent-core/managed/capabilities.mjs +28 -0
- package/packages/piagent-core/managed/compaction.mjs +63 -0
- package/packages/piagent-core/managed/doctor.mjs +107 -0
- package/packages/piagent-core/managed/native-catalog.mjs +42 -0
- package/packages/piagent-core/managed/patch-snapshot.mjs +48 -0
- package/packages/piagent-core/managed/repository-operation.mjs +84 -0
- package/packages/piagent-core/managed/request-stream.mjs +129 -0
- package/packages/piagent-core/managed/resource-loader.mjs +42 -0
- package/packages/piagent-core/managed/review-snapshot-worker.mjs +27 -0
- package/packages/piagent-core/managed/sandbox-diagnostic.mjs +12 -0
- package/packages/piagent-core/managed/search-fallback.mjs +93 -0
- package/packages/piagent-core/managed/session.mjs +472 -0
- package/packages/piagent-core/managed/store.d.mts +3 -0
- package/packages/piagent-core/managed/store.mjs +64 -0
- package/packages/piagent-core/managed/tool-boundary.mjs +243 -0
- package/packages/piagent-core/managed/tool-worker.mjs +36 -0
- package/packages/piagent-core/managed/toolchain.mjs +120 -0
- package/packages/piagent-core/managed/web-fetch.mjs +105 -0
- package/packages/piagent-core/managed/web-search.mjs +96 -0
- package/packages/piagent-core/managed/workflow.mjs +299 -0
- package/packages/piagent-core/package.json +3 -6
- package/packages/piagent-core/policies/base-policy.json +80 -26
- package/packages/piagent-core/runtime/hooks/agent-start-hook.ts +3 -3
- package/packages/piagent-core/runtime/hooks/input-hook.ts +35 -196
- package/packages/piagent-core/runtime/hooks/session-start-hook.ts +2 -0
- package/packages/piagent-core/runtime/inspection/approval-broker.ts +9 -1
- package/packages/piagent-core/runtime/inspection/same-process-bridge-proof.ts +1 -1
- package/packages/piagent-core/runtime/inspection/webui-snapshot.ts +1 -1
- package/packages/piagent-core/runtime/managed-failure.d.mts +10 -0
- package/packages/piagent-core/runtime/managed-failure.mjs +164 -0
- package/packages/piagent-core/runtime/model/model-route-policy.ts +33 -13
- package/packages/piagent-core/runtime/model/model-route-types.ts +10 -6
- package/packages/piagent-core/runtime/orchestration/minimal-delegation-policy.ts +8 -29
- package/packages/piagent-core/runtime/orchestration/owned-work-budget.ts +1 -1
- package/packages/piagent-core/runtime/policy/block-remedy.ts +5 -3
- package/packages/piagent-core/runtime/registration/context-commands.ts +1 -1
- package/packages/piagent-core/runtime/registration/knowledge-tools.ts +7 -7
- package/packages/piagent-core/runtime/registration/policy-tools.ts +2 -2
- package/packages/piagent-core/runtime/registration/session-commands.ts +11 -26
- package/packages/piagent-core/runtime/registration/task-evidence-tools.ts +1 -1
- package/packages/piagent-core/runtime/session/system-prompt.ts +6 -7
- package/packages/piagent-core/runtime/workflows/webui-runtime-command.ts +6 -6
- package/packages/piagent-core/runtime/workflows/webui-workflow.ts +6 -11
- package/packages/piagent-core/security/sensitive-text.js +9 -0
- package/packages/piagent-core/skills/piagent-ops/SKILL.md +8 -10
- package/packages/piagent-core/skills/test-audit/SKILL.md +101 -0
- package/packages/piagent-core/subagents/piagent-planner.md +1 -1
- package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-DGk34zJT.js → MarkdownMessage-Dd9wPk-v.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/index-7I-50Q2U.js +228 -0
- package/packages/piagent-webui/dist/client/assets/{index-DVahUVWy.css → index-DgSmcnP8.css} +1 -1
- package/packages/piagent-webui/dist/client/index.html +2 -2
- package/packages/piagent-webui/extension/piagent-webui.ts +11 -11
- package/packages/piagent-webui/extension/session-stream-adapter.ts +3 -1
- package/packages/piagent-webui/extension/terminal-session-adapter.ts +6 -1
- package/packages/piagent-webui/gateway/company-relay.ts +308 -0
- package/packages/piagent-webui/gateway/control-socket.ts +39 -12
- package/packages/piagent-webui/gateway/gateway-service.ts +109 -28
- package/packages/piagent-webui/gateway/gateway-session-stream.ts +58 -2
- package/packages/piagent-webui/gateway/managed-gateway.mjs +41 -0
- package/packages/piagent-webui/gateway/managed-launch.ts +145 -0
- package/packages/piagent-webui/gateway/managed-projection.ts +20 -0
- package/packages/piagent-webui/gateway/pi-host.ts +22 -4
- package/packages/piagent-webui/gateway/runtime-session-controls.ts +5 -2
- package/packages/piagent-webui/gateway/session-approval-authority.ts +28 -0
- package/packages/piagent-webui/gateway/session-catalog.ts +11 -3
- package/packages/piagent-webui/gateway/session-command-controller.ts +8 -2
- package/packages/piagent-webui/gateway/session-file-facts.ts +25 -0
- package/packages/piagent-webui/gateway/session-inspection-registry.ts +42 -12
- package/packages/piagent-webui/gateway/session-list-cache.ts +68 -0
- package/packages/piagent-webui/gateway/session-runtime-ownership.ts +8 -1
- package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +20 -27
- package/packages/piagent-webui/ownership/managed-terminal-lease.ts +27 -0
- package/packages/piagent-webui/ownership/profile-state.ts +2 -0
- package/packages/piagent-webui/ownership/session-lease-store.ts +22 -1
- package/packages/piagent-webui/server/approval-projection.ts +9 -0
- package/packages/piagent-webui/server/core-inspection-provider.ts +4 -9
- package/packages/piagent-webui/server/loopback-server.ts +37 -1
- package/packages/piagent-webui/server/message-stop-reason.ts +11 -0
- package/packages/piagent-webui/server/tool-call-summary.ts +185 -0
- package/packages/piagent-webui/server/transcript-projection.ts +86 -3
- package/packs/engineering-base/pack.json +4 -0
- package/schemas/piagent-webui/runtime-command-v1.schema.json +1 -2
- package/schemas/piagent-webui/session-catalog-v1.schema.json +8 -0
- package/schemas/piagent-webui/transcript-v1.schema.json +94 -1
- package/scripts/build-docs-site.mjs +23 -12
- package/scripts/check-public-wording.mjs +5 -6
- package/scripts/check-runtime-advisories.mjs +23 -6
- package/scripts/claude-model-additions.mjs +33 -0
- package/scripts/configure-model-scope.sh +36 -8
- package/scripts/configure-subagents.sh +1 -1
- package/scripts/explain-command.mjs +5 -6
- package/scripts/install-global.sh +2 -2
- package/scripts/model-route-evaluation-core.ts +1 -0
- package/scripts/model-scope-prune.mjs +75 -0
- package/scripts/phase-tools-evaluation.mjs +9 -4
- package/scripts/pi-model-route.mjs +1 -1
- package/scripts/piagent-cli.mjs +51 -2
- package/scripts/piagent-dashboard.mjs +27 -4
- package/scripts/piagent-installations.mjs +26 -0
- package/scripts/piagent-studio.mjs +155 -0
- package/scripts/runtime-advisory-exceptions.json +29 -0
- package/scripts/setup.sh +2 -2
- package/scripts/team-doctor.sh +20 -1
- package/scripts/typescript-loader.mjs +6 -0
- package/scripts/verify-local.sh +5 -5
- package/scripts/verify-release-identity.mjs +1 -1
- package/templates/global/settings.json +8 -2
- package/templates/project/.pi/project-context.md +3 -3
- package/templates/project/AGENTS.md +4 -5
- package/docs/piagent-core-reasoning-research-2026-09-06.md +0 -225
- package/docs/piagent-core-research-sources-2026-09-06.json +0 -416
- package/packages/piagent-webui/dist/client/assets/index-DiKRmCEP.js +0 -176
|
@@ -1199,7 +1199,7 @@ export function evaluateExecPolicyCore(command, options) {
|
|
|
1199
1199
|
for (const pattern of policy.blockedCommandPatterns ?? []) {
|
|
1200
1200
|
if (!legacyPatternMatchesSegment(pattern, words)) continue;
|
|
1201
1201
|
matches.push(`forbid:legacy:${pattern}`);
|
|
1202
|
-
reasons.push(`Blocked
|
|
1202
|
+
reasons.push(`Blocked: the command runs ${pattern}, which the project policy forbids`);
|
|
1203
1203
|
}
|
|
1204
1204
|
|
|
1205
1205
|
const nested = extractNestedCommands(braceText, words);
|
|
@@ -1242,7 +1242,7 @@ export function evaluateExecPolicyCore(command, options) {
|
|
|
1242
1242
|
};
|
|
1243
1243
|
for (const pattern of policy.requireConfirmationPatterns ?? []) {
|
|
1244
1244
|
if (segments.some((entry) => confirmationPatternMatches(pattern, entry.words))) {
|
|
1245
|
-
reasons.push(`Confirmation required
|
|
1245
|
+
reasons.push(`Confirmation required: the command runs ${pattern}`);
|
|
1246
1246
|
break;
|
|
1247
1247
|
}
|
|
1248
1248
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { extractShellPathCandidates, normalizePathCandidate } from "./policy-core.js";
|
|
5
|
+
import { normalizeRelative } from "./shell-reach.ts";
|
|
6
|
+
|
|
7
|
+
// Where a path lands relative to the external source checkouts granted to this
|
|
8
|
+
// session (read-only roots under the shared cache).
|
|
9
|
+
|
|
10
|
+
function inside(root: string, target: string): boolean {
|
|
11
|
+
const relative = path.relative(root, target);
|
|
12
|
+
return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function grantedSourceCheckoutRootForPath(cwd: string, candidate: string, roots: string[]): string | undefined {
|
|
16
|
+
if (roots.length === 0) return undefined;
|
|
17
|
+
const absolute = path.resolve(cwd, normalizePathCandidate(candidate));
|
|
18
|
+
let canonical: string;
|
|
19
|
+
try {
|
|
20
|
+
canonical = fs.realpathSync.native(absolute);
|
|
21
|
+
} catch {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
return roots.find((root) => inside(root, canonical));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A shell command can name a file it is about to create. realpath fails for that
|
|
28
|
+
// file, so resolve its nearest existing parent instead; otherwise a new file in
|
|
29
|
+
// the cache, spelled through an alias such as /var for /private/var, escapes
|
|
30
|
+
// both the text match and the realpath match.
|
|
31
|
+
function shellTargetInsideGrantedSourceCheckout(cwd: string, candidate: string, roots: string[]): boolean {
|
|
32
|
+
if (grantedSourceCheckoutRootForPath(cwd, candidate, roots)) return true;
|
|
33
|
+
let parent = path.resolve(cwd, normalizePathCandidate(candidate));
|
|
34
|
+
const rest: string[] = [];
|
|
35
|
+
while (path.dirname(parent) !== parent) {
|
|
36
|
+
rest.unshift(path.basename(parent));
|
|
37
|
+
parent = path.dirname(parent);
|
|
38
|
+
let canonical: string;
|
|
39
|
+
try { canonical = fs.realpathSync.native(parent); } catch { continue; }
|
|
40
|
+
const target = path.join(canonical, ...rest);
|
|
41
|
+
return roots.some((root) => inside(root, target));
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function shellTouchesGrantedSourceCheckout(cwd: string, command: string, roots: string[]): boolean {
|
|
47
|
+
if (roots.length === 0) return false;
|
|
48
|
+
for (const root of roots) {
|
|
49
|
+
const relative = path.relative(cwd, root).split(path.sep).join("/");
|
|
50
|
+
if (command.includes(root) || (relative && command.includes(relative))) return true;
|
|
51
|
+
}
|
|
52
|
+
return extractShellPathCandidates(command)
|
|
53
|
+
.some((candidate) => shellTargetInsideGrantedSourceCheckout(cwd, normalizeRelative(cwd, candidate) ?? candidate, roots));
|
|
54
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
export class ManagedBrokerClient {
|
|
8
|
+
#child; #pending = new Map(); #tail = Promise.resolve(); #closed = false;
|
|
9
|
+
constructor({ executable, profileID }) {
|
|
10
|
+
if (!path.isAbsolute(executable) || fs.realpathSync(executable) !== executable || !/^[a-f0-9]{64}$/.test(profileID)) throw Error('managed-broker-binding-invalid');
|
|
11
|
+
this.#child = spawn(executable, ['managed-broker', '--profile', profileID], {
|
|
12
|
+
stdio: ['pipe', 'pipe', 'pipe'], env: { PATH: '/usr/bin:/bin', LANG: 'en_US.UTF-8' },
|
|
13
|
+
});
|
|
14
|
+
let pending = ''; const decoder = new StringDecoder('utf8');
|
|
15
|
+
const fail = () => { this.#closed = true; for (const item of this.#pending.values()) { clearTimeout(item.timer); item.reject(Error('managed-broker-disconnected')); } this.#pending.clear(); };
|
|
16
|
+
this.#child.stdin.on('error', fail); this.#child.on('error', fail); this.#child.on('close', fail);
|
|
17
|
+
this.#child.stderr.on('data', () => {});
|
|
18
|
+
this.#child.stdout.on('data', chunk => {
|
|
19
|
+
pending += decoder.write(chunk);
|
|
20
|
+
if (Buffer.byteLength(pending) > 2 * 1024 * 1024) { this.#child.kill(); fail(); return; }
|
|
21
|
+
for (;;) {
|
|
22
|
+
const end = pending.indexOf('\n'); if (end < 0) break;
|
|
23
|
+
const line = pending.slice(0, end); pending = pending.slice(end + 1);
|
|
24
|
+
try {
|
|
25
|
+
const result = JSON.parse(line), item = this.#pending.get(result.id);
|
|
26
|
+
if (!item) throw Error('unexpected-broker-response');
|
|
27
|
+
clearTimeout(item.timer); this.#pending.delete(result.id);
|
|
28
|
+
if (result.error) item.reject(Error(`managed-broker:${String(result.error).replace(/[^a-zA-Z_]/g, '').slice(0, 80)}`));
|
|
29
|
+
else item.resolve(result.result);
|
|
30
|
+
} catch { this.#child.kill(); fail(); }
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
request(action, args = {}) {
|
|
35
|
+
const next = this.#tail.then(() => new Promise((resolve, reject) => {
|
|
36
|
+
if (this.#closed) { reject(Error('managed-broker-disconnected')); return; }
|
|
37
|
+
const id = randomUUID();
|
|
38
|
+
const timer = setTimeout(() => { this.#child.kill(); reject(Error('managed-broker-timeout')); }, 25000);
|
|
39
|
+
this.#pending.set(id, { resolve, reject, timer });
|
|
40
|
+
this.#child.stdin.write(JSON.stringify({ ...args, id, action }) + '\n');
|
|
41
|
+
}));
|
|
42
|
+
this.#tail = next.catch(() => {}); return next;
|
|
43
|
+
}
|
|
44
|
+
async dispose() {
|
|
45
|
+
if (this.#closed) return;
|
|
46
|
+
try { await this.request('close'); } catch {}
|
|
47
|
+
this.#child.stdin.end(); this.#child.kill(); this.#closed = true;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { nativeManagedModel, piProvider } from './native-catalog.mjs';
|
|
2
|
+
export const THINKING = ['off','minimal','low','medium','high','xhigh','max'];
|
|
3
|
+
export function nativeThinkingLevels(model) {
|
|
4
|
+
if (!model?.reasoning) return ['off'];
|
|
5
|
+
const mapping=model.thinkingLevelMap??{};
|
|
6
|
+
return THINKING.filter(level=>mapping[level]!==null && (!['xhigh','max'].includes(level)||mapping[level]!==undefined));
|
|
7
|
+
}
|
|
8
|
+
// A selected level must work for the current main route and at least one
|
|
9
|
+
// permitted candidate for every enabled helper. Studio rechecks availability
|
|
10
|
+
// and policy on run creation; this is capability UI, not an admission promise.
|
|
11
|
+
export function managedThinkingLevels(manifest, runtime, main) {
|
|
12
|
+
// A role whose level the Harness fixes does not narrow the member's choice.
|
|
13
|
+
const roles=Object.entries(manifest.harness.configuration).filter(([name,r])=>r&&Array.isArray(r.model_ids)&&(name==='main'||r.effort==null)).map(([,r])=>r);
|
|
14
|
+
return nativeThinkingLevels(main).filter(level=>(!Array.isArray(manifest.thinking_levels)||manifest.thinking_levels.includes(level)) && roles.every(role=>role.model_ids.some(id=>{
|
|
15
|
+
const item=manifest.models.find(m=>m.id===id);
|
|
16
|
+
if(!item)return false;
|
|
17
|
+
const native=nativeManagedModel(runtime,piProvider(item.owned_by),item.provider_model_id);
|
|
18
|
+
return native && nativeThinkingLevels(native).includes(level);
|
|
19
|
+
})));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Pi's rule for a level a model lacks: the next deeper one it has, else the
|
|
23
|
+
// next shallower one. Without a list every level stands.
|
|
24
|
+
export function nearestLevel(level, allowed) {
|
|
25
|
+
if (!Array.isArray(allowed) || allowed.length === 0 || allowed.includes(level)) return level;
|
|
26
|
+
const order = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'], at = Math.max(0, order.indexOf(level));
|
|
27
|
+
return order.slice(at).find(l => allowed.includes(l)) ?? order.slice(0, at).reverse().find(l => allowed.includes(l)) ?? allowed[0];
|
|
28
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { failureCode } from '../runtime/managed-failure.mjs';
|
|
4
|
+
import { currentPlan, unfinished, planText } from './workflow.mjs';
|
|
5
|
+
|
|
6
|
+
// One message longer than a summary part (a pasted log) keeps its start and
|
|
7
|
+
// end for the summary; the transcript itself is unchanged.
|
|
8
|
+
function clip(message, tokens) {
|
|
9
|
+
const keep = tokens * 2, cut = text => text.length <= 2 * keep ? text : `${text.slice(0, keep)}\n[… ${text.length - 2 * keep} characters left out of this summary …]\n${text.slice(-keep)}`;
|
|
10
|
+
if (typeof message.content === 'string') return { ...message, content: cut(message.content) };
|
|
11
|
+
if (!Array.isArray(message.content)) return message;
|
|
12
|
+
return { ...message, content: message.content.map(part => part.type === 'text' ? { ...part, text: cut(part.text) } : part.type === 'thinking' ? { ...part, thinking: cut(part.thinking ?? '') } : part) };
|
|
13
|
+
}
|
|
14
|
+
function addUsage(a, b) {
|
|
15
|
+
const sum = (x, y) => (x ?? 0) + (y ?? 0);
|
|
16
|
+
return { ...a, input: sum(a.input, b.input), output: sum(a.output, b.output), cacheRead: sum(a.cacheRead, b.cacheRead), cacheWrite: sum(a.cacheWrite, b.cacheWrite),
|
|
17
|
+
totalTokens: sum(a.totalTokens, b.totalTokens), cost: Object.fromEntries(Object.keys(a.cost ?? {}).map(k => [k, sum(a.cost[k], b.cost?.[k])])) };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Pi summarises everything older than the kept tail in one request. A
|
|
21
|
+
// conversation can be longer than the window of the model a run uses now
|
|
22
|
+
// (the harness moved from a 1M-token model to a 272k one, or Auto fell
|
|
23
|
+
// back), and that request is then refused, so the conversation could never
|
|
24
|
+
// shrink again. Such a history is summarised in parts that fit, each part
|
|
25
|
+
// updating the summary of the parts before.
|
|
26
|
+
export async function stageCompaction(session, api, sdk) {
|
|
27
|
+
const whole = session._runDefaultCompaction?.bind(session);
|
|
28
|
+
if (!whole || !session._summarizationRetryCallbacks) throw Error('managed-sdk-version-unqualified');
|
|
29
|
+
const { computeFileLists, formatFileOperations } = await import(pathToFileURL(path.join(sdk, 'dist/core/compaction/utils.js')));
|
|
30
|
+
// An unfinished plan is kept word for word: the summary would lose its steps.
|
|
31
|
+
const withPlan = result => {
|
|
32
|
+
const plan = currentPlan(session.sessionManager);
|
|
33
|
+
return unfinished(plan) ? { ...result, summary: `${result.summary}\n\nCurrent plan (kept verbatim; keep it up to date with update_plan):\n${planText(plan)}` } : result;
|
|
34
|
+
};
|
|
35
|
+
session._runDefaultCompaction = async (preparation, model, apiKey, headers, instructions, signal, env, reason) => {
|
|
36
|
+
const { settings, previousSummary } = preparation, room = model.contextWindow - settings.reserveTokens;
|
|
37
|
+
const history = [...preparation.messagesToSummarize, ...preparation.turnPrefixMessages];
|
|
38
|
+
const size = history.reduce((n, m) => n + api.estimateTokens(m), 0) + Math.ceil((previousSummary?.length ?? 0) / 4);
|
|
39
|
+
if (size + 4096 <= room) {
|
|
40
|
+
try { return withPlan(await whole(preparation, model, apiKey, headers, instructions, signal, env, reason)); }
|
|
41
|
+
// Token estimates are rough, and Studio takes at most 4 MiB per body:
|
|
42
|
+
// a single request refused as too large is retried in parts.
|
|
43
|
+
catch (error) { if (signal?.aborted || !['upstream_request_rejected', 'request_too_large'].includes(failureCode(error?.message))) throw error; }
|
|
44
|
+
}
|
|
45
|
+
// Half the window per part leaves room for dense text (Vietnamese, code),
|
|
46
|
+
// the previous summary and the answer.
|
|
47
|
+
const budget = Math.max(8192, Math.floor(room / 2) - settings.reserveTokens), parts = [];
|
|
48
|
+
for (const message of history) {
|
|
49
|
+
const fitted = clip(message, budget), tokens = api.estimateTokens(fitted), part = parts.at(-1);
|
|
50
|
+
if (part && part.tokens + tokens <= budget) { part.messages.push(fitted); part.tokens += tokens; }
|
|
51
|
+
else parts.push({ messages: [fitted], tokens });
|
|
52
|
+
}
|
|
53
|
+
let summary = previousSummary, usage;
|
|
54
|
+
for (const part of parts) {
|
|
55
|
+
const result = await api.generateSummaryWithUsage(part.messages, model, settings.reserveTokens, apiKey, headers, signal, instructions, summary,
|
|
56
|
+
session.thinkingLevel, session.agent.streamFunction, env, session.settingsManager.getRetrySettings(), session._summarizationRetryCallbacks({ source: 'compaction', reason }), undefined);
|
|
57
|
+
summary = result.text; usage = usage ? addUsage(usage, result.usage) : result.usage;
|
|
58
|
+
}
|
|
59
|
+
const { readFiles, modifiedFiles } = computeFileLists(preparation.fileOps);
|
|
60
|
+
return withPlan({ summary: summary + formatFileOperations(readFiles, modifiedFiles), firstKeptEntryId: preparation.firstKeptEntryId,
|
|
61
|
+
tokensBefore: preparation.tokensBefore, usage, details: { readFiles, modifiedFiles } });
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { ManagedToolBoundary } from './tool-boundary.mjs';
|
|
6
|
+
import { ensureSearchTools } from './toolchain.mjs';
|
|
7
|
+
import { nativeManagedModel, piProvider } from './native-catalog.mjs';
|
|
8
|
+
import { fetchPublicPage } from './web-fetch.mjs';
|
|
9
|
+
|
|
10
|
+
// `piagent studio --doctor`: what a company session needs on THIS machine,
|
|
11
|
+
// checked with the real sandbox and the real Agent Watch broker, without
|
|
12
|
+
// sending any model request. Run after installing on a new machine, or when
|
|
13
|
+
// tools misbehave. Required checks decide the exit code; optional ones only
|
|
14
|
+
// report what daily coding may miss (a compiler, Python, git identity).
|
|
15
|
+
const text = result => (result?.content ?? []).filter(part => part.type === 'text').map(part => part.text).join('\n').trim();
|
|
16
|
+
const first = value => String(value ?? '').split('\n')[0].slice(0, 160);
|
|
17
|
+
|
|
18
|
+
export async function runDoctor({ sdkRoot, origin, broker, network = true }) {
|
|
19
|
+
const results = [];
|
|
20
|
+
const check = async (label, required, run) => {
|
|
21
|
+
try { results.push({ label, required, ok: true, detail: first(await run()) }); }
|
|
22
|
+
catch (error) { results.push({ label, required, ok: false, detail: first(error?.message ?? error) }); }
|
|
23
|
+
};
|
|
24
|
+
const sdk = fs.realpathSync(sdkRoot);
|
|
25
|
+
await check('Pi host 0.87.1', true, () => {
|
|
26
|
+
const version = JSON.parse(fs.readFileSync(path.join(sdk, 'package.json'), 'utf8')).version;
|
|
27
|
+
if (version !== '0.87.1') throw Error(`found ${version}; company sessions need 0.87.1`);
|
|
28
|
+
return sdk;
|
|
29
|
+
});
|
|
30
|
+
await check('search tools (ripgrep, fd)', true, async () => { await ensureSearchTools(sdk); return 'ready'; });
|
|
31
|
+
const project = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'piagent-doctor-')));
|
|
32
|
+
let boundary;
|
|
33
|
+
try {
|
|
34
|
+
boundary = new ManagedToolBoundary({ cwd: project, sdkRoot: sdk, protectedRoots: [] });
|
|
35
|
+
const bash = async (command, options) => text(await boundary.invoke('bash', { command, timeout: 60 }, undefined, undefined, undefined, options));
|
|
36
|
+
await check('sandbox starts and runs a command', true, async () => { if (await bash('echo doctor-ok') !== 'doctor-ok') throw Error('unexpected output'); return 'bash'; });
|
|
37
|
+
await check('write, read, edit, list, find, search a file', true, async () => {
|
|
38
|
+
await boundary.invoke('write', { path: 'src/note.txt', content: 'alpha\nbeta\n' });
|
|
39
|
+
await boundary.invoke('edit', { path: 'src/note.txt', edits: [{ oldText: 'beta', newText: 'gamma' }] });
|
|
40
|
+
if (!/gamma/.test(text(await boundary.invoke('read', { path: 'src/note.txt' })))) throw Error('edit not applied');
|
|
41
|
+
if (!/note\.txt/.test(text(await boundary.invoke('ls', { path: 'src' })))) throw Error('ls did not list the file');
|
|
42
|
+
if (!/note\.txt/.test(text(await boundary.invoke('find', { pattern: '*.txt' })))) throw Error('find did not find the file');
|
|
43
|
+
if (!/note\.txt/.test(text(await boundary.invoke('grep', { pattern: 'gamma' })))) throw Error('grep did not find the text');
|
|
44
|
+
return '6 tools';
|
|
45
|
+
});
|
|
46
|
+
await check('project folder names with spaces and Vietnamese', true, async () => {
|
|
47
|
+
await boundary.invoke('write', { path: 'Dự án mới/ghi chú.md', content: 'ổn\n' });
|
|
48
|
+
if (await bash("cat 'Dự án mới/ghi chú.md'") !== 'ổn') throw Error('content changed'); return 'ok';
|
|
49
|
+
});
|
|
50
|
+
await check('files outside the project stay out of reach', true, async () => {
|
|
51
|
+
let refused = false; try { await boundary.invoke('write', { path: '../piagent-doctor-outside.txt', content: 'x' }); } catch { refused = true; }
|
|
52
|
+
if (!refused) { fs.rmSync(path.join(path.dirname(project), 'piagent-doctor-outside.txt'), { force: true }); throw Error('a write outside the project was allowed'); }
|
|
53
|
+
return 'refused';
|
|
54
|
+
});
|
|
55
|
+
await check('commands have no network unless approved', true, async () => {
|
|
56
|
+
let refused = false; try { await bash('curl -sS -m 5 https://example.com/ >/dev/null'); } catch { refused = true; }
|
|
57
|
+
if (!refused) throw Error('a command reached the internet without approval'); return 'blocked';
|
|
58
|
+
});
|
|
59
|
+
await check('git', true, () => bash('git --version'));
|
|
60
|
+
await check('git commit with the member\'s identity', false, async () => {
|
|
61
|
+
await bash('git init -q . && git add -A');
|
|
62
|
+
try { return await bash('git commit -qm doctor && git log -1 --format="%an <%ae>"'); }
|
|
63
|
+
catch (error) { throw Error(/tell me who you are|user\.(name|email)/i.test(String(error?.message)) ? 'no git identity: run git config --global user.name and user.email' : error?.message); }
|
|
64
|
+
});
|
|
65
|
+
await check('node', true, () => bash('node --version'));
|
|
66
|
+
await check('npm', false, () => bash('npm --version'));
|
|
67
|
+
await check('python3', false, () => bash('python3 --version'));
|
|
68
|
+
await check('C compiler (native npm modules, Rust, cgo)', false, async () => {
|
|
69
|
+
await boundary.invoke('write', { path: 'main.c', content: 'int main(void){return 0;}\n' });
|
|
70
|
+
await bash('cc main.c -o main && ./main'); return first(await bash('cc --version'));
|
|
71
|
+
});
|
|
72
|
+
await check('make', false, () => bash('make --version'));
|
|
73
|
+
if (network) await check('web_fetch reads a public page', false, async () => `status ${(await fetchPublicPage('https://example.com/', { maxChars: 1000 })).status}`);
|
|
74
|
+
} finally { await boundary?.dispose(); fs.rmSync(project, { recursive: true, force: true }); }
|
|
75
|
+
if (broker) {
|
|
76
|
+
let manifest = null;
|
|
77
|
+
await check('Agent Watch answers with the company key', true, async () => {
|
|
78
|
+
manifest = await broker.request('config');
|
|
79
|
+
if (manifest?.schema_version !== 2 || !manifest.harness) throw Error('the key has no harness');
|
|
80
|
+
return `key "${manifest.key_label ?? manifest.key_id}"`;
|
|
81
|
+
});
|
|
82
|
+
if (manifest) await check('this Piagent knows every harness model', true, async () => {
|
|
83
|
+
const api = await import(pathToFileURL(path.join(sdk, 'dist/index.js'))), ai = await import(pathToFileURL(path.join(sdk, 'node_modules/@earendil-works/pi-ai/dist/index.js')));
|
|
84
|
+
const runtime = await api.ModelRuntime.create({ credentials: new ai.InMemoryCredentialStore(), modelsPath: null, allowModelNetwork: false, refreshOnCreate: false });
|
|
85
|
+
const roles = Object.entries(manifest.harness.configuration ?? {}).filter(([, role]) => role?.model_ids?.length);
|
|
86
|
+
for (const [role, value] of roles) for (const id of value.model_ids) {
|
|
87
|
+
const model = (manifest.models ?? []).find(entry => entry.id === id);
|
|
88
|
+
if (!model) throw Error(`${role}: the key is not granted a harness model; ask an administrator to save the harness again`);
|
|
89
|
+
if (!nativeManagedModel(runtime, piProvider(model.owned_by), model.provider_model_id)) throw Error(`${role}: unknown to this Piagent; update Piagent`);
|
|
90
|
+
}
|
|
91
|
+
return `${roles.map(([role]) => role).join(', ')}; thinking ${(manifest.thinking_levels ?? []).join('/') || 'off only'}`;
|
|
92
|
+
});
|
|
93
|
+
if (network) await check('Studio is reachable', true, async () => {
|
|
94
|
+
// Any HTTP answer proves the route; an unauthenticated one is refused.
|
|
95
|
+
const response = await fetch(new URL('/v1/models', origin), { signal: AbortSignal.timeout(8000) });
|
|
96
|
+
return `HTTP ${response.status}`;
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return results;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function doctorReport(results) {
|
|
103
|
+
const failed = results.filter(result => !result.ok && result.required), missing = results.filter(result => !result.ok && !result.required);
|
|
104
|
+
const lines = results.map(result => `${result.ok ? ' ok ' : result.required ? ' FAIL' : ' miss'} ${result.label}${result.detail ? ` — ${result.detail}` : ''}`);
|
|
105
|
+
lines.push(failed.length ? `Not ready: ${failed.length} required check(s) failed.` : `Ready for company sessions${missing.length ? `; ${missing.length} optional item(s) missing` : ''}. No model request was sent.`);
|
|
106
|
+
return { text: lines.join('\n'), ok: failed.length === 0 };
|
|
107
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Reviewed gaps in Pi 0.87.1's bundled catalog. Never infer context/output
|
|
2
|
+
// limits for an unknown future model.
|
|
3
|
+
// Claude Sonnet 5.5, checked 2026-09-30:
|
|
4
|
+
// https://platform.claude.com/docs/en/models/overview
|
|
5
|
+
// https://platform.claude.com/docs/en/build-with-claude/effort
|
|
6
|
+
const sonnet55 = {
|
|
7
|
+
id: 'claude-sonnet-5-5', provider: 'anthropic', api: 'anthropic-messages',
|
|
8
|
+
name: 'Claude Sonnet 5.5', reasoning: true, input: ['text', 'image'],
|
|
9
|
+
contextWindow: 1000000, maxTokens: 128000,
|
|
10
|
+
cost: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
|
|
11
|
+
thinkingLevelMap: { off: null, minimal: null, low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'max' },
|
|
12
|
+
compat: { forceAdaptiveThinking: true, supportsTemperature: false, supportsStrictTools: true, supportsMidConvoEffort: true },
|
|
13
|
+
promptCache: { short: 300, long: 3600 },
|
|
14
|
+
};
|
|
15
|
+
// GPT-6.1 Sol through Codex, checked 2026-10-01: Codex keeps the 272k window
|
|
16
|
+
// of its pricing tier (the API model has 1.05M); 128k output; efforts low to
|
|
17
|
+
// max, no none/minimal; $2/$10 per 1M, cached $0.10, doubled above 272k.
|
|
18
|
+
// https://developers.openai.com/api/docs/models/gpt-6.1-sol
|
|
19
|
+
// https://learn.chatgpt.com/docs/models
|
|
20
|
+
// https://pi.dev/models/openai-codex/gpt-6-1-sol
|
|
21
|
+
const sol61 = {
|
|
22
|
+
id: 'gpt-6.1-sol', provider: 'openai-codex', api: 'openai-codex-responses', baseUrl: 'https://chatgpt.com/backend-api',
|
|
23
|
+
name: 'GPT-6.1 Sol', reasoning: true, input: ['text', 'image'],
|
|
24
|
+
contextWindow: 272000, maxTokens: 128000,
|
|
25
|
+
cost: { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 2.5, tiers: [{ inputTokensAbove: 272000, input: 4, output: 15, cacheRead: 0.2, cacheWrite: 5 }] },
|
|
26
|
+
thinkingLevelMap: { off: null, minimal: 'low', low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'max' },
|
|
27
|
+
compat: { supportsOpenAIGrammarTools: true, supportsAdditionalTools: true, supportsToolSearch: true, supportsMidConvoSystemMessages: true },
|
|
28
|
+
inputLimits: { images: { resize: { maxWidth: 2000, maxHeight: 2000, maxBytes: 4718592, jpegQuality: 80 } } },
|
|
29
|
+
};
|
|
30
|
+
const GAPS = { anthropic: [sonnet55], 'openai-codex': [sol61] };
|
|
31
|
+
// Studio's providers: the Claude and Codex subscriptions, or an API-key
|
|
32
|
+
// vendor (DeepSeek, Kimi, GLM, MiMo, Qwen, OpenCode, Grok) whose id is Pi's
|
|
33
|
+
// own provider id; Studio runs vendors as OpenAI Chat Completions.
|
|
34
|
+
const VENDOR = /^[a-z][a-z0-9-]{1,39}$/;
|
|
35
|
+
export const isVendor = provider => typeof provider === 'string' && provider !== 'claude' && provider !== 'codex' && VENDOR.test(provider);
|
|
36
|
+
export function piProvider(provider) { return provider === 'claude' ? 'anthropic' : provider === 'codex' ? 'openai-codex' : provider; }
|
|
37
|
+
export function studioAPI(provider) { return provider === 'claude' ? 'anthropic-messages' : provider === 'codex' ? 'openai-responses' : 'openai-completions'; }
|
|
38
|
+
export function studioPath(provider) { return provider === 'claude' ? '/claude' : provider === 'codex' ? '/v1' : '/openai/v1'; }
|
|
39
|
+
export function nativeManagedModel(runtime, provider, id) {
|
|
40
|
+
const gap = GAPS[provider]?.find(model => model.id === id);
|
|
41
|
+
return runtime.getModel(provider, id) ?? (gap ? structuredClone(gap) : undefined);
|
|
42
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
const quote = value => "'" + value.replaceAll("'", "'\"'\"'") + "'";
|
|
5
|
+
const textContent = result => result.content?.filter(p => p.type === 'text').map(p => p.text).join('\n') ?? '';
|
|
6
|
+
|
|
7
|
+
// The patch a reviewer sees (diff against HEAD plus untracked files), read
|
|
8
|
+
// inside the tool boundary. It comes in parts: the shell tool truncates its
|
|
9
|
+
// output well below the 120 KB a review may cover. Every part names the
|
|
10
|
+
// snapshot's digest, so parts of a patch that changed meanwhile never mix.
|
|
11
|
+
export async function readPatchSnapshot(boundary) {
|
|
12
|
+
const worker = fileURLToPath(new URL('./review-snapshot-worker.mjs', import.meta.url));
|
|
13
|
+
const read = async part => {
|
|
14
|
+
const result = await boundary.invoke('bash', { command: `${quote(process.execPath)} ${quote(worker)} ${part}`, timeout: 20 });
|
|
15
|
+
if (result.details?.truncation?.truncated) throw Error('managed-review-patch-too-large');
|
|
16
|
+
const text = textContent(result).trim(), newline = text.indexOf('\n');
|
|
17
|
+
const [digest, parts] = (newline < 0 ? text : text.slice(0, newline)).split(' ');
|
|
18
|
+
if (!/^[a-f0-9]{64}$/.test(digest ?? '') || !(Number(parts) >= 1 && Number(parts) <= 8)) throw Error('managed-review-patch-unreadable');
|
|
19
|
+
return { digest, parts: Number(parts), bytes: Buffer.from(newline < 0 ? '' : text.slice(newline + 1), 'base64') };
|
|
20
|
+
};
|
|
21
|
+
const first = await read(0), chunks = [first.bytes];
|
|
22
|
+
for (let part = 1; part < first.parts; part += 1) {
|
|
23
|
+
const next = await read(part);
|
|
24
|
+
if (next.digest !== first.digest) throw Error('managed-review-patch-changed');
|
|
25
|
+
chunks.push(next.bytes);
|
|
26
|
+
}
|
|
27
|
+
const patch = Buffer.concat(chunks).toString('utf8');
|
|
28
|
+
if (createHash('sha256').update(patch).digest('hex') !== first.digest) throw Error('managed-review-patch-changed');
|
|
29
|
+
return { patch, digest: first.digest };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Why a required review could not run, in one word for the member.
|
|
33
|
+
export function reviewUnavailableReason(message) {
|
|
34
|
+
const text = String(message ?? '');
|
|
35
|
+
if (/review-(patch|file|untracked)-limit|managed-review-patch-too-large/.test(text)) return 'too_large';
|
|
36
|
+
if (/not a git repository|Could not access 'HEAD'|managed-review-patch-unreadable/i.test(text)) return 'not_git';
|
|
37
|
+
if (/managed-helper-limit|helper_run_limit_reached/.test(text)) return 'limit';
|
|
38
|
+
return 'failed';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The patch as a reviewer reads it: the diff, then each new file in full.
|
|
42
|
+
export function reviewText(snapshot) {
|
|
43
|
+
let value;
|
|
44
|
+
try { value = JSON.parse(snapshot.patch); } catch { return `Patch snapshot ${snapshot.digest}:\n${snapshot.patch}`; }
|
|
45
|
+
const files = (value.untracked ?? []).map(f => f.mode === 'symlink' ? `=== new symlink ${f.path} -> ${f.target}`
|
|
46
|
+
: f.encoding === 'utf8' ? `=== new file ${f.path}\n${f.contents}` : `=== new binary file ${f.path} (${Math.round(f.contents.length * 0.75)} bytes)`);
|
|
47
|
+
return `Patch snapshot ${snapshot.digest}:\n--- diff against HEAD ---\n${value.patch || '(no changes to tracked files)'}\n${files.length ? `--- new files ---\n${files.join('\n\n')}` : ''}`;
|
|
48
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {spawn} from 'node:child_process';
|
|
5
|
+
import {createHash} from 'node:crypto';
|
|
6
|
+
import {managedGit} from './toolchain.mjs';
|
|
7
|
+
|
|
8
|
+
const quote = value => "'" + value.replaceAll("'", "'\"'\"'") + "'";
|
|
9
|
+
const text = result => result.content.filter(p=>p.type==='text').map(p=>p.text).join('\n').trim();
|
|
10
|
+
const hash = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
11
|
+
|
|
12
|
+
export function githubRemote(value) {
|
|
13
|
+
// No arbitrary protocol helper, userinfo, port, query, refspec or SSH command.
|
|
14
|
+
const match = /^(?:https:\/\/github\.com\/|git@github\.com:)([A-Za-z0-9][A-Za-z0-9_.-]{0,99})\/([A-Za-z0-9][A-Za-z0-9_.-]{0,99}?)(?:\.git)?$/.exec(value);
|
|
15
|
+
if (!match || match.slice(1).some(x=>x==='.'||x==='..')) throw Error('managed-repository-origin-unsupported');
|
|
16
|
+
return {repository:`${match[1]}/${match[2]}`,url:`https://github.com/${match[1]}/${match[2]}.git`};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function repositoryFetchPlan(boundary) {
|
|
20
|
+
const result=await boundary.invoke('bash',{command:'git config --local --get remote.origin.url',timeout:5});
|
|
21
|
+
const remote=githubRemote(text(result));
|
|
22
|
+
return Object.freeze({...remote,cwd:boundary.cwd});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function command(binary,args,{cwd,env,signal,secret='',limit=1024*1024}={}) {
|
|
26
|
+
return new Promise((resolve,reject)=>{
|
|
27
|
+
if(signal?.aborted) return reject(Error('managed-operation-cancelled'));
|
|
28
|
+
const child=spawn(binary,args,{cwd,env,stdio:['ignore','pipe','pipe']});let output='',size=0,failed=false;
|
|
29
|
+
const abort=()=>{failed=true;child.kill('SIGKILL')};signal?.addEventListener('abort',abort,{once:true});
|
|
30
|
+
const timer=setTimeout(abort,60000);timer.unref();
|
|
31
|
+
child.stdout.on('data',chunk=>{size+=chunk.length;if(size>limit)abort();else output+=chunk});
|
|
32
|
+
// Credential helpers and Git diagnostics are never relayed to model context.
|
|
33
|
+
child.stderr.on('data',()=>{});child.on('error',()=>{failed=true});
|
|
34
|
+
child.on('close',code=>{clearTimeout(timer);signal?.removeEventListener('abort',abort);
|
|
35
|
+
if(failed||code!==0) reject(Error(signal?.aborted?'managed-operation-cancelled':'managed-repository-fetch-failed'));
|
|
36
|
+
else resolve(secret?output.replaceAll(secret,'[REDACTED]'):output);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function fetchRepositoryBundle(plan, destination, signal) {
|
|
42
|
+
// This private process sees only fixed arguments and an empty bare repo. It
|
|
43
|
+
// never executes a command, hook, helper, filter or config from the project.
|
|
44
|
+
const git=managedGit(), gitHash=hash(git);
|
|
45
|
+
const temporary=fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(),'managed-fetch-')));
|
|
46
|
+
fs.chmodSync(temporary,0o700);
|
|
47
|
+
const env={PATH:'/usr/bin:/bin',HOME:temporary,LANG:'en_US.UTF-8',GIT_CONFIG_NOSYSTEM:'1',GIT_CONFIG_GLOBAL:'/dev/null',GIT_TERMINAL_PROMPT:'0',GIT_ASKPASS:'/usr/bin/false'};
|
|
48
|
+
let token='';
|
|
49
|
+
try {
|
|
50
|
+
// gh reads the logged-in user's own GitHub credential, never a Studio key.
|
|
51
|
+
// A public repository works without gh. Private access remains unavailable
|
|
52
|
+
// until that user signs in; no personal credential is put in agent env.
|
|
53
|
+
for(const candidate of ['/opt/homebrew/bin/gh','/usr/local/bin/gh']) {
|
|
54
|
+
if(!fs.existsSync(candidate))continue;
|
|
55
|
+
const gh=fs.realpathSync(candidate);
|
|
56
|
+
try {token=(await command(gh,['auth','token','--hostname','github.com'],{cwd:temporary,env:{PATH:'/usr/bin:/bin',HOME:os.homedir(),LANG:'en_US.UTF-8'},signal,limit:4096})).trim();} catch {}
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
if(token && !/^[A-Za-z0-9_]{10,2048}$/.test(token)) throw Error('managed-github-credential-invalid');
|
|
60
|
+
await command(git,['init','--bare','--quiet',temporary],{cwd:temporary,env,signal});
|
|
61
|
+
if(hash(git)!==gitHash)throw Error('managed-runtime-changed');
|
|
62
|
+
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});
|
|
64
|
+
// A self-contained bundle cannot be shallow. Do not silently fetch an
|
|
65
|
+
// unbounded history; export fetched objects as a local bare repository.
|
|
66
|
+
fs.cpSync(temporary,destination,{recursive:true,errorOnExist:true,force:false});
|
|
67
|
+
// Remote URL/config is not needed by the untrusted offline import.
|
|
68
|
+
fs.writeFileSync(path.join(destination,'config'),'[core]\n\tbare = true\n');
|
|
69
|
+
} finally {token='';fs.rmSync(temporary,{recursive:true,force:true});}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function executeRepositoryFetch(boundary,plan,signal,{transport=fetchRepositoryBundle}={}) {
|
|
73
|
+
const current=await repositoryFetchPlan(boundary);
|
|
74
|
+
if(current.url!==plan.url||current.cwd!==plan.cwd)throw Error('managed-operation-scope-changed');
|
|
75
|
+
const staging=path.join(boundary.temporary,'approved-origin-'+Date.now());
|
|
76
|
+
try {
|
|
77
|
+
await transport(plan,staging,signal);
|
|
78
|
+
const rechecked=await repositoryFetchPlan(boundary);
|
|
79
|
+
if(rechecked.url!==plan.url)throw Error('managed-operation-scope-changed');
|
|
80
|
+
// Import is sandboxed. Malicious repo hooks/config cannot reach credentials.
|
|
81
|
+
await boundary.invoke('bash',{command:`git -c core.hooksPath=/dev/null -c protocol.allow=never -c protocol.file.allow=always fetch --no-tags --no-recurse-submodules --no-write-fetch-head --update-shallow ${quote(staging)} '+refs/heads/*:refs/remotes/origin/*'`,timeout:30},signal);
|
|
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
|
+
} finally {fs.rmSync(staging,{recursive:true,force:true});}
|
|
84
|
+
}
|