@sema-agent/core 2.13.0 → 3.0.0
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/agents/cascade.d.ts +4 -0
- package/dist/agents/cascade.js +54 -9
- package/dist/agents/repair-loop.d.ts +2 -0
- package/dist/agents/repair-loop.js +101 -2
- package/dist/agents/retain-ledger.d.ts +18 -2
- package/dist/agents/retain-ledger.js +37 -8
- package/dist/agents/roster-store.d.ts +4 -0
- package/dist/agents/roster-store.js +22 -1
- package/dist/agents/teacher.js +4 -2
- package/dist/agents/team.js +7 -4
- package/dist/agents/verify.d.ts +15 -2
- package/dist/agents/verify.js +36 -1
- package/dist/bin/sema-tb.js +2 -2
- package/dist/brain/circuit-breaker.d.ts +1 -0
- package/dist/brain/circuit-breaker.js +26 -9
- package/dist/brain/degrading.js +13 -1
- package/dist/brain/failover.js +2 -0
- package/dist/brain/repetition.js +10 -1
- package/dist/brain/routing.js +5 -1
- package/dist/brain/status-sink.js +5 -1
- package/dist/brain/tool-call-repair.js +32 -14
- package/dist/core/auto-compaction.js +17 -7
- package/dist/core/checkpoint-store.d.ts +1 -0
- package/dist/core/checkpoint-store.js +28 -10
- package/dist/core/consolidate-scope.js +4 -3
- package/dist/core/context-edit.js +3 -0
- package/dist/core/context-guard.js +3 -0
- package/dist/core/git-worktree-env.d.ts +6 -1
- package/dist/core/git-worktree-env.js +12 -1
- package/dist/core/hooks.d.ts +1 -0
- package/dist/core/hooks.js +40 -2
- package/dist/core/lsp-diagnostics.js +13 -7
- package/dist/core/lsp-protocol.d.ts +1 -1
- package/dist/core/lsp-protocol.js +20 -8
- package/dist/core/lsp-session.d.ts +12 -2
- package/dist/core/lsp-session.js +114 -49
- package/dist/core/lsp.d.ts +13 -1
- package/dist/core/lsp.js +64 -9
- package/dist/core/memory-engine/data-plane.js +4 -0
- package/dist/core/memory-engine/engine.d.ts +2 -1
- package/dist/core/memory-engine/engine.js +82 -7
- package/dist/core/memory-engine/file-backend.d.ts +1 -0
- package/dist/core/memory-engine/file-backend.js +13 -3
- package/dist/core/memory-engine/layout.js +12 -1
- package/dist/core/memory.js +3 -0
- package/dist/core/permission-rules.d.ts +2 -1
- package/dist/core/permission-rules.js +24 -3
- package/dist/core/runner/assemble-result.d.ts +1 -1
- package/dist/core/runner/assemble-result.js +0 -1
- package/dist/core/runner/prepare-task.js +11 -0
- package/dist/core/runner/runtask.js +21 -22
- package/dist/core/runner/usage-accounting.d.ts +6 -0
- package/dist/core/runner/usage-accounting.js +9 -8
- package/dist/core/secret-env.js +5 -1
- package/dist/core/sensitive-path-policy.js +9 -3
- package/dist/core/store-contracts/session-repo-contract.js +40 -0
- package/dist/core/task-registry-monitor.js +7 -1
- package/dist/core/tool-policy.js +65 -17
- package/dist/core/trace.d.ts +1 -0
- package/dist/core/types.d.ts +1 -0
- package/dist/engine/compaction/compaction.d.ts +1 -0
- package/dist/engine/compaction/compaction.js +14 -5
- package/dist/engine/lsp/frame-decoder.js +6 -3
- package/dist/engine/lsp/node-lsp-manager.d.ts +2 -0
- package/dist/engine/lsp/node-lsp-manager.js +103 -26
- package/dist/engine/lsp/stdio-lsp-transport.js +16 -7
- package/dist/internal/harness.d.ts +1 -1
- package/dist/internal/harness.js +1 -1
- package/dist/orchestration/workflow-governance.js +43 -9
- package/dist/orchestration/workflow-sandbox-conformance.js +85 -51
- package/dist/orchestration/workflow-script-store.js +34 -4
- package/dist/orchestration/workflow.js +2 -2
- package/dist/prompt-assembly/artifact-store.d.ts +2 -0
- package/dist/prompt-assembly/artifact-store.js +39 -24
- package/dist/prompt-assembly/assemble.js +38 -7
- package/dist/prompt-assembly/epoch.js +19 -2
- package/dist/prompt-assembly/event-registry.js +3 -3
- package/dist/prompt-assembly/explain.js +1 -1
- package/dist/prompt-assembly/tool-catalog.js +16 -1
- package/dist/prompt-assembly/turn-snapshot.js +4 -1
- package/dist/stores/file/fs-atomic.d.ts +3 -0
- package/dist/stores/file/fs-atomic.js +53 -8
- package/dist/stores/file/index.d.ts +1 -0
- package/dist/stores/file/index.js +7 -0
- package/dist/stores/file/session-store.d.ts +2 -0
- package/dist/stores/file/session-store.js +28 -1
- package/dist/tools/fs/bash-readonly-classifier.d.ts +1 -0
- package/dist/tools/fs/bash-readonly-classifier.js +19 -2
- package/dist/tools/fs/fs-bash.d.ts +1 -1
- package/dist/tools/fs/fs-bash.js +93 -5
- package/dist/tools/loop-tick.js +1 -1
- package/dist/tools/monitor.js +55 -9
- package/dist/tools/worktree.d.ts +2 -0
- package/dist/tools/worktree.js +233 -143
- package/package.json +5 -2
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
const PRESENTATION_KEYWORDS = new Set(["description", "title", "examples", "$id"]);
|
|
3
|
+
const SCHEMA_NAME_MAPS = new Set([
|
|
4
|
+
"properties",
|
|
5
|
+
"patternProperties",
|
|
6
|
+
"definitions",
|
|
7
|
+
"$defs",
|
|
8
|
+
"dependentSchemas",
|
|
9
|
+
]);
|
|
2
10
|
function stripPresentation(node) {
|
|
3
11
|
if (Array.isArray(node))
|
|
4
12
|
return node.map(stripPresentation);
|
|
@@ -6,8 +14,15 @@ function stripPresentation(node) {
|
|
|
6
14
|
return node;
|
|
7
15
|
const out = {};
|
|
8
16
|
for (const [k, v] of Object.entries(node)) {
|
|
9
|
-
if (k
|
|
17
|
+
if (PRESENTATION_KEYWORDS.has(k))
|
|
10
18
|
continue;
|
|
19
|
+
if (SCHEMA_NAME_MAPS.has(k) && v !== null && typeof v === "object" && !Array.isArray(v)) {
|
|
20
|
+
const named = {};
|
|
21
|
+
for (const [name, sub] of Object.entries(v))
|
|
22
|
+
named[name] = stripPresentation(sub);
|
|
23
|
+
out[k] = named;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
11
26
|
out[k] = k === "required" && Array.isArray(v) ? [...v].sort() : stripPresentation(v);
|
|
12
27
|
}
|
|
13
28
|
return out;
|
|
@@ -61,7 +61,10 @@ export function buildTurnPromptSnapshot(raw, api, promptHasher) {
|
|
|
61
61
|
loweringVersion: `${lowering.version}/${lowering.form}/${api}`,
|
|
62
62
|
};
|
|
63
63
|
const bump = () => {
|
|
64
|
-
|
|
64
|
+
const next = identityOf(elements);
|
|
65
|
+
if (next === snapshot.cacheIdentity)
|
|
66
|
+
return;
|
|
67
|
+
snapshot.cacheIdentity = next;
|
|
65
68
|
snapshot.onChange?.(snapshot);
|
|
66
69
|
};
|
|
67
70
|
const snapshot = {
|
|
@@ -10,7 +10,10 @@ export declare class AppendLog {
|
|
|
10
10
|
private fd;
|
|
11
11
|
private disposed;
|
|
12
12
|
private readonly path;
|
|
13
|
+
private boundary;
|
|
13
14
|
constructor(path: string);
|
|
15
|
+
private sizeOrUnknown;
|
|
16
|
+
private repairTornTail;
|
|
14
17
|
append(record: unknown, fsync: boolean): void;
|
|
15
18
|
close(): void;
|
|
16
19
|
closeForSwap(): void;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { closeSync, constants as FS, existsSync, linkSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, truncateSync, unlinkSync, writeFileSync, writeSync, fsyncSync, } from "node:fs";
|
|
1
|
+
import { closeSync, constants as FS, existsSync, fstatSync, linkSync, mkdirSync, openSync, readFileSync, readSync, realpathSync, renameSync, truncateSync, unlinkSync, writeFileSync, writeSync, fsyncSync, } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { basename, dirname, join, resolve as resolvePath } from "node:path";
|
|
4
4
|
import { createHash, randomBytes } from "node:crypto";
|
|
@@ -148,30 +148,75 @@ export class AppendLog {
|
|
|
148
148
|
fd;
|
|
149
149
|
disposed = false;
|
|
150
150
|
path;
|
|
151
|
+
boundary = -1;
|
|
151
152
|
constructor(path) {
|
|
152
153
|
this.path = path;
|
|
153
154
|
ensureDir(dirnameOf(path));
|
|
154
155
|
truncateTornTail(path);
|
|
155
|
-
this.fd = openSync(path, "a", 0o600);
|
|
156
|
+
this.fd = openSync(path, "a+", 0o600);
|
|
157
|
+
this.boundary = this.sizeOrUnknown();
|
|
158
|
+
}
|
|
159
|
+
sizeOrUnknown() {
|
|
160
|
+
try {
|
|
161
|
+
return fstatSync(this.fd).size;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return -1;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
repairTornTail() {
|
|
168
|
+
const size = this.sizeOrUnknown();
|
|
169
|
+
if (size < 0) {
|
|
170
|
+
this.boundary = -1;
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (size === this.boundary)
|
|
174
|
+
return;
|
|
175
|
+
if (size === 0) {
|
|
176
|
+
this.boundary = 0;
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const tail = Buffer.allocUnsafe(1);
|
|
180
|
+
try {
|
|
181
|
+
if (readSync(this.fd, tail, 0, 1, size - 1) === 1 && tail[0] === 0x0a) {
|
|
182
|
+
this.boundary = size;
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
truncateTornTail(this.path);
|
|
190
|
+
this.boundary = this.sizeOrUnknown();
|
|
156
191
|
}
|
|
157
192
|
append(record, fsync) {
|
|
158
193
|
if (this.fd < 0 && !this.disposed) {
|
|
159
194
|
truncateTornTail(this.path);
|
|
160
|
-
this.fd = openSync(this.path, "a", 0o600);
|
|
195
|
+
this.fd = openSync(this.path, "a+", 0o600);
|
|
196
|
+
this.boundary = this.sizeOrUnknown();
|
|
161
197
|
}
|
|
162
198
|
if (this.fd < 0) {
|
|
163
199
|
const e = new Error("append: this log was closed (its session/box was deleted or replaced) — refusing to write through a released descriptor");
|
|
164
200
|
e.code = "log_closed";
|
|
165
201
|
throw e;
|
|
166
202
|
}
|
|
203
|
+
this.repairTornTail();
|
|
167
204
|
const buf = Buffer.from(`${JSON.stringify(record)}\n`, "utf8");
|
|
168
205
|
let written = 0;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
206
|
+
try {
|
|
207
|
+
while (written < buf.length) {
|
|
208
|
+
const n = writeSync(this.fd, buf, written, buf.length - written);
|
|
209
|
+
if (n <= 0)
|
|
210
|
+
throw new Error(`append: write made no progress (${written}/${buf.length} bytes) — refusing to leave a torn record`);
|
|
211
|
+
written += n;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
this.boundary = -1;
|
|
216
|
+
throw err;
|
|
174
217
|
}
|
|
218
|
+
if (this.boundary >= 0)
|
|
219
|
+
this.boundary += buf.length;
|
|
175
220
|
if (fsync)
|
|
176
221
|
fsyncSync(this.fd);
|
|
177
222
|
}
|
|
@@ -38,6 +38,7 @@ export declare class FileStorageBackend {
|
|
|
38
38
|
private readonly fileCheckpoints;
|
|
39
39
|
private readonly fileMemory;
|
|
40
40
|
private readonly ttl;
|
|
41
|
+
private readonly fileSessions;
|
|
41
42
|
private readonly fileWorkflowJournal;
|
|
42
43
|
constructor(opts?: FileStorageBackendOptions);
|
|
43
44
|
dispose(): Promise<void>;
|
|
@@ -32,6 +32,7 @@ export class FileStorageBackend {
|
|
|
32
32
|
fileCheckpoints;
|
|
33
33
|
fileMemory;
|
|
34
34
|
ttl;
|
|
35
|
+
fileSessions;
|
|
35
36
|
fileWorkflowJournal;
|
|
36
37
|
constructor(opts = {}) {
|
|
37
38
|
this.root = resolveDataRoot(opts.root);
|
|
@@ -39,6 +40,7 @@ export class FileStorageBackend {
|
|
|
39
40
|
this.lock.acquire();
|
|
40
41
|
try {
|
|
41
42
|
const repo = new FileSessionRepo(this.root);
|
|
43
|
+
this.fileSessions = repo;
|
|
42
44
|
this.ttl = new TtlSessionStore({ repo, evict: opts.evict ?? "forget" });
|
|
43
45
|
this.sessionStore = this.ttl;
|
|
44
46
|
this.fileCheckpoints = new FileCheckpointStore(this.root, opts.checkpoint);
|
|
@@ -63,6 +65,11 @@ export class FileStorageBackend {
|
|
|
63
65
|
}
|
|
64
66
|
catch {
|
|
65
67
|
}
|
|
68
|
+
try {
|
|
69
|
+
await this.fileSessions.dispose();
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
}
|
|
66
73
|
this.fileCheckpoints.close();
|
|
67
74
|
try {
|
|
68
75
|
await this.fileMemory.flushEmbeds();
|
|
@@ -2,10 +2,12 @@ import type { Session, SessionMetadata, SessionRepo, SessionTreeEntry } from "..
|
|
|
2
2
|
export declare class FileSessionRepo implements SessionRepo {
|
|
3
3
|
private readonly dir;
|
|
4
4
|
private readonly tmpDir;
|
|
5
|
+
private readonly joined;
|
|
5
6
|
constructor(root: string);
|
|
6
7
|
private pathFor;
|
|
7
8
|
private read;
|
|
8
9
|
private storage;
|
|
10
|
+
dispose(): Promise<void>;
|
|
9
11
|
create(options?: {
|
|
10
12
|
id?: string;
|
|
11
13
|
}): Promise<Session>;
|
|
@@ -20,11 +20,19 @@ function evictSharedSessionStorage(canonical) {
|
|
|
20
20
|
class FileSessionStorage extends BaseSessionStorage {
|
|
21
21
|
log;
|
|
22
22
|
canonicalPath;
|
|
23
|
+
holders = new Set();
|
|
23
24
|
constructor(log, meta, entries, canonicalPath) {
|
|
24
25
|
super(meta, entries);
|
|
25
26
|
this.log = log;
|
|
26
27
|
this.canonicalPath = canonicalPath;
|
|
27
28
|
}
|
|
29
|
+
addHolder(holder) {
|
|
30
|
+
this.holders.add(holder);
|
|
31
|
+
}
|
|
32
|
+
removeHolder(holder) {
|
|
33
|
+
this.holders.delete(holder);
|
|
34
|
+
return this.holders.size === 0;
|
|
35
|
+
}
|
|
28
36
|
closeLog() {
|
|
29
37
|
this.log.close();
|
|
30
38
|
}
|
|
@@ -46,6 +54,7 @@ class FileSessionStorage extends BaseSessionStorage {
|
|
|
46
54
|
export class FileSessionRepo {
|
|
47
55
|
dir;
|
|
48
56
|
tmpDir;
|
|
57
|
+
joined = new Map();
|
|
49
58
|
constructor(root) {
|
|
50
59
|
this.dir = join(root, "sessions");
|
|
51
60
|
this.tmpDir = join(root, "tmp");
|
|
@@ -77,19 +86,37 @@ export class FileSessionRepo {
|
|
|
77
86
|
storage(id, createdAt, entries, forkedFrom) {
|
|
78
87
|
const canonical = canonicalStoreKey(this.pathFor(id));
|
|
79
88
|
const live = sharedSessionStorages.get(canonical)?.deref();
|
|
80
|
-
if (live !== undefined)
|
|
89
|
+
if (live !== undefined) {
|
|
90
|
+
live.addHolder(this);
|
|
91
|
+
this.joined.set(canonical, new WeakRef(live));
|
|
81
92
|
return live;
|
|
93
|
+
}
|
|
82
94
|
const log = new AppendLog(this.pathFor(id));
|
|
83
95
|
const created = new FileSessionStorage(log, { id, createdAt, ...(forkedFrom !== undefined ? { forkedFrom } : {}) }, entries, canonical);
|
|
84
96
|
sharedSessionStorages.set(canonical, new WeakRef(created));
|
|
97
|
+
created.addHolder(this);
|
|
98
|
+
this.joined.set(canonical, new WeakRef(created));
|
|
85
99
|
sessionStorageFinalizer.register(created, { canonical, log }, created);
|
|
86
100
|
return created;
|
|
87
101
|
}
|
|
102
|
+
async dispose() {
|
|
103
|
+
for (const [canonical, ref] of this.joined) {
|
|
104
|
+
const mine = ref.deref();
|
|
105
|
+
if (mine === undefined)
|
|
106
|
+
continue;
|
|
107
|
+
if (sharedSessionStorages.get(canonical)?.deref() !== mine)
|
|
108
|
+
continue;
|
|
109
|
+
if (mine.removeHolder(this))
|
|
110
|
+
evictSharedSessionStorage(canonical);
|
|
111
|
+
}
|
|
112
|
+
this.joined.clear();
|
|
113
|
+
}
|
|
88
114
|
async create(options = {}) {
|
|
89
115
|
const id = options.id ?? uuidv7();
|
|
90
116
|
const createdAt = new Date().toISOString();
|
|
91
117
|
const path = this.pathFor(id);
|
|
92
118
|
if (!existsSync(path)) {
|
|
119
|
+
evictSharedSessionStorage(canonicalStoreKey(path));
|
|
93
120
|
atomicWriteFile(this.tmpDir, path, `${JSON.stringify({ kind: "meta", id, createdAt })}\n`);
|
|
94
121
|
return new StoredSession(this.storage(id, createdAt, []));
|
|
95
122
|
}
|
|
@@ -14,6 +14,7 @@ export interface CompoundReadonlyVerdict {
|
|
|
14
14
|
reason?: string;
|
|
15
15
|
outOfRootRead?: true;
|
|
16
16
|
outOfRootPaths?: readonly string[];
|
|
17
|
+
checkedPaths?: readonly string[];
|
|
17
18
|
}
|
|
18
19
|
export declare function formatOutOfRootReadApprovalOption(directory: string): string;
|
|
19
20
|
export declare function classifyCompoundReadonlyDetailed(command: string, allow: ReadonlySet<string>, boundary?: BashReadonlyRootBoundary): CompoundReadonlyVerdict;
|
|
@@ -179,6 +179,7 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
|
|
|
179
179
|
const args = tokens.folded.slice(1);
|
|
180
180
|
const findings = [];
|
|
181
181
|
const candidates = [];
|
|
182
|
+
const bareWordOperands = [];
|
|
182
183
|
for (const rawArg of tokens.raw.slice(1)) {
|
|
183
184
|
if (!hasUnquotedExpansionMetachar(rawArg))
|
|
184
185
|
continue;
|
|
@@ -227,6 +228,8 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
|
|
|
227
228
|
continue;
|
|
228
229
|
if (isPathShapedToken(t))
|
|
229
230
|
candidates.push(t);
|
|
231
|
+
else
|
|
232
|
+
bareWordOperands.push(t);
|
|
230
233
|
}
|
|
231
234
|
}
|
|
232
235
|
for (const candidate of candidates) {
|
|
@@ -238,9 +241,16 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
|
|
|
238
241
|
});
|
|
239
242
|
continue;
|
|
240
243
|
}
|
|
241
|
-
if (
|
|
244
|
+
if (withinAnyRoot(boundary.roots, resolved))
|
|
245
|
+
findings.push({ kind: "inside", path: resolved });
|
|
246
|
+
else
|
|
242
247
|
findings.push({ kind: "outside", command: name, path: resolved });
|
|
243
248
|
}
|
|
249
|
+
for (const operand of bareWordOperands) {
|
|
250
|
+
const resolved = resolveOperandLexically(boundary.cwd ?? boundary.roots[0], operand, boundary.homeDir);
|
|
251
|
+
if (resolved !== undefined && withinAnyRoot(boundary.roots, resolved))
|
|
252
|
+
findings.push({ kind: "inside", path: resolved });
|
|
253
|
+
}
|
|
244
254
|
return findings;
|
|
245
255
|
}
|
|
246
256
|
export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
|
|
@@ -383,22 +393,29 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
|
|
|
383
393
|
}
|
|
384
394
|
function evaluateReadBoundary(foldedSegments, boundary) {
|
|
385
395
|
const outside = [];
|
|
396
|
+
const inside = [];
|
|
386
397
|
for (const toks of foldedSegments) {
|
|
387
398
|
for (const finding of collectSegmentBoundaryFindings(toks, boundary)) {
|
|
388
399
|
if (finding.kind === "unresolvable")
|
|
389
400
|
return { reason: finding.reason };
|
|
401
|
+
if (finding.kind === "inside") {
|
|
402
|
+
if (!inside.includes(finding.path))
|
|
403
|
+
inside.push(finding.path);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
390
406
|
if (!outside.some((o) => o.path === finding.path))
|
|
391
407
|
outside.push(finding);
|
|
392
408
|
}
|
|
393
409
|
}
|
|
394
410
|
if (outside.length === 0)
|
|
395
|
-
return {};
|
|
411
|
+
return inside.length > 0 ? { checkedPaths: inside } : {};
|
|
396
412
|
const paths = outside.map((o) => `"${o.path}"`).join(", ");
|
|
397
413
|
const allowed = boundary.roots.length > 0 ? boundary.roots.join(", ") : "(none)";
|
|
398
414
|
return {
|
|
399
415
|
reason: `"${outside[0].command}" reads ${paths}, outside the allowed directories for this session: ${allowed} — not auto-allowed`,
|
|
400
416
|
outOfRootRead: true,
|
|
401
417
|
outOfRootPaths: outside.map((o) => o.path),
|
|
418
|
+
...(inside.length > 0 ? { checkedPaths: inside } : {}),
|
|
402
419
|
};
|
|
403
420
|
}
|
|
404
421
|
export function classifySimpleCommandReadBoundary(command, boundary) {
|
|
@@ -14,7 +14,7 @@ export interface ExecClampOption {
|
|
|
14
14
|
}) => () => void;
|
|
15
15
|
}
|
|
16
16
|
export declare function bashExitOneInterpretation(command: string): string | undefined;
|
|
17
|
-
export declare function canAutoBackground(command: string): boolean;
|
|
17
|
+
export declare function canAutoBackground(command: string, depth?: number): boolean;
|
|
18
18
|
export declare function createBashTool(env: ExecutionEnv, rootCanonical: string, coAuthor?: string | false, cwdRef?: CwdRef, taskOpts?: {
|
|
19
19
|
taskRegistry?: TaskRegistry;
|
|
20
20
|
taskOwner?: string;
|
package/dist/tools/fs/fs-bash.js
CHANGED
|
@@ -6,6 +6,7 @@ import { hasBackgroundShell } from "../../core/background-shell.js";
|
|
|
6
6
|
import { delimitUntrusted } from "../../core/untrusted-text.js";
|
|
7
7
|
import { MCP_IMAGE_MAX_BASE64 } from "../../core/mcp.js";
|
|
8
8
|
import { imageMagicMatches, withinAnyRoot } from "./safety.js";
|
|
9
|
+
import { isRemoteExecutionEnv } from "../../core/remote-env.js";
|
|
9
10
|
import { ghRateLimitHint } from "./gh-rate-limit.js";
|
|
10
11
|
import { resolveBashTimeoutCaps, bashTimeoutCapsSec, bashMaxOutputChars, clipShellOutput, writeShellOverflowFile, createShellOverflowSpoolFence, shellRecoveryHint, CWD_SENTINEL, } from "./fs-shared.js";
|
|
11
12
|
import { BASH_READONLY_DEFAULT_ALLOW, coarseReadonlyCheck, classifyCompoundReadonly, classifySimpleCommandReadBoundary, } from "./bash-readonly-classifier.js";
|
|
@@ -137,7 +138,26 @@ function commandBasename(token) {
|
|
|
137
138
|
const idx = token.lastIndexOf("/");
|
|
138
139
|
return idx === -1 ? token : token.slice(idx + 1);
|
|
139
140
|
}
|
|
140
|
-
|
|
141
|
+
const COMMAND_STRING_SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh", "ash"]);
|
|
142
|
+
function commandStringPayload(tokens) {
|
|
143
|
+
const word = commandBasename(tokens[0] ?? "");
|
|
144
|
+
if (word === "eval")
|
|
145
|
+
return tokens.slice(1).join(" ");
|
|
146
|
+
if (!COMMAND_STRING_SHELLS.has(word))
|
|
147
|
+
return undefined;
|
|
148
|
+
const flagIdx = tokens.findIndex((t, i) => i > 0 && /^-[A-Za-z]*c[A-Za-z]*$/.test(t));
|
|
149
|
+
return flagIdx === -1 ? undefined : tokens.slice(flagIdx + 1).join(" ");
|
|
150
|
+
}
|
|
151
|
+
function unquotePayload(payload) {
|
|
152
|
+
const s = payload.trim();
|
|
153
|
+
const ansiC = /^\$'([\s\S]*)'$/.exec(s);
|
|
154
|
+
if (ansiC)
|
|
155
|
+
return (ansiC[1] ?? "").replace(/\\n/g, "\n").replace(/\\t/g, "\t");
|
|
156
|
+
const quoted = /^(['"])([\s\S]*)\1$/.exec(s);
|
|
157
|
+
return quoted ? (quoted[2] ?? "") : s;
|
|
158
|
+
}
|
|
159
|
+
const AUTO_BACKGROUND_MAX_WRAPPER_DEPTH = 3;
|
|
160
|
+
export function canAutoBackground(command, depth = 0) {
|
|
141
161
|
const segments = command
|
|
142
162
|
.split(/&&|\|\||;|\||\r?\n/)
|
|
143
163
|
.map((s) => s.trim())
|
|
@@ -149,6 +169,9 @@ export function canAutoBackground(command) {
|
|
|
149
169
|
const word = commandBasename(tokens[0] ?? "");
|
|
150
170
|
if (word === "git")
|
|
151
171
|
return false;
|
|
172
|
+
const payload = depth < AUTO_BACKGROUND_MAX_WRAPPER_DEPTH ? commandStringPayload(tokens) : undefined;
|
|
173
|
+
if (payload !== undefined && payload.trim() !== "" && !canAutoBackground(unquotePayload(payload), depth + 1))
|
|
174
|
+
return false;
|
|
152
175
|
if (word === "xargs" && tokens.slice(1).some((t) => commandBasename(t) === "git"))
|
|
153
176
|
return false;
|
|
154
177
|
}
|
|
@@ -641,6 +664,60 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
|
|
|
641
664
|
},
|
|
642
665
|
});
|
|
643
666
|
}
|
|
667
|
+
function createCanonicalReadBoundary(env, readRoots) {
|
|
668
|
+
const lexicalOnly = isRemoteExecutionEnv(env);
|
|
669
|
+
let rootsOnce;
|
|
670
|
+
const canonicalRoots = async (signal) => {
|
|
671
|
+
rootsOnce ??= (async () => {
|
|
672
|
+
const out = [];
|
|
673
|
+
for (const r of readRoots) {
|
|
674
|
+
const c = await env.canonicalPath(r, signal);
|
|
675
|
+
out.push(c.ok ? c.value : r);
|
|
676
|
+
}
|
|
677
|
+
return out;
|
|
678
|
+
})();
|
|
679
|
+
return await rootsOnce;
|
|
680
|
+
};
|
|
681
|
+
const resolve = async (p, signal) => {
|
|
682
|
+
const parts = p.split(/[\\/]/);
|
|
683
|
+
const tail = [];
|
|
684
|
+
while (parts.length > 0) {
|
|
685
|
+
const canon = await env.canonicalPath(parts.join("/") || "/", signal);
|
|
686
|
+
if (canon.ok) {
|
|
687
|
+
return [canon.value.replace(/[\\/]+$/, ""), ...[...tail].reverse()].join("/");
|
|
688
|
+
}
|
|
689
|
+
tail.push(parts.pop());
|
|
690
|
+
}
|
|
691
|
+
return undefined;
|
|
692
|
+
};
|
|
693
|
+
return {
|
|
694
|
+
escapesAfterResolution: async (paths, signal) => {
|
|
695
|
+
if (lexicalOnly || paths.length === 0)
|
|
696
|
+
return [];
|
|
697
|
+
const roots = await canonicalRoots(signal);
|
|
698
|
+
const escaping = [];
|
|
699
|
+
for (const p of paths) {
|
|
700
|
+
const real = await resolve(p, signal);
|
|
701
|
+
if (real === undefined)
|
|
702
|
+
continue;
|
|
703
|
+
if (!withinAnyRoot(roots, real) && !escaping.includes(real))
|
|
704
|
+
escaping.push(real);
|
|
705
|
+
}
|
|
706
|
+
return escaping;
|
|
707
|
+
},
|
|
708
|
+
allResolveInside: async (paths, signal) => {
|
|
709
|
+
if (lexicalOnly || paths.length === 0)
|
|
710
|
+
return false;
|
|
711
|
+
const roots = await canonicalRoots(signal);
|
|
712
|
+
for (const p of paths) {
|
|
713
|
+
const real = await resolve(p, signal);
|
|
714
|
+
if (real === undefined || !withinAnyRoot(roots, real))
|
|
715
|
+
return false;
|
|
716
|
+
}
|
|
717
|
+
return true;
|
|
718
|
+
},
|
|
719
|
+
};
|
|
720
|
+
}
|
|
644
721
|
export function createBashReadonlyTool(env, rootCanonical, allow, execClamp, opts) {
|
|
645
722
|
const timeoutCaps = resolveBashTimeoutCaps(opts);
|
|
646
723
|
const timeoutCapsSecView = bashTimeoutCapsSec(timeoutCaps);
|
|
@@ -659,14 +736,16 @@ export function createBashReadonlyTool(env, rootCanonical, allow, execClamp, opt
|
|
|
659
736
|
}
|
|
660
737
|
return true;
|
|
661
738
|
};
|
|
739
|
+
const canonicalBoundary = createCanonicalReadBoundary(env, readRoots);
|
|
662
740
|
return defineTool({
|
|
663
741
|
name: "Bash",
|
|
664
742
|
contract: { contractId: "core.bash_readonly@1", implementationRevision: "1" },
|
|
665
743
|
description: `Run a SINGLE read-only inspection command (e.g. ${sample}, …) and return its output. Shell ` +
|
|
666
744
|
"operators (pipes, redirects, ;, &&, command substitution, subshells) are rejected and only " +
|
|
667
745
|
"allowlisted commands run. Reads are confined to the workspace root(s): a path outside them is " +
|
|
668
|
-
"refused, as is one that cannot be resolved statically (e.g. a `~` path).
|
|
669
|
-
"
|
|
746
|
+
"refused, as is one that cannot be resolved statically (e.g. a `~` path). " +
|
|
747
|
+
(isRemoteExecutionEnv(env) ? "" : "A path that leads outside via a symlink counts as outside. ") +
|
|
748
|
+
"Still subject to the deployment's approval policy.",
|
|
670
749
|
parameters: Type.Object({
|
|
671
750
|
command: Type.String({ description: "A single allowlisted read-only command (no shell operators)." }),
|
|
672
751
|
timeout: Type.Optional(Type.Number({ description: `Timeout in milliseconds (default ${timeoutCaps.defaultMs}, max ${timeoutCaps.maxMs}; requests above the max are capped to it).` })),
|
|
@@ -678,8 +757,17 @@ export function createBashReadonlyTool(env, rootCanonical, allow, execClamp, opt
|
|
|
678
757
|
if (reason)
|
|
679
758
|
return errorResult(`Error (Bash): ${reason}`);
|
|
680
759
|
const boundary = classifySimpleCommandReadBoundary(command, { roots: readRoots, cwd: rootCanonical });
|
|
681
|
-
if (boundary.reason !== undefined
|
|
682
|
-
|
|
760
|
+
if (boundary.reason !== undefined) {
|
|
761
|
+
const rescued = boundary.outOfRootRead === true && (await canonicalBoundary.allResolveInside(boundary.outOfRootPaths ?? [], ctx.signal));
|
|
762
|
+
if (!rescued && !(await readsOnlyEngineOverflowSpool(boundary))) {
|
|
763
|
+
return errorResult(`Error (Bash): ${boundary.reason}. bash_readonly is confined to the workspace roots; it has no approval path, so the call is refused rather than escalated.`, { code: "readonly_out_of_root", paths: boundary.outOfRootPaths ?? [] });
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
const escaping = await canonicalBoundary.escapesAfterResolution(boundary.checkedPaths ?? [], ctx.signal);
|
|
767
|
+
if (escaping.length > 0) {
|
|
768
|
+
const quoted = escaping.map((p) => `"${p}"`).join(", ");
|
|
769
|
+
return errorResult(`Error (Bash): a path this command reads resolves through a symlink to ${quoted}, outside the allowed directories for this session: ${readRoots.join(", ")} — not auto-allowed. ` +
|
|
770
|
+
"bash_readonly is confined to the workspace roots; it has no approval path, so the call is refused rather than escalated.", { code: "readonly_out_of_root", paths: escaping });
|
|
683
771
|
}
|
|
684
772
|
return await runShell(env, rootCanonical, "Bash", command, msTimeoutToRequestedSec(timeout), timeoutCapsSecView, ctx.signal, undefined, undefined, execClamp, ctx.toolCallId, true);
|
|
685
773
|
},
|
package/dist/tools/loop-tick.js
CHANGED
|
@@ -49,7 +49,7 @@ Read and analyze freely — understanding the state of things has no blast radiu
|
|
|
49
49
|
`;
|
|
50
50
|
const DYNAMIC_APPENDIX = `
|
|
51
51
|
|
|
52
|
-
If a Monitor is armed (
|
|
52
|
+
If a Monitor is armed (its task_id was reported in the receipt when you started it), keep \`delaySeconds\` at 1200–1800s — the Monitor is the wake signal and this is only the fallback heartbeat. If you were woken by a \`<task-notification>\`, handle the event before deciding whether to re-arm. To stop the loop, call ${SCHEDULE_WAKEUP_TOOL_NAME} with \`stop: true\` and TaskStop the monitor (its task id is in that start receipt, and in the \`<task-id>\` of every notification the watch sends).`;
|
|
53
53
|
function pushNotificationAppendix(persistentVariant) {
|
|
54
54
|
const r = persistentVariant
|
|
55
55
|
? "newly blocked on a decision you won't make alone, you're ending the loop"
|
package/dist/tools/monitor.js
CHANGED
|
@@ -29,6 +29,16 @@ const MONITOR_DESCRIPTION = "Run a shell command in the background and watch its
|
|
|
29
29
|
'- Wrong: `until grep -q "BUILD SUCCEEDED" build.log; do sleep 5; done` — if the build fails, this never exits and you wait until the watch times out.\n' +
|
|
30
30
|
'- Right: `until grep -qE "BUILD (SUCCEEDED|FAILED)" build.log; do sleep 5; done` — every terminal state exits.\n' +
|
|
31
31
|
"Before arming, ask: *if this process crashed right now, would my filter emit anything?* If not, widen it.";
|
|
32
|
+
function envBackgroundWallSec(caps, requestedSec) {
|
|
33
|
+
const positive = (v) => (typeof v === "number" && Number.isFinite(v) && v > 0 ? v : undefined);
|
|
34
|
+
const cap = positive(caps.maxBgTimeoutSec);
|
|
35
|
+
const base = requestedSec ?? positive(caps.defaultBgTimeoutSec);
|
|
36
|
+
if (base === undefined)
|
|
37
|
+
return cap;
|
|
38
|
+
if (cap === undefined)
|
|
39
|
+
return base;
|
|
40
|
+
return Math.min(base, cap);
|
|
41
|
+
}
|
|
32
42
|
export function createMonitorTool(env, opts) {
|
|
33
43
|
return defineTool({
|
|
34
44
|
name: "Monitor",
|
|
@@ -56,9 +66,10 @@ export function createMonitorTool(env, opts) {
|
|
|
56
66
|
}
|
|
57
67
|
const isPersistent = persistent === true;
|
|
58
68
|
const timeoutMs = Math.min(MONITOR_MAX_TIMEOUT_MS, Math.max(1, Math.floor(timeout_ms ?? MONITOR_DEFAULT_TIMEOUT_MS)));
|
|
69
|
+
const spawnTimeoutSec = isPersistent ? undefined : Math.ceil(timeoutMs / 1000) + 5;
|
|
59
70
|
const spawn = await env.spawnBackground(command, {
|
|
60
71
|
...(opts.cwdRef !== undefined ? { cwd: opts.cwdRef.current } : {}),
|
|
61
|
-
...(
|
|
72
|
+
...(spawnTimeoutSec !== undefined ? { timeout: spawnTimeoutSec } : {}),
|
|
62
73
|
});
|
|
63
74
|
if (!spawn.ok)
|
|
64
75
|
return errorResult(`Error (Monitor): ${spawn.error.message}`);
|
|
@@ -87,18 +98,50 @@ export function createMonitorTool(env, opts) {
|
|
|
87
98
|
});
|
|
88
99
|
}
|
|
89
100
|
catch (e) {
|
|
90
|
-
|
|
91
|
-
|
|
101
|
+
const why = e instanceof Error ? e.message : String(e);
|
|
102
|
+
let killDetail;
|
|
103
|
+
try {
|
|
104
|
+
const kill = await env.killBackground(spawn.value.shellId);
|
|
105
|
+
if (!kill.ok)
|
|
106
|
+
killDetail = `${kill.error.code}: ${kill.error.message}`;
|
|
107
|
+
}
|
|
108
|
+
catch (killErr) {
|
|
109
|
+
killDetail = killErr instanceof Error ? killErr.message : String(killErr);
|
|
110
|
+
}
|
|
111
|
+
return errorResult(killDetail === undefined
|
|
112
|
+
? `Error (Monitor): the watch process was started but could not be registered (${why}); it has been terminated.`
|
|
113
|
+
: `Error (Monitor): the watch process was started but could not be registered (${why}), and it could NOT be terminated either (${killDetail}). ` +
|
|
114
|
+
`It may still be running as background shell ${spawn.value.shellId}; no task id was minted, so TaskOutput/TaskStop cannot reach it — ` +
|
|
115
|
+
"stop it out of band (the environment's own background time budget and its teardown sweep are the remaining backstops).");
|
|
92
116
|
}
|
|
93
|
-
const bgTimeout = env.backgroundCapabilities
|
|
117
|
+
const bgTimeout = envBackgroundWallSec(env.backgroundCapabilities, spawnTimeoutSec);
|
|
94
118
|
const wasClamped = timeout_ms !== undefined && timeout_ms !== timeoutMs;
|
|
95
119
|
const clampNote = !isPersistent && wasClamped ? ` (clamped to ${timeoutMs}ms from your requested ${timeout_ms}ms)` : "";
|
|
96
120
|
const timeoutIgnoredNote = isPersistent && timeout_ms !== undefined ? ` (the timeout_ms you passed is ignored because persistent is true)` : "";
|
|
121
|
+
const envWallMs = bgTimeout !== undefined ? bgTimeout * 1000 : undefined;
|
|
122
|
+
const envWallWins = !isPersistent && envWallMs !== undefined && envWallMs < timeoutMs;
|
|
123
|
+
const persistentBudget = bgTimeout !== undefined
|
|
124
|
+
? `the execution environment kills background processes after ${bgTimeout}s`
|
|
125
|
+
: `the execution environment's background time budget still applies`;
|
|
126
|
+
const retainedByEnv = env.backgroundCapabilities.retainBackgroundProcesses === true;
|
|
127
|
+
const persistentAnchor = sessionScoped
|
|
128
|
+
? retainedByEnv
|
|
129
|
+
? ". This environment declares retained background processes, so at the run teardown the watch is marked retained and " +
|
|
130
|
+
"survives the session's release: only your TaskStop, the command's own exit, or that budget end it."
|
|
131
|
+
: "; it runs until then, until you stop it, or until the session ends."
|
|
132
|
+
: retainedByEnv
|
|
133
|
+
? ". This mount carries no session identity, so the watch is NOT session-resident despite persistent: true; and because this " +
|
|
134
|
+
"environment declares retained background processes it survives the session's release rather than being settled with the run — " +
|
|
135
|
+
"nothing but your TaskStop, the command's own exit, or that budget will end it, and no later turn can reach it " +
|
|
136
|
+
"(stop it explicitly with TaskStop before you finish)."
|
|
137
|
+
: ". This mount carries no session identity, so the watch is NOT session-resident despite persistent: true — " +
|
|
138
|
+
"it belongs to this task alone and the run teardown settles it, so do not count on it outliving this turn " +
|
|
139
|
+
"(stop it explicitly with TaskStop when you are done).";
|
|
97
140
|
const lifetime = isPersistent
|
|
98
|
-
?
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
141
|
+
? `It is persistent: no watch timeout of its own${timeoutIgnoredNote}, but ${persistentBudget}${persistentAnchor}`
|
|
142
|
+
: envWallWins
|
|
143
|
+
? `It will be killed after ${envWallMs}ms if still running — the execution environment's background time budget (${bgTimeout}s) is shorter than the ${timeoutMs}ms watch timeout${clampNote}, and the shorter of the two wins.`
|
|
144
|
+
: `It will be killed after ${timeoutMs}ms${clampNote} if still running.`;
|
|
102
145
|
const content = onNotify !== undefined
|
|
103
146
|
? `Monitoring in background; task_id=${taskId}. Each stdout line becomes a notification event (lines within ` +
|
|
104
147
|
`~200ms are batched); you'll get a final notification with the exit code when it ends — do not poll. ` +
|
|
@@ -111,8 +154,11 @@ export function createMonitorTool(env, opts) {
|
|
|
111
154
|
type: "monitor-start",
|
|
112
155
|
taskId,
|
|
113
156
|
persistent: isPersistent,
|
|
157
|
+
...(bgTimeout !== undefined ? { envBudgetSec: bgTimeout } : {}),
|
|
158
|
+
...(envWallWins && envWallMs !== undefined ? { effectiveTimeoutMs: envWallMs } : {}),
|
|
114
159
|
...(isPersistent
|
|
115
|
-
?
|
|
160
|
+
?
|
|
161
|
+
{ timeoutIgnored: timeout_ms !== undefined, sessionScoped, ...(retainedByEnv ? { retainedByEnv: true } : {}) }
|
|
116
162
|
: { timeoutMs, ...(timeout_ms !== undefined ? { requestedTimeoutMs: timeout_ms } : {}), wasClamped }),
|
|
117
163
|
},
|
|
118
164
|
};
|
package/dist/tools/worktree.d.ts
CHANGED