@tea-agent/loop-agent 0.28.13 → 0.29.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/AGENTS.md +1 -1
- package/CHANGELOG.md +73 -15
- package/dist/commands/client-recovery.js +56 -1
- package/dist/commands/init-upgrade.js +186 -21
- package/dist/commands/init.js +1 -1
- package/dist/executors/dag-pi-executor.js +52 -3
- package/dist/executors/pi-playwright-cli-tool.js +14 -8
- package/dist/executors/shell-executor.js +288 -0
- package/dist/task/config-types.js +21 -0
- package/dist/worker/console/app-data.js +132 -11
- package/dist/worker/console/chat/pi-runtime.js +24 -42
- package/dist/worker/console/chat/resource-loader.js +11 -20
- package/dist/worker/console/chat/routes.js +7 -8
- package/dist/worker/console/chat/runtime-context.js +1 -1
- package/dist/worker/console/chat/tools.js +67 -54
- package/dist/worker/console/operation-runner.js +15 -1
- package/dist/worker/console/operation-store.js +70 -49
- package/dist/worker/console/operator-actions.js +57 -1
- package/dist/worker/console/static/assets/index-Cwx-ZVEQ.js +29 -0
- package/dist/worker/console/static/favicon.svg +37 -0
- package/dist/worker/console/static/index.html +2 -1
- package/dist/workflows/dag/backend-test-scenario-param.js +846 -0
- package/dist/workflows/dag/backend-test-writer-completeness.js +418 -0
- package/dist/workflows/dag/frontend-test-case-checklist.js +94 -15
- package/dist/workflows/dag/frontend-test-case-manifest.js +104 -0
- package/dist/workflows/dag/frontend-test-html-report.js +106 -24
- package/dist/workflows/dag/frontend-test-result-contract.js +3 -0
- package/dist/workflows/dag/init-hybrid.js +187 -108
- package/dist/workflows/dag/node-execution.js +31 -2
- package/dist/workflows/dag/retry-policy.js +55 -18
- package/dist/workflows/dag/types.js +41 -0
- package/dist/workflows/dag/validate.js +42 -4
- package/docs/operations/README.md +1 -1
- package/docs/templates/README.md +2 -1
- package/docs/templates/agent-dag.schema.json +9 -4
- package/docs/templates/backend-test-dag.json +36 -11
- package/docs/templates/frontend-test-case-checklist.md +1 -1
- package/docs/templates/frontend-test-dag.generate-cases.prompt.md +9 -1
- package/docs/templates/frontend-test-dag.json +125 -267
- package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +7 -1
- package/docs/templates/frontend-test-dag.review-cases.prompt.md +1 -1
- package/docs/templates/frontend-test-standard-scenarios.v1.json +114 -0
- package/docs/templates/init-managed-agents.md +1 -1
- package/harness.json +2 -2
- package/package.json +2 -1
- package/skills/playwright-cli/SKILL.md +1 -1
- package/skills/playwright-cli-case-generator/SKILL.md +1 -1
- package/dist/worker/console/static/assets/index-BfRgtLF4.js +0 -29
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
-
import { chmodSync, existsSync, lstatSync, mkdirSync, renameSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
3
3
|
import { readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
@@ -100,6 +100,98 @@ export function assertSafeAppDataPath(baseDir, candidate) {
|
|
|
100
100
|
}
|
|
101
101
|
return resolved;
|
|
102
102
|
}
|
|
103
|
+
const WINDOWS_REPLACE_RETRY_DELAYS_MS = [10, 25, 50, 100, 200, 400];
|
|
104
|
+
const WINDOWS_REPLACE_RETRY_CODES = new Set([
|
|
105
|
+
"EACCES",
|
|
106
|
+
"EBUSY",
|
|
107
|
+
"EEXIST",
|
|
108
|
+
"EPERM",
|
|
109
|
+
]);
|
|
110
|
+
/**
|
|
111
|
+
* Atomically replace `resolved` with `tmp`.
|
|
112
|
+
* On Windows, destination locks (AV / indexer / concurrent writers) often yield
|
|
113
|
+
* EPERM/EEXIST on rename — retry, then fall back to unlink+rename.
|
|
114
|
+
*/
|
|
115
|
+
export async function replaceFileWithRetry(tmp, resolved, deps = {}) {
|
|
116
|
+
const doRename = deps.rename ?? rename;
|
|
117
|
+
const doRm = deps.rm ?? rm;
|
|
118
|
+
const platform = deps.platform ?? process.platform;
|
|
119
|
+
const sleep = deps.sleep ??
|
|
120
|
+
((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
121
|
+
let lastError;
|
|
122
|
+
for (let attempt = 0;; attempt += 1) {
|
|
123
|
+
try {
|
|
124
|
+
await doRename(tmp, resolved);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
lastError = error;
|
|
129
|
+
const code = error.code ?? "";
|
|
130
|
+
const delay = WINDOWS_REPLACE_RETRY_DELAYS_MS[attempt];
|
|
131
|
+
if (platform !== "win32" ||
|
|
132
|
+
!WINDOWS_REPLACE_RETRY_CODES.has(code) ||
|
|
133
|
+
delay === undefined) {
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
await sleep(delay);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (platform === "win32") {
|
|
140
|
+
const code = lastError?.code ?? "";
|
|
141
|
+
if (WINDOWS_REPLACE_RETRY_CODES.has(code)) {
|
|
142
|
+
try {
|
|
143
|
+
await doRm(resolved, { force: true });
|
|
144
|
+
await doRename(tmp, resolved);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
catch (fallbackError) {
|
|
148
|
+
throw lastError ?? fallbackError;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
throw lastError instanceof Error
|
|
153
|
+
? lastError
|
|
154
|
+
: new Error(String(lastError ?? "replaceFileWithRetry failed"));
|
|
155
|
+
}
|
|
156
|
+
function replaceFileWithRetrySync(tmp, resolved) {
|
|
157
|
+
let lastError;
|
|
158
|
+
for (let attempt = 0;; attempt += 1) {
|
|
159
|
+
try {
|
|
160
|
+
renameSync(tmp, resolved);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
lastError = error;
|
|
165
|
+
const code = error.code ?? "";
|
|
166
|
+
const delay = WINDOWS_REPLACE_RETRY_DELAYS_MS[attempt];
|
|
167
|
+
if (process.platform !== "win32" ||
|
|
168
|
+
!WINDOWS_REPLACE_RETRY_CODES.has(code) ||
|
|
169
|
+
delay === undefined) {
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
const end = Date.now() + delay;
|
|
173
|
+
while (Date.now() < end) {
|
|
174
|
+
/* busy-wait: sync path used only for small boot/SSE writes */
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (process.platform === "win32") {
|
|
179
|
+
const code = lastError?.code ?? "";
|
|
180
|
+
if (WINDOWS_REPLACE_RETRY_CODES.has(code)) {
|
|
181
|
+
try {
|
|
182
|
+
rmSync(resolved, { force: true });
|
|
183
|
+
renameSync(tmp, resolved);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
catch (fallbackError) {
|
|
187
|
+
throw lastError ?? fallbackError;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
throw lastError instanceof Error
|
|
192
|
+
? lastError
|
|
193
|
+
: new Error(String(lastError ?? "replaceFileWithRetrySync failed"));
|
|
194
|
+
}
|
|
103
195
|
export async function writeSecureJson(filePath, value) {
|
|
104
196
|
const dir = path.dirname(filePath);
|
|
105
197
|
ensureSecureDir(dir);
|
|
@@ -108,12 +200,18 @@ export async function writeSecureJson(filePath, value) {
|
|
|
108
200
|
const body = `${JSON.stringify(value, null, 2)}\n`;
|
|
109
201
|
await writeFile(tmp, body, { encoding: "utf8", mode: 0o600 });
|
|
110
202
|
try {
|
|
111
|
-
|
|
203
|
+
try {
|
|
204
|
+
chmodSync(tmp, 0o600);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
// best-effort
|
|
208
|
+
}
|
|
209
|
+
await replaceFileWithRetry(tmp, resolved);
|
|
112
210
|
}
|
|
113
|
-
catch {
|
|
114
|
-
|
|
211
|
+
catch (error) {
|
|
212
|
+
await rm(tmp, { force: true }).catch(() => undefined);
|
|
213
|
+
throw error;
|
|
115
214
|
}
|
|
116
|
-
await rename(tmp, resolved);
|
|
117
215
|
try {
|
|
118
216
|
chmodSync(resolved, 0o600);
|
|
119
217
|
}
|
|
@@ -182,10 +280,33 @@ export function writeSecureJsonSync(filePath, value) {
|
|
|
182
280
|
const dir = path.dirname(filePath);
|
|
183
281
|
ensureSecureDir(dir);
|
|
184
282
|
const resolved = assertSafeAppDataPath(dir, filePath);
|
|
185
|
-
const tmp = `${resolved}.${process.pid}.tmp`;
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
283
|
+
const tmp = `${resolved}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
284
|
+
try {
|
|
285
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, {
|
|
286
|
+
encoding: "utf8",
|
|
287
|
+
mode: 0o600,
|
|
288
|
+
});
|
|
289
|
+
try {
|
|
290
|
+
chmodSync(tmp, 0o600);
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
// best-effort
|
|
294
|
+
}
|
|
295
|
+
replaceFileWithRetrySync(tmp, resolved);
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
try {
|
|
299
|
+
rmSync(tmp, { force: true });
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
// best-effort cleanup
|
|
303
|
+
}
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
chmodSync(resolved, 0o600);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
// best-effort
|
|
311
|
+
}
|
|
191
312
|
}
|
|
@@ -13,11 +13,10 @@
|
|
|
13
13
|
* (noContextFiles / closed surface); credential/model plane is shared via
|
|
14
14
|
* the same agentDir auth.json/models.json.
|
|
15
15
|
* - Active tools are pinned to the operator-chat surface at session create
|
|
16
|
-
* AND re-pinned before each prompt (three-gate, design §7.5):
|
|
17
|
-
* operator action set
|
|
18
|
-
* read/
|
|
19
|
-
* (
|
|
20
|
-
* the SDK registry and can never be activated (2026-07-25 widening).
|
|
16
|
+
* AND re-pinned before each prompt (three-gate, design §7.5 / ADR 0011):
|
|
17
|
+
* the FULL operator action set PLUS full Pi builtins
|
|
18
|
+
* (read/write/edit/bash/grep/find/ls) and optional safe-*. Non-Pi write
|
|
19
|
+
* channels (apply_patch/full-tools/shell/coding-chat) stay excluded.
|
|
21
20
|
*
|
|
22
21
|
* The actual SDK calls are injected via `PiSdkBindings` so this module is
|
|
23
22
|
* unit-testable without a live Pi install. Production bindings come from
|
|
@@ -49,35 +48,34 @@ import { filterActiveInterviewTools } from "../interview/tools.js";
|
|
|
49
48
|
*/
|
|
50
49
|
const OPERATOR_CHAT_SYSTEM_PROMPT_BASE = [
|
|
51
50
|
"You are the General Operator Chat for loop-agent / agent-worker.",
|
|
52
|
-
"You are an OPERATOR:
|
|
53
|
-
"
|
|
54
|
-
"safe-read / safe-grep enforce a repo-relative path boundary, a sensitive-file denylist (.env* / *.key / *.pem / .git/** / auth.json / sessions/**) and secret scrubbing.
|
|
55
|
-
"You do NOT have
|
|
56
|
-
"High-risk mutations
|
|
51
|
+
"You are an OPERATOR first: orchestrate and inspect via operator_* tools, and you also have full Pi repository tools (read, write, edit, bash, grep, find, ls) plus optional safe-read/safe-grep/git-status/git-diff.",
|
|
52
|
+
"Capability honesty (ADR 0011): read/write/edit/bash ARE available. Prefer governed loop-agent / Agent DAG paths (implement-pi / repair-pi) and Human Gate for large refactors, public contracts, credentials, or production-risk changes — treat direct write/edit/bash as soft-disciplined, not as a second DAG kernel.",
|
|
53
|
+
"safe-read / safe-grep enforce a repo-relative path boundary, a sensitive-file denylist (.env* / *.key / *.pem / .git/** / auth.json / sessions/**) and secret scrubbing. Prefer them for sensitive probes; avoid dumping secrets via raw read/bash.",
|
|
54
|
+
"You do NOT have apply_patch / full-tools / shell / coding-chat as alternate coding runtimes — those non-Pi channels stay denied.",
|
|
55
|
+
"High-risk mutations still use prepare + browser Human Gate. Prepare contract via interview then apply via contractApply, prepare DAG runs via prepareDagConfirmation, prepare other mutations via prepareMutationGate, then confirm in the browser Human Gate. When a high-risk action (contractApply / runDag / dagRerun / etc.) fails on missing prepared state, diagnose with status/doctor/dagReport/inspect/contractShow/read/safe-read/safe-grep.",
|
|
57
56
|
"You cannot self-confirm a DAG run: confirmDagConfirmation is a human-only action (executed by the browser with a server-signed confirmation token). You may only prepare it via prepareDagConfirmation; the user must confirm in the UI.",
|
|
58
|
-
"Prefer read-only
|
|
57
|
+
"Prefer read-only diagnosis (status, doctor, dagReport, inspect, contractShow, read, safe-read, safe-grep, git-status) before mutating.",
|
|
59
58
|
].join("\n");
|
|
60
59
|
/**
|
|
61
|
-
* Built-in SDK
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* `echo >` / `tee` / `sed -i` / `node -e writeFile`, bypassing the DAG write
|
|
65
|
-
* boundary. The residual read-only repo probing is served by the custom
|
|
66
|
-
* safe-read / safe-grep / find / ls tools (see buildExploreCustomTools) which
|
|
67
|
-
* enforce a repo-relative path boundary, a sensitive-file denylist, and
|
|
68
|
-
* secret scrubbing on the returned content. edit/write/... stay excluded.
|
|
60
|
+
* Built-in SDK tool names registered for every Chat session (ADR 0011).
|
|
61
|
+
* Full Pi builtins: read/write/edit/bash/grep/find/ls. safe-* custom tools
|
|
62
|
+
* remain available in parallel for bounded sensitive probes.
|
|
69
63
|
*/
|
|
70
64
|
export const OPERATOR_CHAT_BUILTIN_EXPLORE_TOOLS = Object.freeze([
|
|
65
|
+
"read",
|
|
66
|
+
"write",
|
|
67
|
+
"edit",
|
|
68
|
+
"bash",
|
|
69
|
+
"grep",
|
|
71
70
|
"find",
|
|
72
71
|
"ls",
|
|
73
72
|
]);
|
|
74
73
|
/**
|
|
75
74
|
* Canonical active tool-set handed to setActiveToolsByName at every gate:
|
|
76
|
-
* every model-callable operator action (dynamic, from the registry) PLUS
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* excluded at the registry level and thus cannot be activated.
|
|
75
|
+
* every model-callable operator action (dynamic, from the registry) PLUS full
|
|
76
|
+
* Pi builtins (ADR 0011) PLUS safe explore custom tools (safe-read /
|
|
77
|
+
* safe-grep / git-status / git-diff). Non-Pi write channels (apply_patch /
|
|
78
|
+
* full-tools / shell / coding-chat) stay excluded.
|
|
81
79
|
*/
|
|
82
80
|
export const OPERATOR_CHAT_SAFE_EXPLORE_TOOL_IDS = Object.freeze([
|
|
83
81
|
"safe-read",
|
|
@@ -549,13 +547,10 @@ export class ConsolePiRuntime {
|
|
|
549
547
|
const { session } = await this.bindings.createSessionFromServices({
|
|
550
548
|
services,
|
|
551
549
|
sessionManager,
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
// grep/bash are replaced by the safe customTools (M0-A).
|
|
550
|
+
// Gate 1 (ADR 0011): full Pi builtins registered; only non-Pi write
|
|
551
|
+
// channels excluded (apply_patch / full-tools / shell / coding-chat).
|
|
555
552
|
tools: [...OPERATOR_CHAT_BUILTIN_EXPLORE_TOOLS],
|
|
556
553
|
excludeTools: [
|
|
557
|
-
"edit",
|
|
558
|
-
"write",
|
|
559
554
|
"apply_patch",
|
|
560
555
|
"apply-patch",
|
|
561
556
|
"full-tools",
|
|
@@ -563,14 +558,6 @@ export class ConsolePiRuntime {
|
|
|
563
558
|
"coding-chat",
|
|
564
559
|
"coding_chat",
|
|
565
560
|
"shell",
|
|
566
|
-
// M0-A: bash is removed entirely (write-via-redirect escape). The
|
|
567
|
-
// model gets safe-read / safe-grep custom tools instead.
|
|
568
|
-
"bash",
|
|
569
|
-
// M0-A: built-in read/grep are replaced by custom safe-read/safe-grep
|
|
570
|
-
// that enforce a repo-relative boundary, a sensitive-file denylist,
|
|
571
|
-
// and secret scrubbing (roadmap G16).
|
|
572
|
-
"read",
|
|
573
|
-
"grep",
|
|
574
561
|
],
|
|
575
562
|
...(customTools && customTools.length > 0
|
|
576
563
|
? { customTools }
|
|
@@ -836,8 +823,6 @@ export class ConsolePiRuntime {
|
|
|
836
823
|
sessionManager: init.sessionManager,
|
|
837
824
|
tools: [...OPERATOR_CHAT_BUILTIN_EXPLORE_TOOLS],
|
|
838
825
|
excludeTools: [
|
|
839
|
-
"edit",
|
|
840
|
-
"write",
|
|
841
826
|
"apply_patch",
|
|
842
827
|
"apply-patch",
|
|
843
828
|
"full-tools",
|
|
@@ -845,9 +830,6 @@ export class ConsolePiRuntime {
|
|
|
845
830
|
"coding-chat",
|
|
846
831
|
"coding_chat",
|
|
847
832
|
"shell",
|
|
848
|
-
"bash",
|
|
849
|
-
"read",
|
|
850
|
-
"grep",
|
|
851
833
|
],
|
|
852
834
|
...(customTools && customTools.length > 0 ? { customTools } : {}),
|
|
853
835
|
...(resolvedModel ? { model: resolvedModel } : {}),
|
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Operator Chat —
|
|
2
|
+
* Operator Chat — ResourceLoader contract (ADR 0011 full Pi tools).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* are file-WRITING coding tools (edit/write/apply_patch/full-tools/shell/
|
|
10
|
-
* coding-chat) AND bash/read/grep (replaced by the safe versions). hasBash is
|
|
11
|
-
* therefore false: there is no shell escape.
|
|
4
|
+
* Declares the operator-chat surface for capabilities reporting and red-team
|
|
5
|
+
* checks: full Pi builtins (read/write/edit/bash/grep/find/ls), model-callable
|
|
6
|
+
* operator actions, and optional safe-* explore tools. hasBash is true.
|
|
7
|
+
* Non-Pi write channels (apply_patch / full-tools / shell / coding-chat) remain
|
|
8
|
+
* denied. Interview/Official loaders are unchanged.
|
|
12
9
|
*
|
|
13
10
|
* Note: this adapter is NOT the SDK's own ResourceLoader (that is
|
|
14
11
|
* DefaultResourceLoader, created inside createAgentSessionServices with
|
|
@@ -17,13 +14,12 @@
|
|
|
17
14
|
* The three-gate authorization (authorizeOperatorChatTool) is the actual
|
|
18
15
|
* enforcement; this adapter describes what that gate admits.
|
|
19
16
|
*/
|
|
20
|
-
import { OPERATOR_CHAT_ALLOWED_TOOLS, OPERATOR_CHAT_DENIED_TOOLS, OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS, authorizeOperatorChatTool, filterOperatorChatTools, assertNoWriteToolInList, } from "./tools.js";
|
|
21
|
-
/** Built-in
|
|
17
|
+
import { OPERATOR_CHAT_ALLOWED_TOOLS, OPERATOR_CHAT_DENIED_TOOLS, OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS, OPERATOR_CHAT_PI_BUILTIN_TOOLS, authorizeOperatorChatTool, filterOperatorChatTools, assertNoWriteToolInList, } from "./tools.js";
|
|
18
|
+
/** Built-in Pi tool names allowed in every Chat session (ADR 0011). */
|
|
22
19
|
export const OPERATOR_CHAT_BUILTIN_EXPLORE_TOOL_IDS = Object.freeze([
|
|
23
|
-
|
|
24
|
-
"ls",
|
|
20
|
+
...OPERATOR_CHAT_PI_BUILTIN_TOOLS,
|
|
25
21
|
]);
|
|
26
|
-
/** Safe explore custom tool names (
|
|
22
|
+
/** Safe explore custom tool names (optional parallel surface). */
|
|
27
23
|
export const OPERATOR_CHAT_SAFE_EXPLORE_TOOL_IDS = Object.freeze([
|
|
28
24
|
"safe-read",
|
|
29
25
|
"safe-grep",
|
|
@@ -41,11 +37,6 @@ export function createOperatorChatResourceLoader() {
|
|
|
41
37
|
safeExploreToolIds: OPERATOR_CHAT_SAFE_EXPLORE_TOOL_IDS,
|
|
42
38
|
listTools: () => OPERATOR_CHAT_ALLOWED_TOOLS,
|
|
43
39
|
tryActivateTool: (toolId) => {
|
|
44
|
-
// Explore tools (bash/read/grep/find/ls) are run by the SDK's built-in
|
|
45
|
-
// tool registry, not by the operator dispatcher. tryActivateTool only
|
|
46
|
-
// classifies operator actions here, but it must NOT deny an explore
|
|
47
|
-
// tool — that would contradict the real session. Deny only write tools
|
|
48
|
-
// and report unknowns.
|
|
49
40
|
const explore = OPERATOR_CHAT_BUILTIN_EXPLORE_TOOL_IDS.includes(toolId);
|
|
50
41
|
if (explore) {
|
|
51
42
|
return { ok: true, toolId };
|
|
@@ -62,6 +53,6 @@ export function createOperatorChatResourceLoader() {
|
|
|
62
53
|
return { active: [...active], denied };
|
|
63
54
|
},
|
|
64
55
|
allowsEnvToolRestore: false,
|
|
65
|
-
hasBash:
|
|
56
|
+
hasBash: true,
|
|
66
57
|
};
|
|
67
58
|
}
|
|
@@ -176,9 +176,8 @@ export async function handleChatCapabilities(_req, res, deps) {
|
|
|
176
176
|
skipped: skills.skipped,
|
|
177
177
|
promptFragmentCharCount: composeInstructionSkillsPrompt(skills.loaded).length,
|
|
178
178
|
},
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
hasBash: false,
|
|
179
|
+
// ADR 0011: full Pi tools including bash are default-active.
|
|
180
|
+
hasBash: true,
|
|
182
181
|
});
|
|
183
182
|
}
|
|
184
183
|
export async function handleCreateChatSession(req, res, deps) {
|
|
@@ -236,7 +235,7 @@ export async function handleCreateChatSession(req, res, deps) {
|
|
|
236
235
|
sessionFile: handle.sessionFile,
|
|
237
236
|
model: handle.model,
|
|
238
237
|
activeTools: handle.activeTools,
|
|
239
|
-
hasBash:
|
|
238
|
+
hasBash: true,
|
|
240
239
|
});
|
|
241
240
|
}
|
|
242
241
|
catch (error) {
|
|
@@ -342,7 +341,7 @@ export async function handleGetChatSession(_req, res, deps, sessionId) {
|
|
|
342
341
|
composerDraft,
|
|
343
342
|
activeTurnId: deps.events.getActiveTurn(sessionId)?.turnId,
|
|
344
343
|
},
|
|
345
|
-
hasBash:
|
|
344
|
+
hasBash: true,
|
|
346
345
|
lastEventId: lastEvent?.eventId ?? null,
|
|
347
346
|
lastEventSeq: lastEvent?.seq ?? 0,
|
|
348
347
|
});
|
|
@@ -356,7 +355,7 @@ export async function handleListChatSessions(_req, res, deps) {
|
|
|
356
355
|
...session,
|
|
357
356
|
activeTurnId: deps.events.getActiveTurn(session.sessionId)?.turnId,
|
|
358
357
|
})),
|
|
359
|
-
hasBash:
|
|
358
|
+
hasBash: true,
|
|
360
359
|
});
|
|
361
360
|
}
|
|
362
361
|
export async function handlePatchChatSession(req, res, deps, sessionId) {
|
|
@@ -395,7 +394,7 @@ export async function handleReopenChatSession(req, res, deps, sessionId) {
|
|
|
395
394
|
return;
|
|
396
395
|
}
|
|
397
396
|
if (deps.runtime.hasSession(sessionId)) {
|
|
398
|
-
sendJson(res, 200, { ok: true, sessionId, reopened: false, alreadyActive: true, hasBash:
|
|
397
|
+
sendJson(res, 200, { ok: true, sessionId, reopened: false, alreadyActive: true, hasBash: true });
|
|
399
398
|
return;
|
|
400
399
|
}
|
|
401
400
|
const result = await deps.runtime.reopenSession({
|
|
@@ -416,7 +415,7 @@ export async function handleReopenChatSession(req, res, deps, sessionId) {
|
|
|
416
415
|
sessionFile: result.handle.sessionFile,
|
|
417
416
|
model: result.handle.model,
|
|
418
417
|
activeTools: result.handle.activeTools,
|
|
419
|
-
hasBash:
|
|
418
|
+
hasBash: true,
|
|
420
419
|
});
|
|
421
420
|
}
|
|
422
421
|
export async function handleChatPrompt(req, res, deps, sessionId) {
|
|
@@ -16,7 +16,7 @@ export function projectRuntimeContext(input) {
|
|
|
16
16
|
summary: summary(input.systemPrompt),
|
|
17
17
|
},
|
|
18
18
|
skills: input.skills.map((skill) => ({ ...skill, description: summary(skill.description, 200) ?? "" })),
|
|
19
|
-
resources: { mode: "closed", noContextFiles: true, noSkills: true, noExtensions: true, hasBash:
|
|
19
|
+
resources: { mode: "closed", noContextFiles: true, noSkills: true, noExtensions: true, hasBash: true, activeToolCount: input.activeTools.length },
|
|
20
20
|
model: input.model,
|
|
21
21
|
thinkingLevel: input.thinkingLevel,
|
|
22
22
|
suffix: input.systemPromptSuffix ? { present: true, summary: summary(input.systemPromptSuffix) } : { present: false },
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Operator Chat —
|
|
2
|
+
* Operator Chat — tool policy (ADR 0011 UX-first full Pi tools).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Operator Chat registers and activates full Pi builtins
|
|
5
|
+
* (read/write/edit/bash/grep/find/ls) plus model-callable operator actions and
|
|
6
|
+
* optional safe-* explore custom tools. Discipline is primarily soft
|
|
7
|
+
* (system prompt: prefer DAG / Human Gate). Hard denies remain for non-Pi
|
|
8
|
+
* write channels (apply_patch / full-tools / shell / coding-chat) and for
|
|
9
|
+
* modelCallable="never" actions (e.g. confirmDagConfirmation).
|
|
8
10
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* a future resource-loader or SDK drift cannot reactivate removed coding or
|
|
12
|
-
* explore tools merely by changing the active-tool list.
|
|
11
|
+
* Interview / Official surfaces keep their own OFFICIAL_DENIED path; this
|
|
12
|
+
* module only filters Chat-side denial so Interview is not widened.
|
|
13
13
|
*/
|
|
14
14
|
import { buildOperatorCapabilitiesDocument } from "../../../shared/operator/capabilities.js";
|
|
15
15
|
import { OFFICIAL_DENIED_TOOL_IDS } from "../resource-loader.js";
|
|
@@ -41,31 +41,37 @@ export const OPERATOR_CHAT_MODEL_FORBIDDEN_ACTIONS = Object.freeze(buildOperator
|
|
|
41
41
|
* stay defensive against future additions.
|
|
42
42
|
*/
|
|
43
43
|
export const OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS = Object.freeze([]);
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
44
|
+
/** Full Pi builtin explore / coding tools admitted by ADR 0011. */
|
|
45
|
+
export const OPERATOR_CHAT_PI_BUILTIN_TOOLS = Object.freeze([
|
|
46
|
+
"read",
|
|
47
|
+
"write",
|
|
48
|
+
"edit",
|
|
49
|
+
"bash",
|
|
50
|
+
"grep",
|
|
51
|
+
"find",
|
|
52
|
+
"ls",
|
|
53
|
+
]);
|
|
54
|
+
const _PI_BUILTIN_ALLOW = new Set(OPERATOR_CHAT_PI_BUILTIN_TOOLS.map((t) => t.toLowerCase()));
|
|
50
55
|
// Operator actions that OFFICIAL_DENIED_TOOL_IDS carries for Interview safety
|
|
51
|
-
// but that the Chat now deliberately allows.
|
|
52
|
-
// (strip - and _) because OFFICIAL lists both camelCase
|
|
53
|
-
// snake_case (`contract_apply`) variants, and authorize() also normalizes —
|
|
54
|
-
// leaving any variant in would re-deny the action via normalization.
|
|
56
|
+
// but that the Chat now deliberately allows. Compare on a COMPACTED form
|
|
57
|
+
// (strip - and _) because OFFICIAL lists both camelCase and snake_case.
|
|
55
58
|
const _ALLOWED_OPERATOR_ACTIONS_COMPACT = new Set(OPERATOR_CHAT_ALLOWED_TOOLS.map((t) => t.toLowerCase().replace(/[-_]/g, "")));
|
|
59
|
+
/**
|
|
60
|
+
* Tools denied for Operator Chat. Built from OFFICIAL_DENIED minus:
|
|
61
|
+
* - Pi builtins allowed by ADR 0011
|
|
62
|
+
* - model-callable operator actions
|
|
63
|
+
* Does NOT re-add bash/read/grep/edit/write.
|
|
64
|
+
*/
|
|
56
65
|
export const OPERATOR_CHAT_DENIED_TOOLS = Object.freeze([
|
|
57
|
-
...new Set(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}),
|
|
67
|
-
..._REMOVED_SDK_EXPLORE_TOOLS,
|
|
68
|
-
]),
|
|
66
|
+
...new Set(OFFICIAL_DENIED_TOOL_IDS.filter((id) => {
|
|
67
|
+
const lower = id.toLowerCase();
|
|
68
|
+
if (_PI_BUILTIN_ALLOW.has(lower))
|
|
69
|
+
return false;
|
|
70
|
+
if (_ALLOWED_OPERATOR_ACTIONS_COMPACT.has(lower.replace(/[-_]/g, ""))) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
})),
|
|
69
75
|
]);
|
|
70
76
|
/**
|
|
71
77
|
* Operator actions denied by model policy (modelCallable="never"). These are
|
|
@@ -78,11 +84,19 @@ const DENIED_MODEL_POLICY = new Set(OPERATOR_CHAT_MODEL_FORBIDDEN_ACTIONS.map((a
|
|
|
78
84
|
const ALLOWED = new Set(OPERATOR_CHAT_ALLOWED_TOOLS.map((t) => t.toLowerCase()));
|
|
79
85
|
const DENIED_ACTIONS = new Set(OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS.map((a) => a.toLowerCase()));
|
|
80
86
|
const DENIED_TOOLS = new Set(OPERATOR_CHAT_DENIED_TOOLS.map((t) => t.toLowerCase()));
|
|
87
|
+
/** Pi builtins are authorized without being operator registry actions. */
|
|
88
|
+
const PI_BUILTIN_AUTHORIZED = new Set(OPERATOR_CHAT_PI_BUILTIN_TOOLS.map((t) => t.toLowerCase()));
|
|
81
89
|
export function isOperatorChatToolAllowed(toolId) {
|
|
82
|
-
|
|
90
|
+
const id = toolId.trim().toLowerCase();
|
|
91
|
+
if (PI_BUILTIN_AUTHORIZED.has(id))
|
|
92
|
+
return true;
|
|
93
|
+
return ALLOWED.has(id);
|
|
83
94
|
}
|
|
84
95
|
export function isOperatorChatToolDenied(toolId) {
|
|
85
96
|
const id = toolId.trim().toLowerCase();
|
|
97
|
+
// Pi builtins are never denied under ADR 0011.
|
|
98
|
+
if (PI_BUILTIN_AUTHORIZED.has(id))
|
|
99
|
+
return false;
|
|
86
100
|
if (DENIED_TOOLS.has(id))
|
|
87
101
|
return true;
|
|
88
102
|
if (DENIED_ACTIONS.has(id))
|
|
@@ -125,7 +139,7 @@ export function authorizeOperatorChatTool(toolId) {
|
|
|
125
139
|
ok: false,
|
|
126
140
|
code: "tool-denied",
|
|
127
141
|
toolId: id,
|
|
128
|
-
message: `tool "${id}" is denied in General Operator Chat (no
|
|
142
|
+
message: `tool "${id}" is denied in General Operator Chat (no apply_patch/full-tools/shell/coding-chat; modelCallable=never still blocked)`,
|
|
129
143
|
};
|
|
130
144
|
}
|
|
131
145
|
if (!isOperatorChatToolAllowed(id)) {
|
|
@@ -155,35 +169,34 @@ export function filterOperatorChatTools(requested) {
|
|
|
155
169
|
return { allowed, denied };
|
|
156
170
|
}
|
|
157
171
|
/**
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
172
|
+
* Non-Pi write / coding channels that must never appear in Operator Chat.
|
|
173
|
+
* ADR 0011 allows Pi builtins read/write/edit/bash/grep/find/ls; this guard
|
|
174
|
+
* only blocks alternate coding surfaces (apply_patch, full-tools, shell,
|
|
175
|
+
* coding-chat).
|
|
176
|
+
*/
|
|
177
|
+
const NON_PI_WRITE_CHANNELS = [
|
|
178
|
+
"apply_patch",
|
|
179
|
+
"apply-patch",
|
|
180
|
+
"full-tools",
|
|
181
|
+
"full_tools",
|
|
182
|
+
"coding-chat",
|
|
183
|
+
"coding_chat",
|
|
184
|
+
"shell",
|
|
185
|
+
];
|
|
186
|
+
/**
|
|
187
|
+
* Assert that a requested tool set contains no prohibited non-Pi write
|
|
188
|
+
* channel. Pi builtins (including write/edit/bash) are allowed (ADR 0011).
|
|
161
189
|
*/
|
|
162
190
|
export function assertNoWriteToolInList(requested) {
|
|
163
|
-
const writeVariants = [
|
|
164
|
-
"edit",
|
|
165
|
-
"write",
|
|
166
|
-
"apply_patch",
|
|
167
|
-
"apply-patch",
|
|
168
|
-
"full-tools",
|
|
169
|
-
"full_tools",
|
|
170
|
-
"coding-chat",
|
|
171
|
-
"coding_chat",
|
|
172
|
-
"shell",
|
|
173
|
-
"bash",
|
|
174
|
-
"read",
|
|
175
|
-
"grep",
|
|
176
|
-
];
|
|
177
191
|
const lower = new Set(requested.map((t) => t.trim().toLowerCase()));
|
|
178
|
-
for (const v of
|
|
192
|
+
for (const v of NON_PI_WRITE_CHANNELS) {
|
|
179
193
|
if (lower.has(v)) {
|
|
180
|
-
throw new Error(`
|
|
194
|
+
throw new Error(`non-Pi write channel "${v}" must not appear in Operator Chat tool list (ADR 0011)`);
|
|
181
195
|
}
|
|
182
196
|
}
|
|
183
197
|
}
|
|
184
198
|
/**
|
|
185
|
-
* Back-compat alias kept for callers that still reference the old name.
|
|
186
|
-
* also rejects removed SDK explore tools, including bash.
|
|
199
|
+
* Back-compat alias kept for callers that still reference the old name.
|
|
187
200
|
* @deprecated use {@link assertNoWriteToolInList}.
|
|
188
201
|
*/
|
|
189
202
|
export function assertNoBashInToolList(requested) {
|
|
@@ -34,6 +34,7 @@ export async function runOperation(operationId, deps) {
|
|
|
34
34
|
? deps.client.runExternal(op.externalCommand, cliArgs, options)
|
|
35
35
|
: deps.client.run(cliArgs, options));
|
|
36
36
|
let finished;
|
|
37
|
+
let spawnUpdate = Promise.resolve();
|
|
37
38
|
try {
|
|
38
39
|
const result = await run(args, {
|
|
39
40
|
cwd: deps.repoRoot,
|
|
@@ -41,10 +42,21 @@ export async function runOperation(operationId, deps) {
|
|
|
41
42
|
expectJson: true,
|
|
42
43
|
timeoutMs: deps.defaultTimeoutMs,
|
|
43
44
|
onSpawn: (info) => {
|
|
44
|
-
|
|
45
|
+
// Chain onto the per-operation write lock; never fire-and-forget a
|
|
46
|
+
// rejected update (unhandled rejection can crash Console on Windows).
|
|
47
|
+
spawnUpdate = deps.store
|
|
48
|
+
.update(operationId, {
|
|
45
49
|
state: "running",
|
|
46
50
|
pid: info.pid,
|
|
47
51
|
processStartIdentity: info.startedAt,
|
|
52
|
+
})
|
|
53
|
+
.catch((error) => {
|
|
54
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
55
|
+
deps.events.append(operationId, {
|
|
56
|
+
at: new Date().toISOString(),
|
|
57
|
+
kind: "error",
|
|
58
|
+
message: `failed to persist running state: ${message}`,
|
|
59
|
+
});
|
|
48
60
|
});
|
|
49
61
|
stateEvent(deps.events, op, "running", `pid=${info.pid ?? "?"}`);
|
|
50
62
|
},
|
|
@@ -63,9 +75,11 @@ export async function runOperation(operationId, deps) {
|
|
|
63
75
|
});
|
|
64
76
|
},
|
|
65
77
|
});
|
|
78
|
+
await spawnUpdate;
|
|
66
79
|
finished = await finalizeFromWorkerResult(operationId, result, deps);
|
|
67
80
|
}
|
|
68
81
|
catch (error) {
|
|
82
|
+
await spawnUpdate.catch(() => undefined);
|
|
69
83
|
// Cannot prove CLI exit classification → needs-reconcile (not Cancel)
|
|
70
84
|
finished = await deps.store.update(operationId, {
|
|
71
85
|
state: "needs-reconcile",
|