@qvac/harness 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +213 -0
- package/dist/harness.bundle +13 -11
- package/dist-lib/index.d.ts +1 -1
- package/dist-lib/lib/capabilities/completion.d.ts +3 -0
- package/dist-lib/lib/capabilities/completion.js +17 -5
- package/dist-lib/lib/helpers/tool-call-markup.js +15 -7
- package/dist-lib/lib/no-redirect-fetch-impl.d.ts +3 -2
- package/dist-lib/lib/no-redirect-fetch-impl.js +4 -1
- package/dist-lib/lib/tools/exec/adapters/adapter.d.ts +2 -0
- package/dist-lib/lib/tools/exec/adapters/python/distributions.d.ts +1 -0
- package/dist-lib/lib/tools/exec/adapters/python/distributions.js +4 -0
- package/dist-lib/lib/tools/exec/adapters/python/guarded-fetch.d.ts +67 -0
- package/dist-lib/lib/tools/exec/adapters/python/guarded-fetch.js +196 -0
- package/dist-lib/lib/tools/exec/adapters/python/loader.js +2 -1
- package/dist-lib/lib/tools/exec/adapters/python/module-distributions.d.ts +1 -0
- package/dist-lib/lib/tools/exec/adapters/python/module-distributions.js +52 -0
- package/dist-lib/lib/tools/exec/adapters/python/net-shim.d.ts +2 -0
- package/dist-lib/lib/tools/exec/adapters/python/net-shim.js +148 -0
- package/dist-lib/lib/tools/exec/adapters/python/output-policy.js +13 -1
- package/dist-lib/lib/tools/exec/adapters/python/pyodide.d.ts +1 -0
- package/dist-lib/lib/tools/exec/adapters/python/pyodide.js +1 -0
- package/dist-lib/lib/tools/exec/adapters/python/python-adapter.d.ts +9 -3
- package/dist-lib/lib/tools/exec/adapters/python/python-adapter.js +130 -20
- package/dist-lib/lib/tools/exec/adapters/python/run-session.d.ts +3 -0
- package/dist-lib/lib/tools/exec/adapters/python/run-session.js +25 -8
- package/dist-lib/lib/tools/exec/adapters/python/wheels.d.ts +6 -0
- package/dist-lib/lib/tools/exec/adapters/python/wheels.js +17 -3
- package/dist-lib/lib/tools/exec/adapters/shell/shell-adapter.js +7 -1
- package/dist-lib/lib/tools/exec/shell-guidance.d.ts +10 -0
- package/dist-lib/lib/tools/exec/shell-guidance.js +123 -0
- package/dist-lib/lib/tools/exec/video-tools.d.ts +5 -0
- package/dist-lib/lib/tools/exec/video-tools.js +40 -0
- package/dist-lib/lib/tools/exec.d.ts +2 -1
- package/dist-lib/lib/tools/exec.js +59 -32
- package/dist-lib/lib/tools/http-request/truncate-response.d.ts +1 -0
- package/dist-lib/lib/tools/http-request/truncate-response.js +1 -1
- package/dist-lib/lib/tools/http-request.js +4 -1
- package/dist-lib/lib/tools/operations/builders.d.ts +1 -1
- package/dist-lib/lib/tools/operations/builders.js +9 -0
- package/dist-lib/lib/tools/operations/compile.js +41 -18
- package/dist-lib/lib/tools/operations/decoders.d.ts +6 -0
- package/dist-lib/lib/tools/operations/decoders.js +82 -0
- package/dist-lib/lib/tools/operations/manifest.d.ts +16 -2
- package/dist-lib/lib/tools/operations/manifest.js +6 -1
- package/dist-lib/lib/tools/skill.d.ts +3 -0
- package/dist-lib/lib/tools/skill.js +38 -5
- package/dist-lib/lib/tools/skills/manifest-parse.js +43 -1
- package/dist-lib/lib/tools/skills/manifest.d.ts +13 -0
- package/dist-lib/schema/harness.js +20 -1
- package/dist-lib/spec/hrpc/messages.js +93 -5
- package/dist-lib/spec/hyperschema/index.js +93 -5
- package/dist-lib/spec/hyperschema/schema.json +66 -1
- package/dist-lib/spec/hyperschema/types.d.ts +18 -1
- package/package.json +30 -2
- package/wheels/manifest.json +15 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare const PYTHON_BIN_ERROR: string;
|
|
2
|
+
export declare const PACKAGES_NEED_PYTHON_ERROR: string;
|
|
3
|
+
export declare const FILES_NEED_PYTHON_ERROR: string;
|
|
4
|
+
export declare function videoToolError(command: string, packages: readonly string[] | undefined, python: boolean, network?: boolean): string | null;
|
|
5
|
+
export declare const SHELL_DOWNLOAD_NOTE: string;
|
|
6
|
+
export declare function shellDownloadNote(command: string): string | null;
|
|
7
|
+
export declare function pipError(command: string, sandbox?: boolean): string | null;
|
|
8
|
+
export declare function startsLikePython(command: string): boolean;
|
|
9
|
+
export declare function isPythonSourceShaped(command: string): boolean;
|
|
10
|
+
export declare function pythonRedirectError(command: string, sandbox?: boolean): string | null;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { pipInstallTargets, pipMessage, unwrapPythonSource, withoutProgramDir } from './adapters/python/python-adapter.js';
|
|
2
|
+
import { bareBin, firstCommandToken, sanitizeCommand } from './scope.js';
|
|
3
|
+
import { shellArgv } from './shell-argv.js';
|
|
4
|
+
import { downloaderShellError, isTranscoder, TRANSCODE_ERROR, VIDEO_TOOLS_ERROR, videoTool } from './video-tools.js';
|
|
5
|
+
const INTERPRETER = /^(python[\d.]*|py)$/;
|
|
6
|
+
const PIP = /^pip[\d.]*$/;
|
|
7
|
+
export const PYTHON_BIN_ERROR = 'python is not a shell command here. Call exec with language "python" and command set to the ' +
|
|
8
|
+
'Python source itself: drop "python -c" and the quotes around the code. PyPI names go in packages.';
|
|
9
|
+
export const PACKAGES_NEED_PYTHON_ERROR = 'packages only work with language "python". Call exec with language "python", the same packages ' +
|
|
10
|
+
'and the Python source in command.';
|
|
11
|
+
export const FILES_NEED_PYTHON_ERROR = 'inputs and outputs only work with language "python". Call exec with language "python" and the ' +
|
|
12
|
+
'Python source in command.';
|
|
13
|
+
function commandBin(command) {
|
|
14
|
+
return bareBin(firstCommandToken(command).replace(/^.*[/\\]/, '')) ?? '';
|
|
15
|
+
}
|
|
16
|
+
function dashModule(command) {
|
|
17
|
+
return /^\s*(?:python[\d.]*|py)\s+-m\s+([\w.-]+)/.exec(withoutProgramDir(command))?.[1] ?? null;
|
|
18
|
+
}
|
|
19
|
+
function importedModules(source) {
|
|
20
|
+
const modules = [];
|
|
21
|
+
for (const line of source.split('\n')) {
|
|
22
|
+
const from = /^\s*from\s+([\w.]+)\s+import\b/.exec(line)?.[1];
|
|
23
|
+
if (from) {
|
|
24
|
+
modules.push(from);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const imported = /^\s*import\s+([\w.]+(?:\s*,\s*[\w.]+)*)/.exec(line)?.[1];
|
|
28
|
+
if (imported)
|
|
29
|
+
modules.push(...imported.split(',').map((m) => m.trim()));
|
|
30
|
+
}
|
|
31
|
+
return modules.map((m) => m.split('.')[0]);
|
|
32
|
+
}
|
|
33
|
+
export function videoToolError(command, packages, python, network = false) {
|
|
34
|
+
const names = [
|
|
35
|
+
dashModule(command) ?? '',
|
|
36
|
+
...(pipInstallTargets(command) ?? []),
|
|
37
|
+
...(packages ?? []),
|
|
38
|
+
...(python ? importedModules(unwrapPythonSource(command) ?? command) : [commandBin(command)])
|
|
39
|
+
];
|
|
40
|
+
const tools = names.map(videoTool).filter((tool) => tool !== null);
|
|
41
|
+
if (tools.length === 0)
|
|
42
|
+
return null;
|
|
43
|
+
if (!network)
|
|
44
|
+
return VIDEO_TOOLS_ERROR;
|
|
45
|
+
if (tools.some(isTranscoder))
|
|
46
|
+
return TRANSCODE_ERROR;
|
|
47
|
+
if (python)
|
|
48
|
+
return null;
|
|
49
|
+
const program = videoTool(commandBin(command)) ?? videoTool(dashModule(command) ?? '');
|
|
50
|
+
return program ? downloaderShellError(program) : null;
|
|
51
|
+
}
|
|
52
|
+
export const SHELL_DOWNLOAD_NOTE = "The file was saved on this device's disk, not in the chat, so the user cannot see it. To " +
|
|
53
|
+
'give the user a downloaded file, fetch it in exec with language "python" and declare it in outputs.';
|
|
54
|
+
const CURL_VALUED = new Set('AbcCdDeEFHKmoPQrTuUwxXyYz'.split(''));
|
|
55
|
+
const WGET_VALUED = new Set('aABDeIilOoPQRtTUwX'.split(''));
|
|
56
|
+
function writesFile(words, curl) {
|
|
57
|
+
const valued = curl ? CURL_VALUED : WGET_VALUED;
|
|
58
|
+
const output = curl ? 'o' : 'O';
|
|
59
|
+
const long = curl ? '--output' : '--output-document';
|
|
60
|
+
let saves = !curl;
|
|
61
|
+
for (let i = 1; i < words.length; i++) {
|
|
62
|
+
const word = words[i];
|
|
63
|
+
if (curl && /^--remote-name(-all)?$/.test(word))
|
|
64
|
+
saves = true;
|
|
65
|
+
if (word === long || word.startsWith(`${long}=`)) {
|
|
66
|
+
const value = word === long ? words[++i] : word.slice(long.length + 1);
|
|
67
|
+
saves = value !== '-';
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (!/^-[A-Za-z]/.test(word))
|
|
71
|
+
continue;
|
|
72
|
+
for (let j = 1; j < word.length; j++) {
|
|
73
|
+
const flag = word[j];
|
|
74
|
+
if (curl && flag === 'O')
|
|
75
|
+
saves = true;
|
|
76
|
+
if (!valued.has(flag))
|
|
77
|
+
continue;
|
|
78
|
+
const value = j + 1 < word.length ? word.slice(j + 1) : words[++i];
|
|
79
|
+
if (flag === output)
|
|
80
|
+
saves = value !== '-';
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return saves;
|
|
85
|
+
}
|
|
86
|
+
export function shellDownloadNote(command) {
|
|
87
|
+
const bin = commandBin(command);
|
|
88
|
+
if (bin !== 'curl' && bin !== 'wget')
|
|
89
|
+
return null;
|
|
90
|
+
return writesFile(shellArgv(command), bin === 'curl') ? SHELL_DOWNLOAD_NOTE : null;
|
|
91
|
+
}
|
|
92
|
+
export function pipError(command, sandbox = false) {
|
|
93
|
+
if (!PIP.test(commandBin(command)) && dashModule(command) !== 'pip')
|
|
94
|
+
return null;
|
|
95
|
+
return pipMessage(pipInstallTargets(command), sandbox);
|
|
96
|
+
}
|
|
97
|
+
function firstCodeLine(command) {
|
|
98
|
+
return (command
|
|
99
|
+
.split('\n')
|
|
100
|
+
.find((line) => line.trim() && !line.trim().startsWith('#'))
|
|
101
|
+
?.trim() ?? '');
|
|
102
|
+
}
|
|
103
|
+
const PYTHON_START = /^(from\s+[\w.]+\s+import\b|import\s+[\w.]+\s*(,|;|$|\sas\s)|def\s+\w+\s*\(|class\s+\w+|@[\w.]+|(for|while|if|with|try|async)\b[^\n]*:$|"""|\'\'\'|[A-Za-z_][\w.]*(\[[^\]]*\])?\s+=\s+)/;
|
|
104
|
+
export function startsLikePython(command) {
|
|
105
|
+
return PYTHON_START.test(firstCodeLine(command));
|
|
106
|
+
}
|
|
107
|
+
export function isPythonSourceShaped(command) {
|
|
108
|
+
const first = firstCodeLine(command);
|
|
109
|
+
if (/^from\s+[\w.]+\s+import\b/.test(first))
|
|
110
|
+
return true;
|
|
111
|
+
if (/^[A-Za-z_][\w.]*\s*\(/.test(first) && !/^[A-Za-z_]\w*\s*\(\s*\)\s*\{/.test(first)) {
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
if (!PYTHON_START.test(first))
|
|
115
|
+
return false;
|
|
116
|
+
return command.trim().includes('\n') || sanitizeCommand(command) !== null;
|
|
117
|
+
}
|
|
118
|
+
export function pythonRedirectError(command, sandbox = false) {
|
|
119
|
+
const pip = pipError(command, sandbox);
|
|
120
|
+
if (pip)
|
|
121
|
+
return pip;
|
|
122
|
+
return INTERPRETER.test(commandBin(command)) ? PYTHON_BIN_ERROR : null;
|
|
123
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const VIDEO_TOOLS_ERROR: string;
|
|
2
|
+
export declare const TRANSCODE_ERROR: string;
|
|
3
|
+
export declare function downloaderShellError(tool: string): string;
|
|
4
|
+
export declare function videoTool(name: string): string | null;
|
|
5
|
+
export declare function isTranscoder(tool: string): boolean;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const TRANSCODERS = new Set([
|
|
2
|
+
'ffmpeg',
|
|
3
|
+
'ffprobe',
|
|
4
|
+
'ffmpeg-python',
|
|
5
|
+
'imageio-ffmpeg',
|
|
6
|
+
'moviepy',
|
|
7
|
+
'avconv',
|
|
8
|
+
'ytdl'
|
|
9
|
+
]);
|
|
10
|
+
const DOWNLOADERS = new Set([
|
|
11
|
+
'yt-dlp',
|
|
12
|
+
'youtube-dl',
|
|
13
|
+
'you-get',
|
|
14
|
+
'streamlink',
|
|
15
|
+
'pytube',
|
|
16
|
+
'pytubefix'
|
|
17
|
+
]);
|
|
18
|
+
export const VIDEO_TOOLS_ERROR = 'downloading or transcoding video is not possible on this device: yt-dlp, youtube-dl, ffmpeg ' +
|
|
19
|
+
'and similar tools cannot run here, on any path. Do not retry; tell the user it cannot be done.';
|
|
20
|
+
export const TRANSCODE_ERROR = 'ffmpeg is not available on this device: ffmpeg, ffprobe and the packages that run them ' +
|
|
21
|
+
'(moviepy, ffmpeg-python) cannot run here, on any path, so nothing can merge or convert ' +
|
|
22
|
+
'video. Fetch a single file that needs neither, or tell the user it cannot be done.';
|
|
23
|
+
export function downloaderShellError(tool) {
|
|
24
|
+
return (`${tool} is not a program here: it is a Python package. Call exec with language "python", ` +
|
|
25
|
+
`packages: ["${tool}"] and Python source that imports it, and declare the file it saves ` +
|
|
26
|
+
'in outputs so it reaches the user.');
|
|
27
|
+
}
|
|
28
|
+
export function videoTool(name) {
|
|
29
|
+
const bare = name
|
|
30
|
+
.trim()
|
|
31
|
+
.replace(/^['"]|['"]$/g, '')
|
|
32
|
+
.split(/[=<>!~[\s]/)[0]
|
|
33
|
+
.toLowerCase()
|
|
34
|
+
.replace(/\.exe$/, '')
|
|
35
|
+
.replace(/[-_.]+/g, '-');
|
|
36
|
+
return TRANSCODERS.has(bare) || DOWNLOADERS.has(bare) ? bare : null;
|
|
37
|
+
}
|
|
38
|
+
export function isTranscoder(tool) {
|
|
39
|
+
return TRANSCODERS.has(tool);
|
|
40
|
+
}
|
|
@@ -10,6 +10,7 @@ export interface ExecToolOptions {
|
|
|
10
10
|
readonly killProcess?: KillProcess;
|
|
11
11
|
readonly loadPyodide?: LoadPyodideFn;
|
|
12
12
|
readonly disablePyodide?: boolean;
|
|
13
|
+
readonly pythonNetwork?: boolean;
|
|
13
14
|
readonly credentialStore?: CredentialStoreFn;
|
|
14
15
|
readonly skillSettings?: SkillSettingsFn;
|
|
15
16
|
readonly realpath?: Realpath;
|
|
@@ -26,4 +27,4 @@ export declare function resolveScriptRun(rawArgs: Record<string, unknown>, skill
|
|
|
26
27
|
} | {
|
|
27
28
|
error: string;
|
|
28
29
|
} | null;
|
|
29
|
-
export declare function createExecTool({ spawn, killProcess, loadPyodide, disablePyodide, credentialStore, skillSettings, realpath, deniedRoots, skillsDir }?: ExecToolOptions): ToolDefinition;
|
|
30
|
+
export declare function createExecTool({ spawn, killProcess, loadPyodide, disablePyodide, pythonNetwork, credentialStore, skillSettings, realpath, deniedRoots, skillsDir }?: ExecToolOptions): ToolDefinition;
|
|
@@ -2,12 +2,14 @@ import fs from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { AbortedError } from '../helpers/race-abort.js';
|
|
4
4
|
import { spawnEnv } from '../helpers/spawn-path.js';
|
|
5
|
-
import { checkPdfBuildSource, checkBareLengthArgs, checkPptxBuildSource, checkPythonSource, checkUnstagedRead, createPythonAdapter, isPythonLanguage,
|
|
5
|
+
import { checkPdfBuildSource, checkBareLengthArgs, checkPptxBuildSource, checkPythonSource, checkUnstagedRead, createPythonAdapter, isPythonLanguage, parseInputs, parseOutputs, parsePackages, parseWorkdir, pinned, runsThroughPyodide, unwrapPythonSource } from './exec/adapters/python/python-adapter.js';
|
|
6
6
|
import { pythonCreateEffect } from './exec/adapters/python/create-effect.js';
|
|
7
|
+
import { pythonReachesNetwork } from './exec/adapters/python/net-shim.js';
|
|
7
8
|
import { createShellAdapter } from './exec/adapters/shell/shell-adapter.js';
|
|
8
9
|
import { routeObsidianCommand } from './exec/obsidian/route.js';
|
|
9
10
|
import { MAX_OUTPUT_CHARS_LIMIT } from './exec/runner.js';
|
|
10
11
|
import { bareBin, firstCommandToken, sanitizeCommand } from './exec/scope.js';
|
|
12
|
+
import { FILES_NEED_PYTHON_ERROR, isPythonSourceShaped, PACKAGES_NEED_PYTHON_ERROR, pythonRedirectError, startsLikePython, videoToolError } from './exec/shell-guidance.js';
|
|
11
13
|
import { shellArgv } from './exec/shell-argv.js';
|
|
12
14
|
import { canonicalDenyRoots, defaultRealpath, hostPath, resolveKey } from './fs/scope.js';
|
|
13
15
|
import { unquoteArg } from './model-args.js';
|
|
@@ -64,7 +66,7 @@ function isPresent(value) {
|
|
|
64
66
|
}
|
|
65
67
|
function checkFileArgs(args, python) {
|
|
66
68
|
if (!python && (isPresent(args.inputs) || isPresent(args.outputs))) {
|
|
67
|
-
return
|
|
69
|
+
return FILES_NEED_PYTHON_ERROR;
|
|
68
70
|
}
|
|
69
71
|
const inputs = parseInputs(args.inputs);
|
|
70
72
|
if (inputs && 'error' in inputs)
|
|
@@ -100,13 +102,25 @@ function fileSuffix(args) {
|
|
|
100
102
|
const to = Array.isArray(outputs) && outputs.length > 0 ? ` → ${truncateLabel(outputs.join(', '))}` : '';
|
|
101
103
|
return `${from}${to}`;
|
|
102
104
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
+
function stringList(value) {
|
|
106
|
+
if (!Array.isArray(value))
|
|
107
|
+
return undefined;
|
|
108
|
+
return value.filter((v) => typeof v === 'string');
|
|
109
|
+
}
|
|
110
|
+
function commandRequiredError(args, network) {
|
|
105
111
|
const staged = Array.isArray(args.inputs) || Array.isArray(args.outputs);
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
'python source) or script + scriptArgs exactly as the loaded skill reference shows'
|
|
109
|
-
|
|
112
|
+
if (staged) {
|
|
113
|
+
return ('command is required: keep these same inputs/outputs and add either command (the bare ' +
|
|
114
|
+
'python source) or script + scriptArgs exactly as the loaded skill reference shows');
|
|
115
|
+
}
|
|
116
|
+
const packages = stringList(args.packages);
|
|
117
|
+
const impossible = videoToolError('', packages, true, network);
|
|
118
|
+
if (impossible)
|
|
119
|
+
return impossible;
|
|
120
|
+
if (packages?.length) {
|
|
121
|
+
return `command is required: call exec again with language "python", packages: ${JSON.stringify(packages.map(pinned))} and the Python source that uses them in command. Installing and running are the same one call.`;
|
|
122
|
+
}
|
|
123
|
+
return 'command is required';
|
|
110
124
|
}
|
|
111
125
|
function parseScriptArgs(value) {
|
|
112
126
|
if (Array.isArray(value))
|
|
@@ -207,18 +221,27 @@ function scriptLabel(args) {
|
|
|
207
221
|
}
|
|
208
222
|
const WORKDIR_NO_ROOTS_ERROR = 'workdir is not available: no directories are granted to this device, so a working directory ' +
|
|
209
223
|
'cannot be validated. Omit workdir and use absolute paths in the command.';
|
|
210
|
-
function
|
|
224
|
+
function withInferredLanguage(args, disablePyodide) {
|
|
225
|
+
if (disablePyodide || isPythonLanguage(args.language))
|
|
226
|
+
return args;
|
|
227
|
+
const command = String(args.command ?? '').trim();
|
|
228
|
+
const declared = (stringList(args.packages) ?? []).length > 0 && startsLikePython(command);
|
|
229
|
+
const wrapped = unwrapPythonSource(command) !== null;
|
|
230
|
+
return isPythonSourceShaped(command) || declared || wrapped
|
|
231
|
+
? { ...args, language: 'python' }
|
|
232
|
+
: args;
|
|
233
|
+
}
|
|
234
|
+
function checkExecutionScope(args, toolOps, disablePyodide, sandbox, network) {
|
|
211
235
|
const command = String(args.command ?? '').trim();
|
|
236
|
+
const video = videoToolError(command, stringList(args.packages), runsThroughPyodide(args.language, disablePyodide), network);
|
|
237
|
+
if (video)
|
|
238
|
+
return video;
|
|
212
239
|
if (runsThroughPyodide(args.language, disablePyodide))
|
|
213
240
|
return null;
|
|
214
|
-
if (!disablePyodide
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const sourceShaped = (/^from\s+\w+\s+import\b/.test(command) || command.includes('\n')) &&
|
|
219
|
-
looksLikePythonSource(command);
|
|
220
|
-
if (!disablePyodide && (callsFirst || sourceShaped)) {
|
|
221
|
-
return 'this command is Python source: pass language "python" with the bare source in command (dependencies in packages, produced files in outputs)';
|
|
241
|
+
if (!disablePyodide) {
|
|
242
|
+
const redirect = pythonRedirectError(command, sandbox);
|
|
243
|
+
if (redirect)
|
|
244
|
+
return redirect;
|
|
222
245
|
}
|
|
223
246
|
return checkShellScope(command, toolOps);
|
|
224
247
|
}
|
|
@@ -232,8 +255,8 @@ async function skillsEnv(skillSettings, ctx) {
|
|
|
232
255
|
}
|
|
233
256
|
return Object.keys(env).length > 0 ? env : undefined;
|
|
234
257
|
}
|
|
235
|
-
export function createExecTool({ spawn, killProcess, loadPyodide, disablePyodide = false, credentialStore, skillSettings, realpath = defaultRealpath, deniedRoots, skillsDir } = {}) {
|
|
236
|
-
const adapters = [createPythonAdapter(disablePyodide, loadPyodide)];
|
|
258
|
+
export function createExecTool({ spawn, killProcess, loadPyodide, disablePyodide = false, pythonNetwork = !disablePyodide && pythonReachesNetwork(), credentialStore, skillSettings, realpath = defaultRealpath, deniedRoots, skillsDir } = {}) {
|
|
259
|
+
const adapters = [createPythonAdapter(disablePyodide, loadPyodide, pythonNetwork)];
|
|
237
260
|
const fallback = createShellAdapter({ spawn, killProcess, loadRuntime: bareRuntime });
|
|
238
261
|
return {
|
|
239
262
|
name: 'exec',
|
|
@@ -347,7 +370,8 @@ export function createExecTool({ spawn, killProcess, loadPyodide, disablePyodide
|
|
|
347
370
|
}
|
|
348
371
|
},
|
|
349
372
|
autoApprove: false,
|
|
350
|
-
effect(
|
|
373
|
+
effect(rawArgs, ctx) {
|
|
374
|
+
const args = withInferredLanguage(rawArgs, disablePyodide);
|
|
351
375
|
if (typeof args.script === 'string' && args.script.trim() && !args.script.includes('\n')) {
|
|
352
376
|
return 'read';
|
|
353
377
|
}
|
|
@@ -359,11 +383,11 @@ export function createExecTool({ spawn, killProcess, loadPyodide, disablePyodide
|
|
|
359
383
|
const scriptRun = resolveScriptRun(args, skillsDir);
|
|
360
384
|
if (scriptRun && 'error' in scriptRun)
|
|
361
385
|
return scriptRun.error;
|
|
362
|
-
const rawArgs = scriptRun ? scriptRun.args : args;
|
|
386
|
+
const rawArgs = withInferredLanguage(scriptRun ? scriptRun.args : args, disablePyodide);
|
|
363
387
|
const command = String(rawArgs.command ?? '').trim();
|
|
364
388
|
if (!command)
|
|
365
|
-
return commandRequiredError(rawArgs);
|
|
366
|
-
const scopeError = checkExecutionScope(rawArgs, ctx?.toolOps, disablePyodide);
|
|
389
|
+
return commandRequiredError(rawArgs, pythonNetwork && ctx?.sandboxMode !== true);
|
|
390
|
+
const scopeError = checkExecutionScope(rawArgs, ctx?.toolOps, disablePyodide, ctx?.sandboxMode === true, pythonNetwork && ctx?.sandboxMode !== true);
|
|
367
391
|
if (scopeError)
|
|
368
392
|
return scopeError;
|
|
369
393
|
const python = runsThroughPyodide(rawArgs.language, disablePyodide);
|
|
@@ -400,12 +424,12 @@ export function createExecTool({ spawn, killProcess, loadPyodide, disablePyodide
|
|
|
400
424
|
const parsed = parsePackages(rawArgs.packages);
|
|
401
425
|
if (parsed && 'error' in parsed)
|
|
402
426
|
return parsed.error;
|
|
403
|
-
if (parsed && !python)
|
|
404
|
-
return
|
|
405
|
-
}
|
|
427
|
+
if (parsed && !python)
|
|
428
|
+
return PACKAGES_NEED_PYTHON_ERROR;
|
|
406
429
|
return checkFileArgs(rawArgs, python);
|
|
407
430
|
},
|
|
408
|
-
resource(
|
|
431
|
+
resource(rawArgs) {
|
|
432
|
+
const args = withInferredLanguage(rawArgs, disablePyodide);
|
|
409
433
|
const script = typeof args.script === 'string' ? args.script : '';
|
|
410
434
|
if (script.trim() && !script.includes('\n')) {
|
|
411
435
|
return { kind: 'command', label: scriptLabel(args) };
|
|
@@ -426,11 +450,12 @@ export function createExecTool({ spawn, killProcess, loadPyodide, disablePyodide
|
|
|
426
450
|
const scriptRun = resolveScriptRun(args, skillsDir);
|
|
427
451
|
if (scriptRun && 'error' in scriptRun)
|
|
428
452
|
return { error: scriptRun.error };
|
|
429
|
-
const rawArgs = scriptRun ? scriptRun.args : args;
|
|
453
|
+
const rawArgs = withInferredLanguage(scriptRun ? scriptRun.args : args, disablePyodide);
|
|
430
454
|
const command = String(rawArgs.command ?? '').trim();
|
|
431
|
-
if (!command)
|
|
432
|
-
return { error: commandRequiredError(rawArgs) };
|
|
433
|
-
|
|
455
|
+
if (!command) {
|
|
456
|
+
return { error: commandRequiredError(rawArgs, pythonNetwork && ctx?.sandboxMode !== true) };
|
|
457
|
+
}
|
|
458
|
+
const scopeError = checkExecutionScope(rawArgs, ctx?.toolOps, disablePyodide, ctx?.sandboxMode === true, pythonNetwork && ctx?.sandboxMode !== true);
|
|
434
459
|
if (scopeError)
|
|
435
460
|
return { error: scopeError };
|
|
436
461
|
const workdir = rawArgs.workdir === undefined || rawArgs.workdir === null
|
|
@@ -488,7 +513,9 @@ export function createExecTool({ spawn, killProcess, loadPyodide, disablePyodide
|
|
|
488
513
|
attach: ctx?.attach,
|
|
489
514
|
fetchAttachment: ctx?.fetchAttachment,
|
|
490
515
|
turnImages: ctx?.turnImages,
|
|
491
|
-
runBudget: ctx?.runBudget
|
|
516
|
+
runBudget: ctx?.runBudget,
|
|
517
|
+
sandboxMode: ctx?.sandboxMode,
|
|
518
|
+
toolNames: ctx?.toolNames
|
|
492
519
|
});
|
|
493
520
|
return await execution.run({
|
|
494
521
|
cwd,
|
|
@@ -20,7 +20,7 @@ function shrinkable(value) {
|
|
|
20
20
|
return true;
|
|
21
21
|
return typeof value === 'object' && value !== null;
|
|
22
22
|
}
|
|
23
|
-
const ELISION = '… [truncated]';
|
|
23
|
+
export const ELISION = '… [truncated]';
|
|
24
24
|
function fitString(value, budget) {
|
|
25
25
|
const room = budget - ELISION.length - 2;
|
|
26
26
|
if (room <= 0)
|
|
@@ -159,7 +159,10 @@ export function createHttpRequestTool({ fetch: fetchImpl, credentialStore, crede
|
|
|
159
159
|
credentialKey = next.credentialKey;
|
|
160
160
|
}
|
|
161
161
|
const text = await response.text();
|
|
162
|
-
return {
|
|
162
|
+
return {
|
|
163
|
+
status: response.status,
|
|
164
|
+
body: ctx?.uncappedResponse ? text : truncateResponseBody(text)
|
|
165
|
+
};
|
|
163
166
|
}
|
|
164
167
|
catch (err) {
|
|
165
168
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
@@ -12,6 +12,6 @@ export type BuilderOutcome = {
|
|
|
12
12
|
export interface OperationBuilder {
|
|
13
13
|
build: (args: Record<string, unknown>) => BuilderOutcome;
|
|
14
14
|
}
|
|
15
|
-
export declare const BUILDER_NAMES: readonly ['gmail-send', 'gmail-draft', 'calendar-meet-event', 'calendar-add-meet', 'docs-create', 'docs-append-text', 'sheets-create', 'sheets-write-values', 'sheets-append-values', 'drive-create-folder', 'drive-trash', 'notion-create-page', 'notion-insert-content'];
|
|
15
|
+
export declare const BUILDER_NAMES: readonly ['gmail-send', 'gmail-draft', 'gmail-get', 'calendar-meet-event', 'calendar-add-meet', 'docs-create', 'docs-append-text', 'sheets-create', 'sheets-write-values', 'sheets-append-values', 'drive-create-folder', 'drive-trash', 'notion-create-page', 'notion-insert-content'];
|
|
16
16
|
export type BuilderName = (typeof BUILDER_NAMES)[number];
|
|
17
17
|
export declare const BUILDERS: Record<BuilderName, OperationBuilder>;
|
|
@@ -5,6 +5,7 @@ import { buildGmailMessage, GmailEnvelopeSchema } from './gmail-message.js';
|
|
|
5
5
|
export const BUILDER_NAMES = [
|
|
6
6
|
'gmail-send',
|
|
7
7
|
'gmail-draft',
|
|
8
|
+
'gmail-get',
|
|
8
9
|
'calendar-meet-event',
|
|
9
10
|
'calendar-add-meet',
|
|
10
11
|
'docs-create',
|
|
@@ -40,6 +41,13 @@ const gmailDraft = builder(GmailMessageSchema, (args) => ({
|
|
|
40
41
|
body: { message: { raw: buildGmailMessage(args), threadId: args.threadId } },
|
|
41
42
|
encode: { 'message.raw': 'base64url' }
|
|
42
43
|
}));
|
|
44
|
+
const GmailGetSchema = z.object({
|
|
45
|
+
id: z.string().min(1),
|
|
46
|
+
format: z.enum(['metadata', 'full']).optional()
|
|
47
|
+
});
|
|
48
|
+
const gmailGet = builder(GmailGetSchema, (args) => ({
|
|
49
|
+
query: { format: args.format ?? 'metadata' }
|
|
50
|
+
}));
|
|
43
51
|
function createRequest() {
|
|
44
52
|
return {
|
|
45
53
|
createRequest: {
|
|
@@ -165,6 +173,7 @@ const notionInsertContent = builder(NotionInsertSchema, (args) => ({
|
|
|
165
173
|
export const BUILDERS = {
|
|
166
174
|
'gmail-send': gmailSend,
|
|
167
175
|
'gmail-draft': gmailDraft,
|
|
176
|
+
'gmail-get': gmailGet,
|
|
168
177
|
'calendar-meet-event': calendarMeetEvent,
|
|
169
178
|
'calendar-add-meet': calendarAddMeet,
|
|
170
179
|
'docs-create': docsCreate,
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { READ_METHODS } from '../http-methods.js';
|
|
3
|
+
import { truncateResponseBody } from '../http-request/truncate-response.js';
|
|
1
4
|
import { BUILDERS } from './builders.js';
|
|
5
|
+
import { DECODERS } from './decoders.js';
|
|
2
6
|
export function compileOperationTools(catalog, { httpRequestTool, mcpCallTool }) {
|
|
3
7
|
return catalog.flatMap((entry) => (entry.operations ?? []).flatMap((operation) => {
|
|
4
8
|
const transport = operation.request.transport === 'mcp' ? mcpCallTool : httpRequestTool;
|
|
@@ -8,18 +12,19 @@ export function compileOperationTools(catalog, { httpRequestTool, mcpCallTool })
|
|
|
8
12
|
function operationTool(owningSkill, operation, transportTool) {
|
|
9
13
|
const builder = BUILDERS[operation.request.builder];
|
|
10
14
|
const mcp = operation.request.transport === 'mcp';
|
|
15
|
+
const read = !mcp && READ_METHODS.has(operation.request.method);
|
|
11
16
|
return {
|
|
12
17
|
name: operation.tool,
|
|
13
18
|
owningSkill,
|
|
14
19
|
description: operation.description,
|
|
15
20
|
parameters: operation.parameters,
|
|
16
|
-
autoApprove:
|
|
21
|
+
autoApprove: read,
|
|
17
22
|
skillGated: true,
|
|
18
|
-
externalWrite:
|
|
23
|
+
externalWrite: !read,
|
|
19
24
|
toolset: mcp ? 'mcp-call' : 'http-request',
|
|
20
25
|
compiled: true,
|
|
21
26
|
effect() {
|
|
22
|
-
return 'write';
|
|
27
|
+
return read ? 'read' : 'write';
|
|
23
28
|
},
|
|
24
29
|
resource() {
|
|
25
30
|
return hostOf(operation.request.url);
|
|
@@ -29,6 +34,9 @@ function operationTool(owningSkill, operation, transportTool) {
|
|
|
29
34
|
const built = builder.build(args);
|
|
30
35
|
if ('error' in built)
|
|
31
36
|
return { error: `${operation.tool}: ${built.error}` };
|
|
37
|
+
if (read && (built.request.body !== undefined || built.request.encode !== undefined)) {
|
|
38
|
+
return { error: `${operation.tool}: a read operation cannot send a body` };
|
|
39
|
+
}
|
|
32
40
|
const request = operation.request;
|
|
33
41
|
if (request.transport === 'mcp') {
|
|
34
42
|
return transportTool.execute({ url: request.url, method: request.method, params: built.request.params }, skillContext);
|
|
@@ -36,38 +44,53 @@ function operationTool(owningSkill, operation, transportTool) {
|
|
|
36
44
|
const url = fillTemplate(request.url, args, request.urlDefaults);
|
|
37
45
|
if ('error' in url)
|
|
38
46
|
return { error: `${operation.tool}: ${url.error}` };
|
|
47
|
+
const { response } = operation;
|
|
39
48
|
const result = await transportTool.execute({
|
|
40
49
|
url: url.value,
|
|
41
50
|
method: request.method,
|
|
42
51
|
query: { ...request.query, ...built.request.query },
|
|
43
52
|
body: built.request.body,
|
|
44
53
|
encode: built.request.encode
|
|
45
|
-
}, skillContext);
|
|
46
|
-
return
|
|
54
|
+
}, response ? { ...skillContext, uncappedResponse: true } : skillContext);
|
|
55
|
+
return response ? capBody(shapeResponse(result, response)) : result;
|
|
47
56
|
}
|
|
48
57
|
};
|
|
49
58
|
}
|
|
50
|
-
|
|
51
|
-
|
|
59
|
+
const JsonResult = z.looseObject({ status: z.number().optional(), body: z.string() });
|
|
60
|
+
function capBody(result) {
|
|
61
|
+
const parsed = JsonResult.safeParse(result);
|
|
62
|
+
if (!parsed.success)
|
|
63
|
+
return result;
|
|
64
|
+
return { ...parsed.data, body: truncateResponseBody(parsed.data.body) };
|
|
65
|
+
}
|
|
66
|
+
function shapeResponse(result, response) {
|
|
67
|
+
const parsed = JsonResult.safeParse(result);
|
|
68
|
+
if (!parsed.success)
|
|
52
69
|
return result;
|
|
53
|
-
const {
|
|
54
|
-
if (
|
|
70
|
+
const { status } = parsed.data;
|
|
71
|
+
if (status !== undefined && status >= 400)
|
|
55
72
|
return result;
|
|
56
|
-
|
|
73
|
+
const body = jsonObject(parsed.data.body);
|
|
74
|
+
if (!body)
|
|
75
|
+
return result;
|
|
76
|
+
const shaped = 'pick' in response ? pick(body, response.pick) : DECODERS[response.decode].decode(body);
|
|
77
|
+
return { ...parsed.data, body: JSON.stringify(shaped) };
|
|
78
|
+
}
|
|
79
|
+
function jsonObject(body) {
|
|
57
80
|
try {
|
|
58
|
-
parsed = JSON.parse(body);
|
|
81
|
+
const parsed = z.record(z.string(), z.unknown()).safeParse(JSON.parse(body));
|
|
82
|
+
return parsed.success ? parsed.data : null;
|
|
59
83
|
}
|
|
60
84
|
catch {
|
|
61
|
-
return
|
|
85
|
+
return null;
|
|
62
86
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const record = parsed;
|
|
87
|
+
}
|
|
88
|
+
function pick(body, keys) {
|
|
66
89
|
const picked = {};
|
|
67
90
|
for (const key of keys)
|
|
68
|
-
if (key in
|
|
69
|
-
picked[key] =
|
|
70
|
-
return
|
|
91
|
+
if (key in body)
|
|
92
|
+
picked[key] = body[key];
|
|
93
|
+
return picked;
|
|
71
94
|
}
|
|
72
95
|
const PLACEHOLDER = /\{(\w+)\}/g;
|
|
73
96
|
function fillTemplate(template, args, defaults) {
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export interface ResponseDecoder {
|
|
2
|
+
decode: (body: Record<string, unknown>) => Record<string, unknown>;
|
|
3
|
+
}
|
|
4
|
+
export declare const DECODER_NAMES: readonly ['gmail-message'];
|
|
5
|
+
export type DecoderName = (typeof DECODER_NAMES)[number];
|
|
6
|
+
export declare const DECODERS: Record<DecoderName, ResponseDecoder>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import b4a from 'b4a';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { extractPageText } from '../../web-fetch/extract.js';
|
|
4
|
+
import { decodeHtmlEntities } from '../../web-search/html-helpers.js';
|
|
5
|
+
import { ELISION } from '../http-request/truncate-response.js';
|
|
6
|
+
export const DECODER_NAMES = ['gmail-message'];
|
|
7
|
+
const KEPT_HEADERS = ['From', 'To', 'Subject', 'Date', 'Message-ID', 'References'];
|
|
8
|
+
const MAX_TEXT_CHARS = 4000;
|
|
9
|
+
const PartSchema = z.lazy(() => z.object({
|
|
10
|
+
mimeType: z.string().optional(),
|
|
11
|
+
filename: z.string().optional(),
|
|
12
|
+
headers: z.array(z.object({ name: z.string(), value: z.string() })).optional(),
|
|
13
|
+
body: z.object({ data: z.string().optional() }).optional(),
|
|
14
|
+
parts: z.array(PartSchema).optional()
|
|
15
|
+
}));
|
|
16
|
+
const MessageSchema = z.object({
|
|
17
|
+
id: z.string().optional(),
|
|
18
|
+
threadId: z.string().optional(),
|
|
19
|
+
labelIds: z.array(z.string()).optional(),
|
|
20
|
+
snippet: z.string().optional(),
|
|
21
|
+
payload: PartSchema.optional()
|
|
22
|
+
});
|
|
23
|
+
const gmailMessage = {
|
|
24
|
+
decode(body) {
|
|
25
|
+
const parsed = MessageSchema.safeParse(body);
|
|
26
|
+
if (!parsed.success)
|
|
27
|
+
return body;
|
|
28
|
+
const { id, threadId, labelIds, snippet, payload } = parsed.data;
|
|
29
|
+
return {
|
|
30
|
+
id,
|
|
31
|
+
threadId,
|
|
32
|
+
labelIds,
|
|
33
|
+
headers: payload && keptHeaders(payload),
|
|
34
|
+
snippet: snippet && decodeHtmlEntities(snippet),
|
|
35
|
+
text: payload && capped(textOf(payload))
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
function keptHeaders(part) {
|
|
40
|
+
const kept = {};
|
|
41
|
+
for (const header of part.headers ?? []) {
|
|
42
|
+
const name = KEPT_HEADERS.find((known) => known.toLowerCase() === header.name.toLowerCase());
|
|
43
|
+
if (name && !(name in kept))
|
|
44
|
+
kept[name] = header.value;
|
|
45
|
+
}
|
|
46
|
+
return kept;
|
|
47
|
+
}
|
|
48
|
+
function textOf(part) {
|
|
49
|
+
const plain = leaves(part, 'text/plain');
|
|
50
|
+
if (plain.length)
|
|
51
|
+
return plain.join('\n\n');
|
|
52
|
+
const html = leaves(part, 'text/html')
|
|
53
|
+
.map((leaf) => extractPageText(leaf).text)
|
|
54
|
+
.filter((text) => text.length > 0);
|
|
55
|
+
if (html.length)
|
|
56
|
+
return html.join('\n\n');
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
function leaves(part, mimeType) {
|
|
60
|
+
if (part.parts?.length)
|
|
61
|
+
return part.parts.flatMap((child) => leaves(child, mimeType));
|
|
62
|
+
if (part.mimeType?.toLowerCase() !== mimeType || part.filename || !part.body?.data)
|
|
63
|
+
return [];
|
|
64
|
+
const text = decodeData(part.body.data);
|
|
65
|
+
return text ? [text] : [];
|
|
66
|
+
}
|
|
67
|
+
function decodeData(data) {
|
|
68
|
+
try {
|
|
69
|
+
return b4a.toString(b4a.from(data, 'base64url'), 'utf8').replace(/\r\n/g, '\n').trim();
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return '';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function capped(text) {
|
|
76
|
+
if (text === undefined || text.length <= MAX_TEXT_CHARS)
|
|
77
|
+
return text;
|
|
78
|
+
return `${text.slice(0, MAX_TEXT_CHARS)}${ELISION}`;
|
|
79
|
+
}
|
|
80
|
+
export const DECODERS = {
|
|
81
|
+
'gmail-message': gmailMessage
|
|
82
|
+
};
|