@peakflo/agent-harness 0.2.3-rc.1 → 0.2.4-rc.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/dist/adapters/coding-agent-adapter.d.ts +8 -0
- package/dist/adapters/pi-adapter.d.ts +3 -1
- package/dist/adapters/pi-adapter.js +52 -6
- package/dist/runtime/dev-server-guard.d.ts +43 -0
- package/dist/runtime/dev-server-guard.js +74 -0
- package/dist/runtime/session-runner.js +1 -0
- package/dist/types.d.ts +7 -0
- package/package.json +1 -1
|
@@ -42,6 +42,14 @@ export interface SessionConfig {
|
|
|
42
42
|
agentId: string;
|
|
43
43
|
taskId: string;
|
|
44
44
|
workspaceDir: string;
|
|
45
|
+
/**
|
|
46
|
+
* Refuse dev servers and app scaffolding in shell calls unless the turn's
|
|
47
|
+
* own message asks for an app. Absent means the control plane did not say,
|
|
48
|
+
* which keeps the previous behaviour.
|
|
49
|
+
*/
|
|
50
|
+
devServerPolicy?: {
|
|
51
|
+
blockDevServers: boolean;
|
|
52
|
+
};
|
|
45
53
|
serverUrl?: string;
|
|
46
54
|
model?: string;
|
|
47
55
|
reasoningEffort?: ReasoningEffort;
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { CodingAgentAdapter, MessagePart, SessionConfig, SessionMessage, SessionStatus } from './coding-agent-adapter';
|
|
8
8
|
import { type ModelGatewayConfigSource } from './model-gateway';
|
|
9
|
+
export declare const PI_DEV_SERVER_POLICY_ENV = "PF_DEV_SERVER_POLICY_FILE";
|
|
9
10
|
/**
|
|
10
11
|
* Most model providers cap function/tool `name` at 64 characters. Pi forwards
|
|
11
12
|
* MCP tools as `<server>_<tool>`-style names, so a long MCP server name (e.g.
|
|
@@ -43,6 +44,7 @@ export declare function withProviderNameLimitHint(error: string): string;
|
|
|
43
44
|
* characters, before the model can call a tool.
|
|
44
45
|
*/
|
|
45
46
|
export declare function buildPiMcpConfigDocument(servers: NonNullable<SessionConfig['mcpServers']>, onRename?: (name: string, slug: string) => void): Record<string, unknown>;
|
|
47
|
+
export declare const PI_PERMISSION_EXTENSION_SOURCE: string;
|
|
46
48
|
export declare class PiAdapter implements CodingAgentAdapter {
|
|
47
49
|
private modelGateway;
|
|
48
50
|
private sessions;
|
|
@@ -96,7 +98,7 @@ export declare class PiAdapter implements CodingAgentAdapter {
|
|
|
96
98
|
private handleToolEvent;
|
|
97
99
|
private command;
|
|
98
100
|
private sendRecord;
|
|
99
|
-
sendPrompt(sessionId: string, parts: MessagePart[],
|
|
101
|
+
sendPrompt(sessionId: string, parts: MessagePart[], config: SessionConfig): Promise<void>;
|
|
100
102
|
/**
|
|
101
103
|
* THE LONG-TURNS SOFT WARNING'S DELIVERY MECHANISM.
|
|
102
104
|
*
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* process per live turn and converts Pi events to the shared adapter shape.
|
|
7
7
|
*/
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
-
exports.PiAdapter = exports.PI_MCP_SERVER_ALIASES = exports.MAX_PI_MCP_SERVER_SLUG_LENGTH = exports.MAX_PI_NAME_LENGTH = void 0;
|
|
9
|
+
exports.PiAdapter = exports.PI_PERMISSION_EXTENSION_SOURCE = exports.PI_MCP_SERVER_ALIASES = exports.MAX_PI_MCP_SERVER_SLUG_LENGTH = exports.MAX_PI_NAME_LENGTH = exports.PI_DEV_SERVER_POLICY_ENV = void 0;
|
|
10
10
|
exports.findPiNodeRuntime = findPiNodeRuntime;
|
|
11
11
|
exports.sanitizePiSessionName = sanitizePiSessionName;
|
|
12
12
|
exports.sanitizePiMcpServerName = sanitizePiMcpServerName;
|
|
@@ -21,11 +21,13 @@ const string_decoder_1 = require("string_decoder");
|
|
|
21
21
|
const util_1 = require("util");
|
|
22
22
|
const coding_agent_adapter_1 = require("./coding-agent-adapter");
|
|
23
23
|
const model_gateway_1 = require("./model-gateway");
|
|
24
|
+
const dev_server_guard_1 = require("../runtime/dev-server-guard");
|
|
24
25
|
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
25
26
|
const RPC_TIMEOUT_MS = 15_000;
|
|
26
27
|
const MAX_BUFFERED_PARTS = 1_000;
|
|
27
28
|
const MINIMUM_PI_VERSION = [0, 80, 5];
|
|
28
29
|
const PI_PERMISSION_MODE_ENV = 'PEAKFLO_PI_PERMISSION_MODE';
|
|
30
|
+
exports.PI_DEV_SERVER_POLICY_ENV = 'PF_DEV_SERVER_POLICY_FILE';
|
|
29
31
|
const TERMINAL_ERROR_SETTLE_GRACE_MS = 1_000;
|
|
30
32
|
const piInstanceIdCache = new Map();
|
|
31
33
|
/**
|
|
@@ -163,11 +165,30 @@ function buildPiMcpConfigDocument(servers, onRename) {
|
|
|
163
165
|
mcpServers
|
|
164
166
|
};
|
|
165
167
|
}
|
|
166
|
-
|
|
168
|
+
exports.PI_PERMISSION_EXTENSION_SOURCE = `\
|
|
167
169
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
170
|
+
import { readFileSync } from "node:fs";
|
|
168
171
|
|
|
169
172
|
const READ_ONLY_TOOLS = new Set(["read", "grep", "find", "ls"]);
|
|
170
173
|
|
|
174
|
+
/*
|
|
175
|
+
* The dev-server guard. The matcher is embedded from the harness source, so
|
|
176
|
+
* the refusal and the matcher cannot drift apart. The per-turn decision comes
|
|
177
|
+
* from the policy file the harness writes before each prompt (fail open when
|
|
178
|
+
* the file is absent: a session without a policy is unchanged).
|
|
179
|
+
*/
|
|
180
|
+
${dev_server_guard_1.devServerCommandKind.toString()}
|
|
181
|
+
|
|
182
|
+
function devServerBlockForTurn(): boolean {
|
|
183
|
+
try {
|
|
184
|
+
const path = process.env.PF_DEV_SERVER_POLICY_FILE;
|
|
185
|
+
if (!path) return false;
|
|
186
|
+
return JSON.parse(readFileSync(path, "utf8")).block === true;
|
|
187
|
+
} catch {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
171
192
|
function inputSummary(input: unknown): string {
|
|
172
193
|
try {
|
|
173
194
|
return JSON.stringify(input, null, 2).slice(0, 4000);
|
|
@@ -178,6 +199,14 @@ function inputSummary(input: unknown): string {
|
|
|
178
199
|
|
|
179
200
|
export default function permissions(pi: ExtensionAPI) {
|
|
180
201
|
pi.on("tool_call", async (event, ctx) => {
|
|
202
|
+
const command = (event.input as { command?: unknown } | undefined)?.command;
|
|
203
|
+
if (typeof command === "string" && devServerBlockForTurn()) {
|
|
204
|
+
const kind = devServerCommandKind(command);
|
|
205
|
+
if (kind) {
|
|
206
|
+
console.warn(\`[pf-dev-server-guard] refused \${kind}: \${command.slice(0, 200)}\`);
|
|
207
|
+
return { block: true, reason: ${JSON.stringify(dev_server_guard_1.DEV_SERVER_REFUSAL)} };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
181
210
|
if (process.env.${PI_PERMISSION_MODE_ENV} === "allow" || READ_ONLY_TOOLS.has(event.toolName)) return;
|
|
182
211
|
const approved = await ctx.ui.confirm(
|
|
183
212
|
\`Allow \${event.toolName}?\`,
|
|
@@ -351,9 +380,9 @@ class PiAdapter {
|
|
|
351
380
|
const path = (0, path_1.join)(dir, 'permissions.ts');
|
|
352
381
|
(0, fs_1.mkdirSync)(dir, { recursive: true });
|
|
353
382
|
const current = (0, fs_1.existsSync)(path) ? (0, fs_1.readFileSync)(path, 'utf8') : '';
|
|
354
|
-
if (current !== PI_PERMISSION_EXTENSION_SOURCE) {
|
|
383
|
+
if (current !== exports.PI_PERMISSION_EXTENSION_SOURCE) {
|
|
355
384
|
const temporaryPath = `${path}.${process.pid}.tmp`;
|
|
356
|
-
(0, fs_1.writeFileSync)(temporaryPath, PI_PERMISSION_EXTENSION_SOURCE, {
|
|
385
|
+
(0, fs_1.writeFileSync)(temporaryPath, exports.PI_PERMISSION_EXTENSION_SOURCE, {
|
|
357
386
|
mode: 0o600
|
|
358
387
|
});
|
|
359
388
|
(0, fs_1.chmodSync)(temporaryPath, 0o600);
|
|
@@ -510,7 +539,17 @@ class PiAdapter {
|
|
|
510
539
|
args.push('--mcp-config', mcpConfigPath);
|
|
511
540
|
for (const skillPath of skills.paths)
|
|
512
541
|
args.push('--skill', skillPath);
|
|
513
|
-
|
|
542
|
+
// One policy file per Pi process. The harness rewrites it before every
|
|
543
|
+
// prompt; the extension reads it on every shell call.
|
|
544
|
+
const devServerPolicyPath = (0, path_1.join)((0, os_1.homedir)(), '.pf-agent-harness', 'pi', 'policy', `${(0, crypto_1.randomUUID)()}.json`);
|
|
545
|
+
(0, fs_1.mkdirSync)((0, path_1.dirname)(devServerPolicyPath), { recursive: true });
|
|
546
|
+
(0, fs_1.writeFileSync)(devServerPolicyPath, JSON.stringify({ block: false }), {
|
|
547
|
+
mode: 0o600
|
|
548
|
+
});
|
|
549
|
+
const invocation = this.piInvocation(executable, args, {
|
|
550
|
+
...this.processEnv(config, gateway),
|
|
551
|
+
[exports.PI_DEV_SERVER_POLICY_ENV]: devServerPolicyPath
|
|
552
|
+
});
|
|
514
553
|
const child = (0, child_process_1.spawn)(invocation.command, invocation.args, {
|
|
515
554
|
cwd: config.workspaceDir,
|
|
516
555
|
env: invocation.env,
|
|
@@ -521,6 +560,7 @@ class PiAdapter {
|
|
|
521
560
|
});
|
|
522
561
|
const tempId = resumeId || (0, crypto_1.randomUUID)();
|
|
523
562
|
const session = this.createSessionState(tempId, child, config, mcpConfigPath, skills.directory);
|
|
563
|
+
session.devServerPolicyPath = devServerPolicyPath;
|
|
524
564
|
this.sessions.set(tempId, session);
|
|
525
565
|
this.attachProcess(session);
|
|
526
566
|
try {
|
|
@@ -989,7 +1029,7 @@ class PiAdapter {
|
|
|
989
1029
|
this.onDataAvailable?.(session.id);
|
|
990
1030
|
});
|
|
991
1031
|
}
|
|
992
|
-
async sendPrompt(sessionId, parts,
|
|
1032
|
+
async sendPrompt(sessionId, parts, config) {
|
|
993
1033
|
const session = this.sessions.get(sessionId);
|
|
994
1034
|
if (!session)
|
|
995
1035
|
throw new Error(`Session not found: ${sessionId}`);
|
|
@@ -999,6 +1039,12 @@ class PiAdapter {
|
|
|
999
1039
|
.join('\n');
|
|
1000
1040
|
if (!message)
|
|
1001
1041
|
throw new Error('No text content in prompt parts');
|
|
1042
|
+
// Decided per turn from the user's own words, then read by the extension.
|
|
1043
|
+
if (session.devServerPolicyPath) {
|
|
1044
|
+
(0, fs_1.writeFileSync)(session.devServerPolicyPath, JSON.stringify({
|
|
1045
|
+
block: (0, dev_server_guard_1.turnBlocksDevServers)(config.devServerPolicy, message)
|
|
1046
|
+
}), { mode: 0o600 });
|
|
1047
|
+
}
|
|
1002
1048
|
const wasBusy = session.status === 'busy';
|
|
1003
1049
|
session.status = 'busy';
|
|
1004
1050
|
session.lastError = null;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dev-server / scaffolding guard for coding-agent turns.
|
|
3
|
+
*
|
|
4
|
+
* A report or dashboard is delivered as an artifact. Without this guard the
|
|
5
|
+
* agent starts a Node app and a dev server instead, which the user cannot open
|
|
6
|
+
* from the session UI and which leaks a port into the sandbox.
|
|
7
|
+
*
|
|
8
|
+
* ── WHY `devServerCommandKind` IS SELF-CONTAINED ──────────────────────────
|
|
9
|
+
* The Pi extension that enforces the refusal runs as a separate process, so it
|
|
10
|
+
* cannot import this module. It embeds the matcher's SOURCE via `toString()`.
|
|
11
|
+
* That is only safe if the function references nothing outside its own body:
|
|
12
|
+
* no module-level constants, no imports, no closures. Keep it that way; the
|
|
13
|
+
* test that evaluates the embedded copy catches any slip.
|
|
14
|
+
*/
|
|
15
|
+
export declare const DEV_SERVER_REFUSAL = "Reports and dashboards are delivered as artifacts. Use create_artifact (see the html-dashboard skill).";
|
|
16
|
+
/**
|
|
17
|
+
* Returns a short label for the kind of dev-server or scaffolding command, or
|
|
18
|
+
* null when the command is not one. Positive and negative cases are pinned in
|
|
19
|
+
* the tests.
|
|
20
|
+
*/
|
|
21
|
+
export declare function devServerCommandKind(command: string): string | null;
|
|
22
|
+
/**
|
|
23
|
+
* True when the user's message explicitly asks for an app, server, API or
|
|
24
|
+
* project. Verb and noun must sit close together, so "create a sales dashboard
|
|
25
|
+
* for our app" does not count and "build me an Express API" does.
|
|
26
|
+
*/
|
|
27
|
+
export declare function userAsksForApp(text: string): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Whether THIS turn's shell calls are subject to the dev-server refusal.
|
|
30
|
+
* The per-turn decision the Pi backend writes to the policy file.
|
|
31
|
+
*/
|
|
32
|
+
export declare function turnBlocksDevServers(policy: {
|
|
33
|
+
blockDevServers?: boolean;
|
|
34
|
+
} | null | undefined, userMessage: string): boolean;
|
|
35
|
+
/**
|
|
36
|
+
* The refusal, or null when the command may run. `blockDevServers` is the
|
|
37
|
+
* per-agent policy; an explicit app request in the turn overrides it.
|
|
38
|
+
*/
|
|
39
|
+
export declare function devServerRefusalFor(input: {
|
|
40
|
+
command: string;
|
|
41
|
+
blockDevServers: boolean;
|
|
42
|
+
userMessage: string;
|
|
43
|
+
}): string | null;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The dev-server / scaffolding guard for coding-agent turns.
|
|
4
|
+
*
|
|
5
|
+
* A report or dashboard is delivered as an artifact. Without this guard the
|
|
6
|
+
* agent starts a Node app and a dev server instead, which the user cannot open
|
|
7
|
+
* from the session UI and which leaks a port into the sandbox.
|
|
8
|
+
*
|
|
9
|
+
* ── WHY `devServerCommandKind` IS SELF-CONTAINED ──────────────────────────
|
|
10
|
+
* The Pi extension that enforces the refusal runs as a separate process, so it
|
|
11
|
+
* cannot import this module. It embeds the matcher's SOURCE via `toString()`.
|
|
12
|
+
* That is only safe if the function references nothing outside its own body:
|
|
13
|
+
* no module-level constants, no imports, no closures. Keep it that way; the
|
|
14
|
+
* test that evaluates the embedded copy catches any slip.
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.DEV_SERVER_REFUSAL = void 0;
|
|
18
|
+
exports.devServerCommandKind = devServerCommandKind;
|
|
19
|
+
exports.userAsksForApp = userAsksForApp;
|
|
20
|
+
exports.turnBlocksDevServers = turnBlocksDevServers;
|
|
21
|
+
exports.devServerRefusalFor = devServerRefusalFor;
|
|
22
|
+
exports.DEV_SERVER_REFUSAL = 'Reports and dashboards are delivered as artifacts. Use create_artifact (see the html-dashboard skill).';
|
|
23
|
+
/**
|
|
24
|
+
* Returns a short label for the kind of dev-server or scaffolding command, or
|
|
25
|
+
* null when the command is not one. Positive and negative cases are pinned in
|
|
26
|
+
* the tests.
|
|
27
|
+
*/
|
|
28
|
+
function devServerCommandKind(command) {
|
|
29
|
+
const text = String(command || '');
|
|
30
|
+
const checks = [
|
|
31
|
+
['scaffold', /\b(npm|pnpm|yarn|bun)\s+(create|init)\b/],
|
|
32
|
+
['scaffold', /\bnpx\s+create-[\w-]+/],
|
|
33
|
+
[
|
|
34
|
+
'dev-server',
|
|
35
|
+
/(^|[;&|(]\s*)(npx\s+|pnpm\s+(exec\s+)?|yarn\s+)?(vite|next)(\s+(dev|start|serve))?(\s|$|[;&|)])/m
|
|
36
|
+
],
|
|
37
|
+
['dev-server', /\b(vite|next)\s+(dev|serve)\b/],
|
|
38
|
+
['dev-server', /\bnode\s+(-e\s+)?['"]?[^\n]*\.listen\s*\(/],
|
|
39
|
+
['dev-server', /\bnode\s+\S*(server|app|api)\S*\.(c|m)?js\b/],
|
|
40
|
+
['dev-server', /(^|\s)--port(\s|=)/],
|
|
41
|
+
['dev-server', /\b(uvicorn|flask\s+run|python3?\s+-m\s+http\.server)\b/]
|
|
42
|
+
];
|
|
43
|
+
for (const [kind, pattern] of checks) {
|
|
44
|
+
if (pattern.test(text))
|
|
45
|
+
return kind;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* True when the user's message explicitly asks for an app, server, API or
|
|
51
|
+
* project. Verb and noun must sit close together, so "create a sales dashboard
|
|
52
|
+
* for our app" does not count and "build me an Express API" does.
|
|
53
|
+
*/
|
|
54
|
+
function userAsksForApp(text) {
|
|
55
|
+
return /\b(build|create|make|write|scaffold|set\s+up|start|run|deploy)\s+(me\s+|us\s+|a\s+|an\s+|the\s+|my\s+|our\s+|new\s+)*((?!(dashboard|report|chart|graph|summary|table|diagram|spreadsheet)\b)\w+\s+){0,3}(app|application|api|server|service|website|project|repo|repository|express|backend)\b/i.test(String(text || ''));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Whether THIS turn's shell calls are subject to the dev-server refusal.
|
|
59
|
+
* The per-turn decision the Pi backend writes to the policy file.
|
|
60
|
+
*/
|
|
61
|
+
function turnBlocksDevServers(policy, userMessage) {
|
|
62
|
+
return policy?.blockDevServers === true && !userAsksForApp(userMessage);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The refusal, or null when the command may run. `blockDevServers` is the
|
|
66
|
+
* per-agent policy; an explicit app request in the turn overrides it.
|
|
67
|
+
*/
|
|
68
|
+
function devServerRefusalFor(input) {
|
|
69
|
+
if (!input.blockDevServers)
|
|
70
|
+
return null;
|
|
71
|
+
if (userAsksForApp(input.userMessage))
|
|
72
|
+
return null;
|
|
73
|
+
return devServerCommandKind(input.command) ? exports.DEV_SERVER_REFUSAL : null;
|
|
74
|
+
}
|
|
@@ -2084,6 +2084,7 @@ class SessionRunner {
|
|
|
2084
2084
|
// more than the tidier name.
|
|
2085
2085
|
taskId: session.sessionId,
|
|
2086
2086
|
workspaceDir,
|
|
2087
|
+
devServerPolicy: session.agent.devServerPolicy,
|
|
2087
2088
|
model: qualifiedModelId(session.agent.model),
|
|
2088
2089
|
systemPrompt: session.agent.systemPrompt ?? undefined,
|
|
2089
2090
|
skills: session.agent.skills.map((skill) => ({
|
package/dist/types.d.ts
CHANGED
|
@@ -467,6 +467,13 @@ export interface LeasedAgent {
|
|
|
467
467
|
/** Always the skill's PUBLISHED content, never its draft. */
|
|
468
468
|
skills: LeasedSkill[];
|
|
469
469
|
mcpServers: LeasedMcpServer[];
|
|
470
|
+
/**
|
|
471
|
+
* Optional and additive. Absent means an older control plane, and the
|
|
472
|
+
* harness then applies no dev-server refusal.
|
|
473
|
+
*/
|
|
474
|
+
devServerPolicy?: {
|
|
475
|
+
blockDevServers: boolean;
|
|
476
|
+
};
|
|
470
477
|
}
|
|
471
478
|
export interface LeasedModel {
|
|
472
479
|
provider: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peakflo/agent-harness",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4-rc.1",
|
|
4
4
|
"description": "Headless Peakflo coding-agent harness. One codebase, three hosting modes: a Daytona cloud sandbox, a customer VM, and the 20x desktop.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|