praxis-agent 0.46.2 → 0.46.4
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 +303 -51
- 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,4 +1,5 @@
|
|
|
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';
|
|
@@ -247,6 +248,20 @@ function stringInput(input, name, allowEmpty = false) {
|
|
|
247
248
|
}
|
|
248
249
|
return value;
|
|
249
250
|
}
|
|
251
|
+
function stripShellControlTrace(content, token) {
|
|
252
|
+
if (!content.includes(token))
|
|
253
|
+
return content;
|
|
254
|
+
return content
|
|
255
|
+
.split('\n')
|
|
256
|
+
.filter((line) => !line.includes(token))
|
|
257
|
+
.join('\n');
|
|
258
|
+
}
|
|
259
|
+
function commandShellSyntaxArguments(command) {
|
|
260
|
+
const args = [...commandShellArguments(command)];
|
|
261
|
+
const commandIndex = args.indexOf('-c');
|
|
262
|
+
args.splice(commandIndex, 0, '-n');
|
|
263
|
+
return args;
|
|
264
|
+
}
|
|
250
265
|
function optionalString(input, name) {
|
|
251
266
|
const value = input[name];
|
|
252
267
|
if (value === undefined)
|
|
@@ -480,6 +495,14 @@ function formatKilobytes(bytes) {
|
|
|
480
495
|
const value = bytes / 1024;
|
|
481
496
|
return `${value < 10 ? value.toFixed(1) : Math.round(value)}KB`.replace('.0KB', 'KB');
|
|
482
497
|
}
|
|
498
|
+
function newlineStyle(content) {
|
|
499
|
+
return content.match(/\r\n|\n|\r/u)?.[0];
|
|
500
|
+
}
|
|
501
|
+
function normalizeNewlines(content, style) {
|
|
502
|
+
if (style === undefined)
|
|
503
|
+
return content;
|
|
504
|
+
return content.replace(/\r\n|\n|\r/gu, style);
|
|
505
|
+
}
|
|
483
506
|
function parsePdfPages(value) {
|
|
484
507
|
const match = /^(\d+)(?:-(\d+))?$/u.exec(value);
|
|
485
508
|
if (!match)
|
|
@@ -506,6 +529,7 @@ export class LocalToolRegistry {
|
|
|
506
529
|
maxOutputBytes;
|
|
507
530
|
maxFileBytes;
|
|
508
531
|
maxShellTimeoutMs;
|
|
532
|
+
maxSearchTimeoutMs;
|
|
509
533
|
processRunner;
|
|
510
534
|
enableReportFindings;
|
|
511
535
|
environment;
|
|
@@ -513,7 +537,9 @@ export class LocalToolRegistry {
|
|
|
513
537
|
sandbox;
|
|
514
538
|
homeDirectory;
|
|
515
539
|
configRoot;
|
|
540
|
+
sessionCwds = new Map();
|
|
516
541
|
protectedWriteReason;
|
|
542
|
+
mutationTargetExisted = new WeakMap();
|
|
517
543
|
constructor(options) {
|
|
518
544
|
this.cwd = resolve(options.cwd);
|
|
519
545
|
this.cwdProvider = options.cwdProvider;
|
|
@@ -524,7 +550,8 @@ export class LocalToolRegistry {
|
|
|
524
550
|
this.additionalReadDirectories = (options.additionalReadDirectories ?? []).map((directory) => resolve(directory));
|
|
525
551
|
this.maxOutputBytes = options.maxOutputBytes ?? 128 * 1024;
|
|
526
552
|
this.maxFileBytes = options.maxFileBytes ?? 10 * 1024 * 1024;
|
|
527
|
-
this.maxShellTimeoutMs = options.maxShellTimeoutMs ??
|
|
553
|
+
this.maxShellTimeoutMs = options.maxShellTimeoutMs ?? 600_000;
|
|
554
|
+
this.maxSearchTimeoutMs = options.maxShellTimeoutMs ?? 120_000;
|
|
528
555
|
this.enableReportFindings = options.enableReportFindings ?? false;
|
|
529
556
|
this.environment = options.environment;
|
|
530
557
|
this.sessionEnvironment = options.sessionEnvironment;
|
|
@@ -551,7 +578,7 @@ export class LocalToolRegistry {
|
|
|
551
578
|
}
|
|
552
579
|
}
|
|
553
580
|
assertProtectedBashCommand(command, context) {
|
|
554
|
-
const cwd = this.
|
|
581
|
+
const cwd = this.currentBashCwd(context);
|
|
555
582
|
const protectedWrite = context?.preToolUseAllowed
|
|
556
583
|
? (filePath) => {
|
|
557
584
|
const reason = this.protectedWriteReason(filePath);
|
|
@@ -576,6 +603,18 @@ export class LocalToolRegistry {
|
|
|
576
603
|
currentCwd(context) {
|
|
577
604
|
return resolve(context?.cwd || this.cwdProvider?.() || this.cwd);
|
|
578
605
|
}
|
|
606
|
+
currentBashCwd(context) {
|
|
607
|
+
const hostCwd = this.currentCwd(context);
|
|
608
|
+
if (context?.sessionId) {
|
|
609
|
+
const sessionCwd = this.sessionCwds.get(context.sessionId);
|
|
610
|
+
if (sessionCwd) {
|
|
611
|
+
if (sessionCwd.hostCwd === hostCwd)
|
|
612
|
+
return sessionCwd.cwd;
|
|
613
|
+
this.sessionCwds.delete(context.sessionId);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return hostCwd;
|
|
617
|
+
}
|
|
579
618
|
definitions() {
|
|
580
619
|
const definitions = this.enableReportFindings
|
|
581
620
|
? [...TOOL_DEFINITIONS, REPORT_FINDINGS_DEFINITION]
|
|
@@ -646,28 +685,48 @@ export class LocalToolRegistry {
|
|
|
646
685
|
},
|
|
647
686
|
};
|
|
648
687
|
}
|
|
649
|
-
case 'Write':
|
|
650
|
-
|
|
688
|
+
case 'Write': {
|
|
689
|
+
const filePath = await this.filePath(stringInput(call.input, 'file_path'), true, false, context);
|
|
690
|
+
this.assertProtectedWritePath(filePath);
|
|
691
|
+
const targetExisted = await this.pathExists(filePath);
|
|
692
|
+
if (targetExisted &&
|
|
693
|
+
!(await this.wasSuccessfullyRead(filePath, context.messages ?? [], context))) {
|
|
694
|
+
throw new Error('<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>');
|
|
695
|
+
}
|
|
696
|
+
const prepared = {
|
|
651
697
|
...call,
|
|
652
698
|
input: {
|
|
653
|
-
file_path:
|
|
699
|
+
file_path: filePath,
|
|
654
700
|
content: stringInput(call.input, 'content', true),
|
|
655
701
|
},
|
|
656
702
|
};
|
|
703
|
+
this.mutationTargetExisted.set(prepared, targetExisted);
|
|
704
|
+
return prepared;
|
|
705
|
+
}
|
|
657
706
|
case 'Edit': {
|
|
658
707
|
const replaceAll = call.input.replace_all;
|
|
659
708
|
if (replaceAll !== undefined && typeof replaceAll !== 'boolean') {
|
|
660
709
|
throw new Error('replace_all must be a boolean');
|
|
661
710
|
}
|
|
662
|
-
|
|
711
|
+
const oldString = stringInput(call.input, 'old_string', true);
|
|
712
|
+
const filePath = await this.filePath(stringInput(call.input, 'file_path'), oldString === '', false, context);
|
|
713
|
+
this.assertProtectedWritePath(filePath);
|
|
714
|
+
const targetExisted = await this.pathExists(filePath);
|
|
715
|
+
if (targetExisted &&
|
|
716
|
+
!(await this.wasSuccessfullyRead(filePath, context.messages ?? [], context))) {
|
|
717
|
+
throw new Error('<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>');
|
|
718
|
+
}
|
|
719
|
+
const prepared = {
|
|
663
720
|
...call,
|
|
664
721
|
input: {
|
|
665
|
-
file_path:
|
|
666
|
-
old_string:
|
|
722
|
+
file_path: filePath,
|
|
723
|
+
old_string: oldString,
|
|
667
724
|
new_string: stringInput(call.input, 'new_string', true),
|
|
668
725
|
replace_all: replaceAll ?? false,
|
|
669
726
|
},
|
|
670
727
|
};
|
|
728
|
+
this.mutationTargetExisted.set(prepared, targetExisted);
|
|
729
|
+
return prepared;
|
|
671
730
|
}
|
|
672
731
|
case 'NotebookEdit': {
|
|
673
732
|
const requestedPath = stringInput(call.input, 'notebook_path');
|
|
@@ -759,17 +818,24 @@ export class LocalToolRegistry {
|
|
|
759
818
|
async execute(call, context) {
|
|
760
819
|
if (context.signal?.aborted)
|
|
761
820
|
throw abortError();
|
|
821
|
+
const approvedTargetExisted = this.mutationTargetExisted.get(call);
|
|
762
822
|
const prepared = await this.prepare(call, context);
|
|
763
823
|
if (JSON.stringify(prepared.input) !== JSON.stringify(call.input)) {
|
|
764
824
|
throw new Error('Tool input changed after permission approval');
|
|
765
825
|
}
|
|
826
|
+
const executionTargetExisted = this.mutationTargetExisted.get(prepared);
|
|
827
|
+
if (approvedTargetExisted !== undefined &&
|
|
828
|
+
executionTargetExisted !== approvedTargetExisted) {
|
|
829
|
+
throw new Error('Tool input changed after permission approval');
|
|
830
|
+
}
|
|
831
|
+
const targetExisted = approvedTargetExisted ?? executionTargetExisted;
|
|
766
832
|
switch (prepared.name) {
|
|
767
833
|
case 'Read':
|
|
768
834
|
return this.read(prepared, context);
|
|
769
835
|
case 'Write':
|
|
770
|
-
return this.write(prepared);
|
|
836
|
+
return this.write(prepared, context, targetExisted);
|
|
771
837
|
case 'Edit':
|
|
772
|
-
return this.edit(prepared);
|
|
838
|
+
return this.edit(prepared, targetExisted);
|
|
773
839
|
case 'NotebookEdit':
|
|
774
840
|
return this.notebookEdit(prepared);
|
|
775
841
|
case 'Glob':
|
|
@@ -864,11 +930,28 @@ export class LocalToolRegistry {
|
|
|
864
930
|
return join(parent, basename(candidate));
|
|
865
931
|
}
|
|
866
932
|
}
|
|
933
|
+
async pathExists(filePath) {
|
|
934
|
+
try {
|
|
935
|
+
await stat(filePath);
|
|
936
|
+
return true;
|
|
937
|
+
}
|
|
938
|
+
catch (error) {
|
|
939
|
+
if (error.code === 'ENOENT')
|
|
940
|
+
return false;
|
|
941
|
+
throw error;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
867
944
|
async assertStablePath(filePath) {
|
|
868
945
|
if ((await realpath(filePath)) !== filePath) {
|
|
869
946
|
throw new Error('Tool input changed after permission approval');
|
|
870
947
|
}
|
|
871
948
|
}
|
|
949
|
+
async assertStableCreationParent(filePath) {
|
|
950
|
+
const parent = dirname(filePath);
|
|
951
|
+
if ((await realpath(parent)) !== parent) {
|
|
952
|
+
throw new Error('Tool input changed after permission approval');
|
|
953
|
+
}
|
|
954
|
+
}
|
|
872
955
|
async globRoot(requestedPath, context) {
|
|
873
956
|
const displayedPath = isAbsolute(requestedPath)
|
|
874
957
|
? resolve(requestedPath)
|
|
@@ -1053,21 +1136,19 @@ export class LocalToolRegistry {
|
|
|
1053
1136
|
await mkdir(parent, { recursive: true });
|
|
1054
1137
|
return mkdtemp(join(parent, 'pdf-'));
|
|
1055
1138
|
}
|
|
1056
|
-
async write(call) {
|
|
1139
|
+
async write(call, context, preparedTargetExisted) {
|
|
1057
1140
|
const filePath = stringInput(call.input, 'file_path');
|
|
1058
1141
|
this.assertProtectedWritePath(filePath);
|
|
1059
|
-
const
|
|
1060
|
-
if (Buffer.byteLength(content) > this.maxFileBytes) {
|
|
1061
|
-
throw new Error(`Content exceeds ${this.maxFileBytes} byte write limit`);
|
|
1062
|
-
}
|
|
1142
|
+
const requestedContent = stringInput(call.input, 'content', true);
|
|
1063
1143
|
let handle;
|
|
1064
1144
|
let newFile = false;
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1145
|
+
const hasRead = await this.wasSuccessfullyRead(filePath, context.messages ?? [], context);
|
|
1146
|
+
const targetExisted = preparedTargetExisted ?? (await this.pathExists(filePath));
|
|
1147
|
+
if (!targetExisted) {
|
|
1148
|
+
if (Buffer.byteLength(requestedContent) > this.maxFileBytes) {
|
|
1149
|
+
throw new Error(`Content exceeds ${this.maxFileBytes} byte write limit`);
|
|
1150
|
+
}
|
|
1151
|
+
await this.assertStableCreationParent(filePath);
|
|
1071
1152
|
try {
|
|
1072
1153
|
handle = await open(filePath, constants.O_RDWR |
|
|
1073
1154
|
constants.O_CREAT |
|
|
@@ -1076,19 +1157,40 @@ export class LocalToolRegistry {
|
|
|
1076
1157
|
newFile = true;
|
|
1077
1158
|
}
|
|
1078
1159
|
catch (createError) {
|
|
1079
|
-
if (createError.code
|
|
1080
|
-
throw
|
|
1160
|
+
if (createError.code === 'EEXIST') {
|
|
1161
|
+
throw new Error('<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>');
|
|
1081
1162
|
}
|
|
1163
|
+
throw createError;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
else {
|
|
1167
|
+
if (!hasRead) {
|
|
1168
|
+
throw new Error('<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>');
|
|
1169
|
+
}
|
|
1170
|
+
try {
|
|
1082
1171
|
handle = await open(filePath, constants.O_RDWR | constants.O_NOFOLLOW);
|
|
1083
1172
|
}
|
|
1173
|
+
catch (error) {
|
|
1174
|
+
if (error.code === 'ENOENT') {
|
|
1175
|
+
throw new Error('Tool input changed after permission approval');
|
|
1176
|
+
}
|
|
1177
|
+
throw error;
|
|
1178
|
+
}
|
|
1084
1179
|
}
|
|
1085
1180
|
try {
|
|
1086
1181
|
await this.assertStablePath(filePath);
|
|
1087
1182
|
const metadata = await handle.stat();
|
|
1088
1183
|
if (!metadata.isFile())
|
|
1089
1184
|
throw new Error(`Not a file: ${filePath}`);
|
|
1185
|
+
if (!newFile && metadata.size > this.maxFileBytes) {
|
|
1186
|
+
throw new Error(`File exceeds ${this.maxFileBytes} byte write limit`);
|
|
1187
|
+
}
|
|
1090
1188
|
const preContent = newFile ? '' : await handle.readFile('utf8');
|
|
1189
|
+
const content = normalizeNewlines(requestedContent, newlineStyle(preContent));
|
|
1091
1190
|
const encoded = Buffer.from(content);
|
|
1191
|
+
if (encoded.length > this.maxFileBytes) {
|
|
1192
|
+
throw new Error(`Content exceeds ${this.maxFileBytes} byte write limit`);
|
|
1193
|
+
}
|
|
1092
1194
|
const { linesAdded, linesRemoved } = countLineChanges(preContent, content, preContent === '' ? { newFile: true } : undefined);
|
|
1093
1195
|
await handle.write(encoded, 0, encoded.length, 0);
|
|
1094
1196
|
await handle.truncate(encoded.length);
|
|
@@ -1098,28 +1200,84 @@ export class LocalToolRegistry {
|
|
|
1098
1200
|
isError: false,
|
|
1099
1201
|
linesAdded,
|
|
1100
1202
|
linesRemoved,
|
|
1203
|
+
nativeToolUseResult: { filePath, content },
|
|
1101
1204
|
};
|
|
1102
1205
|
}
|
|
1103
1206
|
finally {
|
|
1104
1207
|
await handle.close();
|
|
1105
1208
|
}
|
|
1106
1209
|
}
|
|
1107
|
-
async edit(call) {
|
|
1210
|
+
async edit(call, preparedTargetExisted) {
|
|
1108
1211
|
const filePath = stringInput(call.input, 'file_path');
|
|
1109
1212
|
this.assertProtectedWritePath(filePath);
|
|
1110
|
-
const oldString = stringInput(call.input, 'old_string');
|
|
1213
|
+
const oldString = stringInput(call.input, 'old_string', true);
|
|
1111
1214
|
const newString = stringInput(call.input, 'new_string', true);
|
|
1112
|
-
|
|
1215
|
+
if (oldString === '' && Buffer.byteLength(newString) > this.maxFileBytes) {
|
|
1216
|
+
throw new Error(`Content exceeds ${this.maxFileBytes} byte write limit`);
|
|
1217
|
+
}
|
|
1218
|
+
let handle;
|
|
1219
|
+
let newFile = false;
|
|
1220
|
+
if (oldString === '') {
|
|
1221
|
+
const targetExisted = preparedTargetExisted ?? (await this.pathExists(filePath));
|
|
1222
|
+
if (targetExisted) {
|
|
1223
|
+
handle = await open(filePath, constants.O_RDWR | constants.O_NOFOLLOW);
|
|
1224
|
+
}
|
|
1225
|
+
else {
|
|
1226
|
+
await this.assertStableCreationParent(filePath);
|
|
1227
|
+
try {
|
|
1228
|
+
handle = await open(filePath, constants.O_RDWR |
|
|
1229
|
+
constants.O_CREAT |
|
|
1230
|
+
constants.O_EXCL |
|
|
1231
|
+
constants.O_NOFOLLOW, 0o666);
|
|
1232
|
+
newFile = true;
|
|
1233
|
+
}
|
|
1234
|
+
catch (error) {
|
|
1235
|
+
if (error.code === 'EEXIST') {
|
|
1236
|
+
throw new Error('Tool input changed after permission approval');
|
|
1237
|
+
}
|
|
1238
|
+
throw error;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
else {
|
|
1243
|
+
handle = await open(filePath, constants.O_RDWR | constants.O_NOFOLLOW);
|
|
1244
|
+
}
|
|
1113
1245
|
try {
|
|
1114
1246
|
await this.assertStablePath(filePath);
|
|
1115
1247
|
const metadata = await handle.stat();
|
|
1116
1248
|
if (!metadata.isFile())
|
|
1117
1249
|
throw new Error(`Not a file: ${filePath}`);
|
|
1250
|
+
if (newFile) {
|
|
1251
|
+
const content = newString;
|
|
1252
|
+
const encoded = Buffer.from(content);
|
|
1253
|
+
if (encoded.length > this.maxFileBytes) {
|
|
1254
|
+
throw new Error(`Content exceeds ${this.maxFileBytes} byte write limit`);
|
|
1255
|
+
}
|
|
1256
|
+
const { linesAdded, linesRemoved } = countLineChanges('', content, {
|
|
1257
|
+
newFile: true,
|
|
1258
|
+
});
|
|
1259
|
+
await handle.write(encoded, 0, encoded.length, 0);
|
|
1260
|
+
await handle.truncate(encoded.length);
|
|
1261
|
+
await handle.sync();
|
|
1262
|
+
return {
|
|
1263
|
+
content: `Replaced 1 occurrence(s)`,
|
|
1264
|
+
isError: false,
|
|
1265
|
+
linesAdded,
|
|
1266
|
+
linesRemoved,
|
|
1267
|
+
nativeToolUseResult: { filePath, content },
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
if (oldString === '') {
|
|
1271
|
+
throw new Error('Edit with an empty old_string is only valid for creating a missing file');
|
|
1272
|
+
}
|
|
1118
1273
|
if (metadata.size > this.maxFileBytes) {
|
|
1119
1274
|
throw new Error(`File exceeds ${this.maxFileBytes} byte edit limit`);
|
|
1120
1275
|
}
|
|
1121
1276
|
const source = await handle.readFile('utf8');
|
|
1122
|
-
const
|
|
1277
|
+
const style = newlineStyle(source);
|
|
1278
|
+
const normalizedOldString = normalizeNewlines(oldString, style);
|
|
1279
|
+
const normalizedNewString = normalizeNewlines(newString, style);
|
|
1280
|
+
const occurrences = source.split(normalizedOldString).length - 1;
|
|
1123
1281
|
if (occurrences === 0)
|
|
1124
1282
|
throw new Error('old_string was not found');
|
|
1125
1283
|
if (call.input.replace_all !== true && occurrences !== 1) {
|
|
@@ -1128,13 +1286,14 @@ export class LocalToolRegistry {
|
|
|
1128
1286
|
const replacementCount = call.input.replace_all === true ? occurrences : 1;
|
|
1129
1287
|
const outputBytes = Buffer.byteLength(source) +
|
|
1130
1288
|
replacementCount *
|
|
1131
|
-
(Buffer.byteLength(
|
|
1289
|
+
(Buffer.byteLength(normalizedNewString) -
|
|
1290
|
+
Buffer.byteLength(normalizedOldString));
|
|
1132
1291
|
if (outputBytes > this.maxFileBytes) {
|
|
1133
1292
|
throw new Error(`Edited content exceeds ${this.maxFileBytes} bytes`);
|
|
1134
1293
|
}
|
|
1135
1294
|
const output = call.input.replace_all === true
|
|
1136
|
-
? source.replaceAll(
|
|
1137
|
-
: source.replace(
|
|
1295
|
+
? source.replaceAll(normalizedOldString, normalizedNewString)
|
|
1296
|
+
: source.replace(normalizedOldString, normalizedNewString);
|
|
1138
1297
|
const { linesAdded, linesRemoved } = countLineChanges(source, output);
|
|
1139
1298
|
const encoded = Buffer.from(output);
|
|
1140
1299
|
await handle.write(encoded, 0, encoded.length, 0);
|
|
@@ -1145,6 +1304,7 @@ export class LocalToolRegistry {
|
|
|
1145
1304
|
isError: false,
|
|
1146
1305
|
linesAdded,
|
|
1147
1306
|
linesRemoved,
|
|
1307
|
+
nativeToolUseResult: { filePath, content: output },
|
|
1148
1308
|
};
|
|
1149
1309
|
}
|
|
1150
1310
|
finally {
|
|
@@ -1191,7 +1351,7 @@ export class LocalToolRegistry {
|
|
|
1191
1351
|
async glob(call, context) {
|
|
1192
1352
|
const requestedPath = call.input.path === undefined ? '.' : stringInput(call.input, 'path');
|
|
1193
1353
|
const root = await this.globRoot(requestedPath, context);
|
|
1194
|
-
const timeoutSignal = AbortSignal.timeout(this.
|
|
1354
|
+
const timeoutSignal = AbortSignal.timeout(this.maxSearchTimeoutMs);
|
|
1195
1355
|
const searchSignal = context.signal
|
|
1196
1356
|
? AbortSignal.any([context.signal, timeoutSignal])
|
|
1197
1357
|
: timeoutSignal;
|
|
@@ -1215,7 +1375,7 @@ export class LocalToolRegistry {
|
|
|
1215
1375
|
throw abortError();
|
|
1216
1376
|
if (timeoutSignal.aborted) {
|
|
1217
1377
|
return {
|
|
1218
|
-
content: `Search timed out after ${this.
|
|
1378
|
+
content: `Search timed out after ${this.maxSearchTimeoutMs}ms`,
|
|
1219
1379
|
isError: true,
|
|
1220
1380
|
};
|
|
1221
1381
|
}
|
|
@@ -1240,14 +1400,14 @@ export class LocalToolRegistry {
|
|
|
1240
1400
|
const result = await this.processRunner.run({
|
|
1241
1401
|
command: 'rg',
|
|
1242
1402
|
args,
|
|
1243
|
-
timeoutMs: this.
|
|
1403
|
+
timeoutMs: this.maxSearchTimeoutMs,
|
|
1244
1404
|
cwd: this.currentCwd(context),
|
|
1245
1405
|
...(this.environment ? { env: this.environment } : {}),
|
|
1246
1406
|
...(context.signal ? { signal: context.signal } : {}),
|
|
1247
1407
|
});
|
|
1248
1408
|
if (result.timedOut) {
|
|
1249
1409
|
return {
|
|
1250
|
-
content: `Search timed out after ${this.
|
|
1410
|
+
content: `Search timed out after ${this.maxSearchTimeoutMs}ms`,
|
|
1251
1411
|
isError: true,
|
|
1252
1412
|
};
|
|
1253
1413
|
}
|
|
@@ -1274,39 +1434,131 @@ export class LocalToolRegistry {
|
|
|
1274
1434
|
};
|
|
1275
1435
|
const sandboxed = this.sandbox?.shouldUseSandbox(sandboxPolicyInput) ?? false;
|
|
1276
1436
|
const shell = commandShell();
|
|
1437
|
+
let cwdToken;
|
|
1438
|
+
let sandboxCommandAttempted = false;
|
|
1277
1439
|
let result;
|
|
1278
1440
|
try {
|
|
1279
1441
|
const sessionEnvironment = context.sessionId
|
|
1280
1442
|
? await this.sessionEnvironment?.(context.sessionId)
|
|
1281
1443
|
: undefined;
|
|
1282
|
-
const
|
|
1283
|
-
?
|
|
1284
|
-
|
|
1444
|
+
const processEnvironment = this.environment || sessionEnvironment
|
|
1445
|
+
? { env: { ...this.environment, ...sessionEnvironment } }
|
|
1446
|
+
: {};
|
|
1447
|
+
let executionTimeout = timeout;
|
|
1448
|
+
let syntaxTimeoutResult;
|
|
1449
|
+
if (context.sessionId) {
|
|
1450
|
+
const syntaxStartedAt = Date.now();
|
|
1451
|
+
const syntaxResult = await this.processRunner.run({
|
|
1452
|
+
command: shell,
|
|
1453
|
+
args: commandShellSyntaxArguments(rawCommand),
|
|
1454
|
+
timeoutMs: timeout,
|
|
1455
|
+
cwd: this.currentBashCwd(context),
|
|
1456
|
+
...processEnvironment,
|
|
1285
1457
|
...(context.signal ? { signal: context.signal } : {}),
|
|
1286
|
-
|
|
1287
|
-
|
|
1458
|
+
});
|
|
1459
|
+
executionTimeout = Math.max(1, timeout - (Date.now() - syntaxStartedAt));
|
|
1460
|
+
if (syntaxResult.timedOut) {
|
|
1461
|
+
syntaxTimeoutResult = syntaxResult;
|
|
1462
|
+
}
|
|
1463
|
+
else if (syntaxResult.code === 0) {
|
|
1464
|
+
cwdToken = randomBytes(16).toString('hex');
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
const controlOutputFd = cwdToken ? randomInt(5, 10) : undefined;
|
|
1468
|
+
const statusVariable = cwdToken
|
|
1469
|
+
? `_praxis_exit_status_${cwdToken}`
|
|
1470
|
+
: undefined;
|
|
1471
|
+
const trapStatusVariable = cwdToken
|
|
1472
|
+
? `_praxis_trap_status_${cwdToken}`
|
|
1473
|
+
: undefined;
|
|
1474
|
+
const cwdReportCommand = cwdToken && controlOutputFd
|
|
1475
|
+
? `{ printf "%s%s\\0" "${cwdToken}" "$(pwd -P 2>/dev/null)" >&${controlOutputFd}; } 2>/dev/null`
|
|
1476
|
+
: undefined;
|
|
1477
|
+
const executionCommand = cwdToken && trapStatusVariable && cwdReportCommand
|
|
1478
|
+
? `if [ -n "\${ZSH_VERSION-}" ]; then 0=${JSON.stringify(shell)}; fi
|
|
1479
|
+
trap '${trapStatusVariable}=$?; ${cwdReportCommand}; exit "$${trapStatusVariable}"' EXIT
|
|
1480
|
+
${rawCommand}
|
|
1481
|
+
${statusVariable}=$?
|
|
1482
|
+
${cwdReportCommand}
|
|
1483
|
+
exit "$${statusVariable}"`
|
|
1288
1484
|
: rawCommand;
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1485
|
+
const scriptLoaderCommand = 'eval "$(cat <&4)"';
|
|
1486
|
+
if (syntaxTimeoutResult) {
|
|
1487
|
+
result = syntaxTimeoutResult;
|
|
1488
|
+
}
|
|
1489
|
+
else {
|
|
1490
|
+
let command;
|
|
1491
|
+
if (sandboxed) {
|
|
1492
|
+
sandboxCommandAttempted = true;
|
|
1493
|
+
command = await this.sandbox?.wrapCommand(cwdToken
|
|
1494
|
+
? { ...sandboxPolicyInput, executionCommand: scriptLoaderCommand }
|
|
1495
|
+
: sandboxPolicyInput, {
|
|
1496
|
+
shell,
|
|
1497
|
+
...(context.signal ? { signal: context.signal } : {}),
|
|
1498
|
+
commandId: call.id,
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
else {
|
|
1502
|
+
command = cwdToken ? scriptLoaderCommand : rawCommand;
|
|
1503
|
+
}
|
|
1504
|
+
result = await this.processRunner.run({
|
|
1505
|
+
command: shell,
|
|
1506
|
+
args: commandShellArguments(command ?? rawCommand),
|
|
1507
|
+
timeoutMs: executionTimeout,
|
|
1508
|
+
cwd: this.currentBashCwd(context),
|
|
1509
|
+
...processEnvironment,
|
|
1510
|
+
...(context.signal ? { signal: context.signal } : {}),
|
|
1511
|
+
...(cwdToken && controlOutputFd
|
|
1512
|
+
? {
|
|
1513
|
+
controlOutputBytes: 8192,
|
|
1514
|
+
controlOutputFd,
|
|
1515
|
+
scriptInput: executionCommand,
|
|
1516
|
+
}
|
|
1517
|
+
: {}),
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1299
1520
|
}
|
|
1300
1521
|
finally {
|
|
1301
|
-
if (
|
|
1522
|
+
if (sandboxCommandAttempted)
|
|
1302
1523
|
this.sandbox?.cleanupAfterCommand();
|
|
1303
1524
|
}
|
|
1304
|
-
if (
|
|
1525
|
+
if (cwdToken) {
|
|
1526
|
+
result = {
|
|
1527
|
+
...result,
|
|
1528
|
+
stdout: stripShellControlTrace(result.stdout, cwdToken),
|
|
1529
|
+
stderr: stripShellControlTrace(result.stderr, cwdToken),
|
|
1530
|
+
output: stripShellControlTrace(result.output, cwdToken),
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
1533
|
+
if (sandboxCommandAttempted && this.sandbox) {
|
|
1305
1534
|
result = {
|
|
1306
1535
|
...result,
|
|
1307
1536
|
stderr: this.sandbox.annotateStderr(call.id, result.stderr),
|
|
1308
1537
|
};
|
|
1309
1538
|
}
|
|
1539
|
+
if (cwdToken && !result.timedOut && result.controlOutput) {
|
|
1540
|
+
const controlRecord = result.controlOutput.startsWith(cwdToken)
|
|
1541
|
+
? result.controlOutput.slice(cwdToken.length)
|
|
1542
|
+
: undefined;
|
|
1543
|
+
const terminator = controlRecord?.indexOf('\0') ?? -1;
|
|
1544
|
+
const record = controlRecord && terminator > 0
|
|
1545
|
+
? controlRecord.slice(0, terminator)
|
|
1546
|
+
: undefined;
|
|
1547
|
+
if (record) {
|
|
1548
|
+
try {
|
|
1549
|
+
const canonicalCwd = await realpath(record);
|
|
1550
|
+
if ((await stat(canonicalCwd)).isDirectory() && context.sessionId) {
|
|
1551
|
+
this.sessionCwds.set(context.sessionId, {
|
|
1552
|
+
cwd: canonicalCwd,
|
|
1553
|
+
hostCwd: resolve(context.cwd || this.cwdProvider?.() || this.cwd),
|
|
1554
|
+
});
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
catch {
|
|
1558
|
+
// A command may remove or replace its final directory. Keep prior state.
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1310
1562
|
if (result.timedOut) {
|
|
1311
1563
|
return {
|
|
1312
1564
|
content: `Command timed out after ${timeout}ms`,
|