pinokiod 8.2.20 → 8.2.22

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.
@@ -0,0 +1,126 @@
1
+ # Security TODO (deferred)
2
+
3
+ Written 2026-10-28. These are security items deliberately left out of the
4
+ current work, kept here so they can be revisited later.
5
+
6
+ The common thread: Pinokio owns the environment, the process spawn, the
7
+ network path, the secrets and the whole fleet of installed apps. Each item
8
+ below is a control that only a launcher can apply to every app at once.
9
+
10
+ ---
11
+
12
+ ## A. Foundation (fix before making any "safe" claim)
13
+
14
+ ### A1. Origin check on the WebSocket
15
+ - Where: `server/socket.js` (`new WebSocket.Server({ server })`, no `verifyClient`;
16
+ `ws._origin` is recorded but never enforced).
17
+ - Problem: any page that can open a WebSocket to the daemon can send
18
+ `{method:"shell.run"}`. HTTPS pages are stopped by mixed-content rules, and
19
+ Chrome 154 warns before public HTTP pages, but Pinokio's own app UIs run on
20
+ `http://localhost:<port>` and are exempt from both. An XSS bug in a Gradio
21
+ app or ComfyUI custom node becomes a shell command on the machine. With the
22
+ HTTPS/Caddy router on, `wss://pinokio.localhost` is reachable from HTTPS
23
+ pages too.
24
+ - Fix: accept only Pinokio's own origins (the daemon UI, `*.localhost` router
25
+ hosts, the Anywhere Tailscale host), plus a per-session token for the UI.
26
+
27
+ ### A2. Network exposure of the daemon
28
+ - Where: `server/index.js` (`server.listen(port)` with no host, so all
29
+ interfaces; `cors({origin:'*'})`; `express-session` secret is the literal
30
+ string `"secret"`; no auth middleware).
31
+ - Fix: bind 127.0.0.1 unless LAN sharing or Anywhere is enabled; random
32
+ session secret.
33
+
34
+ ### A3. Static file routes serve the whole Pinokio home
35
+ - Where: `server/index.js` mounts `express.static(kernel.homedir)` at `/asset`
36
+ and `/files` with `serveIndex(hidden: true)`.
37
+ - Problem: `key.json`, every `ENVIRONMENT` file and `connect/*/auth.json` are
38
+ downloadable by anything that can reach the daemon.
39
+ - Fix: deny-list secret files/dirs, or move secrets out of the home tree.
40
+
41
+ ### A4. Secrets hygiene
42
+ - `kernel/key.js` `set()` logs `{ key, val, password }` to the console.
43
+ - Secrets live in plaintext: `~/pinokio/ENVIRONMENT`, per-app `ENVIRONMENT`,
44
+ `key.json`, `cache/HF_AUTH/token`, `connect/<provider>/auth.json`.
45
+ - A new app's ENVIRONMENT inherits every custom key from the system
46
+ ENVIRONMENT (`kernel/environment.js`, `ENV("app")`), so user secrets
47
+ propagate into every app.
48
+ - Fix: stop the logging; OS keychain for storage; stop inheriting secrets into
49
+ app ENVIRONMENT by default.
50
+
51
+ ### A5. Unverified downloads and unpinned system clones
52
+ - `Bin.download` (`kernel/bin/index.js`) does no hash check. Direct URL
53
+ downloads with no hash: cloudflared (`releases/latest`), Homebrew zip and VS
54
+ Build Tools from `cocktailpeanut/bin`, aria2, cmake, mingw, standalone
55
+ python. Only Miniforge and the pip seed wheel are SHA-256 verified.
56
+ - `pinokiocomputer/proto`, `network`, `proxy_registry`, `python` are cloned
57
+ from GitHub HEAD, unpinned, and re-cloned on every version change.
58
+ - Fix: hash table per download (as done for Miniforge); pin system repos to a
59
+ commit or tag.
60
+
61
+ ### A6. PyTorch safe unpickling is disabled globally
62
+ - Where: `kernel/shell.js` sets `TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD=1` for every
63
+ app shell.
64
+ - Problem: turns off the `weights_only=True` default PyTorch has shipped since
65
+ 2.6. Malicious pickle models (nullifAI, Feb 2025) are a known vector.
66
+ - Fix: make it per-app opt-in with a visible warning badge instead of global.
67
+
68
+ ---
69
+
70
+ ## B. Platform security features (the "install through Pinokio and you're safe" story)
71
+
72
+ ### B1. Security advisory feed + Security Center
73
+ - A small signed JSON list of blocked package versions, maintained by Pinokio,
74
+ fetched periodically (the OneCRL idea, applied to AI apps).
75
+ - Enforce through env Pinokio already controls in `Shell.init_env`:
76
+ `PIP_CONSTRAINT` and `UV_CONSTRAINT` (constraints file with
77
+ `pkg!=x.y.z` lines), `UV_EXCLUDE_NEWER`, and bluefairy shims for npm/bun.
78
+ - Example it would have stopped: Ultralytics 8.3.41/42/45/46 (Dec 2024
79
+ cryptominer), which reached ComfyUI users via the Impact Pack.
80
+ - Security Center page: audit every app env (pip version, certifi, blocked
81
+ packages), one-click repair, fleet-wide. No app can do this for other apps.
82
+
83
+ ### B2. Release-age cooldown on by default
84
+ - bluefairy exists (`kernel/bin/bluefairy.js`) but `protection_enabled`
85
+ defaults to `false` (`server/lib/app_preferences.js`), only applies to
86
+ `shell.run` (forced `"off"` for `shell.start`, `kernel.exec`, `bin.exec`),
87
+ and shims only npm/bun/uv, not pip or conda.
88
+ - pnpm 11 and Yarn 4.10 now ship a cooldown on by default. Turn it on
89
+ globally (24h-72h), extend to pip, let the advisory feed override.
90
+
91
+ ### B3. Credential brokering (apps never see real secrets)
92
+ - Run a local credential-injecting proxy and point apps at it via
93
+ `HF_ENDPOINT`, `OPENAI_BASE_URL`, `ANTHROPIC_BASE_URL`, etc. The app gets a
94
+ per-app placeholder; Pinokio adds the real token on the way out.
95
+ - Gives per-app usage visibility and instant revocation. A malicious custom
96
+ node has nothing to exfiltrate.
97
+ - Simpler interim: issue fine-grained per-app HF tokens.
98
+
99
+ ### B4. Model file scanning
100
+ - `kernel/vault/*` (Disk Saver) already walks and hashes every model file.
101
+ Add a picklescan/modelscan pass for `.ckpt`, `.pt`, `.pth`, `.bin` and
102
+ surface results per app. Pairs with A6.
103
+
104
+ ### B5. Per-app sandbox (phased)
105
+ - Chokepoint: every app command goes through `pty.spawn` in `kernel/shell.js`
106
+ (`Shell.init_env` → `exec`). The `exec` API (`kernel/api/exec`) and `sudo`
107
+ path (`shell.js` ~610) bypass it and need the same wrapper.
108
+ - Reference implementation: Anthropic sandbox-runtime (sandbox-exec on macOS,
109
+ bubblewrap on Linux, local proxy for a domain allowlist; Windows in
110
+ progress).
111
+ - Phase 1, observe only: show which domains an app contacted and what it
112
+ wrote outside its folder. Breaks nothing, already a visible value-add.
113
+ - Phase 2: enforce per-app network allowlists (huggingface.co, pypi.org,
114
+ github.com by default).
115
+ - Phase 3: filesystem confinement to app folder + shared caches.
116
+ - Windows is the hard platform; ship macOS/Linux first.
117
+
118
+ ### B6. Script execution boundary (longer term)
119
+ - `kernel/loader.js` `requireJS()` loads every `pinokio.js` / `install.js` /
120
+ `start.js` with plain `require()` in the daemon process, and `pinokio.js`
121
+ runs when an app is merely listed (`api.meta`, menu rendering).
122
+ - `Api.resolveMethod` (`kernel/api/index.js`) dispatches `kernel.*` methods,
123
+ `exec`, `fs.download`, `script.start` and `sudo` to scripts with the live
124
+ kernel object.
125
+ - Options: isolate scripts (vm / worker), capability gate at `resolveMethod`,
126
+ stop executing `pinokio.js` on listing.
@@ -273,6 +273,11 @@ report_errors: false`)
273
273
  await caddy.stop()
274
274
  }
275
275
 
276
+ if (this.kernel.anywhere) {
277
+ ondata({ raw: `stopping Pinokio Anywhere before replacing Miniforge...\r\n` })
278
+ await this.kernel.anywhere.stop()
279
+ }
280
+
276
281
  legacy_path_exists = await this.kernel.exists(`bin/${LEGACY_CONDA_ROOT_DIR}`)
277
282
  if (legacy_path_exists) {
278
283
  console.log("Removing legacy install path...", legacy_path)
@@ -0,0 +1,132 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const { randomUUID } = require('crypto')
4
+
5
+ // The managed CA bundle: the conda-forge ca-certificates package inside Miniforge.
6
+ // Every app's Python HTTPS traffic is pointed at this one file so a revoked root is
7
+ // retired for all apps, old or new, by updating this file alone.
8
+ function managedCertPaths(homedir, platform = process.platform) {
9
+ const dir = platform === 'win32'
10
+ ? path.resolve(homedir, 'bin', 'miniforge', 'Library', 'ssl')
11
+ : path.resolve(homedir, 'bin', 'miniforge', 'ssl')
12
+ return { dir, file: path.resolve(dir, 'cacert.pem') }
13
+ }
14
+
15
+ function findEnvKey(env, key) {
16
+ const wanted = key.toLowerCase()
17
+ return Object.keys(env).find(envKey => envKey.toLowerCase() === wanted)
18
+ }
19
+
20
+ function hasEnvValue(env, key) {
21
+ const envKey = findEnvKey(env, key)
22
+ return !!envKey && typeof env[envKey] === 'string' && env[envKey].trim().length > 0
23
+ }
24
+
25
+ function setEnvValue(env, key, value) {
26
+ env[findEnvKey(env, key) || key] = value
27
+ }
28
+
29
+ // Defaults for the three readers that otherwise fall back to a private certifi copy:
30
+ // httpx and Python's ssl module (SSL_CERT_FILE), requests (REQUESTS_CA_BUNDLE) and pip (PIP_CERT).
31
+ // A default is applied only when none of its related variables is set, because the readers
32
+ // rank them: httpx prefers SSL_CERT_FILE over SSL_CERT_DIR and requests prefers
33
+ // REQUESTS_CA_BUNDLE over CURL_CA_BUNDLE, so defaulting one would silently override a
34
+ // custom setting of the other.
35
+ function applyManagedCertEnv(env, { file, dir, platform = process.platform }) {
36
+ if (!hasEnvValue(env, 'SSL_CERT_FILE') && !hasEnvValue(env, 'SSL_CERT_DIR')) {
37
+ setEnvValue(env, 'SSL_CERT_FILE', file)
38
+ if (platform === 'win32') {
39
+ // conda-forge OpenSSL on Windows points SSL_CERT_DIR at an empty directory, which uv treats
40
+ // as an explicit (empty) certificate source. Give it the directory that holds the bundle.
41
+ setEnvValue(env, 'SSL_CERT_DIR', dir)
42
+ }
43
+ }
44
+ if (!hasEnvValue(env, 'REQUESTS_CA_BUNDLE') && !hasEnvValue(env, 'CURL_CA_BUNDLE')) {
45
+ setEnvValue(env, 'REQUESTS_CA_BUNDLE', file)
46
+ }
47
+ if (!hasEnvValue(env, 'PIP_CERT')) {
48
+ setEnvValue(env, 'PIP_CERT', file)
49
+ }
50
+ return env
51
+ }
52
+
53
+ // pip carries its own certifi copy and, from 24.2 on, loads it no matter what the environment
54
+ // says. The only way to retire a root from an existing environment's pip is to replace that file.
55
+ async function pipVendoredBundles(prefix, platform = process.platform) {
56
+ const sitePackages = []
57
+ if (platform === 'win32') {
58
+ sitePackages.push(path.join(prefix, 'Lib', 'site-packages'))
59
+ } else {
60
+ const lib = path.join(prefix, 'lib')
61
+ const entries = await fs.promises.readdir(lib).catch(() => [])
62
+ for (const entry of entries) {
63
+ if (/^python\d/.test(entry)) sitePackages.push(path.join(lib, entry, 'site-packages'))
64
+ }
65
+ }
66
+ return sitePackages.map(dir => path.join(dir, 'pip', '_vendor', 'certifi', 'cacert.pem'))
67
+ }
68
+
69
+ const bundleCache = new Map()
70
+ async function readManagedBundle(file) {
71
+ const stat = await fs.promises.stat(file)
72
+ const cached = bundleCache.get(file)
73
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) return cached.data
74
+ const data = await fs.promises.readFile(file)
75
+ bundleCache.set(file, { mtimeMs: stat.mtimeMs, size: stat.size, data })
76
+ return data
77
+ }
78
+
79
+ // Replaces pip's vendored cacert.pem under `prefix` with the managed bundle when they differ.
80
+ // The file is published with write-to-temp + rename, never edited in place: in conda
81
+ // environments pip's files are hardlinked from the package cache, and editing in place would
82
+ // change the cached copy too. Returns the paths that were replaced.
83
+ async function repairPipVendoredBundle(prefix, bundleFile, platform = process.platform) {
84
+ const managed = await readManagedBundle(bundleFile).catch(() => null)
85
+ if (!managed) return []
86
+ const repaired = []
87
+ for (const target of await pipVendoredBundles(prefix, platform)) {
88
+ const stat = await fs.promises.stat(target).catch(() => null)
89
+ if (stat && !stat.isFile()) continue
90
+ if (!stat) {
91
+ // No pip here: nothing to do. pip present but its bundle gone (the user deleted a file
92
+ // Pinokio could not replace): write a fresh one, or pip fails on every network call.
93
+ const certifiDir = await fs.promises.stat(path.dirname(target)).catch(() => null)
94
+ if (!certifiDir || !certifiDir.isDirectory()) continue
95
+ } else if (stat.size === managed.length) {
96
+ const current = await fs.promises.readFile(target).catch(() => null)
97
+ if (current && current.equals(managed)) continue
98
+ }
99
+ const temporary = path.join(path.dirname(target), `.cacert-${randomUUID()}.tmp`)
100
+ try {
101
+ await fs.promises.writeFile(temporary, managed, { mode: stat ? stat.mode & 0o777 : 0o644 })
102
+ await fs.promises.rename(temporary, target)
103
+ repaired.push(target)
104
+ } catch (error) {
105
+ // The caller decides what a failed replacement means; it needs to know which file.
106
+ error.target = target
107
+ error.prefix = prefix
108
+ throw error
109
+ } finally {
110
+ await fs.promises.rm(temporary, { force: true }).catch(() => {})
111
+ }
112
+ }
113
+ return repaired
114
+ }
115
+
116
+ // An in-app link to a folder under the Pinokio home: the app file explorer for anything under
117
+ // api/, the home directory listing for everything else. Both are served by the daemon, so the
118
+ // link works from any client, including a browser on another machine.
119
+ function explorerHref(homedir, dir) {
120
+ const relative = path.relative(path.resolve(homedir), path.resolve(dir))
121
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null
122
+ const segments = relative.split(path.sep).map(encodeURIComponent)
123
+ return segments[0] === 'api' ? `/${segments.join('/')}` : `/files/${segments.join('/')}`
124
+ }
125
+
126
+ module.exports = {
127
+ managedCertPaths,
128
+ applyManagedCertEnv,
129
+ pipVendoredBundles,
130
+ repairPipVendoredBundle,
131
+ explorerHref,
132
+ }
package/kernel/shell.js CHANGED
@@ -23,6 +23,8 @@ const ShellStateSync = require('./shell_state_sync')
23
23
  const ShellRunTemplate = require('./api/shell_run_template')
24
24
  const { PYTHON_INSTALL_SPEC, PIP_WHEEL, PIP_SEED_SCRIPT } = require('./bin/conda-pins')
25
25
  const CondaRuntimeGuard = require('./shell_conda_runtime_guard')
26
+ const { managedCertPaths, applyManagedCertEnv, repairPipVendoredBundle, explorerHref } = require('./cert_env')
27
+ const escapeHtml = require('escape-html')
26
28
  const home = os.homedir()
27
29
  const WINDOWS_CMD_CONDA_COMMAND_PATTERN = /(?:^|[&|()]\s*)@?\s*(?:call\s+)?(?:"[^"]*[\\/]conda(?:\.bat|\.exe)?"|(?:[a-z]:)?[^&|()\s"]*[\\/]conda(?:\.bat|\.exe)?|conda(?:\.bat|\.exe)?)\s+/i
28
30
 
@@ -307,15 +309,14 @@ class Shell {
307
309
  setDefaultEnvValue(this.env, "HF_HUB_DISABLE_UPDATE_CHECK", "1")
308
310
  setDefaultEnvValue(this.env, "HF_TOKEN_PATH", this.kernel.envs?.HF_TOKEN_PATH || path.resolve(this.kernel.homedir, "cache", "HF_AUTH", "token"))
309
311
 
310
- if (this.platform === "win32") {
311
- const managedSslDir = path.resolve(this.kernel.homedir, "bin", "miniforge", "Library", "ssl")
312
- const managedCertFile = path.resolve(managedSslDir, "cacert.pem")
313
- const managedCertFileExists = await fs.promises.access(managedCertFile).then(() => true).catch(() => false)
314
- if (managedCertFileExists) {
315
- setDefaultEnvValue(this.env, "SSL_CERT_FILE", managedCertFile)
316
- setDefaultEnvValue(this.env, "SSL_CERT_DIR", managedSslDir)
317
- }
312
+ // Point every app's Python HTTPS trust (httpx, requests, pip, the ssl module) at the
313
+ // managed CA bundle instead of the certifi copy frozen inside each environment.
314
+ const managedCert = managedCertPaths(this.kernel.homedir, this.platform)
315
+ if (await fs.promises.access(managedCert.file).then(() => true).catch(() => false)) {
316
+ applyManagedCertEnv(this.env, { ...managedCert, platform: this.platform })
317
+ }
318
318
 
319
+ if (this.platform === "win32") {
319
320
  // Hugging Face file symlinks regularly fail on non-admin Windows setups.
320
321
  // Default to no-symlink cache mode unless the user/app explicitly overrides it.
321
322
  setDefaultEnvValue(this.env, "HF_HUB_DISABLE_SYMLINKS", "1")
@@ -945,6 +946,44 @@ class Shell {
945
946
  exists(abspath) {
946
947
  return new Promise(r=>fs.access(abspath, fs.constants.F_OK, e => r(!e)))
947
948
  }
949
+ // pip ignores the certificate environment for the certifi copy it carries, so an existing
950
+ // environment's pip is brought in line by replacing that one file with the managed bundle.
951
+ // Runs for the environment being activated only; a match costs one file read.
952
+ // A replacement that fails stops the launch: the app must not run against a stale list
953
+ // without the user knowing. They are told which file to delete, and relaunching retries.
954
+ async repairPipCertBundle(prefix) {
955
+ const managedCert = managedCertPaths(this.kernel.homedir, this.platform)
956
+ let repaired
957
+ try {
958
+ repaired = await repairPipVendoredBundle(prefix, managedCert.file, this.platform)
959
+ } catch (error) {
960
+ const target = error.target || prefix
961
+ const reason = error.message
962
+ const message = `Pinokio could not replace pip's certificate file for this app (${reason}). Delete this file, then launch the app again: ${target}`
963
+ if (typeof this.ondata === "function") {
964
+ const href = explorerHref(this.kernel.homedir, path.dirname(target))
965
+ this.ondata({
966
+ html: [
967
+ `<b>Certificate update blocked</b>`,
968
+ `Pinokio could not replace pip's certificate file for this app (${escapeHtml(reason)}).`,
969
+ `Delete this file, then launch the app again:`,
970
+ `<code>${escapeHtml(target)}</code>`,
971
+ ...(href ? [`Click to open the folder.`] : []),
972
+ ].join("<br>"),
973
+ ...(href ? { href, target: "self" } : {}),
974
+ type: "error",
975
+ }, "notify")
976
+ }
977
+ const blocked = new Error(message)
978
+ blocked.path = target
979
+ blocked.cause = error
980
+ throw blocked
981
+ }
982
+ for (const target of repaired) {
983
+ console.log(`[cert] replaced pip's vendored CA bundle: ${target}`)
984
+ }
985
+ return repaired
986
+ }
948
987
  build (params) {
949
988
  if (params.message) {
950
989
  if (typeof params.message === "string") {
@@ -1125,6 +1164,7 @@ class Shell {
1125
1164
  : this.quoteArgForShell(env_path)
1126
1165
  let env_exists = await this.exists(env_path)
1127
1166
  if (env_exists) {
1167
+ await this.repairPipCertBundle(env_path)
1128
1168
  conda_activation = [
1129
1169
  conda_hook,
1130
1170
  // timeout,
@@ -1165,6 +1205,7 @@ class Shell {
1165
1205
  let env_path = path.resolve(envs_path, conda_name)
1166
1206
  let env_exists = await this.exists(env_path)
1167
1207
  if (env_exists) {
1208
+ await this.repairPipCertBundle(env_path)
1168
1209
  conda_activation = [
1169
1210
  conda_hook,
1170
1211
  // timeout,
@@ -1366,6 +1407,7 @@ class Shell {
1366
1407
  let deactivate_path = (windowsBashVenv ? "deactivate" : (this.platform === 'win32' ? path.resolve(env_path, "Scripts", "deactivate") : "deactivate"))
1367
1408
  let env_exists = await this.exists(env_path)
1368
1409
  if (env_exists) {
1410
+ await this.repairPipCertBundle(env_path)
1369
1411
  if (use_uv) {
1370
1412
  venv_activation = [
1371
1413
  // `python -m venv --upgrade ${env_path}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.2.20",
3
+ "version": "8.2.22",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/server/index.js CHANGED
@@ -1049,7 +1049,8 @@ class Server {
1049
1049
  const dnsName = anywhere && typeof anywhere.dnsNameFrom === "function" ? anywhere.dnsNameFrom(anywhere.lastStatus) : null
1050
1050
  return !!dnsName && parsed.protocol === "https:" && host === dnsName.toLowerCase()
1051
1051
  }
1052
- // the app's menu as launch targets: scripts and web addresses, never interface links
1052
+ // the app's menu as launch targets: scripts and web addresses, never interface links;
1053
+ // text is plain: a launcher may write HTML, which the desktop renders and the phone does not
1053
1054
  async anywhereMenu(api, repoPath) {
1054
1055
  const items = []
1055
1056
  for (const { item, target } of await launcherMenuTargets(api, repoPath)) {
@@ -1057,7 +1058,7 @@ class Server {
1057
1058
  if (!web && !/\.(js|json)$/i.test(target.uri)) continue
1058
1059
  if (isInterfaceLink(target.input)) continue
1059
1060
  items.push({
1060
- text: item.text || "",
1061
+ text: String(item.text || "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(),
1061
1062
  icon: item.icon || "",
1062
1063
  href: target.href,
1063
1064
  default: !!item.default,
@@ -0,0 +1,13 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
2
+ <defs>
3
+ <mask id="cursor-gap" maskUnits="userSpaceOnUse" x="0" y="0" width="40" height="40">
4
+ <rect width="40" height="40" fill="white"/>
5
+ <path d="M23 17V35L28 30L32 38L36 36L32 28L39 27Z" fill="black" stroke="black" stroke-width="3" stroke-linejoin="round"/>
6
+ </mask>
7
+ </defs>
8
+ <g mask="url(#cursor-gap)">
9
+ <path fill-rule="evenodd" d="M5 5H33A3 3 0 0 1 36 8V26A3 3 0 0 1 33 29H5A3 3 0 0 1 2 26V8A3 3 0 0 1 5 5ZM6 9V25H32V9Z"/>
10
+ <path d="M17 29H22V34H29V38H10V34H17Z"/>
11
+ </g>
12
+ <path d="M23 17V35L28 30L32 38L36 36L32 28L39 27Z"/>
13
+ </svg>
@@ -1598,6 +1598,11 @@ body.dark .main-sidebar .main-sidebar-status-badge[data-state="on"] {
1598
1598
  -webkit-mask: url('/pinokio-mark.svg') center / 14px 14px no-repeat;
1599
1599
  mask: url('/pinokio-mark.svg') center / 14px 14px no-repeat;
1600
1600
  }
1601
+ .main-sidebar .remote-control-icon {
1602
+ background: currentColor;
1603
+ -webkit-mask: url('/remote-control.svg') center / contain no-repeat;
1604
+ mask: url('/remote-control.svg') center / contain no-repeat;
1605
+ }
1601
1606
  @media only screen and (min-width: 601px) {
1602
1607
  body.main-sidebar-page {
1603
1608
  display: flex;
@@ -1,6 +1,6 @@
1
1
  <html>
2
2
  <head>
3
- <title>Pinokio Anywhere</title>
3
+ <title>Pinokio Remote</title>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
6
6
  <% if (locals.anywhere_host) { %>
@@ -132,7 +132,7 @@ document.addEventListener('DOMContentLoaded', function() {
132
132
  <section class='task-shell anywhere-shell'>
133
133
  <header class='task-shell-header'>
134
134
  <div class='task-shell-header-main'>
135
- <h1 class='task-title'>Pinokio Anywhere</h1>
135
+ <h1 class='task-title'>Remote</h1>
136
136
  <p class='task-description'>Open this Pinokio and its running apps from your phone or another computer, anywhere, over a private encrypted network.</p>
137
137
  </div>
138
138
  <div class='task-header-actions'>
@@ -146,7 +146,7 @@ document.addEventListener('DOMContentLoaded', function() {
146
146
 
147
147
  <div class='anywhere-state' data-state='off'>
148
148
  <div class='anywhere-card'>
149
- <h2>Pinokio Anywhere is off</h2>
149
+ <h2>Remote access is off</h2>
150
150
  <p>Turn it on to reach this computer from your phone. Nothing is exposed to the public internet: only devices signed in to your own Tailscale account can connect.</p>
151
151
  <p><button class='anywhere-btn' data-action='start'><i class="fa-solid fa-power-off"></i> Turn on</button></p>
152
152
  </div>
@@ -162,7 +162,7 @@ document.addEventListener('DOMContentLoaded', function() {
162
162
  <div class='anywhere-state' data-state='login'>
163
163
  <div class='anywhere-card'>
164
164
  <h2>Sign in in your browser</h2>
165
- <p>Pinokio Anywhere uses <b>Tailscale</b>, a free private network. Sign in with a <b>personal</b> Google, Apple, GitHub or Microsoft account. You will use the same account on your phone.</p>
165
+ <p>Remote access uses <b>Tailscale</b>, a free private network. Sign in with a <b>personal</b> Google, Apple, GitHub or Microsoft account. You will use the same account on your phone.</p>
166
166
  <p><a class='anywhere-btn' id='anywhere-login-link' href='#' target='_blank' rel='noopener'><i class="fa-solid fa-arrow-up-right-from-square"></i> Sign in with Tailscale</a></p>
167
167
  <p class='anywhere-muted'>Waiting for you to finish signing in… <span id='anywhere-login-url' class='anywhere-muted'></span></p>
168
168
  </div>
@@ -40,7 +40,7 @@
40
40
  <div class='main-sidebar-section' aria-label="Configure">
41
41
  <div class="main-sidebar-section-title">Configure</div>
42
42
  <a href="/network" class="tab <%= sidebarSelected === 'network' ? 'selected' : '' %>" data-main-sidebar-home-server-tab><i class="fa-solid fa-wifi"></i><div class='caption'>Home Server</div><span class="main-sidebar-status-badge" data-main-sidebar-home-server-status aria-hidden="true" hidden></span></a>
43
- <a href="/anywhere" class="tab <%= sidebarSelected === 'anywhere' ? 'selected' : '' %>" data-main-sidebar-anywhere-tab><i class="fa-solid fa-mobile-screen"></i><div class='caption'>Pinokio Anywhere</div><span class="main-sidebar-status-badge" data-main-sidebar-anywhere-status aria-hidden="true" hidden></span></a>
43
+ <a href="/anywhere" class="tab <%= sidebarSelected === 'anywhere' ? 'selected' : '' %>" data-main-sidebar-anywhere-tab><span class="main-sidebar-action-icon remote-control-icon" aria-hidden="true"></span><div class='caption'>Remote</div><span class="main-sidebar-status-badge" data-main-sidebar-anywhere-status aria-hidden="true" hidden></span></a>
44
44
  <a class="tab <%= sidebarSelected === 'autolaunch' ? 'selected' : '' %>" href="/autolaunch"><i class="fa-solid fa-power-off"></i><div class='caption'>Autolaunch</div></a>
45
45
  <a href="/connect" class="tab <%= sidebarSelected === 'connect' ? 'selected' : '' %>"><i class="fa-solid fa-plug"></i><div class='caption'>Login</div></a>
46
46
  <a class="tab <%= sidebarSelected === 'settings' ? 'selected' : '' %>" href="/home?mode=settings"><i class="fa-solid fa-gear"></i><div class='caption'>Settings</div></a>
@@ -140,7 +140,7 @@
140
140
  badge.textContent = label;
141
141
  badge.hidden = false;
142
142
  badge.dataset.state = on ? "on" : "off";
143
- tab.setAttribute("title", `Pinokio Anywhere ${label}`);
143
+ tab.setAttribute("title", `Remote ${label}`);
144
144
  };
145
145
  document.addEventListener("pinokio:anywhere-status", (event) => {
146
146
  const detail = event && event.detail ? event.detail : {};
@@ -164,6 +164,7 @@ const menu = [
164
164
  { text: 'Start (low VRAM)', href: 'start.js', params: { lowvram: true } },
165
165
  ] },
166
166
  { text: 'Web UI', icon: 'fa-solid fa-rocket', href: 'http://127.0.0.1:7860' },
167
+ { text: '<div><strong>Tunnel</strong>\n<div>Start first, then run again.</div></div>', href: 'tunnel.js' },
167
168
  { text: 'Edit start', href: 'start.js?fs=view' },
168
169
  { text: 'View source', href: 'start.js?mode=source' },
169
170
  { text: 'Models', href: 'models?fs=true' },
@@ -178,6 +179,7 @@ test('launch returns the flattened launchable menu when there is no default', as
178
179
  { text: 'Start', icon: '', href: 'start.js' },
179
180
  { text: 'Start (low VRAM)', icon: '', href: 'start.js?lowvram=true' },
180
181
  { text: 'Web UI', icon: 'fa-solid fa-rocket', href: 'http://127.0.0.1:7860' },
182
+ { text: 'Tunnel Start first, then run again.', icon: '', href: 'tunnel.js' },
181
183
  ] } })
182
184
  assert.deepEqual(h.calls.process, [])
183
185
  })
@@ -0,0 +1,315 @@
1
+ const assert = require('node:assert/strict')
2
+ const fs = require('node:fs/promises')
3
+ const os = require('node:os')
4
+ const path = require('node:path')
5
+ const test = require('node:test')
6
+
7
+ const {
8
+ managedCertPaths,
9
+ applyManagedCertEnv,
10
+ pipVendoredBundles,
11
+ repairPipVendoredBundle,
12
+ } = require('../kernel/cert_env')
13
+ const Shell = require('../kernel/shell')
14
+
15
+ const MANAGED = '# managed bundle\n-----BEGIN CERTIFICATE-----\nnew\n-----END CERTIFICATE-----\n'
16
+ const STALE = '# stale bundle\n-----BEGIN CERTIFICATE-----\nold\n-----END CERTIFICATE-----\n'
17
+
18
+ async function tempRoot(t, prefix) {
19
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix))
20
+ t.after(() => fs.rm(root, { recursive: true, force: true }).catch(() => {}))
21
+ return root
22
+ }
23
+
24
+ async function managedBundle(t, platform) {
25
+ const root = await tempRoot(t, 'pinokio-cert-env-')
26
+ const paths = managedCertPaths(root, platform)
27
+ await fs.mkdir(paths.dir, { recursive: true })
28
+ await fs.writeFile(paths.file, MANAGED)
29
+ return { root, ...paths }
30
+ }
31
+
32
+ function createKernel(root) {
33
+ return {
34
+ homedir: root,
35
+ bracketedPasteSupport: {},
36
+ connect: { keys: async () => null },
37
+ envs: {},
38
+ exists: async filepath => fs.access(filepath).then(() => true).catch(() => false),
39
+ which: () => null,
40
+ path: (...parts) => path.join(root, ...parts),
41
+ bin: { path: (...parts) => path.join(root, 'bin', ...parts) },
42
+ }
43
+ }
44
+
45
+ test('managedCertPaths follows the Miniforge layout per platform', () => {
46
+ assert.equal(managedCertPaths('/home/u/pinokio', 'darwin').file, path.resolve('/home/u/pinokio/bin/miniforge/ssl/cacert.pem'))
47
+ assert.equal(managedCertPaths('/home/u/pinokio', 'linux').file, path.resolve('/home/u/pinokio/bin/miniforge/ssl/cacert.pem'))
48
+ assert.equal(managedCertPaths('C:\\pinokio', 'win32').file, path.resolve('C:\\pinokio', 'bin', 'miniforge', 'Library', 'ssl', 'cacert.pem'))
49
+ })
50
+
51
+ test('applyManagedCertEnv points httpx, requests and pip at the managed bundle', () => {
52
+ const env = applyManagedCertEnv({}, { file: '/m/cacert.pem', dir: '/m', platform: 'darwin' })
53
+ assert.equal(env.SSL_CERT_FILE, '/m/cacert.pem')
54
+ assert.equal(env.REQUESTS_CA_BUNDLE, '/m/cacert.pem')
55
+ assert.equal(env.PIP_CERT, '/m/cacert.pem')
56
+ assert.equal(env.SSL_CERT_DIR, undefined, 'SSL_CERT_DIR stays a Windows-only uv workaround')
57
+ assert.equal(env.CURL_CA_BUNDLE, undefined, 'curl keeps its own trust source')
58
+ })
59
+
60
+ test('applyManagedCertEnv also gives Windows uv a certificate directory', () => {
61
+ const env = applyManagedCertEnv({}, { file: 'C:\\m\\cacert.pem', dir: 'C:\\m', platform: 'win32' })
62
+ assert.equal(env.SSL_CERT_FILE, 'C:\\m\\cacert.pem')
63
+ assert.equal(env.SSL_CERT_DIR, 'C:\\m')
64
+ assert.equal(env.REQUESTS_CA_BUNDLE, 'C:\\m\\cacert.pem')
65
+ assert.equal(env.PIP_CERT, 'C:\\m\\cacert.pem')
66
+ })
67
+
68
+ test('applyManagedCertEnv never overrides a custom trust setting or its higher-priority sibling', () => {
69
+ const managed = { file: '/m/cacert.pem', dir: '/m', platform: 'darwin' }
70
+
71
+ // httpx prefers SSL_CERT_FILE over SSL_CERT_DIR: a custom directory must not be masked.
72
+ const dirOnly = applyManagedCertEnv({ SSL_CERT_DIR: '/corp/certs' }, managed)
73
+ assert.equal(dirOnly.SSL_CERT_FILE, undefined)
74
+ assert.equal(dirOnly.SSL_CERT_DIR, '/corp/certs')
75
+ assert.equal(dirOnly.REQUESTS_CA_BUNDLE, '/m/cacert.pem')
76
+
77
+ // requests prefers REQUESTS_CA_BUNDLE over CURL_CA_BUNDLE: a custom curl bundle must not be masked.
78
+ const curlOnly = applyManagedCertEnv({ CURL_CA_BUNDLE: '/corp/curl.pem' }, managed)
79
+ assert.equal(curlOnly.REQUESTS_CA_BUNDLE, undefined)
80
+ assert.equal(curlOnly.CURL_CA_BUNDLE, '/corp/curl.pem')
81
+ assert.equal(curlOnly.SSL_CERT_FILE, '/m/cacert.pem')
82
+
83
+ const custom = applyManagedCertEnv({ SSL_CERT_FILE: '/corp/a.pem', REQUESTS_CA_BUNDLE: '/corp/b.pem', PIP_CERT: '/corp/c.pem' }, managed)
84
+ assert.equal(custom.SSL_CERT_FILE, '/corp/a.pem')
85
+ assert.equal(custom.REQUESTS_CA_BUNDLE, '/corp/b.pem')
86
+ assert.equal(custom.PIP_CERT, '/corp/c.pem')
87
+
88
+ // Empty values count as unset, and the existing key casing is reused (Windows env is case-insensitive).
89
+ const empty = applyManagedCertEnv({ ssl_cert_file: '', Pip_Cert: ' ' }, { ...managed, platform: 'win32' })
90
+ assert.equal(empty.ssl_cert_file, '/m/cacert.pem')
91
+ assert.equal(empty.SSL_CERT_FILE, undefined)
92
+ assert.equal(empty.Pip_Cert, '/m/cacert.pem')
93
+ assert.equal(empty.PIP_CERT, undefined)
94
+ })
95
+
96
+ test('pipVendoredBundles finds pip inside venv and conda prefixes on every platform', async t => {
97
+ const root = await tempRoot(t, 'pinokio-pip-bundles-')
98
+ const posix = path.join(root, 'env')
99
+ await fs.mkdir(path.join(posix, 'lib', 'python3.10', 'site-packages'), { recursive: true })
100
+ await fs.mkdir(path.join(posix, 'lib', 'python3.12', 'site-packages'), { recursive: true })
101
+ await fs.mkdir(path.join(posix, 'lib', 'pkgconfig'), { recursive: true })
102
+ assert.deepEqual(await pipVendoredBundles(posix, 'linux'), [
103
+ path.join(posix, 'lib', 'python3.10', 'site-packages', 'pip', '_vendor', 'certifi', 'cacert.pem'),
104
+ path.join(posix, 'lib', 'python3.12', 'site-packages', 'pip', '_vendor', 'certifi', 'cacert.pem'),
105
+ ])
106
+ assert.deepEqual(await pipVendoredBundles(path.join(root, 'win'), 'win32'), [
107
+ path.join(root, 'win', 'Lib', 'site-packages', 'pip', '_vendor', 'certifi', 'cacert.pem'),
108
+ ])
109
+ assert.deepEqual(await pipVendoredBundles(path.join(root, 'missing'), 'linux'), [])
110
+ })
111
+
112
+ test('repairPipVendoredBundle replaces a stale pip bundle once and leaves everything else alone', async t => {
113
+ const managed = await managedBundle(t, 'linux')
114
+ const prefix = path.join(managed.root, 'api', 'app', 'env')
115
+ const certifiDir = path.join(prefix, 'lib', 'python3.10', 'site-packages', 'pip', '_vendor', 'certifi')
116
+ const target = path.join(certifiDir, 'cacert.pem')
117
+ const sibling = path.join(certifiDir, 'core.py')
118
+ await fs.mkdir(certifiDir, { recursive: true })
119
+ await fs.writeFile(target, STALE, { mode: 0o600 })
120
+ await fs.writeFile(sibling, 'print("untouched")\n')
121
+
122
+ assert.deepEqual(await repairPipVendoredBundle(prefix, managed.file, 'linux'), [target])
123
+ assert.equal(await fs.readFile(target, 'utf8'), MANAGED)
124
+ if (process.platform !== 'win32') {
125
+ assert.equal((await fs.stat(target)).mode & 0o777, 0o600, 'file mode is preserved')
126
+ }
127
+ assert.equal(await fs.readFile(sibling, 'utf8'), 'print("untouched")\n')
128
+ assert.deepEqual(await fs.readdir(certifiDir), ['cacert.pem', 'core.py'], 'no temporary files are left behind')
129
+
130
+ // Second activation: the bundle matches, nothing is written.
131
+ const before = await fs.stat(target)
132
+ assert.deepEqual(await repairPipVendoredBundle(prefix, managed.file, 'linux'), [])
133
+ assert.equal((await fs.stat(target)).mtimeMs, before.mtimeMs)
134
+ })
135
+
136
+ test('repairPipVendoredBundle is a no-op without pip or without a managed bundle', async t => {
137
+ const managed = await managedBundle(t, 'linux')
138
+ const uvVenv = path.join(managed.root, 'uv-env')
139
+ await fs.mkdir(path.join(uvVenv, 'lib', 'python3.11', 'site-packages'), { recursive: true })
140
+ assert.deepEqual(await repairPipVendoredBundle(uvVenv, managed.file, 'linux'), [])
141
+
142
+ const prefix = path.join(managed.root, 'env')
143
+ const target = path.join(prefix, 'lib', 'python3.10', 'site-packages', 'pip', '_vendor', 'certifi', 'cacert.pem')
144
+ await fs.mkdir(path.dirname(target), { recursive: true })
145
+ await fs.writeFile(target, STALE)
146
+ assert.deepEqual(await repairPipVendoredBundle(prefix, path.join(managed.root, 'nope.pem'), 'linux'), [])
147
+ assert.equal(await fs.readFile(target, 'utf8'), STALE)
148
+ })
149
+
150
+ test('Shell.init_env applies the managed certificate defaults on every platform when the bundle exists', async t => {
151
+ for (const platform of ['darwin', 'linux', 'win32']) {
152
+ const managed = await managedBundle(t, platform)
153
+ await fs.mkdir(path.join(managed.root, 'api'), { recursive: true })
154
+ const shell = new Shell(createKernel(managed.root))
155
+ shell.platform = platform
156
+ await shell.init_env({ path: process.cwd(), env: {} })
157
+ assert.equal(shell.env.SSL_CERT_FILE, managed.file, platform)
158
+ assert.equal(shell.env.REQUESTS_CA_BUNDLE, managed.file, platform)
159
+ assert.equal(shell.env.PIP_CERT, managed.file, platform)
160
+ assert.equal(shell.env.SSL_CERT_DIR, platform === 'win32' ? managed.dir : undefined, platform)
161
+
162
+ const custom = new Shell(createKernel(managed.root))
163
+ custom.platform = platform
164
+ await custom.init_env({ path: process.cwd(), env: { CURL_CA_BUNDLE: '/corp/curl.pem', SSL_CERT_DIR: '/corp/certs' } })
165
+ assert.equal(custom.env.SSL_CERT_FILE, undefined, platform)
166
+ assert.equal(custom.env.REQUESTS_CA_BUNDLE, undefined, platform)
167
+ assert.equal(custom.env.PIP_CERT, managed.file, platform)
168
+ }
169
+ })
170
+
171
+ test('Shell.init_env sets nothing when the managed bundle is absent', async t => {
172
+ const root = await tempRoot(t, 'pinokio-cert-env-missing-')
173
+ await fs.mkdir(path.join(root, 'api'), { recursive: true })
174
+ const shell = new Shell(createKernel(root))
175
+ shell.platform = 'darwin'
176
+ await shell.init_env({ path: process.cwd(), env: {} })
177
+ assert.equal(shell.env.SSL_CERT_FILE, undefined)
178
+ assert.equal(shell.env.REQUESTS_CA_BUNDLE, undefined)
179
+ assert.equal(shell.env.PIP_CERT, undefined)
180
+ })
181
+
182
+ test('Shell.activate repairs pip in the venv and custom conda env being activated, never in base', async t => {
183
+ const managed = await managedBundle(t, 'linux')
184
+ const kernel = createKernel(managed.root)
185
+ const app = path.join(managed.root, 'api', 'app')
186
+ const stalePip = async prefix => {
187
+ const target = path.join(prefix, 'lib', 'python3.10', 'site-packages', 'pip', '_vendor', 'certifi', 'cacert.pem')
188
+ await fs.mkdir(path.dirname(target), { recursive: true })
189
+ await fs.writeFile(target, STALE)
190
+ return target
191
+ }
192
+ const venv = await stalePip(path.join(app, 'env'))
193
+ const condaByPath = await stalePip(path.join(app, 'conda-env'))
194
+ const condaByName = await stalePip(kernel.bin.path('miniforge', 'envs', 'custom'))
195
+ const base = await stalePip(kernel.bin.path('miniforge'))
196
+ await fs.mkdir(path.join(app, 'env', 'bin'), { recursive: true })
197
+
198
+ const shell = new Shell(kernel)
199
+ shell.platform = 'linux'
200
+ shell.shell = 'bash'
201
+ shell.env = {}
202
+ shell.conda_hook = () => 'conda hook'
203
+ shell.condaRuntimeGuard = null
204
+
205
+ await shell.activate({ path: app, venv: 'env', message: ['true'] })
206
+ assert.equal(await fs.readFile(venv, 'utf8'), MANAGED)
207
+ assert.equal(await fs.readFile(base, 'utf8'), STALE, 'base is managed by the runtime seed, not by activation')
208
+
209
+ await shell.activate({ path: app, conda: { path: 'conda-env' }, message: ['true'] })
210
+ assert.equal(await fs.readFile(condaByPath, 'utf8'), MANAGED)
211
+
212
+ await shell.activate({ path: app, conda: { name: 'custom' }, message: ['true'] })
213
+ assert.equal(await fs.readFile(condaByName, 'utf8'), MANAGED)
214
+ assert.equal(await fs.readFile(base, 'utf8'), STALE)
215
+ })
216
+
217
+ const { explorerHref } = require('../kernel/cert_env')
218
+
219
+ test('explorerHref links into the app file explorer under api/ and the home listing elsewhere', () => {
220
+ const home = path.resolve('/home/u/pinokio')
221
+ assert.equal(explorerHref(home, path.join(home, 'api', 'my app.git', 'app', 'env', 'lib', 'python3.10', 'site-packages', 'pip', '_vendor', 'certifi')),
222
+ '/api/my%20app.git/app/env/lib/python3.10/site-packages/pip/_vendor/certifi')
223
+ assert.equal(explorerHref(home, path.join(home, 'bin', 'miniforge', 'envs', 'custom', 'lib', 'python3.10', 'site-packages', 'pip', '_vendor', 'certifi')),
224
+ '/files/bin/miniforge/envs/custom/lib/python3.10/site-packages/pip/_vendor/certifi')
225
+ assert.equal(explorerHref(home, '/elsewhere/env'), null)
226
+ })
227
+
228
+ const canTestPermissions = process.platform !== 'win32' && typeof process.getuid === 'function' && process.getuid() !== 0
229
+
230
+ test('a pip bundle that cannot be replaced blocks the launch, names the file, and notifies the app page', { skip: !canTestPermissions && 'needs a non-root POSIX user' }, async t => {
231
+ const managed = await managedBundle(t, 'linux')
232
+ const kernel = createKernel(managed.root)
233
+ const app = path.join(managed.root, 'api', 'demo.git')
234
+ const prefix = path.join(app, 'app', 'env')
235
+ const certifiDir = path.join(prefix, 'lib', 'python3.10', 'site-packages', 'pip', '_vendor', 'certifi')
236
+ const target = path.join(certifiDir, 'cacert.pem')
237
+ await fs.mkdir(certifiDir, { recursive: true })
238
+ await fs.writeFile(target, STALE)
239
+ await fs.chmod(certifiDir, 0o500)
240
+ t.after(() => fs.chmod(certifiDir, 0o700).catch(() => {}))
241
+
242
+ const shell = new Shell(kernel)
243
+ shell.platform = 'linux'
244
+ const packets = []
245
+ shell.ondata = (stream, type) => packets.push({ stream, type })
246
+
247
+ await assert.rejects(() => shell.repairPipCertBundle(prefix), error => {
248
+ assert.match(error.message, /could not replace pip's certificate file/)
249
+ assert.match(error.message, /Delete this file, then launch the app again/)
250
+ assert.ok(error.message.includes(target), 'the message names the file to delete')
251
+ assert.equal(error.path, target)
252
+ return true
253
+ })
254
+ assert.equal(await fs.readFile(target, 'utf8'), STALE, 'the original file is left untouched')
255
+ assert.deepEqual(await fs.readdir(certifiDir), ['cacert.pem'], 'no temporary file is left behind')
256
+
257
+ assert.equal(packets.length, 1)
258
+ assert.equal(packets[0].type, 'notify')
259
+ const notice = packets[0].stream
260
+ assert.equal(notice.type, 'error')
261
+ assert.equal(notice.target, 'self')
262
+ assert.equal(notice.href, '/api/demo.git/app/env/lib/python3.10/site-packages/pip/_vendor/certifi')
263
+ assert.ok(notice.html.includes('Delete this file, then launch the app again'))
264
+ assert.ok(notice.html.includes(escapeForHtml(target)))
265
+
266
+ // The user deletes the file and launches again: the fresh bundle is written and the launch proceeds.
267
+ await fs.chmod(certifiDir, 0o700)
268
+ await fs.rm(target)
269
+ assert.deepEqual(await shell.repairPipCertBundle(prefix), [target])
270
+ assert.equal(await fs.readFile(target, 'utf8'), MANAGED)
271
+ if (process.platform !== 'win32') {
272
+ assert.equal((await fs.stat(target)).mode & 0o777, 0o644, 'a recreated file gets a normal mode')
273
+ }
274
+ })
275
+
276
+ test('repairPipVendoredBundle recreates a deleted bundle when pip is present, and ignores an absent pip', async t => {
277
+ const managed = await managedBundle(t, 'linux')
278
+ const prefix = path.join(managed.root, 'env')
279
+ const certifiDir = path.join(prefix, 'lib', 'python3.10', 'site-packages', 'pip', '_vendor', 'certifi')
280
+ const target = path.join(certifiDir, 'cacert.pem')
281
+ await fs.mkdir(certifiDir, { recursive: true })
282
+ assert.deepEqual(await repairPipVendoredBundle(prefix, managed.file, 'linux'), [target])
283
+ assert.equal(await fs.readFile(target, 'utf8'), MANAGED)
284
+
285
+ const noPip = path.join(managed.root, 'nopip')
286
+ await fs.mkdir(path.join(noPip, 'lib', 'python3.10', 'site-packages'), { recursive: true })
287
+ assert.deepEqual(await repairPipVendoredBundle(noPip, managed.file, 'linux'), [])
288
+ assert.equal(await fs.stat(path.join(noPip, 'lib', 'python3.10', 'site-packages', 'pip')).catch(() => null), null, 'nothing is created where pip is absent')
289
+ })
290
+
291
+ test('Shell.activate stops the step when the pip bundle cannot be replaced', { skip: !canTestPermissions && 'needs a non-root POSIX user' }, async t => {
292
+ const managed = await managedBundle(t, 'linux')
293
+ const kernel = createKernel(managed.root)
294
+ const app = path.join(managed.root, 'api', 'demo.git')
295
+ const certifiDir = path.join(app, 'env', 'lib', 'python3.10', 'site-packages', 'pip', '_vendor', 'certifi')
296
+ await fs.mkdir(certifiDir, { recursive: true })
297
+ await fs.mkdir(path.join(app, 'env', 'bin'), { recursive: true })
298
+ await fs.writeFile(path.join(certifiDir, 'cacert.pem'), STALE)
299
+ await fs.chmod(certifiDir, 0o500)
300
+ t.after(() => fs.chmod(certifiDir, 0o700).catch(() => {}))
301
+
302
+ const shell = new Shell(kernel)
303
+ shell.platform = 'linux'
304
+ shell.shell = 'bash'
305
+ shell.env = {}
306
+ shell.ondata = () => {}
307
+ shell.conda_hook = () => 'conda hook'
308
+ shell.condaRuntimeGuard = null
309
+ await assert.rejects(() => shell.activate({ path: app, venv: 'env', message: ['true'] }), /could not replace pip's certificate file/)
310
+ await fs.chmod(certifiDir, 0o700)
311
+ })
312
+
313
+ function escapeForHtml(value) {
314
+ return require('escape-html')(value)
315
+ }
@@ -471,6 +471,65 @@ test('Conda replacement stops a running managed Caddy without restoring it', asy
471
471
  assert.equal(caddyRunning, false)
472
472
  })
473
473
 
474
+ test('Conda replacement waits for Anywhere shutdown before deleting either runtime', async t => {
475
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-conda-anywhere-stop-'))
476
+ t.after(() => fs.rm(root, { recursive: true, force: true }))
477
+ const miniforge = await createMiniforge(root)
478
+ const miniconda = await createLegacyMiniconda(root)
479
+ const kernel = createKernel(root, 'win32')
480
+ let finishStop
481
+ let enteredStop
482
+ const stopping = new Promise(resolve => { enteredStop = resolve })
483
+ const stopped = new Promise(resolve => { finishStop = resolve })
484
+ kernel.anywhere = { stop: async () => { enteredStop(); await stopped } }
485
+ kernel.bin.mods = []
486
+ kernel.bin.download = async () => {}
487
+ kernel.bin.rm = async () => {}
488
+ let bootstrapped = false
489
+ kernel.bin.exec = async payload => {
490
+ if (payload.conda?.skip) {
491
+ bootstrapped = true
492
+ assert.equal(await pathExists(miniforge), false)
493
+ assert.equal(await pathExists(miniconda), false)
494
+ await fs.mkdir(path.join(miniforge, 'conda-meta'), { recursive: true })
495
+ await fs.writeFile(path.join(miniforge, 'python.exe'), 'fake python\n')
496
+ }
497
+ }
498
+
499
+ const conda = createConda(kernel)
500
+ const installing = conda._install({ dependencies: [] }, () => {})
501
+ try {
502
+ await stopping
503
+ await new Promise(setImmediate)
504
+ assert.equal(await pathExists(path.join(miniforge, 'Scripts/conda.exe')), true)
505
+ assert.equal(await pathExists(path.join(miniconda, 'Scripts/conda.exe')), true)
506
+ assert.equal(bootstrapped, false)
507
+ } finally {
508
+ finishStop()
509
+ await installing
510
+ }
511
+ assert.equal(bootstrapped, true)
512
+ })
513
+
514
+ test('Conda replacement preserves both runtimes when Anywhere shutdown fails', async t => {
515
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-conda-anywhere-failure-'))
516
+ t.after(() => fs.rm(root, { recursive: true, force: true }))
517
+ const miniforge = await createMiniforge(root)
518
+ const miniconda = await createLegacyMiniconda(root)
519
+ const kernel = createKernel(root, 'win32')
520
+ const failure = new Error('tailscaled did not stop')
521
+ kernel.anywhere = { stop: async () => { throw failure } }
522
+ kernel.bin.download = async () => {}
523
+ kernel.bin.exec = async () => assert.fail('installer must not run after shutdown failure')
524
+ const conda = createConda(kernel)
525
+ conda.removeInstallPath = async () => assert.fail('runtime deletion must not start after shutdown failure')
526
+
527
+ await assert.rejects(conda._install({ dependencies: [] }, () => {}), error => error === failure)
528
+
529
+ assert.equal(await fs.readFile(path.join(miniforge, 'Scripts/conda.exe'), 'utf8'), 'fake conda\n')
530
+ assert.equal(await fs.readFile(path.join(miniconda, 'Scripts/conda.exe'), 'utf8'), 'fake conda\n')
531
+ })
532
+
474
533
  test('Conda install replaces legacy miniconda with a compatibility alias to miniforge', async () => {
475
534
  const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-conda-miniforge-alias-'))
476
535
  const legacy = await createLegacyMiniconda(root)