@piagent/platform 1.4.0 → 1.4.1
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 +93 -0
- package/README.md +8 -2
- package/README.vi.md +27 -0
- package/docs/command-reference-vietnamese.md +2 -2
- package/docs/distribution-standard.md +7 -7
- package/docs/en/architecture.md +13 -0
- package/docs/languages.json +20 -4
- package/docs/mcp-and-tools.md +112 -1
- package/docs/operator-manual-vietnamese.md +7 -7
- package/docs/publishing-for-teams.md +36 -0
- package/docs/release-install-policy.md +21 -8
- package/docs/runtime-harness-standard.md +1 -1
- package/docs/security-threat-model.md +1 -1
- package/docs/task-implementation-contract.md +11 -4
- package/docs/team-onboarding.md +2 -2
- package/docs/usage-observability.md +70 -0
- package/docs/vi/architecture.md +13 -0
- package/docs-site/architecture.html +5 -1
- package/docs-site/benchmark.html +5 -1
- package/docs-site/commands.html +5 -1
- package/docs-site/content/en/index.html +49 -0
- package/docs-site/content/en/runtime.html +13 -0
- package/docs-site/content/index.html +49 -0
- package/docs-site/content/runtime.html +28 -0
- package/docs-site/context-engine.html +5 -1
- package/docs-site/en/architecture.html +5 -1
- package/docs-site/en/benchmark.html +5 -1
- package/docs-site/en/commands.html +5 -1
- package/docs-site/en/context-engine.html +5 -1
- package/docs-site/en/index.html +54 -1
- package/docs-site/en/mcp-approval.html +5 -1
- package/docs-site/en/mcp-auth.html +5 -1
- package/docs-site/en/mcp-servers.html +5 -1
- package/docs-site/en/mcp.html +5 -1
- package/docs-site/en/permissions.html +5 -1
- package/docs-site/en/profiles.html +5 -1
- package/docs-site/en/quickstart.html +5 -1
- package/docs-site/en/runtime.html +18 -1
- package/docs-site/en/scope.html +5 -1
- package/docs-site/en/security.html +5 -1
- package/docs-site/en/team.html +5 -1
- package/docs-site/en/whats-new.html +5 -1
- package/docs-site/en/workflows.html +5 -1
- package/docs-site/index.html +54 -1
- package/docs-site/mcp-approval.html +5 -1
- package/docs-site/mcp-auth.html +5 -1
- package/docs-site/mcp-servers.html +5 -1
- package/docs-site/mcp.html +5 -1
- package/docs-site/permissions.html +5 -1
- package/docs-site/profiles.html +5 -1
- package/docs-site/quickstart.html +5 -1
- package/docs-site/runtime.html +33 -1
- package/docs-site/scope.html +5 -1
- package/docs-site/security.html +5 -1
- package/docs-site/team.html +5 -1
- package/docs-site/whats-new.html +5 -1
- package/docs-site/workflows.html +5 -1
- package/package.json +14 -2
- package/packages/piagent-core/README.md +3 -2
- package/packages/piagent-core/capabilities/capability-verification-cache.js +17 -7
- package/packages/piagent-core/capabilities/runtime-integrity.js +51 -4
- package/packages/piagent-core/extensions/piagent-guard.ts +18 -235
- package/packages/piagent-core/extensions/runtime-evidence.js +12 -1
- package/packages/piagent-core/extensions/shell-reach.ts +250 -0
- package/packages/piagent-core/mcp/mcp-command-actions.js +1 -1
- package/packages/piagent-core/mcp/mcp-server-catalog.js +135 -5
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-core/runtime/hooks/tool-call-hook.ts +9 -2
- package/packages/piagent-core/runtime/orchestration/owned-work-budget.ts +25 -2
- package/packages/piagent-core/runtime/policy/block-remedy.ts +139 -0
- package/packages/piagent-core/runtime/registration/task-start-tool.ts +9 -13
- package/packages/piagent-core/runtime/session/token-economics.js +34 -0
- package/packages/piagent-core/runtime/tools/phase-tool-runtime.ts +2 -1
- package/packages/piagent-core/runtime/workflows/task-intake.ts +5 -0
- package/packages/piagent-core/security/sensitive-data.js +7 -0
- package/packages/piagent-webui/dist/client/assets/index-bu7XpD9Q.js +172 -0
- package/packages/piagent-webui/dist/client/index.html +1 -1
- package/packages/piagent-webui/extension/lifecycle-controller.ts +15 -0
- package/packages/piagent-webui/extension/piagent-webui.ts +4 -4
- package/packages/piagent-webui/extension/same-session-bridge.ts +3 -3
- package/packages/piagent-webui/gateway/extension-authority.ts +47 -0
- package/packages/piagent-webui/gateway/session-inspection-registry.ts +37 -2
- package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +5 -1
- package/packages/piagent-webui/server/core-inspection-provider.ts +5 -5
- package/packages/piagent-webui/server/transcript-projection.ts +15 -0
- package/scripts/build-docs-site.mjs +21 -1
- package/scripts/explain-command.mjs +240 -0
- package/scripts/install-global.sh +104 -3
- package/scripts/mcp-manage.mjs +17 -4
- package/scripts/pi-session-stats.sh +10 -2
- package/scripts/piagent-cli.mjs +14 -1
- package/scripts/piagent-dashboard.mjs +55 -2
- package/scripts/setup.sh +69 -33
- package/scripts/traffic-snapshot.mjs +214 -0
- package/scripts/typescript-loader.mjs +20 -4
- package/scripts/uninstall-global.sh +53 -2
- package/scripts/update-global.mjs +33 -1
- package/scripts/verify-local.sh +4 -0
- package/scripts/verify-translation-pairing.mjs +121 -0
- package/packages/piagent-webui/dist/client/assets/index-DjMntaK5.js +0 -172
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// What a shell command can reach, and how to say so.
|
|
2
|
+
//
|
|
3
|
+
// These checks used to live inside the guard entrypoint, which imports the Pi
|
|
4
|
+
// host runtime and therefore cannot be loaded anywhere else. `piagent explain`
|
|
5
|
+
// needs the same answers the guard gives, and a second implementation of "does
|
|
6
|
+
// this command reach a protected path" is exactly the defect this project keeps
|
|
7
|
+
// closing: one segment, many readers, each with its own view. So the readers
|
|
8
|
+
// live here and both the guard and the CLI take this one.
|
|
9
|
+
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
extractShellGlobCandidates,
|
|
16
|
+
extractShellPathCandidates,
|
|
17
|
+
matchesProtectedPath,
|
|
18
|
+
normalizePathCandidate
|
|
19
|
+
} from "./policy-core.js";
|
|
20
|
+
|
|
21
|
+
export function normalizeRelative(cwd: string, candidate: unknown): string | undefined {
|
|
22
|
+
if (typeof candidate !== "string" || candidate.trim().length === 0) return undefined;
|
|
23
|
+
let raw = candidate.trim();
|
|
24
|
+
try {
|
|
25
|
+
raw = decodeURIComponent(raw);
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(raw)) {
|
|
30
|
+
if (!raw.toLowerCase().startsWith("file://")) return undefined;
|
|
31
|
+
try {
|
|
32
|
+
raw = fileURLToPath(raw);
|
|
33
|
+
} catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const absolute = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
|
|
38
|
+
return path.relative(cwd, absolute).split(path.sep).join("/");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function resolveRepositoryPathCandidate(cwd: string, candidate: string): string | undefined {
|
|
42
|
+
const normalized = normalizePathCandidate(candidate);
|
|
43
|
+
if (normalized === ".." || normalized.startsWith("../")) return undefined;
|
|
44
|
+
|
|
45
|
+
const relative = path.posix.isAbsolute(normalized)
|
|
46
|
+
? path.relative(cwd, normalized).split(path.sep).join("/")
|
|
47
|
+
: normalized;
|
|
48
|
+
if (relative === ".." || relative.startsWith("../")) return undefined;
|
|
49
|
+
|
|
50
|
+
const pending = relative.split("/").filter((item) => item && item !== ".");
|
|
51
|
+
let current = cwd;
|
|
52
|
+
let resolvedDepth = 0;
|
|
53
|
+
for (let index = 0; index < pending.length; index += 1) {
|
|
54
|
+
const next = path.join(current, pending[index]);
|
|
55
|
+
try {
|
|
56
|
+
fs.lstatSync(next);
|
|
57
|
+
current = next;
|
|
58
|
+
resolvedDepth = index + 1;
|
|
59
|
+
} catch (error) {
|
|
60
|
+
const code = error && typeof error === "object" && "code" in error ? String((error as { code?: unknown }).code) : "";
|
|
61
|
+
if (code !== "ENOENT") return undefined;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let canonicalBase: string;
|
|
67
|
+
try {
|
|
68
|
+
canonicalBase = fs.realpathSync.native(current);
|
|
69
|
+
} catch {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
const canonical = path.resolve(canonicalBase, ...pending.slice(resolvedDepth));
|
|
73
|
+
const canonicalRoot = fs.realpathSync.native(cwd);
|
|
74
|
+
const canonicalRelative = path.relative(canonicalRoot, canonical).split(path.sep).join("/");
|
|
75
|
+
if (canonicalRelative === ".." || canonicalRelative.startsWith("../") || path.isAbsolute(canonicalRelative)) return undefined;
|
|
76
|
+
return canonicalRelative || ".";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function findResolvedProtectedPathInCommand(
|
|
80
|
+
cwd: string,
|
|
81
|
+
command: string,
|
|
82
|
+
protectedPatterns: string[]
|
|
83
|
+
): { candidate: string; resolved: string; pattern: string } | undefined {
|
|
84
|
+
for (const candidate of extractShellPathCandidates(command)) {
|
|
85
|
+
const relative = normalizeRelative(cwd, candidate);
|
|
86
|
+
if (!relative) continue;
|
|
87
|
+
const resolved = resolveRepositoryPathCandidate(cwd, relative);
|
|
88
|
+
if (!resolved || resolved === relative) continue;
|
|
89
|
+
const pattern = matchesProtectedPath(resolved, protectedPatterns);
|
|
90
|
+
if (pattern) return { candidate, resolved, pattern };
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function expandSimpleGlobAlternatives(pattern: string, max = 24): { values: string[]; complete: boolean } {
|
|
96
|
+
let results = [pattern];
|
|
97
|
+
let changed = true;
|
|
98
|
+
let complete = true;
|
|
99
|
+
|
|
100
|
+
while (changed) {
|
|
101
|
+
changed = false;
|
|
102
|
+
const expanded: string[] = [];
|
|
103
|
+
for (const item of results) {
|
|
104
|
+
const match = item.match(/\{([^{}]+)\}/);
|
|
105
|
+
if (!match) {
|
|
106
|
+
expanded.push(item);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
changed = true;
|
|
110
|
+
const options = match[1].split(",").map((option) => option.trim());
|
|
111
|
+
for (const option of options) {
|
|
112
|
+
expanded.push(`${item.slice(0, match.index)}${option}${item.slice((match.index ?? 0) + match[0].length)}`);
|
|
113
|
+
if (expanded.length >= max) {
|
|
114
|
+
complete = false;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (expanded.length >= max) break;
|
|
119
|
+
}
|
|
120
|
+
results = expanded.slice(0, max);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { values: results, complete };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function shellGlobSegmentMatches(patternSegment: string, candidateSegment: string): boolean {
|
|
127
|
+
if (candidateSegment.startsWith(".") && !patternSegment.startsWith(".")) return false;
|
|
128
|
+
let source = "";
|
|
129
|
+
for (let index = 0; index < patternSegment.length; index += 1) {
|
|
130
|
+
const char = patternSegment[index];
|
|
131
|
+
if (char === "*") {
|
|
132
|
+
source += "[^/]*";
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (char === "?") {
|
|
136
|
+
source += "[^/]";
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (char === "[") {
|
|
140
|
+
const end = patternSegment.indexOf("]", index + 1);
|
|
141
|
+
const body = end > index + 1 ? patternSegment.slice(index + 1, end) : "";
|
|
142
|
+
if (body && /^[!^A-Za-z0-9_-]+$/.test(body)) {
|
|
143
|
+
const negated = body.startsWith("!") ? `^${body.slice(1)}` : body;
|
|
144
|
+
source += `[${negated}]`;
|
|
145
|
+
index = end;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
source += char.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
|
|
150
|
+
}
|
|
151
|
+
return new RegExp(`^${source}$`, "i").test(candidateSegment);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function shellGlobMatchesPath(pattern: string, candidate: string): boolean {
|
|
155
|
+
const patternSegments = normalizePathCandidate(pattern).split("/").filter(Boolean);
|
|
156
|
+
const candidateSegments = normalizePathCandidate(candidate).split("/").filter(Boolean);
|
|
157
|
+
|
|
158
|
+
function match(patternIndex: number, candidateIndex: number): boolean {
|
|
159
|
+
if (patternIndex === patternSegments.length) return candidateIndex === candidateSegments.length;
|
|
160
|
+
const patternSegment = patternSegments[patternIndex];
|
|
161
|
+
if (patternSegment === "**") {
|
|
162
|
+
if (match(patternIndex + 1, candidateIndex)) return true;
|
|
163
|
+
for (let next = candidateIndex; next < candidateSegments.length; next += 1) {
|
|
164
|
+
if (match(patternIndex + 1, next + 1)) return true;
|
|
165
|
+
}
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
if (candidateIndex >= candidateSegments.length) return false;
|
|
169
|
+
return shellGlobSegmentMatches(patternSegment, candidateSegments[candidateIndex])
|
|
170
|
+
&& match(patternIndex + 1, candidateIndex + 1);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return patternSegments.length > 0 && candidateSegments.length > 0 && match(0, 0);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function protectedPatternExamples(pattern: string): string[] {
|
|
177
|
+
const normalized = normalizePathCandidate(pattern);
|
|
178
|
+
if (!normalized) return [];
|
|
179
|
+
|
|
180
|
+
const examples = new Set<string>();
|
|
181
|
+
const add = (value: string | undefined) => {
|
|
182
|
+
const normalizedValue = normalizePathCandidate(value ?? "");
|
|
183
|
+
if (normalizedValue) examples.add(normalizedValue);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
add(normalized);
|
|
187
|
+
|
|
188
|
+
if (normalized.endsWith("/**")) {
|
|
189
|
+
const base = normalized.slice(0, -3);
|
|
190
|
+
add(base);
|
|
191
|
+
add(`${base}/probe`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (normalized.startsWith("**/")) {
|
|
195
|
+
const tail = normalized.slice(3);
|
|
196
|
+
const concreteTail = tail
|
|
197
|
+
.replace(/\*\*/g, "nested")
|
|
198
|
+
.replace(/\*/g, tail.includes(".env.") ? "local" : "probe");
|
|
199
|
+
add(tail);
|
|
200
|
+
add(concreteTail);
|
|
201
|
+
add(`nested/${concreteTail}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const concrete = normalized
|
|
205
|
+
.replace(/^\*\*\//, "")
|
|
206
|
+
.replace(/\/\*\*$/, "/probe")
|
|
207
|
+
.replace(/\*\*/g, "nested")
|
|
208
|
+
.replace(/\*/g, normalized.includes(".env.") ? "local" : "probe");
|
|
209
|
+
add(concrete);
|
|
210
|
+
|
|
211
|
+
for (const example of [...examples]) {
|
|
212
|
+
const base = path.posix.basename(example);
|
|
213
|
+
if (base && base !== "probe") examples.add(base);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return [...examples];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function shellGlobTargetsProtectedPath(
|
|
220
|
+
command: string,
|
|
221
|
+
protectedPatterns: string[]
|
|
222
|
+
): { glob: string; pattern: string; example: string } | undefined {
|
|
223
|
+
for (const candidate of extractShellGlobCandidates(command)) {
|
|
224
|
+
if (!/[*?{\[]/.test(candidate)) continue;
|
|
225
|
+
const expanded = expandSimpleGlobAlternatives(candidate);
|
|
226
|
+
if (!expanded.complete) return { glob: candidate, pattern: "bounded glob expansion", example: "a protected path" };
|
|
227
|
+
for (const candidateGlob of expanded.values) {
|
|
228
|
+
for (const pattern of protectedPatterns) {
|
|
229
|
+
for (const example of protectedPatternExamples(pattern)) {
|
|
230
|
+
if (/[*?{\[\]]/.test(example)) continue;
|
|
231
|
+
if (
|
|
232
|
+
shellGlobMatchesPath(candidateGlob, example)
|
|
233
|
+
|| shellGlobMatchesPath(`**/${candidateGlob}`, example)
|
|
234
|
+
|| shellGlobMatchesPath(candidateGlob, path.posix.basename(example))
|
|
235
|
+
) {
|
|
236
|
+
return { glob: candidate, pattern, example };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function unresolvedExpansionReason(subject: string, words: string[]): string {
|
|
246
|
+
const listed = words.map((word) => `\`${word}\``).join(", ");
|
|
247
|
+
return `${subject} builds a filename this guard cannot resolve: ${listed}. `
|
|
248
|
+
+ "The literal text around the expansion makes it a path, but its value is only known at run time, "
|
|
249
|
+
+ "so it cannot be checked against the protected paths. Write the path out, or put the expansion in its own argument.";
|
|
250
|
+
}
|
|
@@ -37,7 +37,7 @@ export const SERVER_ACTIONS = new Set(["get", "approve", "reject", "reset", "ena
|
|
|
37
37
|
export const TERMINAL_ONLY = new Map([
|
|
38
38
|
["add", "piagent-mcp add <name> [--scope <scope>] --url <url> | -- <command> [args...]"],
|
|
39
39
|
["remove", "piagent-mcp remove <name> --scope <scope>"],
|
|
40
|
-
["preset", "piagent-mcp --preset <core|popular|design> --scope <scope>"]
|
|
40
|
+
["preset", "piagent-mcp --preset <core|popular|documents|google|design> --scope <scope>"]
|
|
41
41
|
]);
|
|
42
42
|
|
|
43
43
|
export const HELP_LINES = [
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// Nothing here is a secret. Every credential is referenced as an environment
|
|
7
7
|
// variable, so the config can be read, diffed and committed without leaking one.
|
|
8
8
|
|
|
9
|
-
/** @typedef {{description: string, config: Record<string, unknown>}} CatalogServer */
|
|
9
|
+
/** @typedef {{description: string, maturity?: "stable"|"experimental", config: Record<string, unknown>}} CatalogServer */
|
|
10
10
|
|
|
11
11
|
/** @type {Record<string, CatalogServer>} */
|
|
12
12
|
export const CATALOG_SERVERS = {
|
|
@@ -83,6 +83,85 @@ export const CATALOG_SERVERS = {
|
|
|
83
83
|
lifecycle: "lazy",
|
|
84
84
|
directTools: false
|
|
85
85
|
}
|
|
86
|
+
},
|
|
87
|
+
// Google's own remote servers, one per product, rather than a community server
|
|
88
|
+
// holding every scope at once: a session that only needs to read a document
|
|
89
|
+
// should not be one OAuth grant away from the mailbox.
|
|
90
|
+
//
|
|
91
|
+
// These carry no version pin because there is nothing local to pin -- the
|
|
92
|
+
// endpoint is operated by Google and changes under the same URL. That is a
|
|
93
|
+
// real difference from every command-based server above, where the pin is what
|
|
94
|
+
// stops a floating tag becoming a supply-chain decision each session.
|
|
95
|
+
// Scopes are declared read-only. Write access is one word longer to ask for
|
|
96
|
+
// and cannot be taken back once an agent has used it, so the default grant is
|
|
97
|
+
// the one that cannot lose anything. `clientId` is deliberately absent: every
|
|
98
|
+
// operator registers their own OAuth client, and a client id baked in here
|
|
99
|
+
// would be someone else's project answering for their consent screen.
|
|
100
|
+
"google-drive": {
|
|
101
|
+
description: "EXPERIMENTAL Developer Preview: Google Drive remote MCP server. Read-only by default.",
|
|
102
|
+
maturity: "experimental",
|
|
103
|
+
config: {
|
|
104
|
+
url: "https://drivemcp.googleapis.com/mcp/v1",
|
|
105
|
+
auth: "oauth",
|
|
106
|
+
oauth: { scopes: ["https://www.googleapis.com/auth/drive.readonly"] },
|
|
107
|
+
lifecycle: "lazy",
|
|
108
|
+
directTools: false
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
gmail: {
|
|
112
|
+
description: "EXPERIMENTAL Developer Preview: Gmail remote MCP server. Read-only by default.",
|
|
113
|
+
maturity: "experimental",
|
|
114
|
+
config: {
|
|
115
|
+
url: "https://gmailmcp.googleapis.com/mcp/v1",
|
|
116
|
+
auth: "oauth",
|
|
117
|
+
oauth: { scopes: ["https://www.googleapis.com/auth/gmail.readonly"] },
|
|
118
|
+
lifecycle: "lazy",
|
|
119
|
+
directTools: false
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
"google-docs": {
|
|
123
|
+
description: "EXPERIMENTAL Developer Preview: Google Docs remote MCP server. Read-only by default.",
|
|
124
|
+
maturity: "experimental",
|
|
125
|
+
config: {
|
|
126
|
+
url: "https://docsmcp.googleapis.com/mcp/v1",
|
|
127
|
+
auth: "oauth",
|
|
128
|
+
oauth: {
|
|
129
|
+
scopes: [
|
|
130
|
+
"https://www.googleapis.com/auth/documents.readonly",
|
|
131
|
+
"https://www.googleapis.com/auth/drive.readonly"
|
|
132
|
+
]
|
|
133
|
+
},
|
|
134
|
+
lifecycle: "lazy",
|
|
135
|
+
directTools: false
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
"google-sheets": {
|
|
139
|
+
description: "EXPERIMENTAL Developer Preview: Google Sheets remote MCP server. Read-only by default.",
|
|
140
|
+
maturity: "experimental",
|
|
141
|
+
config: {
|
|
142
|
+
url: "https://sheetsmcp.googleapis.com/mcp/v1",
|
|
143
|
+
auth: "oauth",
|
|
144
|
+
oauth: {
|
|
145
|
+
scopes: [
|
|
146
|
+
"https://www.googleapis.com/auth/spreadsheets.readonly",
|
|
147
|
+
"https://www.googleapis.com/auth/drive.readonly"
|
|
148
|
+
]
|
|
149
|
+
},
|
|
150
|
+
lifecycle: "lazy",
|
|
151
|
+
directTools: false
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
// Converts .docx, .xlsx, .pptx and .pdf to Markdown locally. Local matters:
|
|
155
|
+
// the alternative to this is uploading a document somewhere to read it, and a
|
|
156
|
+
// document worth converting is usually one worth not uploading.
|
|
157
|
+
markitdown: {
|
|
158
|
+
description: "MarkItDown MCP. Converts local docx, xlsx, pptx, pdf and html files to Markdown. Runs offline for file: URIs.",
|
|
159
|
+
config: {
|
|
160
|
+
command: "uvx",
|
|
161
|
+
args: ["markitdown-mcp@0.0.1a4"],
|
|
162
|
+
lifecycle: "lazy",
|
|
163
|
+
directTools: false
|
|
164
|
+
}
|
|
86
165
|
}
|
|
87
166
|
};
|
|
88
167
|
|
|
@@ -100,7 +179,33 @@ export const CATALOG_PREREQUISITES = {
|
|
|
100
179
|
],
|
|
101
180
|
playwright: ["browsers installed on first use (npx playwright install)"],
|
|
102
181
|
figma: ["Piagent approved in the Figma MCP Catalog before remote OAuth can register"],
|
|
103
|
-
"figma-desktop": ["Figma desktop running with Dev Mode MCP enabled"]
|
|
182
|
+
"figma-desktop": ["Figma desktop running with Dev Mode MCP enabled"],
|
|
183
|
+
"google-drive": [
|
|
184
|
+
"a Google Cloud project with drive.googleapis.com and drivemcp.googleapis.com enabled",
|
|
185
|
+
"Developer Preview: no Pi OAuth end-to-end release proof exists yet",
|
|
186
|
+
"Google's supported client guides use an OAuth client id and secret; Pi's public-client/PKCE compatibility is unverified",
|
|
187
|
+
"piagent-mcp add google-drive --url https://drivemcp.googleapis.com/mcp/v1 --oauth-client-id <id> --scope user"
|
|
188
|
+
],
|
|
189
|
+
gmail: [
|
|
190
|
+
"a Google Cloud project with gmail.googleapis.com and gmailmcp.googleapis.com enabled",
|
|
191
|
+
"Developer Preview: no Pi OAuth end-to-end release proof exists yet",
|
|
192
|
+
"Google's supported client guides use an OAuth client id and secret; Pi's public-client/PKCE compatibility is unverified",
|
|
193
|
+
"read the warning in docs/mcp-and-tools.md first: mail is untrusted input reaching an agent that can act"
|
|
194
|
+
],
|
|
195
|
+
"google-docs": [
|
|
196
|
+
"a Google Cloud project with docs.googleapis.com and docsmcp.googleapis.com enabled",
|
|
197
|
+
"Developer Preview: no Pi OAuth end-to-end release proof exists yet",
|
|
198
|
+
"Google's supported client guides use an OAuth client id and secret; Pi's public-client/PKCE compatibility is unverified"
|
|
199
|
+
],
|
|
200
|
+
"google-sheets": [
|
|
201
|
+
"a Google Cloud project with sheets.googleapis.com and sheetsmcp.googleapis.com enabled",
|
|
202
|
+
"Developer Preview: no Pi OAuth end-to-end release proof exists yet",
|
|
203
|
+
"Google's supported client guides use an OAuth client id and secret; Pi's public-client/PKCE compatibility is unverified"
|
|
204
|
+
],
|
|
205
|
+
markitdown: [
|
|
206
|
+
"uv on PATH (https://docs.astral.sh/uv); the first run downloads a Python runtime and roughly 120 MB of dependencies",
|
|
207
|
+
"note: markitdown-mcp is published as an alpha, so pin changes deliberately"
|
|
208
|
+
]
|
|
104
209
|
};
|
|
105
210
|
|
|
106
211
|
// Variables a pinned server references but works without. Context7 answers
|
|
@@ -113,6 +218,11 @@ export const CATALOG_OPTIONAL_ENV = {
|
|
|
113
218
|
context7: ["CONTEXT7_API_KEY"]
|
|
114
219
|
};
|
|
115
220
|
|
|
221
|
+
// `core` is what an install writes when nobody names a preset, and `popular` is
|
|
222
|
+
// what somebody picks without reading the list. Neither may reach a mailbox or a
|
|
223
|
+
// Drive: an install that quietly grants those is a decision the operator never
|
|
224
|
+
// made. The Google servers are therefore only in presets somebody has to type,
|
|
225
|
+
// and in `all`, which means what it says.
|
|
116
226
|
/** @type {Record<string, string[]>} */
|
|
117
227
|
export const CATALOG_PRESETS = {
|
|
118
228
|
minimal: [],
|
|
@@ -122,9 +232,24 @@ export const CATALOG_PRESETS = {
|
|
|
122
232
|
design: ["figma-desktop"],
|
|
123
233
|
"design-local": ["figma-desktop"],
|
|
124
234
|
web: ["context7", "chrome-devtools", "playwright"],
|
|
235
|
+
// The generic documents preset stays local. Google Workspace is Developer
|
|
236
|
+
// Preview and must be selected through an explicitly named Google preset.
|
|
237
|
+
documents: ["markitdown"],
|
|
238
|
+
// One preset per product, because that is why Google ships four servers
|
|
239
|
+
// instead of one. Bundling them back together would mean reading a
|
|
240
|
+
// spreadsheet costs a grant over Drive and every document in it.
|
|
241
|
+
"google-drive": ["google-drive"],
|
|
242
|
+
"google-docs": ["google-docs"],
|
|
243
|
+
"google-sheets": ["google-sheets"],
|
|
244
|
+
"google-mail": ["gmail"],
|
|
245
|
+
google: ["google-drive", "google-docs", "google-sheets"],
|
|
246
|
+
"google-all": ["google-drive", "gmail", "google-docs", "google-sheets"],
|
|
125
247
|
core: ["context7", "chrome-devtools", "github"],
|
|
126
248
|
popular: ["context7", "chrome-devtools", "playwright", "github", "figma-desktop"],
|
|
127
|
-
all: [
|
|
249
|
+
all: [
|
|
250
|
+
"context7", "chrome-devtools", "playwright", "github", "figma-desktop", "figma",
|
|
251
|
+
"markitdown", "google-drive", "gmail", "google-docs", "google-sheets"
|
|
252
|
+
]
|
|
128
253
|
};
|
|
129
254
|
|
|
130
255
|
/** @param {string} name @returns {boolean} */
|
|
@@ -136,7 +261,7 @@ export function isCatalogPreset(name) {
|
|
|
136
261
|
* The servers a preset installs, with their definitions resolved. Throws on an
|
|
137
262
|
* unknown preset so a typo is refused before anything is written.
|
|
138
263
|
* @param {string} name
|
|
139
|
-
* @returns {{name: string, config: Record<string, unknown>, requires: string[]}[]}
|
|
264
|
+
* @returns {{name: string, maturity: "stable"|"experimental", config: Record<string, unknown>, requires: string[]}[]}
|
|
140
265
|
*/
|
|
141
266
|
export function resolvePreset(name) {
|
|
142
267
|
if (!isCatalogPreset(name)) {
|
|
@@ -145,6 +270,11 @@ export function resolvePreset(name) {
|
|
|
145
270
|
return CATALOG_PRESETS[name].map((serverName) => {
|
|
146
271
|
const server = CATALOG_SERVERS[serverName];
|
|
147
272
|
if (!server) throw new Error(`preset ${name} references missing server ${serverName}`);
|
|
148
|
-
return {
|
|
273
|
+
return {
|
|
274
|
+
name: serverName,
|
|
275
|
+
maturity: server.maturity ?? "stable",
|
|
276
|
+
config: server.config,
|
|
277
|
+
requires: CATALOG_PREREQUISITES[serverName] ?? []
|
|
278
|
+
};
|
|
149
279
|
});
|
|
150
280
|
}
|
|
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
|
|
4
4
|
import { toolResultFingerprint } from "../../extensions/context-engine.js";
|
|
5
|
+
import { withRemedy } from "../policy/block-remedy.ts";
|
|
5
6
|
import { observeTrajectorySync } from "../trajectory/trajectory-observability.ts";
|
|
6
7
|
import type { TrajectorySyncResult } from "../trajectory/trajectory-runtime.ts";
|
|
7
8
|
|
|
@@ -75,7 +76,13 @@ export function registerToolCallHook(pi: ExtensionAPI, dependencies: ToolCallHoo
|
|
|
75
76
|
: dependencies.reviewBudgetDecision?.(resolvedEvent, ctx);
|
|
76
77
|
const startDecision = !(decision && typeof decision === "object" && decision.block) ? dependencies.beforeStart?.(resolvedEvent, ctx) : undefined;
|
|
77
78
|
const finalDecision = startDecision && typeof startDecision === "object" && startDecision.block ? startDecision : decision;
|
|
78
|
-
|
|
79
|
+
// Every refusal leaves through here, so this is where it is answered. A
|
|
80
|
+
// remedy attached at the 29 sites that build one is a remedy the next site
|
|
81
|
+
// will forget.
|
|
82
|
+
const answered = finalDecision && typeof finalDecision === "object" && finalDecision.block
|
|
83
|
+
? withRemedy(finalDecision)
|
|
84
|
+
: finalDecision;
|
|
85
|
+
const blockedDecision = answered && typeof answered === "object" ? answered : undefined;
|
|
79
86
|
record(ctx, {
|
|
80
87
|
activityId: `decision:${toolCallId}`,
|
|
81
88
|
event: "tool_decision",
|
|
@@ -91,6 +98,6 @@ export function registerToolCallHook(pi: ExtensionAPI, dependencies: ToolCallHoo
|
|
|
91
98
|
observeTrajectorySync(ctx, dependencies.afterAuthorized?.(resolvedEvent, ctx, { toolCallId }), dependencies.telemetry);
|
|
92
99
|
}
|
|
93
100
|
dependencies.afterDecision?.(resolvedEvent, ctx, { toolCallId, allowed: !blockedDecision?.block, reason: blockedDecision?.reason });
|
|
94
|
-
return
|
|
101
|
+
return answered;
|
|
95
102
|
});
|
|
96
103
|
}
|
|
@@ -7,6 +7,9 @@ import type { HelperRequest, HelperRole } from "./role-policy.ts";
|
|
|
7
7
|
import { validateHelperRequest } from "./role-policy.ts";
|
|
8
8
|
|
|
9
9
|
export const OWNED_WORK_BUDGET_VERSION = "owned-work-budget-v1" as const;
|
|
10
|
+
// `maxRepairPasses` is declared for the repair lane, which reserves no helper
|
|
11
|
+
// role here, so this mechanism never reads it. It is named rather than removed
|
|
12
|
+
// because the repair lane is bounded elsewhere; nothing in this file enforces it.
|
|
10
13
|
export type OwnedWorkCeilings = Readonly<{ maxConcurrentHelpers: number; maxTotalHelpers: number; maxScoutPasses: number; maxPlannerPasses: number; maxReviewPasses: number; maxOracleCalls: number; maxRepairPasses: number; maxWriters: number }>;
|
|
11
14
|
export const DEFAULT_OWNED_WORK_CEILINGS: OwnedWorkCeilings = Object.freeze({ maxConcurrentHelpers: 2, maxTotalHelpers: 3, maxScoutPasses: 1, maxPlannerPasses: 1, maxReviewPasses: 1, maxOracleCalls: 1, maxRepairPasses: 1, maxWriters: 1 });
|
|
12
15
|
export const AUTOMATIC_OWNED_WORK_CEILINGS: OwnedWorkCeilings = Object.freeze({ ...DEFAULT_OWNED_WORK_CEILINGS, maxConcurrentHelpers: 1, maxTotalHelpers: 1 });
|
|
@@ -26,7 +29,27 @@ function statePath(cwd: string, taskRunId: string): string { return path.join(cw
|
|
|
26
29
|
function lockPath(cwd: string, taskRunId: string): string { return `${statePath(cwd, taskRunId)}.lock`; }
|
|
27
30
|
function empty(request: HelperRequest, now: string): BudgetState { return { version: OWNED_WORK_BUDGET_VERSION, taskId: request.taskId, taskRunId: request.taskRunId, terminal: false, reservations: [], updatedAt: now }; }
|
|
28
31
|
function active(state: BudgetState): OwnedWorkReservation[] { return state.reservations.filter((item) => item.status === "active"); }
|
|
29
|
-
|
|
32
|
+
// The per-role ceiling, read from the ceilings the caller passed rather than
|
|
33
|
+
// from constants written here. These limits were real but fixed at one apiece,
|
|
34
|
+
// so `OwnedWorkCeilings` advertised six numbers that nothing ever read: a
|
|
35
|
+
// profile could raise `maxScoutPasses` and be silently ignored, while the two
|
|
36
|
+
// neighbouring fields in the same object did take effect. Half-honoured
|
|
37
|
+
// configuration is worse than none, because the half that works teaches the
|
|
38
|
+
// reader to trust the half that does not.
|
|
39
|
+
//
|
|
40
|
+
// Every default is one, so this changes no behaviour under the shipped
|
|
41
|
+
// ceilings; it makes the object mean what it says.
|
|
42
|
+
function roleLimit(role: HelperRole, ceilings: OwnedWorkCeilings): number {
|
|
43
|
+
switch (role) {
|
|
44
|
+
case "scout": case "retriever": case "researcher": return ceilings.maxScoutPasses;
|
|
45
|
+
case "planner": return ceilings.maxPlannerPasses;
|
|
46
|
+
case "reviewer": return ceilings.maxReviewPasses;
|
|
47
|
+
case "oracle": return ceilings.maxOracleCalls;
|
|
48
|
+
case "worker": return ceilings.maxWriters;
|
|
49
|
+
// An unknown role reserves nothing rather than defaulting to a limit.
|
|
50
|
+
default: return 0;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
30
53
|
|
|
31
54
|
function read(cwd: string, request: HelperRequest, now: string): BudgetState {
|
|
32
55
|
const target = statePath(cwd, request.taskRunId); if (!fs.existsSync(target)) return empty(request, now);
|
|
@@ -136,7 +159,7 @@ export class OwnedWorkBudgetController {
|
|
|
136
159
|
const duplicate = state.reservations.find((item) => item.deduplicationKey === request.deduplicationKey && ["active", "succeeded"].includes(item.status));
|
|
137
160
|
if (duplicate) return { decision: "duplicate", reason: "equivalent-helper-already-owned", reservationId: duplicate.id, recoveredOrphans };
|
|
138
161
|
if (active(state).length >= ceilings.maxConcurrentHelpers || state.reservations.length >= ceilings.maxTotalHelpers) return { decision: "blocked", reason: "helper-budget-exhausted", reservationId: null, recoveredOrphans };
|
|
139
|
-
if (state.reservations.filter((item) => item.role === request.role).length >= roleLimit(request.role)) return { decision: "blocked", reason: `${request.role}-ceiling-reached`, reservationId: null, recoveredOrphans };
|
|
162
|
+
if (state.reservations.filter((item) => item.role === request.role).length >= roleLimit(request.role, ceilings)) return { decision: "blocked", reason: `${request.role}-ceiling-reached`, reservationId: null, recoveredOrphans };
|
|
140
163
|
if (request.authority === "single-writer" && active(state).some((item) => item.authority === "single-writer")) return { decision: "blocked", reason: "single-writer-already-owned", reservationId: null, recoveredOrphans };
|
|
141
164
|
const id = digest(`${request.taskRunId}:${request.deduplicationKey}:${state.reservations.length}`).slice(0, 32);
|
|
142
165
|
state.reservations.push({ id, deduplicationKey: request.deduplicationKey, role: request.role, authority: request.authority, status: "active", reservedAt: now, expiresAt: new Date(Date.parse(now) + request.ceilings.timeSeconds * 1000).toISOString(), completedAt: null, usageRef: null });
|