@peakflo/agent-harness 0.2.3 → 0.2.4-rc.2
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/capabilities.d.ts +8 -0
- package/dist/capabilities.js +10 -0
- package/dist/contract.d.ts +4 -0
- package/dist/contract.js +4 -0
- package/dist/control-plane.d.ts +8 -1
- package/dist/control-plane.js +26 -0
- package/dist/harness.d.ts +2 -0
- package/dist/harness.js +10 -0
- package/dist/runtime/attachments.d.ts +6 -0
- package/dist/runtime/attachments.js +23 -15
- package/dist/runtime/conversation.d.ts +16 -0
- package/dist/runtime/conversation.js +15 -0
- package/dist/runtime/dev-server-guard.d.ts +43 -0
- package/dist/runtime/dev-server-guard.js +74 -0
- package/dist/runtime/knowledge-tools.d.ts +44 -0
- package/dist/runtime/knowledge-tools.js +410 -0
- package/dist/runtime/session-runner.d.ts +17 -0
- package/dist/runtime/session-runner.js +70 -9
- package/dist/runtime/space-files.d.ts +82 -0
- package/dist/runtime/space-files.js +422 -0
- package/dist/types.d.ts +80 -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;
|
package/dist/capabilities.d.ts
CHANGED
|
@@ -24,6 +24,14 @@
|
|
|
24
24
|
* every heartbeat rather than trusting the registration snapshot.
|
|
25
25
|
*/
|
|
26
26
|
import { RunnerCapabilities } from './types';
|
|
27
|
+
/**
|
|
28
|
+
* What this harness build can do beyond the baseline. The control plane reads
|
|
29
|
+
* these to decide what to send: a feature missing here is never sent to.
|
|
30
|
+
*
|
|
31
|
+
* - `spaceFiles`: syncs `space-files/` (read-only) and serves
|
|
32
|
+
* `knowledge_files_list` / `knowledge_file_download` (P9).
|
|
33
|
+
*/
|
|
34
|
+
export declare const HARNESS_FEATURES: readonly ["spaceFiles"];
|
|
27
35
|
export interface ProbeResult {
|
|
28
36
|
installed: boolean;
|
|
29
37
|
version: string | null;
|
package/dist/capabilities.js
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* every heartbeat rather than trusting the registration snapshot.
|
|
26
26
|
*/
|
|
27
27
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
28
|
+
exports.HARNESS_FEATURES = void 0;
|
|
28
29
|
exports.probe = probe;
|
|
29
30
|
exports.probeCapabilities = probeCapabilities;
|
|
30
31
|
const node_child_process_1 = require("node:child_process");
|
|
@@ -33,6 +34,14 @@ const node_os_1 = require("node:os");
|
|
|
33
34
|
const node_path_1 = require("node:path");
|
|
34
35
|
const version_1 = require("./version");
|
|
35
36
|
const PROBE_TIMEOUT_MS = 10_000;
|
|
37
|
+
/**
|
|
38
|
+
* What this harness build can do beyond the baseline. The control plane reads
|
|
39
|
+
* these to decide what to send: a feature missing here is never sent to.
|
|
40
|
+
*
|
|
41
|
+
* - `spaceFiles`: syncs `space-files/` (read-only) and serves
|
|
42
|
+
* `knowledge_files_list` / `knowledge_file_download` (P9).
|
|
43
|
+
*/
|
|
44
|
+
exports.HARNESS_FEATURES = ['spaceFiles'];
|
|
36
45
|
/**
|
|
37
46
|
* The coding-agent backends this harness knows how to drive, and the command
|
|
38
47
|
* that reports each one's version.
|
|
@@ -156,6 +165,7 @@ async function probeCapabilities(options = {}) {
|
|
|
156
165
|
git: git.installed,
|
|
157
166
|
docker: docker.installed,
|
|
158
167
|
maxConcurrentSessions: options.maxConcurrentSessions ?? 1,
|
|
168
|
+
features: [...exports.HARNESS_FEATURES],
|
|
159
169
|
labels: options.labels
|
|
160
170
|
};
|
|
161
171
|
}
|
package/dist/contract.d.ts
CHANGED
|
@@ -194,6 +194,10 @@ export declare const ROUTES: {
|
|
|
194
194
|
readonly lease: (runnerId: string, waitSeconds?: number, protocolVersion?: number) => string;
|
|
195
195
|
/** Bearer: runner credential. Visibility-timeout renewal. */
|
|
196
196
|
readonly leaseRenew: (runnerId: string, leaseId: string) => string;
|
|
197
|
+
/** Bearer: runner credential. Metadata of the knowledge files this turn may read. */
|
|
198
|
+
readonly knowledgeFiles: (runnerId: string, leaseId: string) => string;
|
|
199
|
+
/** Bearer: runner credential. A short-lived download URL for one knowledge file. */
|
|
200
|
+
readonly knowledgeDownload: (runnerId: string, leaseId: string) => string;
|
|
197
201
|
/**
|
|
198
202
|
* Bearer: runner credential. THE RUNTIME-NEUTRAL ROUTE — a Daytona sandbox,
|
|
199
203
|
* a customer VM and the 20x desktop all call this identical path.
|
package/dist/contract.js
CHANGED
|
@@ -234,6 +234,10 @@ exports.ROUTES = {
|
|
|
234
234
|
},
|
|
235
235
|
/** Bearer: runner credential. Visibility-timeout renewal. */
|
|
236
236
|
leaseRenew: (runnerId, leaseId) => `/api/runners/${encodeURIComponent(runnerId)}/lease/${encodeURIComponent(leaseId)}/renew`,
|
|
237
|
+
/** Bearer: runner credential. Metadata of the knowledge files this turn may read. */
|
|
238
|
+
knowledgeFiles: (runnerId, leaseId) => `/api/runners/${encodeURIComponent(runnerId)}/lease/${encodeURIComponent(leaseId)}/knowledge/files`,
|
|
239
|
+
/** Bearer: runner credential. A short-lived download URL for one knowledge file. */
|
|
240
|
+
knowledgeDownload: (runnerId, leaseId) => `/api/runners/${encodeURIComponent(runnerId)}/lease/${encodeURIComponent(leaseId)}/knowledge/download`,
|
|
237
241
|
/**
|
|
238
242
|
* Bearer: runner credential. THE RUNTIME-NEUTRAL ROUTE — a Daytona sandbox,
|
|
239
243
|
* a customer VM and the 20x desktop all call this identical path.
|
package/dist/control-plane.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ControlPlaneErrorBody, CredentialRotateResponse, DeregisterResponse, IngestRequest, IngestResponse, LeaseRenewResponse, LeaseResponse, RunnerCapabilities, RunnerHeartbeatResponse, RunnerRegisterResponse, RunnerRuntime, RunnerStatus, SessionHistoryResponse } from './types';
|
|
1
|
+
import { ControlPlaneErrorBody, CredentialRotateResponse, DeregisterResponse, IngestRequest, IngestResponse, KnowledgeDownloadResponse, KnowledgeFilesResponse, LeaseRenewResponse, LeaseResponse, RunnerCapabilities, RunnerHeartbeatResponse, RunnerRegisterResponse, RunnerRuntime, RunnerStatus, SessionHistoryResponse } from './types';
|
|
2
2
|
export declare class ControlPlaneError extends Error {
|
|
3
3
|
readonly status: number;
|
|
4
4
|
/** The stable machine-readable code from the body, when the server sent one. */
|
|
@@ -130,6 +130,13 @@ export declare class ControlPlaneClient {
|
|
|
130
130
|
*/
|
|
131
131
|
lease(waitSeconds?: number): Promise<LeaseResponse | null>;
|
|
132
132
|
renewLease(leaseId: string, status?: RunnerStatus, githubTokenExpiresAt?: string, modelTokenExpiresAt?: string): Promise<LeaseRenewResponse>;
|
|
133
|
+
/** Metadata of the knowledge files the lease's turn may read. Never a URL. */
|
|
134
|
+
knowledgeFiles(leaseId: string, datastoreId?: string): Promise<KnowledgeFilesResponse>;
|
|
135
|
+
/**
|
|
136
|
+
* A short-lived signed URL for one knowledge file, for the harness to fetch.
|
|
137
|
+
* The caller must not log the response's `url`.
|
|
138
|
+
*/
|
|
139
|
+
knowledgeDownload(leaseId: string, datastoreId: string, fileId: string): Promise<KnowledgeDownloadResponse>;
|
|
133
140
|
/**
|
|
134
141
|
* THE RUNTIME-NEUTRAL ROUTE. A Daytona sandbox, a customer VM and the 20x
|
|
135
142
|
* desktop all call exactly this.
|
package/dist/control-plane.js
CHANGED
|
@@ -263,6 +263,32 @@ class ControlPlaneClient {
|
|
|
263
263
|
});
|
|
264
264
|
}
|
|
265
265
|
/* ------------------------------------------------------------------ */
|
|
266
|
+
/* 4b. knowledge files (P9) */
|
|
267
|
+
/* ------------------------------------------------------------------ */
|
|
268
|
+
/** Metadata of the knowledge files the lease's turn may read. Never a URL. */
|
|
269
|
+
async knowledgeFiles(leaseId, datastoreId) {
|
|
270
|
+
const runnerId = this.requireRunnerId();
|
|
271
|
+
return this.send({
|
|
272
|
+
method: 'POST',
|
|
273
|
+
path: contract_1.ROUTES.knowledgeFiles(runnerId, leaseId),
|
|
274
|
+
token: this.requireCredential(),
|
|
275
|
+
body: { protocolVersion: contract_1.PROTOCOL_VERSION, datastoreId }
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* A short-lived signed URL for one knowledge file, for the harness to fetch.
|
|
280
|
+
* The caller must not log the response's `url`.
|
|
281
|
+
*/
|
|
282
|
+
async knowledgeDownload(leaseId, datastoreId, fileId) {
|
|
283
|
+
const runnerId = this.requireRunnerId();
|
|
284
|
+
return this.send({
|
|
285
|
+
method: 'POST',
|
|
286
|
+
path: contract_1.ROUTES.knowledgeDownload(runnerId, leaseId),
|
|
287
|
+
token: this.requireCredential(),
|
|
288
|
+
body: { protocolVersion: contract_1.PROTOCOL_VERSION, datastoreId, fileId }
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
/* ------------------------------------------------------------------ */
|
|
266
292
|
/* 5. ingest */
|
|
267
293
|
/* ------------------------------------------------------------------ */
|
|
268
294
|
/**
|
package/dist/harness.d.ts
CHANGED
|
@@ -119,6 +119,8 @@ export declare class Harness {
|
|
|
119
119
|
private readonly client;
|
|
120
120
|
private readonly health;
|
|
121
121
|
private readonly proxy;
|
|
122
|
+
/** One loopback host for every session this process runs (P9 knowledge tools). */
|
|
123
|
+
private readonly knowledgeHost;
|
|
122
124
|
private readonly store;
|
|
123
125
|
/** The model-facing conversation log. See `ports.ts#ConversationStore`. */
|
|
124
126
|
private readonly conversation;
|
package/dist/harness.js
CHANGED
|
@@ -12,6 +12,7 @@ const github_workspace_1 = require("./github-workspace");
|
|
|
12
12
|
const health_1 = require("./health");
|
|
13
13
|
const model_probe_1 = require("./model-probe");
|
|
14
14
|
const ports_default_1 = require("./ports-default");
|
|
15
|
+
const knowledge_tools_1 = require("./runtime/knowledge-tools");
|
|
15
16
|
const lease_renewal_1 = require("./runtime/lease-renewal");
|
|
16
17
|
const mcp_auth_proxy_1 = require("./runtime/mcp-auth-proxy");
|
|
17
18
|
const session_runner_1 = require("./runtime/session-runner");
|
|
@@ -94,6 +95,8 @@ class Harness {
|
|
|
94
95
|
client;
|
|
95
96
|
health;
|
|
96
97
|
proxy;
|
|
98
|
+
/** One loopback host for every session this process runs (P9 knowledge tools). */
|
|
99
|
+
knowledgeHost = new knowledge_tools_1.KnowledgeToolHost({ logger: console });
|
|
97
100
|
store;
|
|
98
101
|
/** The model-facing conversation log. See `ports.ts#ConversationStore`. */
|
|
99
102
|
conversation;
|
|
@@ -1293,6 +1296,13 @@ class Harness {
|
|
|
1293
1296
|
store: this.store,
|
|
1294
1297
|
workspace: this.workspace,
|
|
1295
1298
|
proxy: this.proxy,
|
|
1299
|
+
knowledge: {
|
|
1300
|
+
host: this.knowledgeHost,
|
|
1301
|
+
control: {
|
|
1302
|
+
list: (datastoreId) => this.client.knowledgeFiles(lease.leaseId, datastoreId),
|
|
1303
|
+
download: (datastoreId, fileId) => this.client.knowledgeDownload(lease.leaseId, datastoreId, fileId)
|
|
1304
|
+
}
|
|
1305
|
+
},
|
|
1296
1306
|
secrets: this.secrets,
|
|
1297
1307
|
events: this.events,
|
|
1298
1308
|
telemetry: this.telemetry,
|
|
@@ -60,6 +60,12 @@ export declare function sanitizeAttachmentMimeType(raw: unknown): string;
|
|
|
60
60
|
* the id's own shape.
|
|
61
61
|
*/
|
|
62
62
|
export declare function turnToken(turnId: string): string;
|
|
63
|
+
/**
|
|
64
|
+
* Add one pattern to a repository workspace's local exclude file. Shared by
|
|
65
|
+
* attachments and Space files (P9): both are mirrors of stored bytes that an
|
|
66
|
+
* agent must not commit by accident.
|
|
67
|
+
*/
|
|
68
|
+
export declare function excludePatternFromGit(workspaceDir: string, pattern: string, logger: Pick<Console, 'warn'>): Promise<void>;
|
|
63
69
|
/**
|
|
64
70
|
* Download every attachment into `<workspaceDir>/attachments/`.
|
|
65
71
|
*
|
|
@@ -4,6 +4,7 @@ exports.ATTACHMENT_DOWNLOAD_TIMEOUT_MS = exports.MAX_ATTACHMENT_BYTES = exports.
|
|
|
4
4
|
exports.sanitizeAttachmentName = sanitizeAttachmentName;
|
|
5
5
|
exports.sanitizeAttachmentMimeType = sanitizeAttachmentMimeType;
|
|
6
6
|
exports.turnToken = turnToken;
|
|
7
|
+
exports.excludePatternFromGit = excludePatternFromGit;
|
|
7
8
|
exports.syncAttachmentsToWorkspace = syncAttachmentsToWorkspace;
|
|
8
9
|
exports.attachmentPromptNote = attachmentPromptNote;
|
|
9
10
|
/**
|
|
@@ -192,25 +193,15 @@ async function resolveGitDir(workspaceDir) {
|
|
|
192
193
|
}
|
|
193
194
|
}
|
|
194
195
|
/**
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
* Written to `<gitdir>/info/exclude`, NOT `.gitignore` — the former is local
|
|
200
|
-
* to this checkout and is never itself a tracked file the agent could commit
|
|
201
|
-
* or a diff it could see, which matters because `.gitignore` churn would be
|
|
202
|
-
* a confusing, unexplained line in every turn's diff. A no-op for a
|
|
203
|
-
* `scratch` workspace (no `.git` at all, resolved by `resolveGitDir`) and for
|
|
204
|
-
* anything that goes wrong reading or writing it — this is housekeeping, not
|
|
205
|
-
* a security boundary on its own (the per-file and per-attachment safeguards
|
|
206
|
-
* above are), and must never fail a turn.
|
|
196
|
+
* Add one pattern to a repository workspace's local exclude file. Shared by
|
|
197
|
+
* attachments and Space files (P9): both are mirrors of stored bytes that an
|
|
198
|
+
* agent must not commit by accident.
|
|
207
199
|
*/
|
|
208
|
-
async function
|
|
200
|
+
async function excludePatternFromGit(workspaceDir, pattern, logger) {
|
|
209
201
|
const gitDir = await resolveGitDir(workspaceDir);
|
|
210
202
|
if (!gitDir)
|
|
211
203
|
return;
|
|
212
204
|
const excludePath = (0, node_path_1.join)(gitDir, 'info', 'exclude');
|
|
213
|
-
const pattern = `/${exports.ATTACHMENTS_DIR}/`;
|
|
214
205
|
try {
|
|
215
206
|
let existing = '';
|
|
216
207
|
try {
|
|
@@ -226,9 +217,26 @@ async function excludeAttachmentsDirFromGit(workspaceDir, logger) {
|
|
|
226
217
|
await (0, promises_1.appendFile)(excludePath, `${prefix}${pattern}\n`);
|
|
227
218
|
}
|
|
228
219
|
catch (error) {
|
|
229
|
-
logger.warn(`[attachments] could not add ${
|
|
220
|
+
logger.warn(`[attachments] could not add ${pattern} to ${excludePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
230
221
|
}
|
|
231
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* Best-effort: keep `attachments/` out of `git status` in a repository-mode
|
|
225
|
+
* workspace, so an agent that stages "everything that changed" cannot commit
|
|
226
|
+
* a user's uploaded file into the repository it is working on.
|
|
227
|
+
*
|
|
228
|
+
* Written to `<gitdir>/info/exclude`, NOT `.gitignore` — the former is local
|
|
229
|
+
* to this checkout and is never itself a tracked file the agent could commit
|
|
230
|
+
* or a diff it could see, which matters because `.gitignore` churn would be
|
|
231
|
+
* a confusing, unexplained line in every turn's diff. A no-op for a
|
|
232
|
+
* `scratch` workspace (no `.git` at all, resolved by `resolveGitDir`) and for
|
|
233
|
+
* anything that goes wrong reading or writing it — this is housekeeping, not
|
|
234
|
+
* a security boundary on its own (the per-file and per-attachment safeguards
|
|
235
|
+
* above are), and must never fail a turn.
|
|
236
|
+
*/
|
|
237
|
+
async function excludeAttachmentsDirFromGit(workspaceDir, logger) {
|
|
238
|
+
await excludePatternFromGit(workspaceDir, `/${exports.ATTACHMENTS_DIR}/`, logger);
|
|
239
|
+
}
|
|
232
240
|
/**
|
|
233
241
|
* Download every attachment into `<workspaceDir>/attachments/`.
|
|
234
242
|
*
|
|
@@ -72,6 +72,22 @@ export declare function trimHistory(history: ModelMessage[], maxChars?: number):
|
|
|
72
72
|
* Returns the raw input unchanged when there is nothing to replay, so a first
|
|
73
73
|
* turn is byte-for-byte what it always was and cannot regress.
|
|
74
74
|
*/
|
|
75
|
+
/**
|
|
76
|
+
* THE KEY ONE AGENT'S CONVERSATION IS STORED UNDER on this runner.
|
|
77
|
+
*
|
|
78
|
+
* A chat can be answered by several agents, and each agent keeps its own
|
|
79
|
+
* thread: its model memory and its backend checkpoint. Keying by the chat alone
|
|
80
|
+
* would hand agent B the replay of agent A's answers. The transcript itself
|
|
81
|
+
* stays keyed by the chat; only this memory is per agent.
|
|
82
|
+
*
|
|
83
|
+
* A session with no agent id (an older control plane) keeps the chat-only key.
|
|
84
|
+
*/
|
|
85
|
+
export declare function conversationKey(session: {
|
|
86
|
+
sessionId: string;
|
|
87
|
+
agent?: {
|
|
88
|
+
agentId?: string;
|
|
89
|
+
} | null;
|
|
90
|
+
}): string;
|
|
75
91
|
export declare function buildReplayPrompt(history: ModelMessage[], input: string): string;
|
|
76
92
|
/**
|
|
77
93
|
* Reduce a turn's projected parts to the assistant text the model produced.
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
37
|
exports.MAX_REPLAY_CHARS = void 0;
|
|
38
38
|
exports.trimHistory = trimHistory;
|
|
39
|
+
exports.conversationKey = conversationKey;
|
|
39
40
|
exports.buildReplayPrompt = buildReplayPrompt;
|
|
40
41
|
exports.assistantTextFrom = assistantTextFrom;
|
|
41
42
|
/**
|
|
@@ -121,6 +122,20 @@ function trimHistory(history, maxChars = exports.MAX_REPLAY_CHARS) {
|
|
|
121
122
|
* Returns the raw input unchanged when there is nothing to replay, so a first
|
|
122
123
|
* turn is byte-for-byte what it always was and cannot regress.
|
|
123
124
|
*/
|
|
125
|
+
/**
|
|
126
|
+
* THE KEY ONE AGENT'S CONVERSATION IS STORED UNDER on this runner.
|
|
127
|
+
*
|
|
128
|
+
* A chat can be answered by several agents, and each agent keeps its own
|
|
129
|
+
* thread: its model memory and its backend checkpoint. Keying by the chat alone
|
|
130
|
+
* would hand agent B the replay of agent A's answers. The transcript itself
|
|
131
|
+
* stays keyed by the chat; only this memory is per agent.
|
|
132
|
+
*
|
|
133
|
+
* A session with no agent id (an older control plane) keeps the chat-only key.
|
|
134
|
+
*/
|
|
135
|
+
function conversationKey(session) {
|
|
136
|
+
const agentId = session.agent?.agentId;
|
|
137
|
+
return agentId ? `${session.sessionId}#${agentId}` : session.sessionId;
|
|
138
|
+
}
|
|
124
139
|
function buildReplayPrompt(history, input) {
|
|
125
140
|
const trimmed = trimHistory(history);
|
|
126
141
|
if (trimmed.length === 0)
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { KnowledgeDownloadResponse, KnowledgeFilesResponse } from '../types';
|
|
2
|
+
/** The name the agent sees the server under. */
|
|
3
|
+
export declare const KNOWLEDGE_SERVER_NAME = "pf-knowledge";
|
|
4
|
+
export declare const KNOWLEDGE_FILES_LIST_TOOL = "knowledge_files_list";
|
|
5
|
+
export declare const KNOWLEDGE_FILE_DOWNLOAD_TOOL = "knowledge_file_download";
|
|
6
|
+
/** The control plane's answers, as this module needs them. Injected for tests. */
|
|
7
|
+
export interface KnowledgeControl {
|
|
8
|
+
list(datastoreId?: string): Promise<KnowledgeFilesResponse>;
|
|
9
|
+
download(datastoreId: string, fileId: string): Promise<KnowledgeDownloadResponse>;
|
|
10
|
+
}
|
|
11
|
+
/** The active turn a session's tools act for. Absent between turns. */
|
|
12
|
+
export interface KnowledgeTurn {
|
|
13
|
+
control: KnowledgeControl;
|
|
14
|
+
workspaceDir: string;
|
|
15
|
+
}
|
|
16
|
+
/** A name safe as one path segment, for a download's name. */
|
|
17
|
+
export declare function safeDownloadName(raw: unknown): string;
|
|
18
|
+
export interface KnowledgeToolHostOptions {
|
|
19
|
+
fetchImpl?: typeof fetch;
|
|
20
|
+
logger: Pick<Console, 'log' | 'warn' | 'error'>;
|
|
21
|
+
downloadTimeoutMs?: number;
|
|
22
|
+
}
|
|
23
|
+
export declare class KnowledgeToolHost {
|
|
24
|
+
private readonly options;
|
|
25
|
+
private server;
|
|
26
|
+
private port;
|
|
27
|
+
private starting;
|
|
28
|
+
private readonly tokenBySession;
|
|
29
|
+
private readonly sessionByToken;
|
|
30
|
+
private readonly turns;
|
|
31
|
+
constructor(options: KnowledgeToolHostOptions);
|
|
32
|
+
/** The endpoint a session's agent is configured with. Starts the listener on first use. */
|
|
33
|
+
endpointFor(sessionId: string): Promise<string>;
|
|
34
|
+
/** Bind the current turn's lease, or clear it with null when the turn ends. */
|
|
35
|
+
bindTurn(sessionId: string, turn: KnowledgeTurn | null): void;
|
|
36
|
+
stop(): Promise<void>;
|
|
37
|
+
private start;
|
|
38
|
+
private handle;
|
|
39
|
+
private send;
|
|
40
|
+
private dispatch;
|
|
41
|
+
private callTool;
|
|
42
|
+
private list;
|
|
43
|
+
private download;
|
|
44
|
+
}
|