@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.
Files changed (55) hide show
  1. package/CHANGELOG.md +213 -0
  2. package/dist/harness.bundle +13 -11
  3. package/dist-lib/index.d.ts +1 -1
  4. package/dist-lib/lib/capabilities/completion.d.ts +3 -0
  5. package/dist-lib/lib/capabilities/completion.js +17 -5
  6. package/dist-lib/lib/helpers/tool-call-markup.js +15 -7
  7. package/dist-lib/lib/no-redirect-fetch-impl.d.ts +3 -2
  8. package/dist-lib/lib/no-redirect-fetch-impl.js +4 -1
  9. package/dist-lib/lib/tools/exec/adapters/adapter.d.ts +2 -0
  10. package/dist-lib/lib/tools/exec/adapters/python/distributions.d.ts +1 -0
  11. package/dist-lib/lib/tools/exec/adapters/python/distributions.js +4 -0
  12. package/dist-lib/lib/tools/exec/adapters/python/guarded-fetch.d.ts +67 -0
  13. package/dist-lib/lib/tools/exec/adapters/python/guarded-fetch.js +196 -0
  14. package/dist-lib/lib/tools/exec/adapters/python/loader.js +2 -1
  15. package/dist-lib/lib/tools/exec/adapters/python/module-distributions.d.ts +1 -0
  16. package/dist-lib/lib/tools/exec/adapters/python/module-distributions.js +52 -0
  17. package/dist-lib/lib/tools/exec/adapters/python/net-shim.d.ts +2 -0
  18. package/dist-lib/lib/tools/exec/adapters/python/net-shim.js +148 -0
  19. package/dist-lib/lib/tools/exec/adapters/python/output-policy.js +13 -1
  20. package/dist-lib/lib/tools/exec/adapters/python/pyodide.d.ts +1 -0
  21. package/dist-lib/lib/tools/exec/adapters/python/pyodide.js +1 -0
  22. package/dist-lib/lib/tools/exec/adapters/python/python-adapter.d.ts +9 -3
  23. package/dist-lib/lib/tools/exec/adapters/python/python-adapter.js +130 -20
  24. package/dist-lib/lib/tools/exec/adapters/python/run-session.d.ts +3 -0
  25. package/dist-lib/lib/tools/exec/adapters/python/run-session.js +25 -8
  26. package/dist-lib/lib/tools/exec/adapters/python/wheels.d.ts +6 -0
  27. package/dist-lib/lib/tools/exec/adapters/python/wheels.js +17 -3
  28. package/dist-lib/lib/tools/exec/adapters/shell/shell-adapter.js +7 -1
  29. package/dist-lib/lib/tools/exec/shell-guidance.d.ts +10 -0
  30. package/dist-lib/lib/tools/exec/shell-guidance.js +123 -0
  31. package/dist-lib/lib/tools/exec/video-tools.d.ts +5 -0
  32. package/dist-lib/lib/tools/exec/video-tools.js +40 -0
  33. package/dist-lib/lib/tools/exec.d.ts +2 -1
  34. package/dist-lib/lib/tools/exec.js +59 -32
  35. package/dist-lib/lib/tools/http-request/truncate-response.d.ts +1 -0
  36. package/dist-lib/lib/tools/http-request/truncate-response.js +1 -1
  37. package/dist-lib/lib/tools/http-request.js +4 -1
  38. package/dist-lib/lib/tools/operations/builders.d.ts +1 -1
  39. package/dist-lib/lib/tools/operations/builders.js +9 -0
  40. package/dist-lib/lib/tools/operations/compile.js +41 -18
  41. package/dist-lib/lib/tools/operations/decoders.d.ts +6 -0
  42. package/dist-lib/lib/tools/operations/decoders.js +82 -0
  43. package/dist-lib/lib/tools/operations/manifest.d.ts +16 -2
  44. package/dist-lib/lib/tools/operations/manifest.js +6 -1
  45. package/dist-lib/lib/tools/skill.d.ts +3 -0
  46. package/dist-lib/lib/tools/skill.js +38 -5
  47. package/dist-lib/lib/tools/skills/manifest-parse.js +43 -1
  48. package/dist-lib/lib/tools/skills/manifest.d.ts +13 -0
  49. package/dist-lib/schema/harness.js +20 -1
  50. package/dist-lib/spec/hrpc/messages.js +93 -5
  51. package/dist-lib/spec/hyperschema/index.js +93 -5
  52. package/dist-lib/spec/hyperschema/schema.json +66 -1
  53. package/dist-lib/spec/hyperschema/types.d.ts +18 -1
  54. package/package.json +30 -2
  55. 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, looksLikePythonSource, parseInputs, parseOutputs, parsePackages, parseWorkdir, runsThroughPyodide, unwrapPythonSource } from './exec/adapters/python/python-adapter.js';
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 'inputs and outputs require language "python" with Pyodide enabled';
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
- const PYTHON_BINS = new Set(['python', 'python3', 'py', 'pip', 'pip3']);
104
- function commandRequiredError(args) {
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
- return staged
107
- ? 'command is required: keep these same inputs/outputs and add either command (the bare ' +
108
- 'python source) or script + scriptArgs exactly as the loaded skill reference shows'
109
- : 'command is required';
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 checkExecutionScope(args, toolOps, disablePyodide) {
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 && PYTHON_BINS.has(bareBin(firstCommandToken(command)) ?? '')) {
215
- return 'run Python by passing language="python" with the bare Python source in command; the exec shell path does not run python or pip';
216
- }
217
- const callsFirst = /^[A-Za-z_][\w.]*\s*\(/.test(command);
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(args, ctx) {
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 'packages require language "python" with Pyodide enabled';
405
- }
427
+ if (parsed && !python)
428
+ return PACKAGES_NEED_PYTHON_ERROR;
406
429
  return checkFileArgs(rawArgs, python);
407
430
  },
408
- resource(args) {
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
- const scopeError = checkExecutionScope(rawArgs, ctx?.toolOps, disablePyodide);
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,
@@ -1 +1,2 @@
1
1
  export declare function truncateResponseBody(body: string): string;
2
+ export declare const ELISION = "\u2026 [truncated]";
@@ -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 { status: response.status, body: truncateResponseBody(text) };
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: false,
21
+ autoApprove: read,
17
22
  skillGated: true,
18
- externalWrite: true,
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 operation.response ? pickResponse(result, operation.response.pick) : result;
54
+ }, response ? { ...skillContext, uncappedResponse: true } : skillContext);
55
+ return response ? capBody(shapeResponse(result, response)) : result;
47
56
  }
48
57
  };
49
58
  }
50
- function pickResponse(result, keys) {
51
- if (!result || typeof result !== 'object')
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 { body, status } = result;
54
- if (typeof body !== 'string' || (typeof status === 'number' && status >= 400))
70
+ const { status } = parsed.data;
71
+ if (status !== undefined && status >= 400)
55
72
  return result;
56
- let parsed;
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 result;
85
+ return null;
62
86
  }
63
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
64
- return result;
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 record)
69
- picked[key] = record[key];
70
- return { ...result, body: JSON.stringify(picked) };
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
+ };