@qvac/harness 0.3.0 → 0.3.2

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 (68) hide show
  1. package/CHANGELOG.md +262 -0
  2. package/dist/harness.bundle +15 -11
  3. package/dist-lib/index.d.ts +1 -1
  4. package/dist-lib/lib/capabilities/completion.d.ts +5 -1
  5. package/dist-lib/lib/capabilities/completion.js +20 -8
  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 +5 -1
  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 +72 -0
  13. package/dist-lib/lib/tools/exec/adapters/python/guarded-fetch.js +222 -0
  14. package/dist-lib/lib/tools/exec/adapters/python/interrupt-timer.d.ts +5 -0
  15. package/dist-lib/lib/tools/exec/adapters/python/interrupt-timer.js +29 -0
  16. package/dist-lib/lib/tools/exec/adapters/python/loader.js +2 -1
  17. package/dist-lib/lib/tools/exec/adapters/python/module-distributions.d.ts +1 -0
  18. package/dist-lib/lib/tools/exec/adapters/python/module-distributions.js +52 -0
  19. package/dist-lib/lib/tools/exec/adapters/python/named-hosts.d.ts +2 -0
  20. package/dist-lib/lib/tools/exec/adapters/python/named-hosts.js +15 -0
  21. package/dist-lib/lib/tools/exec/adapters/python/net-shim.d.ts +2 -0
  22. package/dist-lib/lib/tools/exec/adapters/python/net-shim.js +148 -0
  23. package/dist-lib/lib/tools/exec/adapters/python/output-policy.js +13 -1
  24. package/dist-lib/lib/tools/exec/adapters/python/pyodide.d.ts +4 -1
  25. package/dist-lib/lib/tools/exec/adapters/python/pyodide.js +42 -7
  26. package/dist-lib/lib/tools/exec/adapters/python/python-adapter.d.ts +9 -3
  27. package/dist-lib/lib/tools/exec/adapters/python/python-adapter.js +137 -21
  28. package/dist-lib/lib/tools/exec/adapters/python/run-session.d.ts +5 -0
  29. package/dist-lib/lib/tools/exec/adapters/python/run-session.js +33 -8
  30. package/dist-lib/lib/tools/exec/adapters/python/wheels.d.ts +6 -0
  31. package/dist-lib/lib/tools/exec/adapters/python/wheels.js +17 -3
  32. package/dist-lib/lib/tools/exec/adapters/python/worker/client.d.ts +2 -1
  33. package/dist-lib/lib/tools/exec/adapters/python/worker/client.js +16 -3
  34. package/dist-lib/lib/tools/exec/adapters/python/worker/entry.js +15 -2
  35. package/dist-lib/lib/tools/exec/adapters/python/worker/protocol.d.ts +6 -0
  36. package/dist-lib/lib/tools/exec/adapters/python/worker/protocol.js +6 -2
  37. package/dist-lib/lib/tools/exec/adapters/python/worker/spawn-bare.d.ts +1 -0
  38. package/dist-lib/lib/tools/exec/adapters/python/worker/spawn-bare.js +8 -0
  39. package/dist-lib/lib/tools/exec/adapters/python/worker/spawn-node.d.ts +1 -0
  40. package/dist-lib/lib/tools/exec/adapters/python/worker/spawn-node.js +8 -0
  41. package/dist-lib/lib/tools/exec/adapters/shell/shell-adapter.js +7 -1
  42. package/dist-lib/lib/tools/exec/shell-guidance.d.ts +10 -0
  43. package/dist-lib/lib/tools/exec/shell-guidance.js +123 -0
  44. package/dist-lib/lib/tools/exec/video-tools.d.ts +5 -0
  45. package/dist-lib/lib/tools/exec/video-tools.js +40 -0
  46. package/dist-lib/lib/tools/exec.d.ts +2 -1
  47. package/dist-lib/lib/tools/exec.js +93 -33
  48. package/dist-lib/lib/tools/http-request/truncate-response.d.ts +1 -0
  49. package/dist-lib/lib/tools/http-request/truncate-response.js +1 -1
  50. package/dist-lib/lib/tools/http-request.js +4 -1
  51. package/dist-lib/lib/tools/operations/builders.d.ts +1 -1
  52. package/dist-lib/lib/tools/operations/builders.js +9 -0
  53. package/dist-lib/lib/tools/operations/compile.js +41 -18
  54. package/dist-lib/lib/tools/operations/decoders.d.ts +6 -0
  55. package/dist-lib/lib/tools/operations/decoders.js +82 -0
  56. package/dist-lib/lib/tools/operations/manifest.d.ts +16 -2
  57. package/dist-lib/lib/tools/operations/manifest.js +6 -1
  58. package/dist-lib/lib/tools/skill.d.ts +3 -0
  59. package/dist-lib/lib/tools/skill.js +38 -5
  60. package/dist-lib/lib/tools/skills/manifest-parse.js +43 -1
  61. package/dist-lib/lib/tools/skills/manifest.d.ts +13 -0
  62. package/dist-lib/schema/harness.js +20 -1
  63. package/dist-lib/spec/hrpc/messages.js +93 -5
  64. package/dist-lib/spec/hyperschema/index.js +93 -5
  65. package/dist-lib/spec/hyperschema/schema.json +66 -1
  66. package/dist-lib/spec/hyperschema/types.d.ts +18 -1
  67. package/package.json +40 -2
  68. package/wheels/manifest.json +15 -0
@@ -0,0 +1,2 @@
1
+ export declare function pythonReachesNetwork(): boolean;
2
+ export declare const NET_SHIM = "\ndef _qvac_install_net():\n import errno, http.client, io\n import js\n from pyodide.ffi import can_run_sync, run_sync, to_js\n\n if getattr(http.client, '_qvac_net', False):\n return\n\n class _Response(http.client.HTTPResponse):\n # the body is read in full by the fetch, so there is no socket behind it\n def __init__(self, status, headers, body, url):\n self.status = self.code = status\n self.reason = http.client.responses.get(status, '')\n self.headers = self.msg = http.client.HTTPMessage()\n for name, value in headers:\n self.headers[name] = value\n self.fp = io.BytesIO(body)\n self.version = 11\n self.will_close = True\n self.chunked = False\n self.length = len(body)\n self.url = url\n self.debuglevel = 0\n\n def read(self, amt=None):\n return self.fp.read() if amt is None or amt < 0 else self.fp.read(amt)\n\n def read1(self, n=-1):\n return self.read(n)\n\n def readinto(self, b):\n data = self.fp.read(len(b))\n b[: len(data)] = data\n return len(data)\n\n def peek(self, n=0):\n pos = self.fp.tell()\n data = self.fp.read(max(n, 1))\n self.fp.seek(pos)\n return data\n\n def isclosed(self):\n return self.fp.closed\n\n def close(self):\n self.fp.close()\n\n def fileno(self):\n raise OSError('this response has no socket')\n\n class _Connection(http.client.HTTPConnection):\n scheme = 'http'\n\n def __init__(self, host, port=None, timeout=None, source_address=None, blocksize=8192, **kw):\n self.host, self.port, self.timeout = host, port, timeout\n self.sock = None\n self.debuglevel = 0\n self._tunnel_host = None\n self._request = None\n self._response = None\n\n def set_debuglevel(self, level):\n pass\n\n def set_tunnel(self, host, port=None, headers=None):\n self._tunnel_host = host\n\n def connect(self):\n pass\n\n def close(self):\n pass\n\n def putrequest(self, method, url, skip_host=False, skip_accept_encoding=False):\n self._request = [method, url, {}, b'']\n self._response = None\n\n def putheader(self, header, *values):\n self._request[2][header] = ', '.join(str(v) for v in values)\n\n # A socket connects while the request is sent, so that is where a failure lands and\n # where urllib turns it into URLError. A body sent after the headers waits for it.\n def endheaders(self, message_body=None, *, encode_chunked=False):\n if message_body is not None:\n self.send(message_body)\n if message_body is not None or self._request[0] in ('GET', 'HEAD', 'DELETE', 'OPTIONS'):\n self._response = self._fetch()\n\n def send(self, data):\n if hasattr(data, 'read'):\n data = data.read()\n if isinstance(data, str):\n data = data.encode()\n self._request[3] += bytes(data)\n\n def request(self, method, url, body=None, headers={}, *, encode_chunked=False):\n self.putrequest(method, url)\n for name, value in dict(headers).items():\n self.putheader(name, value)\n self.endheaders(body)\n\n def getresponse(self):\n response = self._response or self._fetch()\n self._response = None\n return response\n\n def _fetch(self):\n method, url, headers, body = self._request\n if not url.startswith(('http://', 'https://')):\n port = '' if self.port in (None, self.default_port) else ':%d' % self.port\n url = '%s://%s%s%s' % (self.scheme, self.host, port, url)\n if not can_run_sync():\n raise OSError(errno.EHOSTUNREACH, 'Host is unreachable')\n # the fetch hands back decoded bytes, so no encoding is asked for and none reported\n skip = ('accept-encoding', 'connection', 'host', 'content-length')\n headers = {k: v for k, v in headers.items() if k.lower() not in skip}\n headers['Accept-Encoding'] = 'identity'\n init = {'method': method, 'headers': headers, 'redirect': 'manual'}\n if body:\n init['body'] = to_js(body)\n try:\n response = run_sync(js.fetch(url, to_js(init, dict_converter=js.Object.fromEntries)))\n data = bytes(run_sync(response.arrayBuffer()).to_py())\n except Exception as error:\n # the guard's own refusal, or the transport's: either says more than an errno\n raise OSError(getattr(error, 'message', None) or str(error)) from None\n entries = [(str(k), str(v)) for k, v in response.headers.entries()]\n drop = ('content-encoding', 'transfer-encoding', 'content-length')\n kept = [(k, v) for k, v in entries if k.lower() not in drop]\n kept.append(('Content-Length', str(len(data))))\n return _Response(int(response.status), kept, data, url)\n\n class _HTTPSConnection(_Connection):\n scheme = 'https'\n default_port = 443\n\n http.client.HTTPConnection = _Connection\n http.client.HTTPSConnection = _HTTPSConnection\n http.client._qvac_net = True\n\n_qvac_install_net()\ndel _qvac_install_net\n";
@@ -0,0 +1,148 @@
1
+ export function pythonReachesNetwork() {
2
+ return (typeof globalThis.Bare !== 'undefined' &&
3
+ typeof WebAssembly.Suspending === 'function');
4
+ }
5
+ export const NET_SHIM = `
6
+ def _qvac_install_net():
7
+ import errno, http.client, io
8
+ import js
9
+ from pyodide.ffi import can_run_sync, run_sync, to_js
10
+
11
+ if getattr(http.client, '_qvac_net', False):
12
+ return
13
+
14
+ class _Response(http.client.HTTPResponse):
15
+ # the body is read in full by the fetch, so there is no socket behind it
16
+ def __init__(self, status, headers, body, url):
17
+ self.status = self.code = status
18
+ self.reason = http.client.responses.get(status, '')
19
+ self.headers = self.msg = http.client.HTTPMessage()
20
+ for name, value in headers:
21
+ self.headers[name] = value
22
+ self.fp = io.BytesIO(body)
23
+ self.version = 11
24
+ self.will_close = True
25
+ self.chunked = False
26
+ self.length = len(body)
27
+ self.url = url
28
+ self.debuglevel = 0
29
+
30
+ def read(self, amt=None):
31
+ return self.fp.read() if amt is None or amt < 0 else self.fp.read(amt)
32
+
33
+ def read1(self, n=-1):
34
+ return self.read(n)
35
+
36
+ def readinto(self, b):
37
+ data = self.fp.read(len(b))
38
+ b[: len(data)] = data
39
+ return len(data)
40
+
41
+ def peek(self, n=0):
42
+ pos = self.fp.tell()
43
+ data = self.fp.read(max(n, 1))
44
+ self.fp.seek(pos)
45
+ return data
46
+
47
+ def isclosed(self):
48
+ return self.fp.closed
49
+
50
+ def close(self):
51
+ self.fp.close()
52
+
53
+ def fileno(self):
54
+ raise OSError('this response has no socket')
55
+
56
+ class _Connection(http.client.HTTPConnection):
57
+ scheme = 'http'
58
+
59
+ def __init__(self, host, port=None, timeout=None, source_address=None, blocksize=8192, **kw):
60
+ self.host, self.port, self.timeout = host, port, timeout
61
+ self.sock = None
62
+ self.debuglevel = 0
63
+ self._tunnel_host = None
64
+ self._request = None
65
+ self._response = None
66
+
67
+ def set_debuglevel(self, level):
68
+ pass
69
+
70
+ def set_tunnel(self, host, port=None, headers=None):
71
+ self._tunnel_host = host
72
+
73
+ def connect(self):
74
+ pass
75
+
76
+ def close(self):
77
+ pass
78
+
79
+ def putrequest(self, method, url, skip_host=False, skip_accept_encoding=False):
80
+ self._request = [method, url, {}, b'']
81
+ self._response = None
82
+
83
+ def putheader(self, header, *values):
84
+ self._request[2][header] = ', '.join(str(v) for v in values)
85
+
86
+ # A socket connects while the request is sent, so that is where a failure lands and
87
+ # where urllib turns it into URLError. A body sent after the headers waits for it.
88
+ def endheaders(self, message_body=None, *, encode_chunked=False):
89
+ if message_body is not None:
90
+ self.send(message_body)
91
+ if message_body is not None or self._request[0] in ('GET', 'HEAD', 'DELETE', 'OPTIONS'):
92
+ self._response = self._fetch()
93
+
94
+ def send(self, data):
95
+ if hasattr(data, 'read'):
96
+ data = data.read()
97
+ if isinstance(data, str):
98
+ data = data.encode()
99
+ self._request[3] += bytes(data)
100
+
101
+ def request(self, method, url, body=None, headers={}, *, encode_chunked=False):
102
+ self.putrequest(method, url)
103
+ for name, value in dict(headers).items():
104
+ self.putheader(name, value)
105
+ self.endheaders(body)
106
+
107
+ def getresponse(self):
108
+ response = self._response or self._fetch()
109
+ self._response = None
110
+ return response
111
+
112
+ def _fetch(self):
113
+ method, url, headers, body = self._request
114
+ if not url.startswith(('http://', 'https://')):
115
+ port = '' if self.port in (None, self.default_port) else ':%d' % self.port
116
+ url = '%s://%s%s%s' % (self.scheme, self.host, port, url)
117
+ if not can_run_sync():
118
+ raise OSError(errno.EHOSTUNREACH, 'Host is unreachable')
119
+ # the fetch hands back decoded bytes, so no encoding is asked for and none reported
120
+ skip = ('accept-encoding', 'connection', 'host', 'content-length')
121
+ headers = {k: v for k, v in headers.items() if k.lower() not in skip}
122
+ headers['Accept-Encoding'] = 'identity'
123
+ init = {'method': method, 'headers': headers, 'redirect': 'manual'}
124
+ if body:
125
+ init['body'] = to_js(body)
126
+ try:
127
+ response = run_sync(js.fetch(url, to_js(init, dict_converter=js.Object.fromEntries)))
128
+ data = bytes(run_sync(response.arrayBuffer()).to_py())
129
+ except Exception as error:
130
+ # the guard's own refusal, or the transport's: either says more than an errno
131
+ raise OSError(getattr(error, 'message', None) or str(error)) from None
132
+ entries = [(str(k), str(v)) for k, v in response.headers.entries()]
133
+ drop = ('content-encoding', 'transfer-encoding', 'content-length')
134
+ kept = [(k, v) for k, v in entries if k.lower() not in drop]
135
+ kept.append(('Content-Length', str(len(data))))
136
+ return _Response(int(response.status), kept, data, url)
137
+
138
+ class _HTTPSConnection(_Connection):
139
+ scheme = 'https'
140
+ default_port = 443
141
+
142
+ http.client.HTTPConnection = _Connection
143
+ http.client.HTTPSConnection = _HTTPSConnection
144
+ http.client._qvac_net = True
145
+
146
+ _qvac_install_net()
147
+ del _qvac_install_net
148
+ `;
@@ -19,7 +19,11 @@ const MIME_TYPES = {
19
19
  json: 'application/json',
20
20
  txt: 'text/plain',
21
21
  md: 'text/markdown',
22
- zip: 'application/zip'
22
+ zip: 'application/zip',
23
+ mp4: 'video/mp4',
24
+ webm: 'video/webm',
25
+ m4a: 'audio/mp4',
26
+ mp3: 'audio/mpeg'
23
27
  };
24
28
  const DENIED_EXTENSIONS = new Set([
25
29
  'pptm',
@@ -48,6 +52,14 @@ function hasExpectedSignature(extension, bytes) {
48
52
  if (extension === 'pptx' || extension === 'docx' || extension === 'xlsx' || extension === 'zip') {
49
53
  return matches(bytes, [0x50, 0x4b, 0x03, 0x04]);
50
54
  }
55
+ if (extension === 'mp4' || extension === 'm4a') {
56
+ return matches(bytes.slice(4), [0x66, 0x74, 0x79, 0x70]);
57
+ }
58
+ if (extension === 'webm')
59
+ return matches(bytes, [0x1a, 0x45, 0xdf, 0xa3]);
60
+ if (extension === 'mp3') {
61
+ return (matches(bytes, [0x49, 0x44, 0x33]) || (bytes[0] === 0xff && ((bytes[1] ?? 0) & 0xe0) === 0xe0));
62
+ }
51
63
  if (extension === 'pdf')
52
64
  return matches(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d]);
53
65
  if (extension === 'png')
@@ -1,4 +1,4 @@
1
- import type { AbortSignal as BareAbortSignal } from 'bare-abort-controller';
1
+ import { type AbortSignal as BareAbortSignal } from 'bare-abort-controller';
2
2
  import { pyodideLoadOptions } from './loader.ts';
3
3
  import { type LoadPyodideFn, type PyodideLike, type PythonRunResult } from './run-session.ts';
4
4
  export { pyodideLoadOptions };
@@ -15,5 +15,8 @@ export type RunPythonOptions = {
15
15
  readonly outputs?: readonly string[];
16
16
  readonly workdir?: string;
17
17
  readonly loadPyodide?: LoadPyodideFn;
18
+ readonly network?: boolean;
19
+ readonly hosts?: readonly string[];
20
+ readonly askConnect?: (host: string) => Promise<boolean>;
18
21
  };
19
22
  export declare function runPython(code: string, options?: RunPythonOptions): Promise<PythonRunResult>;
@@ -1,8 +1,10 @@
1
+ import { AbortController } from 'bare-abort-controller';
1
2
  import process from 'process';
2
3
  import ScopeLock from 'scope-lock';
3
4
  import { AbortedError, raceAbort } from '../../../../helpers/race-abort.js';
4
5
  import { hlog } from '../../../../log.js';
5
6
  import { MAX_OUTPUT_CHARS } from '../../runner.js';
7
+ import { startInterruptTimer } from './interrupt-timer.js';
6
8
  import { defaultLoad, pyodideLoadOptions } from './loader.js';
7
9
  import { isPyodideFatal, performRun } from './run-session.js';
8
10
  import { runPythonInWorker } from './worker/client.js';
@@ -32,7 +34,11 @@ function getPyodide(load) {
32
34
  }
33
35
  const runLock = new ScopeLock();
34
36
  async function runInProcess(load, spec, options) {
37
+ const deadline = Date.now() + options.timeoutMs;
38
+ const timeoutMessage = `Command timed out after ${options.timeoutMs}ms`;
35
39
  let timedOut = false;
40
+ const stop = new AbortController();
41
+ const ask = options.askConnect;
36
42
  const run = (async () => {
37
43
  await runLock.lock();
38
44
  try {
@@ -40,12 +46,26 @@ async function runInProcess(load, spec, options) {
40
46
  if (isCancelled())
41
47
  throw new AbortedError();
42
48
  const py = await getPyodide(load);
43
- const { result, evict } = await performRun(py, spec, { isCancelled });
44
- if (evict) {
49
+ const timer = startInterruptTimer(Math.max(0, deadline - Date.now()));
50
+ py.setInterruptBuffer(timer.interrupt);
51
+ let outcome;
52
+ try {
53
+ outcome = await performRun(py, spec, {
54
+ isCancelled,
55
+ askConnect: ask && ((host) => raceAbort(ask(host), stop.signal).catch(() => false))
56
+ });
57
+ }
58
+ finally {
59
+ if (timer.disarm())
60
+ timedOut = true;
61
+ }
62
+ if (outcome.evict) {
45
63
  cache.delete(load);
46
64
  hlog('python: interpreter evicted; next run boots fresh');
47
65
  }
48
- return result;
66
+ if (timedOut)
67
+ throw new Error(timeoutMessage);
68
+ return outcome.result;
49
69
  }
50
70
  catch (error) {
51
71
  if (isPyodideFatal(error))
@@ -60,7 +80,7 @@ async function runInProcess(load, spec, options) {
60
80
  const timeout = new Promise((_, reject) => {
61
81
  timer = setTimeout(() => {
62
82
  timedOut = true;
63
- reject(new Error(`Command timed out after ${options.timeoutMs}ms`));
83
+ reject(new Error(timeoutMessage));
64
84
  }, options.timeoutMs);
65
85
  });
66
86
  try {
@@ -69,6 +89,7 @@ async function runInProcess(load, spec, options) {
69
89
  finally {
70
90
  if (timer)
71
91
  clearTimeout(timer);
92
+ stop.abort(undefined);
72
93
  }
73
94
  }
74
95
  function workerAvailable() {
@@ -88,20 +109,34 @@ export async function runPython(code, options = {}) {
88
109
  outputs: options.outputs,
89
110
  workdir: options.workdir,
90
111
  maxOutputChars: options.maxOutputChars ?? MAX_OUTPUT_CHARS,
112
+ network: options.network === true,
113
+ hosts: options.hosts,
91
114
  installTimeoutMs: timeoutMs
92
115
  };
93
116
  if (options.loadPyodide) {
94
- return runInProcess(options.loadPyodide, spec, { timeoutMs, signal: options.signal });
117
+ return runInProcess(options.loadPyodide, spec, {
118
+ timeoutMs,
119
+ signal: options.signal,
120
+ askConnect: options.askConnect
121
+ });
95
122
  }
96
123
  if (!workerAvailable()) {
97
124
  hlog('python: worker entry unavailable in this bundle; running in-process');
98
- return runInProcess(defaultLoad, spec, { timeoutMs, signal: options.signal });
125
+ return runInProcess(defaultLoad, spec, {
126
+ timeoutMs,
127
+ signal: options.signal,
128
+ askConnect: options.askConnect
129
+ });
99
130
  }
100
131
  await runLock.lock();
101
132
  try {
102
133
  if (options.signal?.aborted)
103
134
  throw new AbortedError();
104
- return await runPythonInWorker(spec, { timeoutMs, signal: options.signal });
135
+ return await runPythonInWorker(spec, {
136
+ timeoutMs,
137
+ signal: options.signal,
138
+ askConnect: options.askConnect
139
+ });
105
140
  }
106
141
  finally {
107
142
  runLock.unlock();
@@ -4,8 +4,11 @@ import { type LoadPyodideFn } from './pyodide.ts';
4
4
  export declare function isPythonLanguage(language: unknown): boolean;
5
5
  export declare function runsThroughPyodide(language: unknown, disablePyodide: boolean): boolean;
6
6
  export declare function unwrapPythonSource(command: string): string | null;
7
+ export declare function withoutProgramDir(command: string): string;
7
8
  export declare function pipInstallTargets(command: string): string[] | null;
8
- export declare function looksLikePythonSource(command: string): boolean;
9
+ export declare function pinned(spec: string): string;
10
+ export declare function pipMessage(targets: readonly string[] | null, sandbox?: boolean): string;
11
+ export declare function pipFromSourceHint(stderr: string, source: string, packages: readonly string[] | undefined, sandbox?: boolean): string | null;
9
12
  export declare function stringLiteralProgramHint(source: string): string | null;
10
13
  export declare function checkCollapsedCompound(source: string): string | null;
11
14
  export declare function mangledPptxApiHint(source: string): string | null;
@@ -44,7 +47,7 @@ export declare function parseOutputs(value: unknown): string[] | {
44
47
  export declare function parseWorkdir(value: unknown): string | {
45
48
  error: string;
46
49
  } | undefined;
47
- export declare function missingModuleHint(stderr: string, packages: readonly string[] | undefined): string | null;
50
+ export declare function missingModuleHint(stderr: string, packages: readonly string[] | undefined, sandbox?: boolean, network?: boolean): string | null;
48
51
  export declare function nonePlaceholderHint(stderr: string): string | null;
49
52
  export declare function slideShapesHint(stderr: string): string | null;
50
53
  export declare function shapeFontHint(stderr: string): string | null;
@@ -53,6 +56,9 @@ export declare function paragraphFillHint(stderr: string): string | null;
53
56
  export declare function readOnlyPropertyHint(stderr: string): string | null;
54
57
  export declare function paragraphIndexHint(stderr: string, outputs: readonly string[]): string | null;
55
58
  export declare function shellNameHint(stderr: string): string | null;
59
+ export declare function noProcessHint(stderr: string, sandbox?: boolean): string | null;
60
+ export declare function noNetworkHint(stderr: string, tools?: readonly string[], network?: boolean): string | null;
61
+ export declare function nativeWheelHint(stderr: string): string | null;
56
62
  export declare function packageFetchFailedHint(stderr: string): string | null;
57
63
  export declare const IMAGE_INPUT: RegExp;
58
64
  export declare function pptxHasEmbeddedMedia(bytes: Uint8Array): boolean;
@@ -72,4 +78,4 @@ export declare function checkTurnImagesStaged(source: string, inputs: readonly E
72
78
  export declare function deckDeliveredStop(attachments: readonly {
73
79
  readonly fileName: string;
74
80
  }[] | undefined, exitCode: number, source?: string): string | null;
75
- export declare function createPythonAdapter(disabled: boolean, loadPyodide?: LoadPyodideFn): ExecutionAdapter;
81
+ export declare function createPythonAdapter(disabled: boolean, loadPyodide?: LoadPyodideFn, network?: boolean): ExecutionAdapter;
@@ -2,6 +2,10 @@ import b4a from 'b4a';
2
2
  import { attachOutputs, stageInputs } from './attachments.js';
3
3
  import { checkFileName, checkOutputName, MAX_INPUTS, MAX_OUTPUTS } from './output-policy.js';
4
4
  import { runPython } from './pyodide.js';
5
+ import { isTranscoder, TRANSCODE_ERROR, VIDEO_TOOLS_ERROR, videoTool } from '../../video-tools.js';
6
+ import { moduleDistribution } from './distributions.js';
7
+ import { namedHosts } from './named-hosts.js';
8
+ import { normalizeDistribution, planVendoredInstall, vendoredSpec } from './wheels.js';
5
9
  export function isPythonLanguage(language) {
6
10
  return typeof language === 'string' && language.trim().toLowerCase() === 'python';
7
11
  }
@@ -114,7 +118,7 @@ function unwrapBareInterpreter(s) {
114
118
  return body || null;
115
119
  }
116
120
  export function unwrapPythonSource(command) {
117
- const trimmed = command.trim();
121
+ const trimmed = withoutProgramDir(command).trim();
118
122
  const heredoc = unwrapHeredoc(trimmed);
119
123
  if (heredoc !== null)
120
124
  return heredoc;
@@ -123,8 +127,11 @@ export function unwrapPythonSource(command) {
123
127
  return dashC;
124
128
  return unwrapBareInterpreter(trimmed);
125
129
  }
130
+ export function withoutProgramDir(command) {
131
+ return command.trimStart().replace(/^[^\s'"]*[/\\](?=[^\s/\\]+(\s|$))/, '');
132
+ }
126
133
  function pipInstallRest(command) {
127
- const s = command.trim();
134
+ const s = withoutProgramDir(command).trim();
128
135
  let i = 0;
129
136
  const pip = leadingToken(s, PIPS);
130
137
  if (pip) {
@@ -178,17 +185,64 @@ export function pipInstallTargets(command) {
178
185
  continue;
179
186
  if (!REQUIREMENT.test(token))
180
187
  return null;
188
+ if (/^pip3?$/i.test(token.split(/[=<>!~[]/)[0]))
189
+ continue;
181
190
  targets.push(token);
182
191
  }
183
192
  return targets.length > 0 ? targets : null;
184
193
  }
185
- export function looksLikePythonSource(command) {
186
- const first = command.trimStart().split('\n')[0];
187
- if (/^\s*(from\s+\w+\s+import\b|import\s+\w+)/.test(first))
188
- return true;
189
- if (/^\s*(from|import)\s+(fpdf|pypdf|pptx)\b/m.test(command))
190
- return true;
191
- return /\b(FPDF|PdfReader|PdfWriter|Presentation)\s*\(/.test(command);
194
+ const SANDBOX_NO_INSTALL = 'and sandbox mode installs only the packages bundled with this app, which do not include it. Use only what is already installed, or tell the user it is unavailable while sandbox mode is on.';
195
+ const specName = (spec) => normalizeDistribution(spec.split(/[=<>!~[]/)[0]);
196
+ export function pinned(spec) {
197
+ return /[=<>!~]/.test(spec) ? spec : (vendoredSpec(spec) ?? spec);
198
+ }
199
+ export function pipMessage(targets, sandbox = false) {
200
+ const specs = targets?.map(pinned) ?? null;
201
+ if (sandbox && !(specs?.length && planVendoredInstall(specs))) {
202
+ return `pip is not available, ${SANDBOX_NO_INSTALL}`;
203
+ }
204
+ const declare = specs ? `packages: ${JSON.stringify(specs)}` : 'the PyPI names in packages';
205
+ return `pip is not available. Call exec with language "python", ${declare} and the Python source in command. Installing and running are the same one call.`;
206
+ }
207
+ const PIP_CALL = /\b(?:subprocess\.\w+|os\.(?:system|popen)|Popen|check_(?:output|call))\s*\(/;
208
+ const STRING_LITERAL = /"([^"\n]*)"|'([^'\n]*)'/g;
209
+ function pipLineTargets(line) {
210
+ const words = [...line.matchAll(STRING_LITERAL)].map((match) => (match[1] ?? match[2]));
211
+ const whole = words.find((word) => /\bpip3?\b[^]*\binstall\b/.test(word));
212
+ if (whole !== undefined)
213
+ return pipInstallTargets(whole.trim());
214
+ const at = words.findIndex((word) => /^pip3?$/.test(word));
215
+ if (at === -1)
216
+ return undefined;
217
+ return pipInstallTargets(words.slice(at).join(' '));
218
+ }
219
+ export function pipFromSourceHint(stderr, source, packages, sandbox = false) {
220
+ if (!/emscripten does not support processes/.test(stderr))
221
+ return null;
222
+ for (const line of source.split('\n')) {
223
+ if (!PIP_CALL.test(line))
224
+ continue;
225
+ const targets = pipLineTargets(line);
226
+ if (targets === undefined)
227
+ continue;
228
+ const declared = packages ?? [];
229
+ const has = new Set(declared.map(specName));
230
+ const missing = (targets ?? []).filter((t) => !has.has(specName(t))).map(pinned);
231
+ if (sandbox && (targets === null || !planVendoredInstall([...declared, ...missing]))) {
232
+ return `pip cannot run here: this Python cannot start processes, so that line failed, ${SANDBOX_NO_INSTALL} Delete the pip line.`;
233
+ }
234
+ if (targets === null) {
235
+ return 'pip cannot run here: this Python cannot start processes, so that line failed. Delete it and declare the PyPI names in packages on this same call.';
236
+ }
237
+ if (missing.length) {
238
+ return `pip cannot run here: this Python cannot start processes, so that line failed. Delete it and send the same source with packages: ${JSON.stringify([...declared, ...missing])} on this one call. Installing and running are the same call.`;
239
+ }
240
+ if (declared.length) {
241
+ return `pip cannot run here: this Python cannot start processes, so that line failed. packages: ${JSON.stringify(declared)} already installs before your code runs, so delete the pip line and send the rest.`;
242
+ }
243
+ return 'pip cannot run here: this Python cannot start processes, so that line failed. Delete it and declare the PyPI names in packages on this same call.';
244
+ }
245
+ return null;
192
246
  }
193
247
  export function stringLiteralProgramHint(source) {
194
248
  const first = source.trimStart()[0];
@@ -771,16 +825,35 @@ function prepare(args, cwd) {
771
825
  return workdir;
772
826
  return { packages, inputs, outputs, workdir };
773
827
  }
774
- export function missingModuleHint(stderr, packages) {
828
+ export function missingModuleHint(stderr, packages, sandbox = false, network = false) {
775
829
  const module = /ModuleNotFoundError: No module named '([^']+)'/.exec(stderr)?.[1];
776
830
  if (!module)
777
831
  return null;
778
832
  const root = module.split('.')[0];
779
- const declared = (packages ?? []).some((p) => p.split(/[=<>!~[]/)[0].trim().toLowerCase() === root.toLowerCase());
780
- const because = declared
781
- ? `"${root}" was declared in packages but does not provide that module`
782
- : `"${root}" was not declared`;
783
- return `${because}: add the PyPI distribution that provides it to the packages argument (the distribution name can differ from the module, e.g. module "pptx" comes from "python-pptx"). There is no pip and no shell on this path.`;
833
+ const tool = videoTool(root);
834
+ if (tool && (!network || isTranscoder(tool)))
835
+ return network ? TRANSCODE_ERROR : VIDEO_TOOLS_ERROR;
836
+ if (module !== root) {
837
+ const then = sandbox
838
+ ? 'If the path is right it ships as its own distribution under a shared namespace, which sandbox mode cannot install: tell the user.'
839
+ : 'If the path is right it ships as its own distribution under a shared namespace, as google.* does: declare that distribution in packages.';
840
+ return `"${module}" failed to import although "${root}" loaded. Check the import path first. ${then}`;
841
+ }
842
+ const distribution = moduleDistribution(root);
843
+ const provider = distribution ?? root;
844
+ const vendored = vendoredSpec(provider);
845
+ if (sandbox && !vendored)
846
+ return `module "${root}" is not installed, ${SANDBOX_NO_INSTALL}`;
847
+ const spec = vendored ?? provider;
848
+ const renamed = distribution !== null && distribution.toLowerCase() !== root.toLowerCase();
849
+ const declared = (packages ?? []).map(specName);
850
+ if (declared.includes(root.toLowerCase()) && renamed) {
851
+ return `"${root}" was declared in packages but does not provide that module; the distribution that does is "${spec}": put that in packages instead. There is no pip.`;
852
+ }
853
+ if (declared.includes(root.toLowerCase()) || declared.includes(provider.toLowerCase())) {
854
+ return `"${provider}" is already declared in packages and still does not provide "${root}", so declaring it again cannot help. Import the module name the library really exports, or tell the user it is unavailable on this device.`;
855
+ }
856
+ return `module "${root}" is not installed: add "${spec}" to packages (packages: ["${spec}"]) and run the same source again. There is no pip.`;
784
857
  }
785
858
  export function nonePlaceholderHint(stderr) {
786
859
  if (!/AttributeError: 'NoneType' object has no attribute '(text|text_frame)'/.test(stderr)) {
@@ -833,6 +906,33 @@ export function shellNameHint(stderr) {
833
906
  return null;
834
907
  return `"${name}" is not defined because command already runs as Python source — remove the ${name} invocation and pass only the Python code itself`;
835
908
  }
909
+ export function noProcessHint(stderr, sandbox = false) {
910
+ if (!/emscripten does not support processes/.test(stderr))
911
+ return null;
912
+ const packages = sandbox
913
+ ? ' In sandbox mode only the packages bundled with this app install;'
914
+ : ' Declare PyPI packages in packages;';
915
+ return `Python here cannot start processes: there is no shell and no pip from the source.${packages} run a shell command as its own exec call without language.`;
916
+ }
917
+ const FETCH_TOOLS = ['http_request', 'web_fetch'];
918
+ export function noNetworkHint(stderr, tools, network = false) {
919
+ if (network)
920
+ return null;
921
+ if (!/Host is unreachable|Network is unreachable|socket\.gaierror|URLError|Temporary failure in name resolution|NewConnectionError|XMLHttpRequest is not defined/.test(stderr)) {
922
+ return null;
923
+ }
924
+ const failed = 'Python here cannot open network connections (requests, urllib and sockets fail), and retrying will not change that.';
925
+ const fetchers = FETCH_TOOLS.filter((name) => !tools || tools.includes(name));
926
+ return fetchers.length
927
+ ? `${failed} Fetch the URL with the ${fetchers.join(' or ')} tool and pass the data into the source.`
928
+ : `${failed} No tool that fetches a URL is available to you, so tell the user.`;
929
+ }
930
+ export function nativeWheelHint(stderr) {
931
+ const name = /Can't find a pure Python 3 wheel for '([^']+)'/.exec(stderr)?.[1];
932
+ if (!name)
933
+ return null;
934
+ return `"${name}" is built from compiled code, and this device installs only pure-Python packages plus the set already built into its Python. Nothing about this call can install it: use a pure-Python library instead, or tell the user it is not available here.`;
935
+ }
836
936
  export function packageFetchFailedHint(stderr) {
837
937
  const name = /Can't fetch metadata for '([^']+)'/.exec(stderr)?.[1];
838
938
  if (!name)
@@ -964,7 +1064,7 @@ async function collect(result, declared, attach, runBudget, source) {
964
1064
  }
965
1065
  }
966
1066
  }
967
- const { attachments, missingOutputs, rejectedOutputs } = await attachOutputs(attach, result.files ?? new Map(), declared, runBudget);
1067
+ const { attachments, missingOutputs, rejectedOutputs } = await attachOutputs(attach, (result.exitCode === 0 && result.files) || new Map(), declared, runBudget);
968
1068
  const rejected = [...(result.rejected ?? []), ...rejectedOutputs];
969
1069
  const stop = deckDeliveredStop(attachments, result.exitCode, source);
970
1070
  if (stop && result.exitCode === 0) {
@@ -987,7 +1087,7 @@ async function collect(result, declared, attach, runBudget, source) {
987
1087
  ...(rejected.length > 0 && { rejectedOutputs: rejected })
988
1088
  };
989
1089
  }
990
- export function createPythonAdapter(disabled, loadPyodide) {
1090
+ export function createPythonAdapter(disabled, loadPyodide, network = false) {
991
1091
  return {
992
1092
  resolve(args, ctx) {
993
1093
  if (!runsThroughPyodide(args.language, disabled))
@@ -997,6 +1097,12 @@ export function createPythonAdapter(disabled, loadPyodide) {
997
1097
  const fetchAttachment = ctx?.fetchAttachment;
998
1098
  const turnImages = ctx?.turnImages;
999
1099
  const runBudget = ctx?.runBudget;
1100
+ const sandbox = ctx?.sandboxMode === true;
1101
+ const toolNames = ctx?.toolNames;
1102
+ const approved = ctx?.approval === 'allowed' || ctx?.approval === 'skip-all';
1103
+ const reach = network && !sandbox && approved;
1104
+ const gated = reach && ctx?.approval === 'allowed';
1105
+ const confirmConnect = ctx?.confirmConnect;
1000
1106
  return {
1001
1107
  async run(options) {
1002
1108
  if (!command)
@@ -1035,10 +1141,13 @@ export function createPythonAdapter(disabled, loadPyodide) {
1035
1141
  });
1036
1142
  if (staged && 'error' in staged)
1037
1143
  return fail(staged.error);
1038
- const install = pipInstallTargets(command);
1144
+ const install = pipInstallTargets(command)?.map(pinned) ?? null;
1039
1145
  const packages = install
1040
1146
  ? [...new Set([...(prepared.packages ?? []), ...install])]
1041
1147
  : prepared.packages;
1148
+ if (sandbox && install && !planVendoredInstall(packages ?? [])) {
1149
+ return fail(pipMessage(install, true));
1150
+ }
1042
1151
  const result = await runPython(install ? 'pass' : source, {
1043
1152
  timeoutMs: options.timeoutMs,
1044
1153
  maxOutputChars: options.maxOutputChars,
@@ -1047,7 +1156,10 @@ export function createPythonAdapter(disabled, loadPyodide) {
1047
1156
  inputs: staged?.files,
1048
1157
  outputs: prepared.outputs,
1049
1158
  workdir: prepared.workdir,
1050
- loadPyodide
1159
+ loadPyodide,
1160
+ network: reach,
1161
+ hosts: gated ? namedHosts(source) : undefined,
1162
+ askConnect: gated ? confirmConnect : undefined
1051
1163
  });
1052
1164
  const soft = softFailedDeckHint(result.stdout, result.exitCode, source);
1053
1165
  if (soft)
@@ -1056,7 +1168,11 @@ export function createPythonAdapter(disabled, loadPyodide) {
1056
1168
  if (missingPic)
1057
1169
  return fail(missingPic);
1058
1170
  const hint = packageFetchFailedHint(result.stderr) ??
1059
- missingModuleHint(result.stderr, packages) ??
1171
+ nativeWheelHint(result.stderr) ??
1172
+ missingModuleHint(result.stderr, packages, sandbox, reach) ??
1173
+ pipFromSourceHint(result.stderr, source, packages, sandbox) ??
1174
+ noProcessHint(result.stderr, sandbox) ??
1175
+ noNetworkHint(result.stderr, toolNames, reach) ??
1060
1176
  missingFileHint(result.stderr) ??
1061
1177
  rgbColorImportHint(result.stderr) ??
1062
1178
  nonePlaceholderHint(result.stderr) ??
@@ -1077,7 +1193,7 @@ export function createPythonAdapter(disabled, loadPyodide) {
1077
1193
  const installed = install && result.exitCode === 0
1078
1194
  ? {
1079
1195
  ...hinted,
1080
- stdout: `installed: ${packages?.join(', ')}\nnothing was run — send the Python source that builds the deck in command\n${hinted.stdout}`
1196
+ stdout: `installed: ${packages?.join(', ')}\nnothing was run — call exec again with language "python", packages: ${JSON.stringify(packages ?? [])} and the Python source in command\n${hinted.stdout}`
1081
1197
  }
1082
1198
  : hinted;
1083
1199
  const undelivered = result.undeclared?.length
@@ -4,6 +4,8 @@ export type PyodideLike = {
4
4
  filename?: string;
5
5
  }): unknown;
6
6
  runPythonAsync(code: string): Promise<unknown>;
7
+ ffi?: unknown;
8
+ pyimport?(name: string): unknown;
7
9
  loadPackage(name: string): Promise<unknown>;
8
10
  setStdout(options: {
9
11
  batched: (output: string) => void;
@@ -62,6 +64,8 @@ export type RunSpec = {
62
64
  readonly workdir?: string | undefined;
63
65
  readonly maxOutputChars: number;
64
66
  readonly installTimeoutMs: number;
67
+ readonly network?: boolean | undefined;
68
+ readonly hosts?: readonly string[] | undefined;
65
69
  };
66
70
  export type RunOutcome = {
67
71
  readonly result: PythonRunResult;
@@ -72,4 +76,5 @@ export declare function isPyodideFatal(error: unknown): boolean;
72
76
  export declare function repairDoubleEscapedSource(code: string, message: string): string | null;
73
77
  export declare function performRun(py: PyodideLike, spec: RunSpec, opts?: {
74
78
  readonly isCancelled?: () => boolean;
79
+ readonly askConnect?: ((host: string) => Promise<boolean>) | undefined;
75
80
  }): Promise<RunOutcome>;