@peakflo/agent-harness 0.2.3 → 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.
@@ -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[], _config: SessionConfig): Promise<void>;
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
- const PI_PERMISSION_EXTENSION_SOURCE = `\
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
- const invocation = this.piInvocation(executable, args, this.processEnv(config, gateway));
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, _config) {
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",
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",