praxis-agent 0.46.2 → 0.46.5
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/README.md +3 -0
- package/dist/platform/bounded-process-runner.d.ts +4 -0
- package/dist/platform/bounded-process-runner.js +59 -1
- package/dist/sandbox/claude-sandbox-runtime.d.ts +1 -0
- package/dist/sandbox/claude-sandbox-runtime.js +1 -1
- package/dist/tools/local-tools.d.ts +7 -0
- package/dist/tools/local-tools.js +319 -52
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -153,6 +153,9 @@ troubleshooting. Run `praxis --help` for the authoritative command surface.
|
|
|
153
153
|
structured JSON/JSONL, context compaction, tool loops, and bounded execution.
|
|
154
154
|
- **Built-in tools** — read, write, edit, glob, search, shell, notebook, PDF,
|
|
155
155
|
image, web, scheduled prompts, workflows, and worktrees.
|
|
156
|
+
- **Shell lifecycle** — foreground Bash allows up to 10 minutes and carries a
|
|
157
|
+
validated final working directory across calls in the same session without
|
|
158
|
+
leaking state across sessions or overriding an explicit `/cd`.
|
|
156
159
|
- **Permission boundary** — local allow/ask/deny rules, safe and bare modes,
|
|
157
160
|
searchable scoped-rule creation/removal, local/project/user atomic settings
|
|
158
161
|
writes, tool-specific Bash/PowerShell/file/notebook/WebFetch/Skill approval
|
|
@@ -5,6 +5,7 @@ export interface ProcessResult {
|
|
|
5
5
|
code: number;
|
|
6
6
|
timedOut: boolean;
|
|
7
7
|
truncated: boolean;
|
|
8
|
+
controlOutput?: string;
|
|
8
9
|
}
|
|
9
10
|
export interface BoundedProcessRunnerOptions {
|
|
10
11
|
cwd: string;
|
|
@@ -18,6 +19,9 @@ export interface RunProcessOptions {
|
|
|
18
19
|
signal?: AbortSignal;
|
|
19
20
|
onOutput?: (output: string) => void | Promise<void>;
|
|
20
21
|
env?: Readonly<Record<string, string>>;
|
|
22
|
+
controlOutputBytes?: number;
|
|
23
|
+
controlOutputFd?: number;
|
|
24
|
+
scriptInput?: string;
|
|
21
25
|
}
|
|
22
26
|
export declare function joinedProcessOutput(result: ProcessResult): string;
|
|
23
27
|
export declare class BoundedProcessRunner {
|
|
@@ -87,15 +87,35 @@ export class BoundedProcessRunner {
|
|
|
87
87
|
const sensitiveValues = sensitiveEnvironmentValues(process.env);
|
|
88
88
|
const longestSensitiveValueBytes = sensitiveValues.reduce((longest, value) => Math.max(longest, Buffer.byteLength(value)), 0);
|
|
89
89
|
const rawOutputLimit = this.options.maxOutputBytes + Math.max(3, longestSensitiveValueBytes);
|
|
90
|
+
const controlOutputFd = options.controlOutputBytes === undefined
|
|
91
|
+
? undefined
|
|
92
|
+
: (options.controlOutputFd ?? 3);
|
|
93
|
+
const scriptInputFd = options.scriptInput === undefined ? undefined : 4;
|
|
94
|
+
if (controlOutputFd !== undefined &&
|
|
95
|
+
scriptInputFd !== undefined &&
|
|
96
|
+
controlOutputFd === scriptInputFd) {
|
|
97
|
+
reject(new Error('Process control output and script input FDs conflict'));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const highestFd = Math.max(2, controlOutputFd ?? 2, scriptInputFd ?? 2);
|
|
101
|
+
const stdio = Array.from({ length: highestFd + 1 }, () => 'ignore');
|
|
102
|
+
stdio[1] = 'pipe';
|
|
103
|
+
stdio[2] = 'pipe';
|
|
104
|
+
if (controlOutputFd !== undefined)
|
|
105
|
+
stdio[controlOutputFd] = 'pipe';
|
|
106
|
+
if (scriptInputFd !== undefined)
|
|
107
|
+
stdio[scriptInputFd] = 'pipe';
|
|
90
108
|
const child = spawn(options.command, options.args, {
|
|
91
109
|
cwd: options.cwd ?? this.options.cwd,
|
|
92
110
|
detached: process.platform !== 'win32',
|
|
93
111
|
env: { ...sanitizeChildEnvironment(), ...options.env },
|
|
94
|
-
stdio
|
|
112
|
+
stdio,
|
|
95
113
|
});
|
|
96
114
|
const chunks = { stdout: [], stderr: [] };
|
|
97
115
|
const combined = [];
|
|
98
116
|
const retainedBytes = { stdout: 0, stderr: 0, combined: 0 };
|
|
117
|
+
const controlChunks = [];
|
|
118
|
+
let controlBytes = 0;
|
|
99
119
|
let outputBytes = 0;
|
|
100
120
|
let timedOut = false;
|
|
101
121
|
let settled = false;
|
|
@@ -136,8 +156,41 @@ export class BoundedProcessRunner {
|
|
|
136
156
|
}
|
|
137
157
|
updateLiveOutput();
|
|
138
158
|
};
|
|
159
|
+
if (!child.stdout || !child.stderr) {
|
|
160
|
+
reject(new Error('Process output streams were not created'));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
139
163
|
child.stdout.on('data', (chunk) => retain('stdout', chunk));
|
|
140
164
|
child.stderr.on('data', (chunk) => retain('stderr', chunk));
|
|
165
|
+
const controlStream = controlOutputFd === undefined ? undefined : child.stdio[controlOutputFd];
|
|
166
|
+
if (controlStream) {
|
|
167
|
+
controlStream.on('data', (chunk) => {
|
|
168
|
+
const remaining = Math.max(0, (options.controlOutputBytes ?? 0) - controlBytes);
|
|
169
|
+
if (remaining <= 0)
|
|
170
|
+
return;
|
|
171
|
+
const retained = chunk.subarray(0, remaining);
|
|
172
|
+
controlChunks.push(retained);
|
|
173
|
+
controlBytes += retained.length;
|
|
174
|
+
});
|
|
175
|
+
child.once('exit', () => {
|
|
176
|
+
setImmediate(() => controlStream.destroy());
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
const scriptStream = scriptInputFd === undefined ? undefined : child.stdio[scriptInputFd];
|
|
180
|
+
if (scriptStream) {
|
|
181
|
+
scriptStream.on('error', () => {
|
|
182
|
+
// The child process result remains authoritative if it exits before
|
|
183
|
+
// consuming the complete script.
|
|
184
|
+
});
|
|
185
|
+
if (!('end' in scriptStream)) {
|
|
186
|
+
reject(new Error('Process script input stream is not writable'));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
scriptStream.end(options.scriptInput);
|
|
190
|
+
child.once('exit', () => {
|
|
191
|
+
setImmediate(() => scriptStream.destroy());
|
|
192
|
+
});
|
|
193
|
+
}
|
|
141
194
|
const kill = () => {
|
|
142
195
|
if (child.pid === undefined)
|
|
143
196
|
return;
|
|
@@ -200,6 +253,11 @@ export class BoundedProcessRunner {
|
|
|
200
253
|
code: code ?? 1,
|
|
201
254
|
timedOut,
|
|
202
255
|
truncated,
|
|
256
|
+
...(options.controlOutputBytes === undefined
|
|
257
|
+
? {}
|
|
258
|
+
: {
|
|
259
|
+
controlOutput: Buffer.concat(controlChunks).toString('utf8'),
|
|
260
|
+
}),
|
|
203
261
|
});
|
|
204
262
|
}, reject);
|
|
205
263
|
});
|
|
@@ -136,7 +136,7 @@ export class ClaudeSandboxRuntime {
|
|
|
136
136
|
throw new Error('Sandbox has not been initialized');
|
|
137
137
|
if (!this.shouldUseSandbox(input))
|
|
138
138
|
return input.command;
|
|
139
|
-
return this.backend.wrapWithSandbox(input.command, options.shell, undefined, options.signal, {
|
|
139
|
+
return this.backend.wrapWithSandbox(input.executionCommand ?? input.command, options.shell, undefined, options.signal, {
|
|
140
140
|
...(options.commandId ? { commandId: options.commandId } : {}),
|
|
141
141
|
commandText: input.command,
|
|
142
142
|
});
|
|
@@ -26,6 +26,7 @@ export interface BashSandboxRuntime {
|
|
|
26
26
|
wrapCommand(input: {
|
|
27
27
|
command: string;
|
|
28
28
|
dangerouslyDisableSandbox?: boolean;
|
|
29
|
+
executionCommand?: string;
|
|
29
30
|
}, options?: {
|
|
30
31
|
shell?: string;
|
|
31
32
|
signal?: AbortSignal;
|
|
@@ -43,6 +44,7 @@ export declare class LocalToolRegistry implements ToolRegistry {
|
|
|
43
44
|
private readonly maxOutputBytes;
|
|
44
45
|
private readonly maxFileBytes;
|
|
45
46
|
private readonly maxShellTimeoutMs;
|
|
47
|
+
private readonly maxSearchTimeoutMs;
|
|
46
48
|
private readonly processRunner;
|
|
47
49
|
private readonly enableReportFindings;
|
|
48
50
|
private readonly environment;
|
|
@@ -50,11 +52,14 @@ export declare class LocalToolRegistry implements ToolRegistry {
|
|
|
50
52
|
private readonly sandbox;
|
|
51
53
|
private readonly homeDirectory;
|
|
52
54
|
private readonly configRoot;
|
|
55
|
+
private readonly sessionCwds;
|
|
53
56
|
private readonly protectedWriteReason;
|
|
57
|
+
private readonly mutationTargetExisted;
|
|
54
58
|
constructor(options: LocalToolRegistryOptions);
|
|
55
59
|
private assertProtectedWritePath;
|
|
56
60
|
private assertProtectedBashCommand;
|
|
57
61
|
private currentCwd;
|
|
62
|
+
private currentBashCwd;
|
|
58
63
|
definitions(): readonly ModelToolDefinition[];
|
|
59
64
|
schedulingPolicy(call: ModelToolCall): {
|
|
60
65
|
concurrency: "concurrent";
|
|
@@ -78,7 +83,9 @@ export declare class LocalToolRegistry implements ToolRegistry {
|
|
|
78
83
|
private workspacePath;
|
|
79
84
|
private filePath;
|
|
80
85
|
private resolvePath;
|
|
86
|
+
private pathExists;
|
|
81
87
|
private assertStablePath;
|
|
88
|
+
private assertStableCreationParent;
|
|
82
89
|
private globRoot;
|
|
83
90
|
private wasSuccessfullyRead;
|
|
84
91
|
private read;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { constants } from 'node:fs';
|
|
2
|
+
import { randomBytes, randomInt } from 'node:crypto';
|
|
2
3
|
import { mkdir, mkdtemp, open, realpath, stat, writeFile, } from 'node:fs/promises';
|
|
3
4
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve, } from 'node:path';
|
|
4
5
|
import { homedir, tmpdir } from 'node:os';
|
|
5
6
|
import sharp from 'sharp';
|
|
7
|
+
import { countTokens } from '@anthropic-ai/tokenizer';
|
|
6
8
|
import { commandShell, commandShellArguments, } from '../platform/command-shell.js';
|
|
7
9
|
import { BoundedProcessRunner, joinedProcessOutput, } from '../platform/bounded-process-runner.js';
|
|
8
10
|
import { globFiles } from './glob.js';
|
|
@@ -247,6 +249,20 @@ function stringInput(input, name, allowEmpty = false) {
|
|
|
247
249
|
}
|
|
248
250
|
return value;
|
|
249
251
|
}
|
|
252
|
+
function stripShellControlTrace(content, token) {
|
|
253
|
+
if (!content.includes(token))
|
|
254
|
+
return content;
|
|
255
|
+
return content
|
|
256
|
+
.split('\n')
|
|
257
|
+
.filter((line) => !line.includes(token))
|
|
258
|
+
.join('\n');
|
|
259
|
+
}
|
|
260
|
+
function commandShellSyntaxArguments(command) {
|
|
261
|
+
const args = [...commandShellArguments(command)];
|
|
262
|
+
const commandIndex = args.indexOf('-c');
|
|
263
|
+
args.splice(commandIndex, 0, '-n');
|
|
264
|
+
return args;
|
|
265
|
+
}
|
|
250
266
|
function optionalString(input, name) {
|
|
251
267
|
const value = input[name];
|
|
252
268
|
if (value === undefined)
|
|
@@ -447,6 +463,8 @@ function truncateOutput(content, maxBytes) {
|
|
|
447
463
|
? `${retained.content}\n[output truncated]`
|
|
448
464
|
: retained.content;
|
|
449
465
|
}
|
|
466
|
+
const TEXT_READ_MAX_BYTES = 256 * 1024;
|
|
467
|
+
const TEXT_READ_MAX_TOKENS = 25_000;
|
|
450
468
|
function abortError() {
|
|
451
469
|
return new DOMException('Tool execution aborted', 'AbortError');
|
|
452
470
|
}
|
|
@@ -480,6 +498,14 @@ function formatKilobytes(bytes) {
|
|
|
480
498
|
const value = bytes / 1024;
|
|
481
499
|
return `${value < 10 ? value.toFixed(1) : Math.round(value)}KB`.replace('.0KB', 'KB');
|
|
482
500
|
}
|
|
501
|
+
function newlineStyle(content) {
|
|
502
|
+
return content.match(/\r\n|\n|\r/u)?.[0];
|
|
503
|
+
}
|
|
504
|
+
function normalizeNewlines(content, style) {
|
|
505
|
+
if (style === undefined)
|
|
506
|
+
return content;
|
|
507
|
+
return content.replace(/\r\n|\n|\r/gu, style);
|
|
508
|
+
}
|
|
483
509
|
function parsePdfPages(value) {
|
|
484
510
|
const match = /^(\d+)(?:-(\d+))?$/u.exec(value);
|
|
485
511
|
if (!match)
|
|
@@ -506,6 +532,7 @@ export class LocalToolRegistry {
|
|
|
506
532
|
maxOutputBytes;
|
|
507
533
|
maxFileBytes;
|
|
508
534
|
maxShellTimeoutMs;
|
|
535
|
+
maxSearchTimeoutMs;
|
|
509
536
|
processRunner;
|
|
510
537
|
enableReportFindings;
|
|
511
538
|
environment;
|
|
@@ -513,7 +540,9 @@ export class LocalToolRegistry {
|
|
|
513
540
|
sandbox;
|
|
514
541
|
homeDirectory;
|
|
515
542
|
configRoot;
|
|
543
|
+
sessionCwds = new Map();
|
|
516
544
|
protectedWriteReason;
|
|
545
|
+
mutationTargetExisted = new WeakMap();
|
|
517
546
|
constructor(options) {
|
|
518
547
|
this.cwd = resolve(options.cwd);
|
|
519
548
|
this.cwdProvider = options.cwdProvider;
|
|
@@ -524,7 +553,8 @@ export class LocalToolRegistry {
|
|
|
524
553
|
this.additionalReadDirectories = (options.additionalReadDirectories ?? []).map((directory) => resolve(directory));
|
|
525
554
|
this.maxOutputBytes = options.maxOutputBytes ?? 128 * 1024;
|
|
526
555
|
this.maxFileBytes = options.maxFileBytes ?? 10 * 1024 * 1024;
|
|
527
|
-
this.maxShellTimeoutMs = options.maxShellTimeoutMs ??
|
|
556
|
+
this.maxShellTimeoutMs = options.maxShellTimeoutMs ?? 600_000;
|
|
557
|
+
this.maxSearchTimeoutMs = options.maxShellTimeoutMs ?? 120_000;
|
|
528
558
|
this.enableReportFindings = options.enableReportFindings ?? false;
|
|
529
559
|
this.environment = options.environment;
|
|
530
560
|
this.sessionEnvironment = options.sessionEnvironment;
|
|
@@ -551,7 +581,7 @@ export class LocalToolRegistry {
|
|
|
551
581
|
}
|
|
552
582
|
}
|
|
553
583
|
assertProtectedBashCommand(command, context) {
|
|
554
|
-
const cwd = this.
|
|
584
|
+
const cwd = this.currentBashCwd(context);
|
|
555
585
|
const protectedWrite = context?.preToolUseAllowed
|
|
556
586
|
? (filePath) => {
|
|
557
587
|
const reason = this.protectedWriteReason(filePath);
|
|
@@ -576,6 +606,18 @@ export class LocalToolRegistry {
|
|
|
576
606
|
currentCwd(context) {
|
|
577
607
|
return resolve(context?.cwd || this.cwdProvider?.() || this.cwd);
|
|
578
608
|
}
|
|
609
|
+
currentBashCwd(context) {
|
|
610
|
+
const hostCwd = this.currentCwd(context);
|
|
611
|
+
if (context?.sessionId) {
|
|
612
|
+
const sessionCwd = this.sessionCwds.get(context.sessionId);
|
|
613
|
+
if (sessionCwd) {
|
|
614
|
+
if (sessionCwd.hostCwd === hostCwd)
|
|
615
|
+
return sessionCwd.cwd;
|
|
616
|
+
this.sessionCwds.delete(context.sessionId);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return hostCwd;
|
|
620
|
+
}
|
|
579
621
|
definitions() {
|
|
580
622
|
const definitions = this.enableReportFindings
|
|
581
623
|
? [...TOOL_DEFINITIONS, REPORT_FINDINGS_DEFINITION]
|
|
@@ -646,28 +688,48 @@ export class LocalToolRegistry {
|
|
|
646
688
|
},
|
|
647
689
|
};
|
|
648
690
|
}
|
|
649
|
-
case 'Write':
|
|
650
|
-
|
|
691
|
+
case 'Write': {
|
|
692
|
+
const filePath = await this.filePath(stringInput(call.input, 'file_path'), true, false, context);
|
|
693
|
+
this.assertProtectedWritePath(filePath);
|
|
694
|
+
const targetExisted = await this.pathExists(filePath);
|
|
695
|
+
if (targetExisted &&
|
|
696
|
+
!(await this.wasSuccessfullyRead(filePath, context.messages ?? [], context))) {
|
|
697
|
+
throw new Error('<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>');
|
|
698
|
+
}
|
|
699
|
+
const prepared = {
|
|
651
700
|
...call,
|
|
652
701
|
input: {
|
|
653
|
-
file_path:
|
|
702
|
+
file_path: filePath,
|
|
654
703
|
content: stringInput(call.input, 'content', true),
|
|
655
704
|
},
|
|
656
705
|
};
|
|
706
|
+
this.mutationTargetExisted.set(prepared, targetExisted);
|
|
707
|
+
return prepared;
|
|
708
|
+
}
|
|
657
709
|
case 'Edit': {
|
|
658
710
|
const replaceAll = call.input.replace_all;
|
|
659
711
|
if (replaceAll !== undefined && typeof replaceAll !== 'boolean') {
|
|
660
712
|
throw new Error('replace_all must be a boolean');
|
|
661
713
|
}
|
|
662
|
-
|
|
714
|
+
const oldString = stringInput(call.input, 'old_string', true);
|
|
715
|
+
const filePath = await this.filePath(stringInput(call.input, 'file_path'), oldString === '', false, context);
|
|
716
|
+
this.assertProtectedWritePath(filePath);
|
|
717
|
+
const targetExisted = await this.pathExists(filePath);
|
|
718
|
+
if (targetExisted &&
|
|
719
|
+
!(await this.wasSuccessfullyRead(filePath, context.messages ?? [], context))) {
|
|
720
|
+
throw new Error('<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>');
|
|
721
|
+
}
|
|
722
|
+
const prepared = {
|
|
663
723
|
...call,
|
|
664
724
|
input: {
|
|
665
|
-
file_path:
|
|
666
|
-
old_string:
|
|
725
|
+
file_path: filePath,
|
|
726
|
+
old_string: oldString,
|
|
667
727
|
new_string: stringInput(call.input, 'new_string', true),
|
|
668
728
|
replace_all: replaceAll ?? false,
|
|
669
729
|
},
|
|
670
730
|
};
|
|
731
|
+
this.mutationTargetExisted.set(prepared, targetExisted);
|
|
732
|
+
return prepared;
|
|
671
733
|
}
|
|
672
734
|
case 'NotebookEdit': {
|
|
673
735
|
const requestedPath = stringInput(call.input, 'notebook_path');
|
|
@@ -759,17 +821,24 @@ export class LocalToolRegistry {
|
|
|
759
821
|
async execute(call, context) {
|
|
760
822
|
if (context.signal?.aborted)
|
|
761
823
|
throw abortError();
|
|
824
|
+
const approvedTargetExisted = this.mutationTargetExisted.get(call);
|
|
762
825
|
const prepared = await this.prepare(call, context);
|
|
763
826
|
if (JSON.stringify(prepared.input) !== JSON.stringify(call.input)) {
|
|
764
827
|
throw new Error('Tool input changed after permission approval');
|
|
765
828
|
}
|
|
829
|
+
const executionTargetExisted = this.mutationTargetExisted.get(prepared);
|
|
830
|
+
if (approvedTargetExisted !== undefined &&
|
|
831
|
+
executionTargetExisted !== approvedTargetExisted) {
|
|
832
|
+
throw new Error('Tool input changed after permission approval');
|
|
833
|
+
}
|
|
834
|
+
const targetExisted = approvedTargetExisted ?? executionTargetExisted;
|
|
766
835
|
switch (prepared.name) {
|
|
767
836
|
case 'Read':
|
|
768
837
|
return this.read(prepared, context);
|
|
769
838
|
case 'Write':
|
|
770
|
-
return this.write(prepared);
|
|
839
|
+
return this.write(prepared, context, targetExisted);
|
|
771
840
|
case 'Edit':
|
|
772
|
-
return this.edit(prepared);
|
|
841
|
+
return this.edit(prepared, targetExisted);
|
|
773
842
|
case 'NotebookEdit':
|
|
774
843
|
return this.notebookEdit(prepared);
|
|
775
844
|
case 'Glob':
|
|
@@ -864,11 +933,28 @@ export class LocalToolRegistry {
|
|
|
864
933
|
return join(parent, basename(candidate));
|
|
865
934
|
}
|
|
866
935
|
}
|
|
936
|
+
async pathExists(filePath) {
|
|
937
|
+
try {
|
|
938
|
+
await stat(filePath);
|
|
939
|
+
return true;
|
|
940
|
+
}
|
|
941
|
+
catch (error) {
|
|
942
|
+
if (error.code === 'ENOENT')
|
|
943
|
+
return false;
|
|
944
|
+
throw error;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
867
947
|
async assertStablePath(filePath) {
|
|
868
948
|
if ((await realpath(filePath)) !== filePath) {
|
|
869
949
|
throw new Error('Tool input changed after permission approval');
|
|
870
950
|
}
|
|
871
951
|
}
|
|
952
|
+
async assertStableCreationParent(filePath) {
|
|
953
|
+
const parent = dirname(filePath);
|
|
954
|
+
if ((await realpath(parent)) !== parent) {
|
|
955
|
+
throw new Error('Tool input changed after permission approval');
|
|
956
|
+
}
|
|
957
|
+
}
|
|
872
958
|
async globRoot(requestedPath, context) {
|
|
873
959
|
const displayedPath = isAbsolute(requestedPath)
|
|
874
960
|
? resolve(requestedPath)
|
|
@@ -954,8 +1040,20 @@ export class LocalToolRegistry {
|
|
|
954
1040
|
: selected
|
|
955
1041
|
.map((line, index) => `${(offset === 0 ? 0 : offset) + index}\t${line}`)
|
|
956
1042
|
.join('\n');
|
|
1043
|
+
if (!notebook) {
|
|
1044
|
+
const contentBytes = Buffer.byteLength(content);
|
|
1045
|
+
if (contentBytes > TEXT_READ_MAX_BYTES) {
|
|
1046
|
+
throw new Error(`Read result is ${contentBytes} bytes, which exceeds the ${formatKilobytes(TEXT_READ_MAX_BYTES)} limit. Use offset and limit to read specific portions.`);
|
|
1047
|
+
}
|
|
1048
|
+
const contentTokens = countTokens(content);
|
|
1049
|
+
if (contentTokens > TEXT_READ_MAX_TOKENS) {
|
|
1050
|
+
throw new Error(`Read result is ${contentTokens} tokens, which exceeds the ${TEXT_READ_MAX_TOKENS} token limit. Use offset and limit to read specific portions.`);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
957
1053
|
return {
|
|
958
|
-
content:
|
|
1054
|
+
content: notebook
|
|
1055
|
+
? truncateOutput(content, this.maxOutputBytes)
|
|
1056
|
+
: content,
|
|
959
1057
|
isError: false,
|
|
960
1058
|
accessedPaths: [filePath],
|
|
961
1059
|
...(notebook
|
|
@@ -1053,21 +1151,19 @@ export class LocalToolRegistry {
|
|
|
1053
1151
|
await mkdir(parent, { recursive: true });
|
|
1054
1152
|
return mkdtemp(join(parent, 'pdf-'));
|
|
1055
1153
|
}
|
|
1056
|
-
async write(call) {
|
|
1154
|
+
async write(call, context, preparedTargetExisted) {
|
|
1057
1155
|
const filePath = stringInput(call.input, 'file_path');
|
|
1058
1156
|
this.assertProtectedWritePath(filePath);
|
|
1059
|
-
const
|
|
1060
|
-
if (Buffer.byteLength(content) > this.maxFileBytes) {
|
|
1061
|
-
throw new Error(`Content exceeds ${this.maxFileBytes} byte write limit`);
|
|
1062
|
-
}
|
|
1157
|
+
const requestedContent = stringInput(call.input, 'content', true);
|
|
1063
1158
|
let handle;
|
|
1064
1159
|
let newFile = false;
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1160
|
+
const hasRead = await this.wasSuccessfullyRead(filePath, context.messages ?? [], context);
|
|
1161
|
+
const targetExisted = preparedTargetExisted ?? (await this.pathExists(filePath));
|
|
1162
|
+
if (!targetExisted) {
|
|
1163
|
+
if (Buffer.byteLength(requestedContent) > this.maxFileBytes) {
|
|
1164
|
+
throw new Error(`Content exceeds ${this.maxFileBytes} byte write limit`);
|
|
1165
|
+
}
|
|
1166
|
+
await this.assertStableCreationParent(filePath);
|
|
1071
1167
|
try {
|
|
1072
1168
|
handle = await open(filePath, constants.O_RDWR |
|
|
1073
1169
|
constants.O_CREAT |
|
|
@@ -1076,19 +1172,40 @@ export class LocalToolRegistry {
|
|
|
1076
1172
|
newFile = true;
|
|
1077
1173
|
}
|
|
1078
1174
|
catch (createError) {
|
|
1079
|
-
if (createError.code
|
|
1080
|
-
throw
|
|
1175
|
+
if (createError.code === 'EEXIST') {
|
|
1176
|
+
throw new Error('<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>');
|
|
1081
1177
|
}
|
|
1178
|
+
throw createError;
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
else {
|
|
1182
|
+
if (!hasRead) {
|
|
1183
|
+
throw new Error('<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>');
|
|
1184
|
+
}
|
|
1185
|
+
try {
|
|
1082
1186
|
handle = await open(filePath, constants.O_RDWR | constants.O_NOFOLLOW);
|
|
1083
1187
|
}
|
|
1188
|
+
catch (error) {
|
|
1189
|
+
if (error.code === 'ENOENT') {
|
|
1190
|
+
throw new Error('Tool input changed after permission approval');
|
|
1191
|
+
}
|
|
1192
|
+
throw error;
|
|
1193
|
+
}
|
|
1084
1194
|
}
|
|
1085
1195
|
try {
|
|
1086
1196
|
await this.assertStablePath(filePath);
|
|
1087
1197
|
const metadata = await handle.stat();
|
|
1088
1198
|
if (!metadata.isFile())
|
|
1089
1199
|
throw new Error(`Not a file: ${filePath}`);
|
|
1200
|
+
if (!newFile && metadata.size > this.maxFileBytes) {
|
|
1201
|
+
throw new Error(`File exceeds ${this.maxFileBytes} byte write limit`);
|
|
1202
|
+
}
|
|
1090
1203
|
const preContent = newFile ? '' : await handle.readFile('utf8');
|
|
1204
|
+
const content = normalizeNewlines(requestedContent, newlineStyle(preContent));
|
|
1091
1205
|
const encoded = Buffer.from(content);
|
|
1206
|
+
if (encoded.length > this.maxFileBytes) {
|
|
1207
|
+
throw new Error(`Content exceeds ${this.maxFileBytes} byte write limit`);
|
|
1208
|
+
}
|
|
1092
1209
|
const { linesAdded, linesRemoved } = countLineChanges(preContent, content, preContent === '' ? { newFile: true } : undefined);
|
|
1093
1210
|
await handle.write(encoded, 0, encoded.length, 0);
|
|
1094
1211
|
await handle.truncate(encoded.length);
|
|
@@ -1098,28 +1215,84 @@ export class LocalToolRegistry {
|
|
|
1098
1215
|
isError: false,
|
|
1099
1216
|
linesAdded,
|
|
1100
1217
|
linesRemoved,
|
|
1218
|
+
nativeToolUseResult: { filePath, content },
|
|
1101
1219
|
};
|
|
1102
1220
|
}
|
|
1103
1221
|
finally {
|
|
1104
1222
|
await handle.close();
|
|
1105
1223
|
}
|
|
1106
1224
|
}
|
|
1107
|
-
async edit(call) {
|
|
1225
|
+
async edit(call, preparedTargetExisted) {
|
|
1108
1226
|
const filePath = stringInput(call.input, 'file_path');
|
|
1109
1227
|
this.assertProtectedWritePath(filePath);
|
|
1110
|
-
const oldString = stringInput(call.input, 'old_string');
|
|
1228
|
+
const oldString = stringInput(call.input, 'old_string', true);
|
|
1111
1229
|
const newString = stringInput(call.input, 'new_string', true);
|
|
1112
|
-
|
|
1230
|
+
if (oldString === '' && Buffer.byteLength(newString) > this.maxFileBytes) {
|
|
1231
|
+
throw new Error(`Content exceeds ${this.maxFileBytes} byte write limit`);
|
|
1232
|
+
}
|
|
1233
|
+
let handle;
|
|
1234
|
+
let newFile = false;
|
|
1235
|
+
if (oldString === '') {
|
|
1236
|
+
const targetExisted = preparedTargetExisted ?? (await this.pathExists(filePath));
|
|
1237
|
+
if (targetExisted) {
|
|
1238
|
+
handle = await open(filePath, constants.O_RDWR | constants.O_NOFOLLOW);
|
|
1239
|
+
}
|
|
1240
|
+
else {
|
|
1241
|
+
await this.assertStableCreationParent(filePath);
|
|
1242
|
+
try {
|
|
1243
|
+
handle = await open(filePath, constants.O_RDWR |
|
|
1244
|
+
constants.O_CREAT |
|
|
1245
|
+
constants.O_EXCL |
|
|
1246
|
+
constants.O_NOFOLLOW, 0o666);
|
|
1247
|
+
newFile = true;
|
|
1248
|
+
}
|
|
1249
|
+
catch (error) {
|
|
1250
|
+
if (error.code === 'EEXIST') {
|
|
1251
|
+
throw new Error('Tool input changed after permission approval');
|
|
1252
|
+
}
|
|
1253
|
+
throw error;
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
else {
|
|
1258
|
+
handle = await open(filePath, constants.O_RDWR | constants.O_NOFOLLOW);
|
|
1259
|
+
}
|
|
1113
1260
|
try {
|
|
1114
1261
|
await this.assertStablePath(filePath);
|
|
1115
1262
|
const metadata = await handle.stat();
|
|
1116
1263
|
if (!metadata.isFile())
|
|
1117
1264
|
throw new Error(`Not a file: ${filePath}`);
|
|
1265
|
+
if (newFile) {
|
|
1266
|
+
const content = newString;
|
|
1267
|
+
const encoded = Buffer.from(content);
|
|
1268
|
+
if (encoded.length > this.maxFileBytes) {
|
|
1269
|
+
throw new Error(`Content exceeds ${this.maxFileBytes} byte write limit`);
|
|
1270
|
+
}
|
|
1271
|
+
const { linesAdded, linesRemoved } = countLineChanges('', content, {
|
|
1272
|
+
newFile: true,
|
|
1273
|
+
});
|
|
1274
|
+
await handle.write(encoded, 0, encoded.length, 0);
|
|
1275
|
+
await handle.truncate(encoded.length);
|
|
1276
|
+
await handle.sync();
|
|
1277
|
+
return {
|
|
1278
|
+
content: `Replaced 1 occurrence(s)`,
|
|
1279
|
+
isError: false,
|
|
1280
|
+
linesAdded,
|
|
1281
|
+
linesRemoved,
|
|
1282
|
+
nativeToolUseResult: { filePath, content },
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
if (oldString === '') {
|
|
1286
|
+
throw new Error('Edit with an empty old_string is only valid for creating a missing file');
|
|
1287
|
+
}
|
|
1118
1288
|
if (metadata.size > this.maxFileBytes) {
|
|
1119
1289
|
throw new Error(`File exceeds ${this.maxFileBytes} byte edit limit`);
|
|
1120
1290
|
}
|
|
1121
1291
|
const source = await handle.readFile('utf8');
|
|
1122
|
-
const
|
|
1292
|
+
const style = newlineStyle(source);
|
|
1293
|
+
const normalizedOldString = normalizeNewlines(oldString, style);
|
|
1294
|
+
const normalizedNewString = normalizeNewlines(newString, style);
|
|
1295
|
+
const occurrences = source.split(normalizedOldString).length - 1;
|
|
1123
1296
|
if (occurrences === 0)
|
|
1124
1297
|
throw new Error('old_string was not found');
|
|
1125
1298
|
if (call.input.replace_all !== true && occurrences !== 1) {
|
|
@@ -1128,13 +1301,14 @@ export class LocalToolRegistry {
|
|
|
1128
1301
|
const replacementCount = call.input.replace_all === true ? occurrences : 1;
|
|
1129
1302
|
const outputBytes = Buffer.byteLength(source) +
|
|
1130
1303
|
replacementCount *
|
|
1131
|
-
(Buffer.byteLength(
|
|
1304
|
+
(Buffer.byteLength(normalizedNewString) -
|
|
1305
|
+
Buffer.byteLength(normalizedOldString));
|
|
1132
1306
|
if (outputBytes > this.maxFileBytes) {
|
|
1133
1307
|
throw new Error(`Edited content exceeds ${this.maxFileBytes} bytes`);
|
|
1134
1308
|
}
|
|
1135
1309
|
const output = call.input.replace_all === true
|
|
1136
|
-
? source.replaceAll(
|
|
1137
|
-
: source.replace(
|
|
1310
|
+
? source.replaceAll(normalizedOldString, normalizedNewString)
|
|
1311
|
+
: source.replace(normalizedOldString, normalizedNewString);
|
|
1138
1312
|
const { linesAdded, linesRemoved } = countLineChanges(source, output);
|
|
1139
1313
|
const encoded = Buffer.from(output);
|
|
1140
1314
|
await handle.write(encoded, 0, encoded.length, 0);
|
|
@@ -1145,6 +1319,7 @@ export class LocalToolRegistry {
|
|
|
1145
1319
|
isError: false,
|
|
1146
1320
|
linesAdded,
|
|
1147
1321
|
linesRemoved,
|
|
1322
|
+
nativeToolUseResult: { filePath, content: output },
|
|
1148
1323
|
};
|
|
1149
1324
|
}
|
|
1150
1325
|
finally {
|
|
@@ -1191,7 +1366,7 @@ export class LocalToolRegistry {
|
|
|
1191
1366
|
async glob(call, context) {
|
|
1192
1367
|
const requestedPath = call.input.path === undefined ? '.' : stringInput(call.input, 'path');
|
|
1193
1368
|
const root = await this.globRoot(requestedPath, context);
|
|
1194
|
-
const timeoutSignal = AbortSignal.timeout(this.
|
|
1369
|
+
const timeoutSignal = AbortSignal.timeout(this.maxSearchTimeoutMs);
|
|
1195
1370
|
const searchSignal = context.signal
|
|
1196
1371
|
? AbortSignal.any([context.signal, timeoutSignal])
|
|
1197
1372
|
: timeoutSignal;
|
|
@@ -1215,7 +1390,7 @@ export class LocalToolRegistry {
|
|
|
1215
1390
|
throw abortError();
|
|
1216
1391
|
if (timeoutSignal.aborted) {
|
|
1217
1392
|
return {
|
|
1218
|
-
content: `Search timed out after ${this.
|
|
1393
|
+
content: `Search timed out after ${this.maxSearchTimeoutMs}ms`,
|
|
1219
1394
|
isError: true,
|
|
1220
1395
|
};
|
|
1221
1396
|
}
|
|
@@ -1240,14 +1415,14 @@ export class LocalToolRegistry {
|
|
|
1240
1415
|
const result = await this.processRunner.run({
|
|
1241
1416
|
command: 'rg',
|
|
1242
1417
|
args,
|
|
1243
|
-
timeoutMs: this.
|
|
1418
|
+
timeoutMs: this.maxSearchTimeoutMs,
|
|
1244
1419
|
cwd: this.currentCwd(context),
|
|
1245
1420
|
...(this.environment ? { env: this.environment } : {}),
|
|
1246
1421
|
...(context.signal ? { signal: context.signal } : {}),
|
|
1247
1422
|
});
|
|
1248
1423
|
if (result.timedOut) {
|
|
1249
1424
|
return {
|
|
1250
|
-
content: `Search timed out after ${this.
|
|
1425
|
+
content: `Search timed out after ${this.maxSearchTimeoutMs}ms`,
|
|
1251
1426
|
isError: true,
|
|
1252
1427
|
};
|
|
1253
1428
|
}
|
|
@@ -1274,39 +1449,131 @@ export class LocalToolRegistry {
|
|
|
1274
1449
|
};
|
|
1275
1450
|
const sandboxed = this.sandbox?.shouldUseSandbox(sandboxPolicyInput) ?? false;
|
|
1276
1451
|
const shell = commandShell();
|
|
1452
|
+
let cwdToken;
|
|
1453
|
+
let sandboxCommandAttempted = false;
|
|
1277
1454
|
let result;
|
|
1278
1455
|
try {
|
|
1279
1456
|
const sessionEnvironment = context.sessionId
|
|
1280
1457
|
? await this.sessionEnvironment?.(context.sessionId)
|
|
1281
1458
|
: undefined;
|
|
1282
|
-
const
|
|
1283
|
-
?
|
|
1284
|
-
|
|
1459
|
+
const processEnvironment = this.environment || sessionEnvironment
|
|
1460
|
+
? { env: { ...this.environment, ...sessionEnvironment } }
|
|
1461
|
+
: {};
|
|
1462
|
+
let executionTimeout = timeout;
|
|
1463
|
+
let syntaxTimeoutResult;
|
|
1464
|
+
if (context.sessionId) {
|
|
1465
|
+
const syntaxStartedAt = Date.now();
|
|
1466
|
+
const syntaxResult = await this.processRunner.run({
|
|
1467
|
+
command: shell,
|
|
1468
|
+
args: commandShellSyntaxArguments(rawCommand),
|
|
1469
|
+
timeoutMs: timeout,
|
|
1470
|
+
cwd: this.currentBashCwd(context),
|
|
1471
|
+
...processEnvironment,
|
|
1285
1472
|
...(context.signal ? { signal: context.signal } : {}),
|
|
1286
|
-
|
|
1287
|
-
|
|
1473
|
+
});
|
|
1474
|
+
executionTimeout = Math.max(1, timeout - (Date.now() - syntaxStartedAt));
|
|
1475
|
+
if (syntaxResult.timedOut) {
|
|
1476
|
+
syntaxTimeoutResult = syntaxResult;
|
|
1477
|
+
}
|
|
1478
|
+
else if (syntaxResult.code === 0) {
|
|
1479
|
+
cwdToken = randomBytes(16).toString('hex');
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
const controlOutputFd = cwdToken ? randomInt(5, 10) : undefined;
|
|
1483
|
+
const statusVariable = cwdToken
|
|
1484
|
+
? `_praxis_exit_status_${cwdToken}`
|
|
1485
|
+
: undefined;
|
|
1486
|
+
const trapStatusVariable = cwdToken
|
|
1487
|
+
? `_praxis_trap_status_${cwdToken}`
|
|
1488
|
+
: undefined;
|
|
1489
|
+
const cwdReportCommand = cwdToken && controlOutputFd
|
|
1490
|
+
? `{ printf "%s%s\\0" "${cwdToken}" "$(pwd -P 2>/dev/null)" >&${controlOutputFd}; } 2>/dev/null`
|
|
1491
|
+
: undefined;
|
|
1492
|
+
const executionCommand = cwdToken && trapStatusVariable && cwdReportCommand
|
|
1493
|
+
? `if [ -n "\${ZSH_VERSION-}" ]; then 0=${JSON.stringify(shell)}; fi
|
|
1494
|
+
trap '${trapStatusVariable}=$?; ${cwdReportCommand}; exit "$${trapStatusVariable}"' EXIT
|
|
1495
|
+
${rawCommand}
|
|
1496
|
+
${statusVariable}=$?
|
|
1497
|
+
${cwdReportCommand}
|
|
1498
|
+
exit "$${statusVariable}"`
|
|
1288
1499
|
: rawCommand;
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1500
|
+
const scriptLoaderCommand = 'eval "$(cat <&4)"';
|
|
1501
|
+
if (syntaxTimeoutResult) {
|
|
1502
|
+
result = syntaxTimeoutResult;
|
|
1503
|
+
}
|
|
1504
|
+
else {
|
|
1505
|
+
let command;
|
|
1506
|
+
if (sandboxed) {
|
|
1507
|
+
sandboxCommandAttempted = true;
|
|
1508
|
+
command = await this.sandbox?.wrapCommand(cwdToken
|
|
1509
|
+
? { ...sandboxPolicyInput, executionCommand: scriptLoaderCommand }
|
|
1510
|
+
: sandboxPolicyInput, {
|
|
1511
|
+
shell,
|
|
1512
|
+
...(context.signal ? { signal: context.signal } : {}),
|
|
1513
|
+
commandId: call.id,
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
else {
|
|
1517
|
+
command = cwdToken ? scriptLoaderCommand : rawCommand;
|
|
1518
|
+
}
|
|
1519
|
+
result = await this.processRunner.run({
|
|
1520
|
+
command: shell,
|
|
1521
|
+
args: commandShellArguments(command ?? rawCommand),
|
|
1522
|
+
timeoutMs: executionTimeout,
|
|
1523
|
+
cwd: this.currentBashCwd(context),
|
|
1524
|
+
...processEnvironment,
|
|
1525
|
+
...(context.signal ? { signal: context.signal } : {}),
|
|
1526
|
+
...(cwdToken && controlOutputFd
|
|
1527
|
+
? {
|
|
1528
|
+
controlOutputBytes: 8192,
|
|
1529
|
+
controlOutputFd,
|
|
1530
|
+
scriptInput: executionCommand,
|
|
1531
|
+
}
|
|
1532
|
+
: {}),
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1299
1535
|
}
|
|
1300
1536
|
finally {
|
|
1301
|
-
if (
|
|
1537
|
+
if (sandboxCommandAttempted)
|
|
1302
1538
|
this.sandbox?.cleanupAfterCommand();
|
|
1303
1539
|
}
|
|
1304
|
-
if (
|
|
1540
|
+
if (cwdToken) {
|
|
1541
|
+
result = {
|
|
1542
|
+
...result,
|
|
1543
|
+
stdout: stripShellControlTrace(result.stdout, cwdToken),
|
|
1544
|
+
stderr: stripShellControlTrace(result.stderr, cwdToken),
|
|
1545
|
+
output: stripShellControlTrace(result.output, cwdToken),
|
|
1546
|
+
};
|
|
1547
|
+
}
|
|
1548
|
+
if (sandboxCommandAttempted && this.sandbox) {
|
|
1305
1549
|
result = {
|
|
1306
1550
|
...result,
|
|
1307
1551
|
stderr: this.sandbox.annotateStderr(call.id, result.stderr),
|
|
1308
1552
|
};
|
|
1309
1553
|
}
|
|
1554
|
+
if (cwdToken && !result.timedOut && result.controlOutput) {
|
|
1555
|
+
const controlRecord = result.controlOutput.startsWith(cwdToken)
|
|
1556
|
+
? result.controlOutput.slice(cwdToken.length)
|
|
1557
|
+
: undefined;
|
|
1558
|
+
const terminator = controlRecord?.indexOf('\0') ?? -1;
|
|
1559
|
+
const record = controlRecord && terminator > 0
|
|
1560
|
+
? controlRecord.slice(0, terminator)
|
|
1561
|
+
: undefined;
|
|
1562
|
+
if (record) {
|
|
1563
|
+
try {
|
|
1564
|
+
const canonicalCwd = await realpath(record);
|
|
1565
|
+
if ((await stat(canonicalCwd)).isDirectory() && context.sessionId) {
|
|
1566
|
+
this.sessionCwds.set(context.sessionId, {
|
|
1567
|
+
cwd: canonicalCwd,
|
|
1568
|
+
hostCwd: resolve(context.cwd || this.cwdProvider?.() || this.cwd),
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
catch {
|
|
1573
|
+
// A command may remove or replace its final directory. Keep prior state.
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1310
1577
|
if (result.timedOut) {
|
|
1311
1578
|
return {
|
|
1312
1579
|
content: `Command timed out after ${timeout}ms`,
|