@modelprofile.com/flexharness 6.1.0 → 7.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_ts/00_commitinfo_data.js +1 -1
- package/dist_ts_chat/classes.chatsession.d.ts +37 -0
- package/dist_ts_chat/classes.chatsession.js +102 -0
- package/dist_ts_chat/index.d.ts +2 -0
- package/dist_ts_chat/index.js +2 -0
- package/dist_ts_chat/interfaces.d.ts +72 -0
- package/dist_ts_chat/interfaces.js +2 -0
- package/dist_ts_chat/plugins.d.ts +4 -0
- package/dist_ts_chat/plugins.js +4 -0
- package/dist_ts_chat_cli/components.chatapp.d.ts +10 -0
- package/dist_ts_chat_cli/components.chatapp.js +75 -0
- package/dist_ts_chat_cli/components.inputarea.d.ts +7 -0
- package/dist_ts_chat_cli/components.inputarea.js +18 -0
- package/dist_ts_chat_cli/components.message.d.ts +12 -0
- package/dist_ts_chat_cli/components.message.js +21 -0
- package/dist_ts_chat_cli/components.messagelist.d.ts +9 -0
- package/dist_ts_chat_cli/components.messagelist.js +19 -0
- package/dist_ts_chat_cli/components.statusbar.d.ts +9 -0
- package/dist_ts_chat_cli/components.statusbar.js +15 -0
- package/dist_ts_chat_cli/index.d.ts +2 -0
- package/dist_ts_chat_cli/index.js +2 -0
- package/dist_ts_chat_cli/plugins.d.ts +12 -0
- package/dist_ts_chat_cli/plugins.js +14 -0
- package/dist_ts_chat_cli/startchat.d.ts +18 -0
- package/dist_ts_chat_cli/startchat.js +17 -0
- package/dist_ts_chat_web/flexchat-input.d.ts +21 -0
- package/dist_ts_chat_web/flexchat-input.js +172 -0
- package/dist_ts_chat_web/flexchat-message.d.ts +26 -0
- package/dist_ts_chat_web/flexchat-message.js +174 -0
- package/dist_ts_chat_web/flexchat-window.d.ts +34 -0
- package/dist_ts_chat_web/flexchat-window.js +394 -0
- package/dist_ts_chat_web/index.d.ts +3 -0
- package/dist_ts_chat_web/index.js +4 -0
- package/dist_ts_chat_web/plugins.d.ts +6 -0
- package/dist_ts_chat_web/plugins.js +4 -0
- package/dist_ts_compaction/index.d.ts +2 -0
- package/dist_ts_compaction/index.js +2 -0
- package/dist_ts_compaction/plugins.d.ts +4 -0
- package/dist_ts_compaction/plugins.js +3 -0
- package/dist_ts_compaction/smartagent.compaction.d.ts +14 -0
- package/dist_ts_compaction/smartagent.compaction.js +49 -0
- package/dist_ts_document/index.d.ts +19 -0
- package/dist_ts_document/index.js +55 -0
- package/dist_ts_document/plugins.d.ts +3 -0
- package/dist_ts_document/plugins.js +4 -0
- package/dist_ts_kvm/classes.browserkvm.d.ts +22 -0
- package/dist_ts_kvm/classes.browserkvm.js +271 -0
- package/dist_ts_kvm/classes.kvmterminal.d.ts +8 -0
- package/dist_ts_kvm/classes.kvmterminal.js +88 -0
- package/dist_ts_kvm/commandwrappers.d.ts +14 -0
- package/dist_ts_kvm/commandwrappers.js +58 -0
- package/dist_ts_kvm/index.d.ts +6 -0
- package/dist_ts_kvm/index.js +7 -0
- package/dist_ts_kvm/interfaces.d.ts +106 -0
- package/dist_ts_kvm/interfaces.js +2 -0
- package/dist_ts_kvm/ocr.d.ts +14 -0
- package/dist_ts_kvm/ocr.js +23 -0
- package/dist_ts_kvm/plugins.d.ts +6 -0
- package/dist_ts_kvm/plugins.js +10 -0
- package/dist_ts_kvm/tools.d.ts +15 -0
- package/dist_ts_kvm/tools.js +40 -0
- package/dist_ts_mcp/index.d.ts +86 -0
- package/dist_ts_mcp/index.js +284 -0
- package/dist_ts_mcp/requestcontext.d.ts +11 -0
- package/dist_ts_mcp/requestcontext.js +42 -0
- package/dist_ts_media/index.d.ts +8 -0
- package/dist_ts_media/index.js +21 -0
- package/dist_ts_media/plugins.d.ts +2 -0
- package/dist_ts_media/plugins.js +3 -0
- package/dist_ts_ocr/classes.imageocr.d.ts +19 -0
- package/dist_ts_ocr/classes.imageocr.js +27 -0
- package/dist_ts_ocr/index.d.ts +2 -0
- package/dist_ts_ocr/index.js +3 -0
- package/dist_ts_ocr/ocr.mistral.d.ts +75 -0
- package/dist_ts_ocr/ocr.mistral.js +77 -0
- package/dist_ts_tools/index.d.ts +17 -0
- package/dist_ts_tools/index.js +10 -0
- package/dist_ts_tools/plugins.d.ts +7 -0
- package/dist_ts_tools/plugins.js +6 -0
- package/dist_ts_tools/tool.browser.d.ts +11 -0
- package/dist_ts_tools/tool.browser.js +128 -0
- package/dist_ts_tools/tool.context.d.ts +4 -0
- package/dist_ts_tools/tool.context.js +23 -0
- package/dist_ts_tools/tool.filesystem.d.ts +11 -0
- package/dist_ts_tools/tool.filesystem.js +106 -0
- package/dist_ts_tools/tool.http.d.ts +2 -0
- package/dist_ts_tools/tool.http.js +65 -0
- package/dist_ts_tools/tool.json.d.ts +2 -0
- package/dist_ts_tools/tool.json.js +47 -0
- package/dist_ts_tools/tool.shell.d.ts +15 -0
- package/dist_ts_tools/tool.shell.js +329 -0
- package/dist_ts_tools/utils.truncation.d.ts +10 -0
- package/dist_ts_tools/utils.truncation.js +26 -0
- package/dist_ts_tools_node/index.d.ts +5 -0
- package/dist_ts_tools_node/index.js +4 -0
- package/dist_ts_tools_node/plugins.d.ts +13 -0
- package/dist_ts_tools_node/plugins.js +13 -0
- package/dist_ts_tools_node/tool.context.d.ts +31 -0
- package/dist_ts_tools_node/tool.context.js +837 -0
- package/dist_ts_tools_node/tool.persistence.d.ts +15 -0
- package/dist_ts_tools_node/tool.persistence.js +129 -0
- package/dist_ts_tools_node/tools.local.d.ts +11 -0
- package/dist_ts_tools_node/tools.local.js +15 -0
- package/package.json +112 -4
- package/readme.md +122 -66
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/readme.md +122 -66
- package/ts_chat/classes.chatsession.ts +116 -0
- package/ts_chat/index.ts +8 -0
- package/ts_chat/interfaces.ts +64 -0
- package/ts_chat/plugins.ts +7 -0
- package/ts_chat/readme.md +49 -0
- package/ts_chat_cli/components.chatapp.ts +103 -0
- package/ts_chat_cli/components.inputarea.ts +29 -0
- package/ts_chat_cli/components.message.ts +48 -0
- package/ts_chat_cli/components.messagelist.ts +45 -0
- package/ts_chat_cli/components.statusbar.ts +31 -0
- package/ts_chat_cli/index.ts +2 -0
- package/ts_chat_cli/plugins.ts +21 -0
- package/ts_chat_cli/readme.md +40 -0
- package/ts_chat_cli/startchat.ts +34 -0
- package/ts_chat_web/flexchat-input.ts +184 -0
- package/ts_chat_web/flexchat-message.ts +185 -0
- package/ts_chat_web/flexchat-window.ts +416 -0
- package/ts_chat_web/index.ts +3 -0
- package/ts_chat_web/plugins.ts +9 -0
- package/ts_chat_web/readme.md +46 -0
- package/ts_compaction/index.ts +2 -0
- package/ts_compaction/plugins.ts +6 -0
- package/ts_compaction/readme.md +37 -0
- package/ts_compaction/smartagent.compaction.ts +59 -0
- package/ts_document/index.ts +82 -0
- package/ts_document/plugins.ts +3 -0
- package/ts_document/readme.md +49 -0
- package/ts_kvm/classes.browserkvm.ts +335 -0
- package/ts_kvm/classes.kvmterminal.ts +103 -0
- package/ts_kvm/commandwrappers.ts +83 -0
- package/ts_kvm/index.ts +6 -0
- package/ts_kvm/interfaces.ts +152 -0
- package/ts_kvm/ocr.ts +41 -0
- package/ts_kvm/plugins.ts +13 -0
- package/ts_kvm/readme.md +482 -0
- package/ts_kvm/tools.ts +58 -0
- package/ts_mcp/index.ts +470 -0
- package/ts_mcp/readme.md +37 -0
- package/ts_mcp/requestcontext.ts +66 -0
- package/ts_media/index.ts +29 -0
- package/ts_media/plugins.ts +2 -0
- package/ts_media/readme.md +37 -0
- package/ts_ocr/classes.imageocr.ts +53 -0
- package/ts_ocr/index.ts +2 -0
- package/ts_ocr/ocr.mistral.ts +192 -0
- package/ts_ocr/readme.md +48 -0
- package/ts_tools/index.ts +49 -0
- package/ts_tools/plugins.ts +7 -0
- package/ts_tools/readme.md +37 -0
- package/ts_tools/tool.browser.ts +159 -0
- package/ts_tools/tool.context.ts +23 -0
- package/ts_tools/tool.filesystem.ts +144 -0
- package/ts_tools/tool.http.ts +78 -0
- package/ts_tools/tool.json.ts +53 -0
- package/ts_tools/tool.shell.ts +418 -0
- package/ts_tools/utils.truncation.ts +39 -0
- package/ts_tools_node/index.ts +5 -0
- package/ts_tools_node/plugins.ts +16 -0
- package/ts_tools_node/readme.md +37 -0
- package/ts_tools_node/tool.context.ts +991 -0
- package/ts_tools_node/tool.persistence.ts +146 -0
- package/ts_tools_node/tools.local.ts +26 -0
|
@@ -0,0 +1,991 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import type { IToolJobStore } from '@modelprofile.com/flexharness-agent';
|
|
3
|
+
import type { IToolPermissionRequest, IToolInvocationOptions, IToolRunOptions, IToolShellResult, IShellCommand, IToolShellContext, IToolJobRequest, IShellStartedResult, IShellFinishedResult, IToolJobState, IToolJobHandle, IToolJobEvent, IToolJobOutputEvent, TToolJobEvent, TToolJobEventListener, IToolJobContext, IToolFilesystemReadOptions, IToolFilesystemListOptions, IToolFilesystemContext, IToolBrowserRunOptions, TBrowserToolAction, IBrowserToolInput, IToolBrowserContext, IToolExecutionContext } from '@modelprofile.com/flexharness-agent';
|
|
4
|
+
|
|
5
|
+
export interface ILocalToolExecutionContextOptions {
|
|
6
|
+
cwd?: string;
|
|
7
|
+
rootDir?: string;
|
|
8
|
+
/** Additional canonical roots available to filesystem read/list operations only. */
|
|
9
|
+
additionalReadOnlyRoots?: string[];
|
|
10
|
+
abortSignal?: AbortSignal;
|
|
11
|
+
/** Guards each local filesystem operation or shell side effect. */
|
|
12
|
+
beforeOperation?: () => Promise<void> | void;
|
|
13
|
+
/** Hard combined stdout/stderr capture limit for each local shell command. Default: 1 MiB. */
|
|
14
|
+
maxShellOutputBytes?: number;
|
|
15
|
+
/** Number of terminal background job snapshots retained for get/list. Default: 100. */
|
|
16
|
+
maxRetainedJobs?: number;
|
|
17
|
+
/** Optional durable store used to restore terminal jobs after host restarts. */
|
|
18
|
+
jobStore?: IToolJobStore;
|
|
19
|
+
/** Maximum wait for each local shell output listener before its process is terminated. Default: 30 seconds. */
|
|
20
|
+
outputListenerTimeoutMs?: number;
|
|
21
|
+
/** Maximum wait for job-store load/save operations. Defaults to outputListenerTimeoutMs. */
|
|
22
|
+
jobStoreOperationTimeoutMs?: number;
|
|
23
|
+
/** Reconciles a persisted running job whose original process ownership was lost. */
|
|
24
|
+
settleOrphanedJob?: (
|
|
25
|
+
job: IToolJobState,
|
|
26
|
+
abortSignal: AbortSignal,
|
|
27
|
+
) => Promise<IToolJobState> | IToolJobState;
|
|
28
|
+
requestPermission?: (request: IToolPermissionRequest) => Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const DEFAULT_LOCAL_SHELL_MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
32
|
+
const LOCAL_SHELL_KILL_GRACE_MS = 1000;
|
|
33
|
+
|
|
34
|
+
export const createLocalToolExecutionContext = (options: ILocalToolExecutionContextOptions = {}): IToolExecutionContext => {
|
|
35
|
+
const cwd = options.cwd ?? process.cwd();
|
|
36
|
+
const rootDir = options.rootDir;
|
|
37
|
+
const additionalReadOnlyRoots = [...new Set((options.additionalReadOnlyRoots ?? []).map(
|
|
38
|
+
(rootDirArg, index) => {
|
|
39
|
+
if (typeof rootDirArg !== 'string' || !rootDirArg.trim()) {
|
|
40
|
+
throw new Error(`additionalReadOnlyRoots[${index}] must be a non-empty path.`);
|
|
41
|
+
}
|
|
42
|
+
return plugins.path.resolve(rootDirArg);
|
|
43
|
+
},
|
|
44
|
+
))];
|
|
45
|
+
const maxShellOutputBytes = options.maxShellOutputBytes ?? DEFAULT_LOCAL_SHELL_MAX_OUTPUT_BYTES;
|
|
46
|
+
if (!Number.isSafeInteger(maxShellOutputBytes) || maxShellOutputBytes <= 0) {
|
|
47
|
+
throw new Error('maxShellOutputBytes must be a positive integer.');
|
|
48
|
+
}
|
|
49
|
+
const maxRetainedJobs = options.maxRetainedJobs ?? 100;
|
|
50
|
+
if (!Number.isSafeInteger(maxRetainedJobs) || maxRetainedJobs < 0) {
|
|
51
|
+
throw new Error('maxRetainedJobs must be a non-negative integer.');
|
|
52
|
+
}
|
|
53
|
+
const outputListenerTimeoutMs = options.outputListenerTimeoutMs ?? 30_000;
|
|
54
|
+
if (!Number.isSafeInteger(outputListenerTimeoutMs) || outputListenerTimeoutMs <= 0) {
|
|
55
|
+
throw new Error('outputListenerTimeoutMs must be a positive integer.');
|
|
56
|
+
}
|
|
57
|
+
const jobStoreOperationTimeoutMs = options.jobStoreOperationTimeoutMs
|
|
58
|
+
?? outputListenerTimeoutMs;
|
|
59
|
+
if (!Number.isSafeInteger(jobStoreOperationTimeoutMs) || jobStoreOperationTimeoutMs <= 0) {
|
|
60
|
+
throw new Error('jobStoreOperationTimeoutMs must be a positive integer.');
|
|
61
|
+
}
|
|
62
|
+
const jobs = createLocalToolJobContext({
|
|
63
|
+
cwd,
|
|
64
|
+
rootDir,
|
|
65
|
+
maxOutputBytes: maxShellOutputBytes,
|
|
66
|
+
maxRetainedJobs,
|
|
67
|
+
jobStore: options.jobStore,
|
|
68
|
+
outputListenerTimeoutMs,
|
|
69
|
+
jobStoreOperationTimeoutMs,
|
|
70
|
+
settleOrphanedJob: options.settleOrphanedJob,
|
|
71
|
+
beforeOperation: options.beforeOperation,
|
|
72
|
+
abortSignal: options.abortSignal,
|
|
73
|
+
});
|
|
74
|
+
return {
|
|
75
|
+
cwd,
|
|
76
|
+
rootDir,
|
|
77
|
+
additionalReadOnlyRoots,
|
|
78
|
+
abortSignal: options.abortSignal,
|
|
79
|
+
requestPermission: options.requestPermission,
|
|
80
|
+
shell: {
|
|
81
|
+
run: (command, runOptions) => runLocalShellCommand(command, {
|
|
82
|
+
cwd: resolveLocalPath(runOptions?.cwd ?? cwd, rootDir),
|
|
83
|
+
timeoutMs: runOptions?.timeoutMs,
|
|
84
|
+
abortSignal: runOptions?.abortSignal ?? options.abortSignal,
|
|
85
|
+
onOutput: runOptions?.onOutput,
|
|
86
|
+
outputListenerTimeoutMs,
|
|
87
|
+
maxOutputBytes: maxShellOutputBytes,
|
|
88
|
+
beforeOperation: options.beforeOperation,
|
|
89
|
+
}),
|
|
90
|
+
runStructured: (command, runOptions) => runLocalStructuredCommand(command, {
|
|
91
|
+
cwd: resolveLocalPath(runOptions?.cwd ?? cwd, rootDir),
|
|
92
|
+
timeoutMs: runOptions?.timeoutMs,
|
|
93
|
+
abortSignal: runOptions?.abortSignal ?? options.abortSignal,
|
|
94
|
+
onOutput: runOptions?.onOutput,
|
|
95
|
+
outputListenerTimeoutMs,
|
|
96
|
+
maxOutputBytes: maxShellOutputBytes,
|
|
97
|
+
beforeOperation: options.beforeOperation,
|
|
98
|
+
}),
|
|
99
|
+
},
|
|
100
|
+
jobs,
|
|
101
|
+
fs: {
|
|
102
|
+
readFile: async (filePath, readOptions, invocationOptions) => {
|
|
103
|
+
const abortSignal = invocationOptions?.abortSignal ?? options.abortSignal;
|
|
104
|
+
throwIfAborted(abortSignal);
|
|
105
|
+
const resolved = resolveLocalReadPath(filePath, rootDir, cwd);
|
|
106
|
+
const readRoots = plugins.path.isAbsolute(filePath)
|
|
107
|
+
? [...(rootDir ? [rootDir] : []), ...additionalReadOnlyRoots]
|
|
108
|
+
: rootDir
|
|
109
|
+
? [rootDir]
|
|
110
|
+
: [];
|
|
111
|
+
const operationPath = readRoots.length > 0
|
|
112
|
+
? await resolveCanonicalExistingLocalReadPath(resolved, filePath, readRoots)
|
|
113
|
+
: resolved;
|
|
114
|
+
await runBeforeLocalOperation(options.beforeOperation, abortSignal);
|
|
115
|
+
const content = await plugins.fs.promises.readFile(operationPath, 'utf8');
|
|
116
|
+
throwIfAborted(abortSignal);
|
|
117
|
+
if (readOptions?.startLine !== undefined || readOptions?.endLine !== undefined) {
|
|
118
|
+
const lines = content.split('\n');
|
|
119
|
+
const start = Math.max((readOptions.startLine ?? 1) - 1, 0);
|
|
120
|
+
const end = Math.max(readOptions.endLine ?? lines.length, start);
|
|
121
|
+
return lines.slice(start, end).join('\n');
|
|
122
|
+
}
|
|
123
|
+
return content;
|
|
124
|
+
},
|
|
125
|
+
writeFile: async (filePath, content, invocationOptions) => {
|
|
126
|
+
const abortSignal = invocationOptions?.abortSignal ?? options.abortSignal;
|
|
127
|
+
throwIfAborted(abortSignal);
|
|
128
|
+
const resolved = resolveLocalPath(filePath, rootDir, cwd);
|
|
129
|
+
const parentPath = plugins.path.dirname(resolved);
|
|
130
|
+
const canonicalBoundary = rootDir
|
|
131
|
+
? {
|
|
132
|
+
rootDir,
|
|
133
|
+
canonicalRoot: await resolveCanonicalProspectivePath(plugins.path.resolve(rootDir)),
|
|
134
|
+
}
|
|
135
|
+
: undefined;
|
|
136
|
+
if (canonicalBoundary) {
|
|
137
|
+
const canonicalProspectiveParent = await resolveCanonicalProspectivePath(parentPath);
|
|
138
|
+
assertCanonicalPathAllowed(
|
|
139
|
+
canonicalProspectiveParent,
|
|
140
|
+
canonicalBoundary.canonicalRoot,
|
|
141
|
+
filePath,
|
|
142
|
+
canonicalBoundary.rootDir,
|
|
143
|
+
);
|
|
144
|
+
await assertExistingWriteTargetAllowed(
|
|
145
|
+
resolved,
|
|
146
|
+
canonicalBoundary.canonicalRoot,
|
|
147
|
+
filePath,
|
|
148
|
+
canonicalBoundary.rootDir,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
await runBeforeLocalOperation(options.beforeOperation, abortSignal);
|
|
152
|
+
await plugins.fs.promises.mkdir(parentPath, { recursive: true });
|
|
153
|
+
throwIfAborted(abortSignal);
|
|
154
|
+
if (canonicalBoundary) {
|
|
155
|
+
const canonicalParent = await plugins.fs.promises.realpath(parentPath);
|
|
156
|
+
assertCanonicalPathAllowed(
|
|
157
|
+
canonicalParent,
|
|
158
|
+
canonicalBoundary.canonicalRoot,
|
|
159
|
+
filePath,
|
|
160
|
+
canonicalBoundary.rootDir,
|
|
161
|
+
);
|
|
162
|
+
await assertExistingWriteTargetAllowed(
|
|
163
|
+
resolved,
|
|
164
|
+
canonicalBoundary.canonicalRoot,
|
|
165
|
+
filePath,
|
|
166
|
+
canonicalBoundary.rootDir,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
await plugins.fs.promises.writeFile(resolved, content, 'utf8');
|
|
170
|
+
throwIfAborted(abortSignal);
|
|
171
|
+
return `Written ${Buffer.byteLength(content, 'utf8')} bytes to ${filePath}`;
|
|
172
|
+
},
|
|
173
|
+
listDirectory: async (directoryPath, listOptions, invocationOptions) => {
|
|
174
|
+
const abortSignal = invocationOptions?.abortSignal ?? options.abortSignal;
|
|
175
|
+
throwIfAborted(abortSignal);
|
|
176
|
+
const resolved = resolveLocalReadPath(directoryPath, rootDir, cwd);
|
|
177
|
+
const readRoots = plugins.path.isAbsolute(directoryPath)
|
|
178
|
+
? [...(rootDir ? [rootDir] : []), ...additionalReadOnlyRoots]
|
|
179
|
+
: rootDir
|
|
180
|
+
? [rootDir]
|
|
181
|
+
: [];
|
|
182
|
+
const operationPath = readRoots.length > 0
|
|
183
|
+
? await resolveCanonicalExistingLocalReadPath(resolved, directoryPath, readRoots)
|
|
184
|
+
: resolved;
|
|
185
|
+
await runBeforeLocalOperation(options.beforeOperation, abortSignal);
|
|
186
|
+
const result = await listLocalDirectory(operationPath, !!listOptions?.recursive, abortSignal);
|
|
187
|
+
throwIfAborted(abortSignal);
|
|
188
|
+
return result;
|
|
189
|
+
},
|
|
190
|
+
deletePath: async (targetPath, invocationOptions) => {
|
|
191
|
+
const abortSignal = invocationOptions?.abortSignal ?? options.abortSignal;
|
|
192
|
+
throwIfAborted(abortSignal);
|
|
193
|
+
const resolved = resolveLocalPath(targetPath, rootDir, cwd);
|
|
194
|
+
if (rootDir) {
|
|
195
|
+
await resolveCanonicalExistingLocalPath(resolved, targetPath, rootDir);
|
|
196
|
+
}
|
|
197
|
+
await runBeforeLocalOperation(options.beforeOperation, abortSignal);
|
|
198
|
+
await plugins.fs.promises.rm(resolved, { recursive: false, force: false });
|
|
199
|
+
throwIfAborted(abortSignal);
|
|
200
|
+
return `Deleted ${targetPath}`;
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const resolveLocalPath = (targetPath: string, rootDir?: string, baseDir?: string): string => {
|
|
207
|
+
const base = rootDir ?? baseDir ?? process.cwd();
|
|
208
|
+
const resolved = plugins.path.isAbsolute(targetPath)
|
|
209
|
+
? plugins.path.resolve(targetPath)
|
|
210
|
+
: plugins.path.resolve(base, targetPath);
|
|
211
|
+
if (rootDir) {
|
|
212
|
+
const resolvedRoot = plugins.path.resolve(rootDir);
|
|
213
|
+
if (resolved !== resolvedRoot && !resolved.startsWith(resolvedRoot + plugins.path.sep)) {
|
|
214
|
+
throw new Error(`Access denied: "${targetPath}" is outside allowed root "${rootDir}"`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return resolved;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const resolveLocalReadPath = (
|
|
221
|
+
targetPath: string,
|
|
222
|
+
rootDir?: string,
|
|
223
|
+
baseDir?: string,
|
|
224
|
+
): string => plugins.path.isAbsolute(targetPath)
|
|
225
|
+
? plugins.path.resolve(targetPath)
|
|
226
|
+
: plugins.path.resolve(rootDir ?? baseDir ?? process.cwd(), targetPath);
|
|
227
|
+
|
|
228
|
+
const resolveCanonicalExistingLocalReadPath = async (
|
|
229
|
+
resolvedPath: string,
|
|
230
|
+
targetPath: string,
|
|
231
|
+
readRoots: string[],
|
|
232
|
+
): Promise<string> => {
|
|
233
|
+
const canonicalPath = await plugins.fs.promises.realpath(resolvedPath);
|
|
234
|
+
for (const readRoot of readRoots) {
|
|
235
|
+
const canonicalRoot = await resolveCanonicalProspectivePath(plugins.path.resolve(readRoot));
|
|
236
|
+
if (canonicalPathIsAllowed(canonicalPath, canonicalRoot)) return canonicalPath;
|
|
237
|
+
}
|
|
238
|
+
throw new Error(`Access denied: "${targetPath}" resolves outside allowed read roots.`);
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const resolveCanonicalExistingLocalPath = async (
|
|
242
|
+
resolvedPath: string,
|
|
243
|
+
targetPath: string,
|
|
244
|
+
rootDir: string,
|
|
245
|
+
): Promise<string> => {
|
|
246
|
+
const canonicalRoot = await resolveCanonicalProspectivePath(plugins.path.resolve(rootDir));
|
|
247
|
+
const canonicalPath = await plugins.fs.promises.realpath(resolvedPath);
|
|
248
|
+
assertCanonicalPathAllowed(canonicalPath, canonicalRoot, targetPath, rootDir);
|
|
249
|
+
return canonicalPath;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const resolveCanonicalProspectivePath = async (targetPath: string): Promise<string> => {
|
|
253
|
+
let existingAncestor = targetPath;
|
|
254
|
+
while (true) {
|
|
255
|
+
try {
|
|
256
|
+
const canonicalAncestor = await plugins.fs.promises.realpath(existingAncestor);
|
|
257
|
+
return plugins.path.resolve(
|
|
258
|
+
canonicalAncestor,
|
|
259
|
+
plugins.path.relative(existingAncestor, targetPath),
|
|
260
|
+
);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
const errorCode = (error as NodeJS.ErrnoException).code;
|
|
263
|
+
if (errorCode !== 'ENOENT' && errorCode !== 'ENOTDIR') {
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
const parentPath = plugins.path.dirname(existingAncestor);
|
|
267
|
+
if (parentPath === existingAncestor) {
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
existingAncestor = parentPath;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const assertExistingWriteTargetAllowed = async (
|
|
276
|
+
resolvedPath: string,
|
|
277
|
+
canonicalRoot: string,
|
|
278
|
+
targetPath: string,
|
|
279
|
+
rootDir: string,
|
|
280
|
+
): Promise<void> => {
|
|
281
|
+
try {
|
|
282
|
+
await plugins.fs.promises.lstat(resolvedPath);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
let canonicalTarget: string;
|
|
290
|
+
try {
|
|
291
|
+
canonicalTarget = await plugins.fs.promises.realpath(resolvedPath);
|
|
292
|
+
} catch (error) {
|
|
293
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
294
|
+
throw createAccessDeniedError(targetPath, rootDir);
|
|
295
|
+
}
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
298
|
+
assertCanonicalPathAllowed(canonicalTarget, canonicalRoot, targetPath, rootDir);
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const assertCanonicalPathAllowed = (
|
|
302
|
+
canonicalPath: string,
|
|
303
|
+
canonicalRoot: string,
|
|
304
|
+
targetPath: string,
|
|
305
|
+
rootDir: string,
|
|
306
|
+
): void => {
|
|
307
|
+
if (!canonicalPathIsAllowed(canonicalPath, canonicalRoot)) {
|
|
308
|
+
throw createAccessDeniedError(targetPath, rootDir);
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const canonicalPathIsAllowed = (
|
|
313
|
+
canonicalPath: string,
|
|
314
|
+
canonicalRoot: string,
|
|
315
|
+
): boolean => {
|
|
316
|
+
const relativePath = plugins.path.relative(canonicalRoot, canonicalPath);
|
|
317
|
+
return relativePath !== '..'
|
|
318
|
+
&& !relativePath.startsWith(`..${plugins.path.sep}`)
|
|
319
|
+
&& !plugins.path.isAbsolute(relativePath);
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const createAccessDeniedError = (targetPath: string, rootDir: string): Error =>
|
|
323
|
+
new Error(`Access denied: "${targetPath}" resolves outside canonical root "${rootDir}"`);
|
|
324
|
+
|
|
325
|
+
const throwIfAborted = (abortSignal?: AbortSignal): void => {
|
|
326
|
+
abortSignal?.throwIfAborted();
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const runBeforeLocalOperation = async (
|
|
330
|
+
beforeOperation: ILocalToolExecutionContextOptions['beforeOperation'],
|
|
331
|
+
abortSignal?: AbortSignal,
|
|
332
|
+
): Promise<void> => {
|
|
333
|
+
throwIfAborted(abortSignal);
|
|
334
|
+
if (!beforeOperation) return;
|
|
335
|
+
const guardPromise = Promise.resolve().then(() => beforeOperation());
|
|
336
|
+
void guardPromise.catch(() => undefined);
|
|
337
|
+
if (!abortSignal) {
|
|
338
|
+
await guardPromise;
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
let onAbort!: () => void;
|
|
342
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
343
|
+
onAbort = () => reject(
|
|
344
|
+
abortSignal.reason ?? new DOMException('Local operation aborted', 'AbortError'),
|
|
345
|
+
);
|
|
346
|
+
abortSignal.addEventListener('abort', onAbort, { once: true });
|
|
347
|
+
if (abortSignal.aborted) onAbort();
|
|
348
|
+
});
|
|
349
|
+
try {
|
|
350
|
+
await Promise.race([guardPromise, aborted]);
|
|
351
|
+
} finally {
|
|
352
|
+
abortSignal.removeEventListener('abort', onAbort);
|
|
353
|
+
}
|
|
354
|
+
throwIfAborted(abortSignal);
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const listLocalDirectory = async (
|
|
358
|
+
directoryPath: string,
|
|
359
|
+
recursive: boolean,
|
|
360
|
+
abortSignal?: AbortSignal,
|
|
361
|
+
): Promise<string[]> => {
|
|
362
|
+
throwIfAborted(abortSignal);
|
|
363
|
+
const entries = await plugins.fs.promises.readdir(directoryPath, { withFileTypes: true });
|
|
364
|
+
throwIfAborted(abortSignal);
|
|
365
|
+
const result: string[] = [];
|
|
366
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
367
|
+
throwIfAborted(abortSignal);
|
|
368
|
+
const relativePath = entry.name + (entry.isDirectory() ? '/' : '');
|
|
369
|
+
result.push(relativePath);
|
|
370
|
+
if (recursive && entry.isDirectory()) {
|
|
371
|
+
const childEntries = await listLocalDirectory(
|
|
372
|
+
plugins.path.join(directoryPath, entry.name),
|
|
373
|
+
true,
|
|
374
|
+
abortSignal,
|
|
375
|
+
);
|
|
376
|
+
result.push(...childEntries.map((childEntry) => `${entry.name}/${childEntry}`));
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
throwIfAborted(abortSignal);
|
|
380
|
+
return result;
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
type TLocalShellTerminationReason = 'abort' | 'error' | 'timeout' | 'output-limit' | 'output-listener';
|
|
384
|
+
|
|
385
|
+
/** @internal */
|
|
386
|
+
export const handleLocalShellChildError = (
|
|
387
|
+
error: Error,
|
|
388
|
+
childPid: number | undefined,
|
|
389
|
+
handlers: {
|
|
390
|
+
settleSpawnFailure: (error: Error) => void;
|
|
391
|
+
retainError: (error: Error) => void;
|
|
392
|
+
requestTermination: () => void;
|
|
393
|
+
},
|
|
394
|
+
): void => {
|
|
395
|
+
if (childPid === undefined) {
|
|
396
|
+
handlers.settleSpawnFailure(error);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
handlers.retainError(error);
|
|
400
|
+
handlers.requestTermination();
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const runLocalShellCommand = async (
|
|
404
|
+
command: string,
|
|
405
|
+
options: IToolRunOptions & {
|
|
406
|
+
maxOutputBytes: number;
|
|
407
|
+
outputListenerTimeoutMs?: number;
|
|
408
|
+
beforeOperation?: ILocalToolExecutionContextOptions['beforeOperation'];
|
|
409
|
+
},
|
|
410
|
+
): Promise<IToolShellResult> => runLocalProcessCommand(command, options);
|
|
411
|
+
|
|
412
|
+
const runLocalStructuredCommand = async (
|
|
413
|
+
command: IShellCommand,
|
|
414
|
+
options: IToolRunOptions & {
|
|
415
|
+
maxOutputBytes: number;
|
|
416
|
+
outputListenerTimeoutMs?: number;
|
|
417
|
+
beforeOperation?: ILocalToolExecutionContextOptions['beforeOperation'];
|
|
418
|
+
},
|
|
419
|
+
): Promise<IToolShellResult> => {
|
|
420
|
+
validateLocalStructuredCommand(command);
|
|
421
|
+
return runLocalProcessCommand(command, options);
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const validateLocalStructuredCommand = (command: IShellCommand): void => {
|
|
425
|
+
if (!command.executable.trim()) throw new Error('Structured shell executable must not be empty.');
|
|
426
|
+
if (!command.args.every((argument) => typeof argument === 'string')) {
|
|
427
|
+
throw new Error('Structured shell arguments must all be strings.');
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
const runLocalProcessCommand = async (
|
|
432
|
+
command: string | IShellCommand,
|
|
433
|
+
options: IToolRunOptions & {
|
|
434
|
+
maxOutputBytes: number;
|
|
435
|
+
outputListenerTimeoutMs?: number;
|
|
436
|
+
beforeOperation?: ILocalToolExecutionContextOptions['beforeOperation'];
|
|
437
|
+
},
|
|
438
|
+
): Promise<IToolShellResult> => {
|
|
439
|
+
if (options.beforeOperation) {
|
|
440
|
+
await runBeforeLocalOperation(options.beforeOperation, options.abortSignal);
|
|
441
|
+
} else {
|
|
442
|
+
throwIfAborted(options.abortSignal);
|
|
443
|
+
}
|
|
444
|
+
return new Promise<IToolShellResult>((resolve) => {
|
|
445
|
+
const ownsProcessGroup = process.platform !== 'win32';
|
|
446
|
+
const executable = typeof command === 'string' ? 'bash' : command.executable;
|
|
447
|
+
const args = typeof command === 'string' ? ['-lc', command] : command.args;
|
|
448
|
+
const child = plugins.childProcess.spawn(executable, args, {
|
|
449
|
+
cwd: options.cwd,
|
|
450
|
+
detached: ownsProcessGroup,
|
|
451
|
+
shell: false,
|
|
452
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
453
|
+
});
|
|
454
|
+
const stdoutChunks: Buffer[] = [];
|
|
455
|
+
const stderrChunks: Buffer[] = [];
|
|
456
|
+
let capturedBytes = 0;
|
|
457
|
+
let settled = false;
|
|
458
|
+
let terminationReason: TLocalShellTerminationReason | undefined;
|
|
459
|
+
let childError: Error | undefined;
|
|
460
|
+
let signalError: Error | undefined;
|
|
461
|
+
let outputListenerError: Error | undefined;
|
|
462
|
+
let outputQueue: Promise<void> = Promise.resolve();
|
|
463
|
+
const outputAbortController = new AbortController();
|
|
464
|
+
const outputDecoders = {
|
|
465
|
+
stdout: new plugins.StringDecoder('utf8'),
|
|
466
|
+
stderr: new plugins.StringDecoder('utf8'),
|
|
467
|
+
};
|
|
468
|
+
let killTimer: NodeJS.Timeout | undefined;
|
|
469
|
+
const timeoutTimer = options.timeoutMs && options.timeoutMs > 0
|
|
470
|
+
? setTimeout(() => requestTermination('timeout'), options.timeoutMs)
|
|
471
|
+
: undefined;
|
|
472
|
+
const signalProcess = (signal: NodeJS.Signals) => {
|
|
473
|
+
if (child.pid === undefined) return;
|
|
474
|
+
if (ownsProcessGroup) {
|
|
475
|
+
try {
|
|
476
|
+
process.kill(-child.pid, signal);
|
|
477
|
+
return;
|
|
478
|
+
} catch (error) {
|
|
479
|
+
if ((error as NodeJS.ErrnoException).code === 'ESRCH') return;
|
|
480
|
+
signalError = error as Error;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
try {
|
|
484
|
+
child.kill(signal);
|
|
485
|
+
} catch (error) {
|
|
486
|
+
if ((error as NodeJS.ErrnoException).code !== 'ESRCH') {
|
|
487
|
+
signalError = error as Error;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
function requestTermination(reason: TLocalShellTerminationReason): void {
|
|
492
|
+
if (terminationReason) return;
|
|
493
|
+
terminationReason = reason;
|
|
494
|
+
outputAbortController.abort(
|
|
495
|
+
new Error(`Command output delivery stopped because the command is terminating (${reason}).`),
|
|
496
|
+
);
|
|
497
|
+
signalProcess('SIGTERM');
|
|
498
|
+
killTimer = setTimeout(() => signalProcess('SIGKILL'), LOCAL_SHELL_KILL_GRACE_MS);
|
|
499
|
+
}
|
|
500
|
+
const onAbort = () => requestTermination('abort');
|
|
501
|
+
const deliverOutput = async (
|
|
502
|
+
stream: 'stdout' | 'stderr',
|
|
503
|
+
text: string,
|
|
504
|
+
): Promise<void> => {
|
|
505
|
+
const listenerPromise = Promise.resolve(options.onOutput!(stream, text));
|
|
506
|
+
void listenerPromise.catch(() => undefined);
|
|
507
|
+
let timeoutTimer: NodeJS.Timeout | undefined;
|
|
508
|
+
let onOutputAbort!: () => void;
|
|
509
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
510
|
+
onOutputAbort = () => reject(outputAbortController.signal.reason);
|
|
511
|
+
outputAbortController.signal.addEventListener('abort', onOutputAbort, { once: true });
|
|
512
|
+
if (outputAbortController.signal.aborted) onOutputAbort();
|
|
513
|
+
});
|
|
514
|
+
const timeout = options.outputListenerTimeoutMs === undefined
|
|
515
|
+
? undefined
|
|
516
|
+
: new Promise<never>((_resolve, reject) => {
|
|
517
|
+
timeoutTimer = setTimeout(
|
|
518
|
+
() => reject(new Error(
|
|
519
|
+
`Command output listener timed out after ${options.outputListenerTimeoutMs}ms.`,
|
|
520
|
+
)),
|
|
521
|
+
options.outputListenerTimeoutMs,
|
|
522
|
+
);
|
|
523
|
+
});
|
|
524
|
+
try {
|
|
525
|
+
await Promise.race([listenerPromise, aborted, ...(timeout ? [timeout] : [])]);
|
|
526
|
+
} finally {
|
|
527
|
+
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
528
|
+
outputAbortController.signal.removeEventListener('abort', onOutputAbort);
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
const enqueueOutput = (
|
|
532
|
+
stream: 'stdout' | 'stderr',
|
|
533
|
+
source: NodeJS.ReadableStream | null,
|
|
534
|
+
text: string,
|
|
535
|
+
) => {
|
|
536
|
+
if (!text || !options.onOutput) return;
|
|
537
|
+
source?.pause();
|
|
538
|
+
outputQueue = outputQueue
|
|
539
|
+
.then(() => outputListenerError ? undefined : deliverOutput(stream, text))
|
|
540
|
+
.catch((error) => {
|
|
541
|
+
outputListenerError = error instanceof Error ? error : new Error(String(error));
|
|
542
|
+
requestTermination('output-listener');
|
|
543
|
+
})
|
|
544
|
+
.finally(() => source?.resume());
|
|
545
|
+
};
|
|
546
|
+
const captureChunk = (
|
|
547
|
+
stream: 'stdout' | 'stderr',
|
|
548
|
+
chunks: Buffer[],
|
|
549
|
+
source: NodeJS.ReadableStream | null,
|
|
550
|
+
chunk: Buffer | string,
|
|
551
|
+
) => {
|
|
552
|
+
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
553
|
+
const remainingBytes = Math.max(options.maxOutputBytes - capturedBytes, 0);
|
|
554
|
+
if (remainingBytes > 0) {
|
|
555
|
+
const capturedChunk = chunkBuffer.subarray(0, remainingBytes);
|
|
556
|
+
chunks.push(capturedChunk);
|
|
557
|
+
capturedBytes += capturedChunk.byteLength;
|
|
558
|
+
enqueueOutput(stream, source, outputDecoders[stream].write(capturedChunk));
|
|
559
|
+
}
|
|
560
|
+
if (chunkBuffer.byteLength > remainingBytes) {
|
|
561
|
+
requestTermination('output-limit');
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
const onStdout = (chunk: Buffer | string) => captureChunk('stdout', stdoutChunks, child.stdout, chunk);
|
|
565
|
+
const onStderr = (chunk: Buffer | string) => captureChunk('stderr', stderrChunks, child.stderr, chunk);
|
|
566
|
+
const cleanup = () => {
|
|
567
|
+
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
568
|
+
if (killTimer) clearTimeout(killTimer);
|
|
569
|
+
options.abortSignal?.removeEventListener('abort', onAbort);
|
|
570
|
+
child.stdout?.removeListener('data', onStdout);
|
|
571
|
+
child.stderr?.removeListener('data', onStderr);
|
|
572
|
+
child.removeListener('error', onChildError);
|
|
573
|
+
child.removeListener('exit', onChildExit);
|
|
574
|
+
child.removeListener('close', onChildClose);
|
|
575
|
+
};
|
|
576
|
+
const settle = (result: IToolShellResult) => {
|
|
577
|
+
if (settled) return;
|
|
578
|
+
settled = true;
|
|
579
|
+
cleanup();
|
|
580
|
+
resolve(result);
|
|
581
|
+
};
|
|
582
|
+
const buildOutput = () => ({
|
|
583
|
+
stdout: Buffer.concat(stdoutChunks).toString('utf8'),
|
|
584
|
+
stderr: Buffer.concat(stderrChunks).toString('utf8'),
|
|
585
|
+
});
|
|
586
|
+
function onChildError(error: Error): void {
|
|
587
|
+
handleLocalShellChildError(error, child.pid, {
|
|
588
|
+
settleSpawnFailure: (spawnError) => {
|
|
589
|
+
const output = buildOutput();
|
|
590
|
+
settle({
|
|
591
|
+
exitCode: 1,
|
|
592
|
+
stdout: output.stdout,
|
|
593
|
+
stderr: `${output.stderr}${spawnError.message}`,
|
|
594
|
+
});
|
|
595
|
+
},
|
|
596
|
+
retainError: (processError) => {
|
|
597
|
+
childError = processError;
|
|
598
|
+
},
|
|
599
|
+
requestTermination: () => requestTermination('error'),
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
function onChildExit(): void {
|
|
603
|
+
if (ownsProcessGroup) signalProcess('SIGKILL');
|
|
604
|
+
}
|
|
605
|
+
async function finishChildClose(code: number | null, signal: NodeJS.Signals | null): Promise<void> {
|
|
606
|
+
enqueueOutput('stdout', child.stdout, outputDecoders.stdout.end());
|
|
607
|
+
enqueueOutput('stderr', child.stderr, outputDecoders.stderr.end());
|
|
608
|
+
// Process outcome is fixed at close. Later aborts/timeouts must not reclassify it while
|
|
609
|
+
// already-received output finishes its bounded listener delivery.
|
|
610
|
+
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
611
|
+
if (killTimer) clearTimeout(killTimer);
|
|
612
|
+
options.abortSignal?.removeEventListener('abort', onAbort);
|
|
613
|
+
await outputQueue;
|
|
614
|
+
if (terminationReason) {
|
|
615
|
+
signalProcess('SIGKILL');
|
|
616
|
+
}
|
|
617
|
+
const output = buildOutput();
|
|
618
|
+
const statusMessage = terminationReason === 'timeout'
|
|
619
|
+
? `Command timed out after ${options.timeoutMs}ms.`
|
|
620
|
+
: terminationReason === 'output-limit'
|
|
621
|
+
? `Command output exceeded the local limit of ${options.maxOutputBytes} bytes and was terminated.`
|
|
622
|
+
: terminationReason === 'output-listener'
|
|
623
|
+
? `Command output listener failed and the command was terminated: ${outputListenerError?.message ?? 'unknown listener error'}`
|
|
624
|
+
: terminationReason === 'abort'
|
|
625
|
+
? 'Command was aborted.'
|
|
626
|
+
: '';
|
|
627
|
+
const signalErrorMessage = signalError ? `Failed to signal command process: ${signalError.message}` : '';
|
|
628
|
+
const childErrorMessage = childError ? `Command process error: ${childError.message}` : '';
|
|
629
|
+
settle({
|
|
630
|
+
exitCode: terminationReason === 'timeout'
|
|
631
|
+
? 124
|
|
632
|
+
: terminationReason === 'abort'
|
|
633
|
+
? 130
|
|
634
|
+
: terminationReason === 'output-limit'
|
|
635
|
+
? 1
|
|
636
|
+
: terminationReason === 'output-listener'
|
|
637
|
+
? 1
|
|
638
|
+
: terminationReason === 'error'
|
|
639
|
+
? 1
|
|
640
|
+
: code ?? 1,
|
|
641
|
+
stdout: output.stdout,
|
|
642
|
+
stderr: [output.stderr, statusMessage, childErrorMessage, signalErrorMessage].filter(Boolean).join('\n').trim(),
|
|
643
|
+
signal: signal ?? undefined,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
function onChildClose(code: number | null, signal: NodeJS.Signals | null): void {
|
|
647
|
+
void finishChildClose(code, signal);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
options.abortSignal?.addEventListener('abort', onAbort, { once: true });
|
|
651
|
+
if (options.abortSignal?.aborted) onAbort();
|
|
652
|
+
child.stdout?.on('data', onStdout);
|
|
653
|
+
child.stderr?.on('data', onStderr);
|
|
654
|
+
child.once('error', onChildError);
|
|
655
|
+
child.once('exit', onChildExit);
|
|
656
|
+
child.once('close', onChildClose);
|
|
657
|
+
});
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
interface ICreateLocalToolJobContextOptions {
|
|
661
|
+
cwd: string;
|
|
662
|
+
rootDir?: string;
|
|
663
|
+
maxOutputBytes: number;
|
|
664
|
+
maxRetainedJobs: number;
|
|
665
|
+
jobStore?: IToolJobStore;
|
|
666
|
+
outputListenerTimeoutMs: number;
|
|
667
|
+
jobStoreOperationTimeoutMs: number;
|
|
668
|
+
settleOrphanedJob?: (
|
|
669
|
+
job: IToolJobState,
|
|
670
|
+
abortSignal: AbortSignal,
|
|
671
|
+
) => Promise<IToolJobState> | IToolJobState;
|
|
672
|
+
beforeOperation?: ILocalToolExecutionContextOptions['beforeOperation'];
|
|
673
|
+
abortSignal?: AbortSignal;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const assertOrphanSettlement = (
|
|
677
|
+
persistedJob: IToolJobState,
|
|
678
|
+
settledJob: IToolJobState,
|
|
679
|
+
): void => {
|
|
680
|
+
if (
|
|
681
|
+
!settledJob
|
|
682
|
+
|| settledJob.executionId !== persistedJob.executionId
|
|
683
|
+
|| settledJob.type !== persistedJob.type
|
|
684
|
+
|| !['finished', 'failed', 'aborted'].includes(settledJob.state)
|
|
685
|
+
) {
|
|
686
|
+
throw new Error(
|
|
687
|
+
`Orphan settlement for background execution "${persistedJob.executionId}" must return a terminal state with the same executionId and type.`,
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
const createLocalToolJobContext = (
|
|
693
|
+
options: ICreateLocalToolJobContextOptions,
|
|
694
|
+
): IToolJobContext => {
|
|
695
|
+
const states = new Map<string, IToolJobState>();
|
|
696
|
+
const controllers = new Map<string, AbortController>();
|
|
697
|
+
const completions = new Map<string, Promise<IToolJobState>>();
|
|
698
|
+
const terminalExecutionIds: string[] = [];
|
|
699
|
+
const listeners = new Set<TToolJobEventListener>();
|
|
700
|
+
let persistenceRevision = 0;
|
|
701
|
+
let persistenceError: unknown;
|
|
702
|
+
let persistenceQueue: Promise<void> = Promise.resolve();
|
|
703
|
+
const reportListenerError = (error: unknown): void => {
|
|
704
|
+
process.emitWarning(error instanceof Error ? error : String(error), {
|
|
705
|
+
code: 'SMARTAGENT_JOB_LISTENER_ERROR',
|
|
706
|
+
});
|
|
707
|
+
};
|
|
708
|
+
const runJobStoreOperation = async <TValue>(
|
|
709
|
+
operation: (abortSignal: AbortSignal) => Promise<TValue>,
|
|
710
|
+
): Promise<TValue> => {
|
|
711
|
+
const controller = new AbortController();
|
|
712
|
+
const timeoutError = new Error(
|
|
713
|
+
`Job store operation timed out after ${options.jobStoreOperationTimeoutMs}ms.`,
|
|
714
|
+
);
|
|
715
|
+
let timer: NodeJS.Timeout | undefined;
|
|
716
|
+
const operationPromise = Promise.resolve().then(() => operation(controller.signal));
|
|
717
|
+
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
|
718
|
+
timer = setTimeout(() => {
|
|
719
|
+
controller.abort(timeoutError);
|
|
720
|
+
reject(timeoutError);
|
|
721
|
+
}, options.jobStoreOperationTimeoutMs);
|
|
722
|
+
});
|
|
723
|
+
try {
|
|
724
|
+
return await Promise.race([operationPromise, timeoutPromise]);
|
|
725
|
+
} finally {
|
|
726
|
+
if (timer) clearTimeout(timer);
|
|
727
|
+
}
|
|
728
|
+
};
|
|
729
|
+
const persist = (): Promise<void> => {
|
|
730
|
+
if (!options.jobStore) return Promise.resolve();
|
|
731
|
+
const snapshot = structuredClone([...states.values()]);
|
|
732
|
+
const operation = persistenceQueue.then(async () => {
|
|
733
|
+
if (persistenceError !== undefined) throw persistenceError;
|
|
734
|
+
persistenceRevision = await runJobStoreOperation((abortSignal) =>
|
|
735
|
+
options.jobStore!.save(snapshot, persistenceRevision, abortSignal));
|
|
736
|
+
});
|
|
737
|
+
persistenceQueue = operation.catch((error) => {
|
|
738
|
+
persistenceError ??= error;
|
|
739
|
+
});
|
|
740
|
+
return operation;
|
|
741
|
+
};
|
|
742
|
+
const flushPersistence = async (): Promise<void> => {
|
|
743
|
+
if (persistenceError !== undefined) throw persistenceError;
|
|
744
|
+
await persistenceQueue;
|
|
745
|
+
if (persistenceError !== undefined) throw persistenceError;
|
|
746
|
+
};
|
|
747
|
+
const emitLifecycle = (event: IToolJobEvent): void => {
|
|
748
|
+
for (const listener of [...listeners]) {
|
|
749
|
+
try {
|
|
750
|
+
const result = listener(event);
|
|
751
|
+
if (result && typeof result.then === 'function') {
|
|
752
|
+
void result.catch(reportListenerError);
|
|
753
|
+
}
|
|
754
|
+
} catch (error) {
|
|
755
|
+
reportListenerError(error);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
};
|
|
759
|
+
const emitOutput = async (event: IToolJobOutputEvent): Promise<void> => {
|
|
760
|
+
for (const listener of [...listeners]) {
|
|
761
|
+
let timer: NodeJS.Timeout | undefined;
|
|
762
|
+
try {
|
|
763
|
+
await Promise.race([
|
|
764
|
+
Promise.resolve().then(() => listener(event)),
|
|
765
|
+
new Promise<never>((_resolve, reject) => {
|
|
766
|
+
timer = setTimeout(
|
|
767
|
+
() => reject(new Error(
|
|
768
|
+
`Job output listener timed out after ${options.outputListenerTimeoutMs}ms.`,
|
|
769
|
+
)),
|
|
770
|
+
options.outputListenerTimeoutMs,
|
|
771
|
+
);
|
|
772
|
+
}),
|
|
773
|
+
]);
|
|
774
|
+
} finally {
|
|
775
|
+
if (timer) clearTimeout(timer);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
};
|
|
779
|
+
const ready = (async () => {
|
|
780
|
+
const snapshot = options.jobStore
|
|
781
|
+
? await runJobStoreOperation((abortSignal) => options.jobStore!.load(abortSignal))
|
|
782
|
+
: undefined;
|
|
783
|
+
if (!snapshot) return;
|
|
784
|
+
persistenceRevision = snapshot.revision;
|
|
785
|
+
let changed = false;
|
|
786
|
+
for (const restoredState of snapshot.jobs) {
|
|
787
|
+
let state = structuredClone(restoredState);
|
|
788
|
+
if (state.state === 'running') {
|
|
789
|
+
if (options.settleOrphanedJob) {
|
|
790
|
+
const settledState = await runJobStoreOperation(async (abortSignal) =>
|
|
791
|
+
options.settleOrphanedJob!(structuredClone(state), abortSignal));
|
|
792
|
+
assertOrphanSettlement(state, settledState);
|
|
793
|
+
state = structuredClone(settledState);
|
|
794
|
+
} else {
|
|
795
|
+
const now = Date.now();
|
|
796
|
+
state.state = 'failed';
|
|
797
|
+
state.exitCode = null;
|
|
798
|
+
state.stderr = [state.stderr, 'Background execution was interrupted by a host restart.']
|
|
799
|
+
.filter(Boolean)
|
|
800
|
+
.join('\n');
|
|
801
|
+
state.updatedAt = now;
|
|
802
|
+
state.finishedAt = now;
|
|
803
|
+
}
|
|
804
|
+
changed = true;
|
|
805
|
+
}
|
|
806
|
+
states.set(state.executionId, state);
|
|
807
|
+
terminalExecutionIds.push(state.executionId);
|
|
808
|
+
}
|
|
809
|
+
while (terminalExecutionIds.length > options.maxRetainedJobs) {
|
|
810
|
+
const expiredExecutionId = terminalExecutionIds.shift();
|
|
811
|
+
if (expiredExecutionId) {
|
|
812
|
+
states.delete(expiredExecutionId);
|
|
813
|
+
changed = true;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
if (changed) await persist();
|
|
817
|
+
})();
|
|
818
|
+
|
|
819
|
+
return {
|
|
820
|
+
start: async (request, invocationOptions) => {
|
|
821
|
+
await ready;
|
|
822
|
+
await flushPersistence();
|
|
823
|
+
const operationAbortSignal = invocationOptions?.abortSignal ?? options.abortSignal;
|
|
824
|
+
const clonedRequest = structuredClone(request);
|
|
825
|
+
if (clonedRequest.type !== 'shell' || !clonedRequest.command) {
|
|
826
|
+
throw new Error('The local job context only supports structured shell jobs.');
|
|
827
|
+
}
|
|
828
|
+
validateLocalStructuredCommand(clonedRequest.command);
|
|
829
|
+
const commandCwd = resolveLocalPath(clonedRequest.cwd ?? options.cwd, options.rootDir);
|
|
830
|
+
await runBeforeLocalOperation(options.beforeOperation, operationAbortSignal);
|
|
831
|
+
const executionId = `shell_${plugins.crypto.randomUUID()}`;
|
|
832
|
+
const startedAt = Date.now();
|
|
833
|
+
const runningState: IToolJobState = {
|
|
834
|
+
executionId,
|
|
835
|
+
type: 'shell',
|
|
836
|
+
state: 'running',
|
|
837
|
+
request: clonedRequest,
|
|
838
|
+
stdout: '',
|
|
839
|
+
stderr: '',
|
|
840
|
+
startedAt,
|
|
841
|
+
updatedAt: startedAt,
|
|
842
|
+
};
|
|
843
|
+
const startedEvent = structuredClone({
|
|
844
|
+
type: 'started',
|
|
845
|
+
job: runningState,
|
|
846
|
+
} satisfies IToolJobEvent);
|
|
847
|
+
const controller = new AbortController();
|
|
848
|
+
states.set(executionId, runningState);
|
|
849
|
+
controllers.set(executionId, controller);
|
|
850
|
+
try {
|
|
851
|
+
await persist();
|
|
852
|
+
} catch (error) {
|
|
853
|
+
states.delete(executionId);
|
|
854
|
+
controllers.delete(executionId);
|
|
855
|
+
throw error;
|
|
856
|
+
}
|
|
857
|
+
try {
|
|
858
|
+
throwIfAborted(operationAbortSignal);
|
|
859
|
+
} catch (error) {
|
|
860
|
+
states.delete(executionId);
|
|
861
|
+
controllers.delete(executionId);
|
|
862
|
+
try {
|
|
863
|
+
await persist();
|
|
864
|
+
} catch (rollbackError) {
|
|
865
|
+
process.emitWarning(
|
|
866
|
+
rollbackError instanceof Error ? rollbackError : String(rollbackError),
|
|
867
|
+
{ code: 'SMARTAGENT_JOB_ADMISSION_ROLLBACK_ERROR' },
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
throw error;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
let outputSequence = 0;
|
|
874
|
+
let outputPersistenceFailed = false;
|
|
875
|
+
let resolveProcessResult!: (result: IToolShellResult) => void;
|
|
876
|
+
let rejectProcessResult!: (error: unknown) => void;
|
|
877
|
+
const processResult = new Promise<IToolShellResult>((resolve, reject) => {
|
|
878
|
+
resolveProcessResult = resolve;
|
|
879
|
+
rejectProcessResult = reject;
|
|
880
|
+
});
|
|
881
|
+
const completion = (async (): Promise<IToolJobState> => {
|
|
882
|
+
let terminalState: IToolJobState;
|
|
883
|
+
try {
|
|
884
|
+
const result = await processResult;
|
|
885
|
+
const finishedAt = Date.now();
|
|
886
|
+
terminalState = {
|
|
887
|
+
executionId,
|
|
888
|
+
type: 'shell',
|
|
889
|
+
state: controller.signal.aborted
|
|
890
|
+
? 'aborted'
|
|
891
|
+
: result.exitCode === 0
|
|
892
|
+
? 'finished'
|
|
893
|
+
: 'failed',
|
|
894
|
+
request: clonedRequest,
|
|
895
|
+
exitCode: result.exitCode,
|
|
896
|
+
stdout: result.stdout,
|
|
897
|
+
stderr: result.stderr ?? '',
|
|
898
|
+
signal: result.signal,
|
|
899
|
+
startedAt,
|
|
900
|
+
updatedAt: finishedAt,
|
|
901
|
+
finishedAt,
|
|
902
|
+
};
|
|
903
|
+
} catch (error) {
|
|
904
|
+
const finishedAt = Date.now();
|
|
905
|
+
terminalState = {
|
|
906
|
+
executionId,
|
|
907
|
+
type: 'shell',
|
|
908
|
+
state: controller.signal.aborted ? 'aborted' : 'failed',
|
|
909
|
+
request: clonedRequest,
|
|
910
|
+
exitCode: null,
|
|
911
|
+
stdout: states.get(executionId)?.stdout ?? '',
|
|
912
|
+
stderr: [
|
|
913
|
+
states.get(executionId)?.stderr,
|
|
914
|
+
error instanceof Error ? error.message : String(error),
|
|
915
|
+
].filter(Boolean).join('\n'),
|
|
916
|
+
startedAt,
|
|
917
|
+
updatedAt: finishedAt,
|
|
918
|
+
finishedAt,
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
states.set(executionId, terminalState);
|
|
922
|
+
controllers.delete(executionId);
|
|
923
|
+
completions.delete(executionId);
|
|
924
|
+
terminalExecutionIds.push(executionId);
|
|
925
|
+
while (terminalExecutionIds.length > options.maxRetainedJobs) {
|
|
926
|
+
const expiredExecutionId = terminalExecutionIds.shift();
|
|
927
|
+
if (expiredExecutionId) states.delete(expiredExecutionId);
|
|
928
|
+
}
|
|
929
|
+
if (!outputPersistenceFailed) await persist();
|
|
930
|
+
emitLifecycle({ type: 'finished', job: structuredClone(terminalState) });
|
|
931
|
+
return terminalState;
|
|
932
|
+
})();
|
|
933
|
+
completions.set(executionId, completion);
|
|
934
|
+
const handle: IToolJobHandle = { executionId, state: 'running', completion };
|
|
935
|
+
const processCompletion = runLocalProcessCommand(clonedRequest.command, {
|
|
936
|
+
cwd: commandCwd,
|
|
937
|
+
timeoutMs: clonedRequest.timeoutMs,
|
|
938
|
+
abortSignal: controller.signal,
|
|
939
|
+
maxOutputBytes: options.maxOutputBytes,
|
|
940
|
+
onOutput: async (stream, chunk) => {
|
|
941
|
+
const state = states.get(executionId);
|
|
942
|
+
if (!state || state.state !== 'running') return;
|
|
943
|
+
if (stream === 'stdout') state.stdout = `${state.stdout ?? ''}${chunk}`;
|
|
944
|
+
else state.stderr = `${state.stderr ?? ''}${chunk}`;
|
|
945
|
+
state.updatedAt = Date.now();
|
|
946
|
+
try {
|
|
947
|
+
await persist();
|
|
948
|
+
} catch (error) {
|
|
949
|
+
outputPersistenceFailed = true;
|
|
950
|
+
persistenceError ??= error;
|
|
951
|
+
throw error;
|
|
952
|
+
}
|
|
953
|
+
await emitOutput({
|
|
954
|
+
type: 'output',
|
|
955
|
+
job: structuredClone(state),
|
|
956
|
+
stream,
|
|
957
|
+
chunk,
|
|
958
|
+
sequence: ++outputSequence,
|
|
959
|
+
});
|
|
960
|
+
},
|
|
961
|
+
});
|
|
962
|
+
void processCompletion.then(resolveProcessResult, rejectProcessResult);
|
|
963
|
+
emitLifecycle(startedEvent);
|
|
964
|
+
return handle;
|
|
965
|
+
},
|
|
966
|
+
get: async (executionId) => {
|
|
967
|
+
await ready;
|
|
968
|
+
await flushPersistence();
|
|
969
|
+
const state = states.get(executionId);
|
|
970
|
+
if (!state) throw new Error(`Unknown background execution "${executionId}".`);
|
|
971
|
+
return structuredClone(state);
|
|
972
|
+
},
|
|
973
|
+
list: async () => {
|
|
974
|
+
await ready;
|
|
975
|
+
await flushPersistence();
|
|
976
|
+
return structuredClone([...states.values()]);
|
|
977
|
+
},
|
|
978
|
+
abort: async (executionId) => {
|
|
979
|
+
await ready;
|
|
980
|
+
const state = states.get(executionId);
|
|
981
|
+
if (!state) throw new Error(`Unknown background execution "${executionId}".`);
|
|
982
|
+
if (state.state !== 'running') return;
|
|
983
|
+
controllers.get(executionId)?.abort(new DOMException('Background execution aborted', 'AbortError'));
|
|
984
|
+
await completions.get(executionId);
|
|
985
|
+
},
|
|
986
|
+
subscribe: (listener) => {
|
|
987
|
+
listeners.add(listener);
|
|
988
|
+
return () => listeners.delete(listener);
|
|
989
|
+
},
|
|
990
|
+
};
|
|
991
|
+
};
|