pinokiod 8.2.2 → 8.2.4

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,147 @@
1
+ /*
2
+ Minimal client for the tailscaled LocalAPI.
3
+
4
+ tailscaled exposes an HTTP API over a unix socket (mac/linux) or a named
5
+ pipe (Windows). We talk to it with plain node `http` by passing the socket
6
+ path as `socketPath`; node's net layer handles both unix sockets and
7
+ `\\.\pipe\...` names. The conda-forge `tailscaled` package ships only the
8
+ daemon (no `tailscale` CLI), so every operation goes through this client.
9
+
10
+ Verified against tailscaled 1.104.1 (see spec/requirements/pinokio-anywhere.md):
11
+ GET /localapi/v0/status -> ipnstate.Status JSON
12
+ GET /localapi/v0/prefs -> ipn.Prefs JSON
13
+ PATCH /localapi/v0/prefs <- ipn.MaskedPrefs ({HostnameSet:true, Hostname:"pinokio", ...})
14
+ POST /localapi/v0/login-interactive -> 204; AuthURL shows up in /status and on the IPN bus (BrowseToURL)
15
+ POST /localapi/v0/logout -> 204
16
+ GET /localapi/v0/serve-config -> ipn.ServeConfig JSON (or null) + Etag header
17
+ POST /localapi/v0/serve-config <- ipn.ServeConfig JSON, optional If-Match: <etag>
18
+ POST /localapi/v0/query-feature?feature=serve -> {Complete, Text, URL} (URL = one-click tailnet approval)
19
+ */
20
+ const http = require('http')
21
+
22
+ class LocalAPIError extends Error {
23
+ constructor(message, status, body) {
24
+ super(message)
25
+ this.name = 'LocalAPIError'
26
+ this.status = status
27
+ this.body = body
28
+ }
29
+ }
30
+
31
+ class LocalAPI {
32
+ constructor({ socketPath, timeout = 10000 } = {}) {
33
+ if (!socketPath) {
34
+ throw new Error('LocalAPI requires a socketPath')
35
+ }
36
+ this.socketPath = socketPath
37
+ this.timeout = timeout
38
+ this.base = '/localapi/v0'
39
+ }
40
+ request(method, route, { body, headers, timeout } = {}) {
41
+ const payload = typeof body === 'undefined' || body === null
42
+ ? null
43
+ : (typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body))
44
+ const reqHeaders = Object.assign({
45
+ // tailscaled rejects non-loopback origins without this on some
46
+ // transports (the Windows GUI loopback port); harmless on sockets.
47
+ 'Sec-Tailscale': 'localapi',
48
+ 'Host': 'local-tailscaled.sock',
49
+ }, headers || {})
50
+ if (payload !== null) {
51
+ reqHeaders['Content-Type'] = reqHeaders['Content-Type'] || 'application/json'
52
+ reqHeaders['Content-Length'] = Buffer.byteLength(payload)
53
+ }
54
+ return new Promise((resolve, reject) => {
55
+ const req = http.request({
56
+ socketPath: this.socketPath,
57
+ method,
58
+ path: this.base + route,
59
+ headers: reqHeaders,
60
+ }, (res) => {
61
+ const chunks = []
62
+ res.on('data', (chunk) => chunks.push(chunk))
63
+ res.on('end', () => {
64
+ const text = Buffer.concat(chunks).toString('utf8')
65
+ resolve({
66
+ status: res.statusCode,
67
+ headers: res.headers,
68
+ text,
69
+ json: () => {
70
+ if (!text || !text.trim()) return null
71
+ return JSON.parse(text)
72
+ },
73
+ })
74
+ })
75
+ res.on('error', reject)
76
+ })
77
+ req.setTimeout(timeout || this.timeout, () => {
78
+ req.destroy(new Error(`LocalAPI ${method} ${route} timed out`))
79
+ })
80
+ req.on('error', reject)
81
+ if (payload !== null) {
82
+ req.write(payload)
83
+ }
84
+ req.end()
85
+ })
86
+ }
87
+ async expect(method, route, options, okStatuses = [200, 204]) {
88
+ const res = await this.request(method, route, options)
89
+ if (!okStatuses.includes(res.status)) {
90
+ throw new LocalAPIError(`LocalAPI ${method} ${route} failed: ${res.status} ${res.text.trim()}`, res.status, res.text)
91
+ }
92
+ return res
93
+ }
94
+ async status() {
95
+ const res = await this.expect('GET', '/status')
96
+ return res.json()
97
+ }
98
+ // masked: an ipn.MaskedPrefs object, e.g. { HostnameSet: true, Hostname: 'pinokio' }
99
+ async patchPrefs(masked) {
100
+ const res = await this.expect('PATCH', '/prefs', { body: masked })
101
+ return res.json()
102
+ }
103
+ async loginInteractive() {
104
+ await this.expect('POST', '/login-interactive', { body: '' })
105
+ return true
106
+ }
107
+ async logout() {
108
+ await this.expect('POST', '/logout', { body: '' })
109
+ return true
110
+ }
111
+ // Restarts the backend's login state machine (what `tailscale up --force-reauth`
112
+ // does): a stale or invalidated login URL is dropped and a fresh one is minted
113
+ // on the next login-interactive.
114
+ async start(options = {}) {
115
+ await this.expect('POST', '/start', { body: options })
116
+ return true
117
+ }
118
+ async serveConfig() {
119
+ const res = await this.expect('GET', '/serve-config')
120
+ return { config: res.json(), etag: res.headers.etag || res.headers.Etag || null }
121
+ }
122
+ async setServeConfig(config, etag) {
123
+ const headers = {}
124
+ if (etag) {
125
+ headers['If-Match'] = etag
126
+ }
127
+ await this.expect('POST', '/serve-config', { body: config || {}, headers })
128
+ return true
129
+ }
130
+ async queryFeature(feature) {
131
+ const res = await this.request('POST', `/query-feature?feature=${encodeURIComponent(feature)}`, { body: '' })
132
+ if (res.status !== 200) {
133
+ throw new LocalAPIError(`query-feature ${feature} failed: ${res.status} ${res.text.trim()}`, res.status, res.text)
134
+ }
135
+ return res.json()
136
+ }
137
+ async ping() {
138
+ try {
139
+ const res = await this.request('GET', '/status', { timeout: 2000 })
140
+ return res.status === 200
141
+ } catch (e) {
142
+ return false
143
+ }
144
+ }
145
+ }
146
+
147
+ module.exports = { LocalAPI, LocalAPIError }
@@ -0,0 +1,51 @@
1
+ /*
2
+ Stable remote ports for Pinokio Anywhere.
3
+
4
+ An app may bind a different local port from one launch to the next, but its
5
+ Tailscale address must not move: bookmarks, home-screen icons and links all
6
+ point at https://<node>.<tailnet>.ts.net:<port>. So every app is given a
7
+ remote port once, kept in ~/pinokio/anywhere/ports.json, and Serve maps that
8
+ fixed port to whatever local port the app has right now.
9
+
10
+ Remote ports live inside tailscaled's own network stack, so they never need
11
+ to be free on the machine itself.
12
+ */
13
+ const fs = require('fs')
14
+ const path = require('path')
15
+
16
+ const FIRST_PORT = 43000
17
+ const LAST_PORT = 43999
18
+
19
+ async function loadPortMap(file) {
20
+ try {
21
+ const map = JSON.parse(await fs.promises.readFile(file, 'utf8'))
22
+ return map && typeof map === 'object' && !Array.isArray(map) ? map : {}
23
+ } catch (_) {
24
+ return {}
25
+ }
26
+ }
27
+
28
+ async function savePortMap(file, map) {
29
+ await fs.promises.mkdir(path.dirname(file), { recursive: true })
30
+ await fs.promises.writeFile(file, JSON.stringify(map, null, 2))
31
+ }
32
+
33
+ // Returns a copy of `map` with a port for every key, giving new keys the
34
+ // lowest port not already taken. Existing assignments are never changed.
35
+ function assignPorts(map, keys) {
36
+ const out = Object.assign({}, map)
37
+ const used = new Set(Object.values(out).map(Number))
38
+ let next = FIRST_PORT
39
+ let changed = false
40
+ for (const key of keys) {
41
+ if (Number.isInteger(out[key])) continue
42
+ while (used.has(next)) next += 1
43
+ if (next > LAST_PORT) throw new Error('Pinokio Anywhere has no free remote ports left')
44
+ out[key] = next
45
+ used.add(next)
46
+ changed = true
47
+ }
48
+ return { map: out, changed }
49
+ }
50
+
51
+ module.exports = { loadPortMap, savePortMap, assignPorts }
@@ -0,0 +1,95 @@
1
+ /*
2
+ Builds the ipn.ServeConfig that tailscaled needs so pinokiod and each running
3
+ Pinokio app are reachable over HTTPS at a fixed address.
4
+
5
+ MagicDNS has no subdomains, so routes are per port. The remote port is the
6
+ app's stable port (see ports.js), the target is whatever local port the app
7
+ has right now:
8
+
9
+ https://pinokio.<tailnet>.ts.net -> http://127.0.0.1:<pinokiod port>
10
+ https://pinokio.<tailnet>.ts.net:43000 -> http://127.0.0.1:7860 (comfy)
11
+ https://pinokio.<tailnet>.ts.net:43001 -> http://127.0.0.1:8188 (forge)
12
+
13
+ Serve-only: no AllowFunnel entries are ever emitted, so nothing is public.
14
+ */
15
+
16
+ const DEFAULT_HTTPS_PORT = 443
17
+
18
+ function normalizePort(value) {
19
+ const port = Number(value)
20
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
21
+ return null
22
+ }
23
+ return port
24
+ }
25
+
26
+ /*
27
+ options:
28
+ dnsName "pinokio.tail1234.ts.net" (no trailing dot)
29
+ serverPort pinokiod's own port (mapped to :443)
30
+ targets [{ listen, target }]: remote port -> local port
31
+ */
32
+ function buildServeConfig({ dnsName, serverPort, targets = [] } = {}) {
33
+ const host = String(dnsName || '').replace(/\.$/, '')
34
+ if (!host) {
35
+ throw new Error('buildServeConfig requires dnsName')
36
+ }
37
+ const config = { TCP: {}, Web: {} }
38
+ const add = (listenPort, targetPort) => {
39
+ config.TCP[String(listenPort)] = { HTTPS: true }
40
+ config.Web[`${host}:${listenPort}`] = {
41
+ Handlers: {
42
+ '/': { Proxy: `http://127.0.0.1:${targetPort}` }
43
+ }
44
+ }
45
+ }
46
+ const server = normalizePort(serverPort)
47
+ if (server) {
48
+ add(DEFAULT_HTTPS_PORT, server)
49
+ }
50
+ const seen = new Set([DEFAULT_HTTPS_PORT])
51
+ const entries = []
52
+ for (const raw of targets) {
53
+ const listen = normalizePort(raw && raw.listen)
54
+ const target = normalizePort(raw && raw.target)
55
+ if (!listen || !target || seen.has(listen)) continue
56
+ seen.add(listen)
57
+ entries.push({ listen, target })
58
+ }
59
+ // deterministic key order so unchanged mappings compare equal
60
+ for (const entry of entries.sort((a, b) => a.listen - b.listen)) {
61
+ add(entry.listen, entry.target)
62
+ }
63
+ return config
64
+ }
65
+
66
+ // Lists what a serve config exposes: [{ port, local, target, url, name }]
67
+ // `names` maps a remote port to the app name it was assigned to.
68
+ function describeServeConfig(config, dnsName, names = {}) {
69
+ const host = String(dnsName || '').replace(/\.$/, '')
70
+ const out = []
71
+ if (!config || !config.Web) return out
72
+ for (const key of Object.keys(config.Web)) {
73
+ const match = key.match(/^(.*):(\d+)$/)
74
+ if (!match) continue
75
+ const port = Number(match[2])
76
+ const handlers = config.Web[key].Handlers || {}
77
+ const root = handlers['/'] || {}
78
+ const local = root.Proxy ? Number((String(root.Proxy).match(/:(\d+)$/) || [])[1]) || null : null
79
+ out.push({
80
+ port,
81
+ local,
82
+ target: root.Proxy || null,
83
+ url: port === DEFAULT_HTTPS_PORT ? `https://${host || match[1]}` : `https://${host || match[1]}:${port}`,
84
+ name: names[port] || null,
85
+ })
86
+ }
87
+ out.sort((a, b) => a.port - b.port)
88
+ return out
89
+ }
90
+
91
+ function sameServeConfig(a, b) {
92
+ return JSON.stringify(a || null) === JSON.stringify(b || null)
93
+ }
94
+
95
+ module.exports = { buildServeConfig, describeServeConfig, sameServeConfig, DEFAULT_HTTPS_PORT }
package/kernel/bin/bun.js CHANGED
@@ -1,7 +1,7 @@
1
1
  const semver = require("semver")
2
2
  const Util = require("../util")
3
3
 
4
- const BUN_VERSION = "1.3.11"
4
+ const BUN_VERSION = "1.4.4"
5
5
 
6
6
  class Bun {
7
7
  description = "Installs Bun, a fast JavaScript runtime and package manager."
@@ -2,6 +2,8 @@ const fs = require('fs')
2
2
  const path = require('path')
3
3
  const { execFile } = require('child_process')
4
4
  const { managedPythonEnv } = require('../python_env')
5
+ const semver = require('semver')
6
+ const { PIP_MIN_VERSION } = require('./conda-pins')
5
7
 
6
8
  async function buildCondaListFromMeta(minicondaPath, useCondaList) {
7
9
  if (!useCondaList) {
@@ -108,7 +110,20 @@ function resolveCondaBinary(minicondaPath, platform = process.platform, allowFal
108
110
  return allowFallback ? 'conda' : null
109
111
  }
110
112
 
113
+ async function hasSecureManagedPip(prefix, platform = process.platform) {
114
+ const python = path.join(prefix, platform === 'win32' ? 'python.exe' : 'bin/python')
115
+ return await new Promise(resolve => {
116
+ execFile(python, ['-I', '-c', 'from importlib.metadata import version; print(version("pip"))'], {
117
+ env: managedPythonEnv(), windowsHide: true, timeout: 15000,
118
+ }, (error, stdout) => {
119
+ const version = !error && semver.valid(String(stdout).trim())
120
+ resolve(!!version && semver.gte(version, PIP_MIN_VERSION))
121
+ })
122
+ })
123
+ }
124
+
111
125
  module.exports = {
112
126
  buildCondaListFromMeta,
113
127
  managedCondaRuns,
128
+ hasSecureManagedPip,
114
129
  }
@@ -1,6 +1,22 @@
1
1
  const semver = require('semver')
2
2
 
3
3
  const CONDA_PIN_VERSION = "26.5.3"
4
+ // Conda 26.9.4 has no Python 3.10 builds. Apply its CA-bundle requirements
5
+ // explicitly while retaining the managed Python version required by apps.
6
+ const TLS_PACKAGE_MINIMUMS = {
7
+ 'ca-certificates': '2026.10.19',
8
+ certifi: '2026.10.19',
9
+ }
10
+ // conda-forge's patched pip requires Python 3.11; this official wheel supports 3.10.
11
+ // Bootstrap URL and hash: https://discuss.python.org/t/109576
12
+ const PIP_MIN_VERSION = '26.2.2'
13
+ const PIP_INSTALL_SPEC = 'pip @ https://files.pythonhosted.org/packages/3f/06/3e4420302363fbb71be3c7f35ce042f54bfca330e3e66b3a2e3c4cca579f/pip-26.2.2-py3-none-any.whl#sha256=fd7f4dfa8190e014013b9f08f3e973f4e4c26ffd179a204895533a7254813a70'
14
+ const PIP_INSTALL_COMMAND = `python -I -m pip --isolated --disable-pip-version-check install --no-index --no-deps "${PIP_INSTALL_SPEC}"`
15
+ const TLS_PACKAGE_SPECS = Object.entries(TLS_PACKAGE_MINIMUMS).map(([name, version]) => `${name}>=${version}`)
16
+ const hasSecureTlsPackages = (versions = {}) => Object.entries(TLS_PACKAGE_MINIMUMS).every(([name, minimum]) => {
17
+ const version = semver.coerce(versions[name])
18
+ return version && semver.gte(version, minimum)
19
+ })
4
20
  const PYTHON_PIN_VERSION = "3.10.20"
5
21
  const PYTHON_INSTALL_SPEC = `python=${PYTHON_PIN_VERSION}`
6
22
  const WINDOWS_PYTHON_SSL_FIX_SPEC = `${PYTHON_INSTALL_SPEC}=*_1_cpython`
@@ -30,6 +46,12 @@ const isExpectedPythonPinned = (platform, version, build) => {
30
46
 
31
47
  module.exports = {
32
48
  CONDA_PIN_VERSION,
49
+ TLS_PACKAGE_MINIMUMS,
50
+ PIP_MIN_VERSION,
51
+ PIP_INSTALL_SPEC,
52
+ PIP_INSTALL_COMMAND,
53
+ TLS_PACKAGE_SPECS,
54
+ hasSecureTlsPackages,
33
55
  PYTHON_INSTALL_SPEC,
34
56
  WINDOWS_PYTHON_SSL_FIX_SPEC,
35
57
  isExpectedCondaPinned,
@@ -1,18 +1,37 @@
1
1
  const fs = require('fs')
2
2
  const { glob } = require('glob')
3
3
  const semver = require('semver')
4
+ const { createHash } = require('crypto')
5
+ const { execFile } = require('child_process')
6
+ const { managedPythonEnv } = require('../python_env')
4
7
  const { isPathRemovalBlockedError, removePath } = require('../path_removal')
5
- const { buildCondaListFromMeta, managedCondaRuns } = require('./conda-meta')
8
+ const { buildCondaListFromMeta, managedCondaRuns, hasSecureManagedPip } = require('./conda-meta')
6
9
  const {
7
10
  CONDA_PIN_VERSION,
11
+ PIP_INSTALL_COMMAND,
12
+ TLS_PACKAGE_MINIMUMS,
13
+ TLS_PACKAGE_SPECS,
14
+ hasSecureTlsPackages,
8
15
  PYTHON_INSTALL_SPEC,
9
16
  WINDOWS_PYTHON_SSL_FIX_SPEC,
10
17
  isExpectedCondaPinned,
11
18
  isExpectedPythonPinned,
12
19
  } = require('./conda-pins')
13
20
 
14
- const MINIFORGE_RELEASE = "26.3.2-3"
21
+ const MINIFORGE_RELEASE = "26.9.4-0"
15
22
  const MINIFORGE_BASE_URL = `https://github.com/conda-forge/miniforge/releases/download/${MINIFORGE_RELEASE}`
23
+ // GitHub's published SHA-256 asset digests for Miniforge 26.9.4-0.
24
+ const MINIFORGE_SHA256 = {
25
+ darwin: {
26
+ x64: '4bf8d162f90a9ae79fbe1c3b152a9957b6cf4e7385758fa21e0340229d444df1',
27
+ arm64: '85512f5495465644e09655d84bc1df79d978353abd9ef0d0a8f3df75da44c760',
28
+ },
29
+ linux: {
30
+ x64: '621428f40496a90702753b09237794518a081d9979d9f845a525de761b6056f7',
31
+ arm64: 'd265b10be348403a3bb8d475ff989beba48db9baa5fe7ee28eb38213538056bb',
32
+ },
33
+ win32: { x64: '43638698f5ee811debae27b0dc3ab5c0300129bd400a847376c4bd2f6be15ceb' },
34
+ }
16
35
  const CONDA_ROOT_DIR = "miniforge"
17
36
  const LEGACY_CONDA_ROOT_DIR = "miniconda"
18
37
 
@@ -44,11 +63,22 @@ class Conda {
44
63
  pinnedPackages() {
45
64
  return [
46
65
  `conda ==${CONDA_PIN_VERSION}`,
66
+ ...TLS_PACKAGE_SPECS,
47
67
  ].join("\n")
48
68
  }
49
69
  async hasCondaMeta() {
50
70
  return await this.kernel.exists(`bin/${CONDA_ROOT_DIR}/conda-meta`)
51
71
  }
72
+ async verifyInstaller(installer) {
73
+ const expected = MINIFORGE_SHA256[this.kernel.platform]?.[this.kernel.arch]
74
+ const hash = createHash('sha256')
75
+ for await (const chunk of fs.createReadStream(this.kernel.bin.path(installer))) {
76
+ hash.update(chunk)
77
+ }
78
+ if (!expected || hash.digest('hex') !== expected) {
79
+ throw new Error('Miniforge installer SHA-256 verification failed. The installer was not run and existing environments were preserved.')
80
+ }
81
+ }
52
82
  env() {
53
83
  let base = {
54
84
  CONDA_PREFIX: this.kernel.bin.path(CONDA_ROOT_DIR),
@@ -78,6 +108,7 @@ class Conda {
78
108
  channel_priority: flexible
79
109
  create_default_packages:
80
110
  - ${PYTHON_INSTALL_SPEC}
111
+ ${TLS_PACKAGE_SPECS.map(spec => ` - ${spec}`).join("\n")}
81
112
  envs_dirs:
82
113
  - ${this.kernel.bin.path(`${CONDA_ROOT_DIR}/envs`)}
83
114
  pkgs_dirs:
@@ -138,12 +169,37 @@ report_errors: false`)
138
169
  this.kernel.bin.installed.conda = conda
139
170
  this.kernel.bin.installed.conda_versions = conda_versions
140
171
  this.kernel.bin.installed.conda_builds = conda_builds
141
- if (!(conda_check.conda && conda_check.mamba && conda_check.python)) {
172
+ if (!(conda_check.conda && conda_check.mamba && conda_check.python && hasSecureTlsPackages(conda_versions))) {
142
173
  return false
143
174
  }
144
- return await managedCondaRuns(this.kernel.bin.path(CONDA_ROOT_DIR), this.kernel.platform)
175
+ return await hasSecureManagedPip(this.kernel.bin.path(CONDA_ROOT_DIR), this.kernel.platform) && await managedCondaRuns(this.kernel.bin.path(CONDA_ROOT_DIR), this.kernel.platform)
145
176
  }
146
177
  async install(req, ondata) {
178
+ await this.check()
179
+ const { conda_versions: versions, conda_builds: builds } = this.kernel.bin.installed
180
+ if (isExpectedCondaPinned(versions.conda) &&
181
+ isExpectedPythonPinned(this.kernel.platform, versions.python, builds.python) &&
182
+ await managedCondaRuns(this.kernel.bin.path(CONDA_ROOT_DIR), this.kernel.platform)) {
183
+ // Refresh a working runtime in place, preserving named app environments.
184
+ await this.init()
185
+ const bootstrap = await this.kernel.bin.exec({
186
+ message: [PIP_INSTALL_COMMAND, `python -I -m pip --isolated install --no-deps --only-binary=:all: --index-url https://pypi.org/simple "certifi>=${TLS_PACKAGE_MINIMUMS.certifi}"`],
187
+ }, ondata)
188
+ if (bootstrap?.error?.length) throw new Error("Conda certificate bootstrap failed; existing environments were preserved.")
189
+ const python = this.kernel.bin.path(CONDA_ROOT_DIR, this.kernel.platform === 'win32' ? 'python.exe' : 'bin/python')
190
+ const output = await new Promise((resolve, reject) => execFile(python,
191
+ ['-I', '-c', 'import certifi; from importlib.metadata import version; print(certifi.where()); print(version("certifi"))'],
192
+ { env: managedPythonEnv(), windowsHide: true, timeout: 15000 },
193
+ (error, stdout) => error ? reject(error) : resolve(stdout.trim())))
194
+ const [caFile, certifiVersion] = output.split(/[\r\n]+/)
195
+ if (!semver.valid(certifiVersion) || !semver.gte(certifiVersion, TLS_PACKAGE_MINIMUMS.certifi)) throw new Error("The certificate bootstrap did not complete.")
196
+ const result = await this.kernel.bin.exec({
197
+ message: [`conda install -y --override-channels -c conda-forge "${this.kernel.platform === "win32" ? WINDOWS_PYTHON_SSL_FIX_SPEC : PYTHON_INSTALL_SPEC}" "conda-libmamba-solver>=25.4.0" pip ${TLS_PACKAGE_SPECS.map(spec => `"${spec}"`).join(" ")}`, PIP_INSTALL_COMMAND],
198
+ env: { CONDA_SSL_VERIFY: caFile },
199
+ }, ondata)
200
+ if (result?.error?.length || !(await this.check())) throw new Error("Conda certificate update failed; existing environments were preserved.")
201
+ return
202
+ }
147
203
  for(let i=0; i<5; i++) {
148
204
  await this._install(req, ondata)
149
205
  let installed = await this.check()
@@ -170,6 +226,7 @@ report_errors: false`)
170
226
 
171
227
  ondata({ raw: `downloading installer: ${installer_url}...\r\n` })
172
228
  await this.kernel.bin.download(installer_url, installer, ondata)
229
+ await this.verifyInstaller(installer)
173
230
 
174
231
  const caddy = this.kernel.bin.mod && this.kernel.bin.mod.caddy
175
232
  if (caddy && typeof caddy.running === "function" && await caddy.running()) {
@@ -227,13 +284,16 @@ report_errors: false`)
227
284
  `"conda=${CONDA_PIN_VERSION}"`,
228
285
  this.kernel.platform === "win32" ? `"${WINDOWS_PYTHON_SSL_FIX_SPEC}"` : `"${PYTHON_INSTALL_SPEC}"`,
229
286
  `"conda-libmamba-solver>=25.4.0"`,
287
+ "pip",
288
+ ...TLS_PACKAGE_SPECS.map(spec => `"${spec}"`),
230
289
  ]
231
290
 
232
291
  let cmds = [
233
292
  "conda clean -y --all",
234
293
  `conda install -y --override-channels -c conda-forge ${condaPackages.join(" ")} ${mods}`.trim(),
294
+ PIP_INSTALL_COMMAND,
235
295
  ]
236
- await this.kernel.bin.exec({
296
+ const result = await this.kernel.bin.exec({
237
297
  message: cmds,
238
298
  env: {
239
299
  PIP_REQUIRE_VIRTUALENV: "false"
@@ -241,6 +301,7 @@ report_errors: false`)
241
301
  }, (stream) => {
242
302
  ondata(stream)
243
303
  })
304
+ if (result?.error?.length) throw new Error("Conda package or pip installation failed.")
244
305
  if (this.kernel.platform === "win32") {
245
306
  // copy python.exe to python3.exe so you can run with both python3 and python
246
307
  await fs.promises.copyFile(
@@ -21,10 +21,11 @@ const LLVM = require('./llvm')
21
21
  const VS = require("./vs")
22
22
  const Cuda = require("./cuda")
23
23
  const Torch = require("./torch")
24
- const { buildCondaListFromMeta, managedCondaRuns } = require('./conda-meta')
24
+ const { buildCondaListFromMeta, managedCondaRuns, hasSecureManagedPip } = require('./conda-meta')
25
25
  const {
26
26
  isExpectedCondaPinned,
27
27
  isExpectedPythonPinned,
28
+ hasSecureTlsPackages,
28
29
  } = require('./conda-pins')
29
30
  const { glob } = require('glob')
30
31
  const fakeUa = require('fake-useragent');
@@ -412,8 +413,8 @@ class Bin {
412
413
  }
413
414
  }
414
415
 
415
- if (conda_check.conda && conda_check.mamba && conda_check.python) {
416
- this.correct_conda = await managedCondaRuns(this.kernel.bin.path("miniforge"), this.platform)
416
+ if (conda_check.conda && conda_check.mamba && conda_check.python && hasSecureTlsPackages(conda_versions)) {
417
+ this.correct_conda = await hasSecureManagedPip(this.kernel.bin.path("miniforge"), this.platform) && await managedCondaRuns(this.kernel.bin.path("miniforge"), this.platform)
417
418
  }
418
419
  }
419
420
  this.installed.conda = conda
@@ -2,7 +2,7 @@ const fs = require('fs')
2
2
  const fetch = require('cross-fetch')
3
3
  const { rimraf } = require('rimraf')
4
4
  const decompress = require('decompress');
5
- const NODE_VERSION = "24.18.0"
5
+ const NODE_VERSION = "24.22.1"
6
6
  const PNPM_VERSION = "11.9.0"
7
7
 
8
8
  const normalizeVersion = (version) => {
@@ -270,6 +270,35 @@ module.exports = {
270
270
  ]
271
271
  }
272
272
  },
273
+ anywhere: (kernel) => {
274
+ let requirements = [
275
+ { name: "conda", },
276
+ { name: "zip", },
277
+ ]
278
+ if (platform === "darwin") {
279
+ requirements.push({ name: "brew" })
280
+ }
281
+ requirements = requirements.concat([
282
+ { name: "git", },
283
+ { name: "uv", },
284
+ { name: "node", },
285
+ { name: "tailscaled", },
286
+ { name: "py" },
287
+ { name: "bluefairy" },
288
+ ])
289
+ return {
290
+ icon: "fa-solid fa-mobile-screen",
291
+ title: "Pinokio Anywhere",
292
+ description: "Reach this Pinokio and its apps from your phone or another computer over a private Tailscale network",
293
+ requirements,
294
+ conda_requirements: [
295
+ zip_cmd,
296
+ "uv",
297
+ "git",
298
+ "tailscaled"
299
+ ]
300
+ }
301
+ },
273
302
  connect: (kernel) => {
274
303
  let requirements = [
275
304
  { name: "conda", },
@@ -0,0 +1,72 @@
1
+ const path = require('path')
2
+ const semver = require('semver')
3
+
4
+ // conda-forge ships only the daemon binary (no `tailscale` CLI); Pinokio
5
+ // drives it over its LocalAPI (see kernel/anywhere).
6
+ const TAILSCALED_SPEC = "tailscaled=1.104.2"
7
+ const TAILSCALED_REQUIREMENT = ">=1.104.2" // Blocks the compromised eMudhra intermediate keys.
8
+
9
+ class Tailscaled {
10
+ description = "Installs the Tailscale daemon for Pinokio Anywhere: reach this Pinokio and its apps from your phone or another computer over a private network."
11
+ cmd() {
12
+ return TAILSCALED_SPEC
13
+ }
14
+ binPath() {
15
+ if (this.kernel.platform === "win32") {
16
+ return this.kernel.bin.path("miniforge", "Library", "bin", "tailscaled.exe")
17
+ }
18
+ return this.kernel.bin.path("miniforge", "bin", "tailscaled")
19
+ }
20
+ async install(req, ondata) {
21
+ if (this.kernel.anywhere) await this.kernel.anywhere.stop()
22
+ await this.kernel.bin.exec({
23
+ message: [
24
+ // a stale libmamba index cache reports the package as missing even
25
+ // though `conda search` sees it; clearing just the index fixes that.
26
+ "conda clean -y -i",
27
+ `conda install -y --override-channels -c conda-forge ${this.cmd()}`
28
+ ]
29
+ }, ondata)
30
+ }
31
+ async installed() {
32
+ try {
33
+ const conda = this.kernel.bin.installed && this.kernel.bin.installed.conda
34
+ if (!conda || !conda.has("tailscaled")) {
35
+ return false
36
+ }
37
+ const versions = this.kernel.bin.installed.conda_versions || {}
38
+ const coerced = semver.coerce(versions.tailscaled)
39
+ if (!coerced) {
40
+ return false
41
+ }
42
+ return semver.satisfies(coerced, TAILSCALED_REQUIREMENT)
43
+ } catch (e) {
44
+ console.log("tailscaled installed check error", e)
45
+ return false
46
+ }
47
+ }
48
+ // Called by kernel.bin.init() for every module: resume the daemon when the
49
+ // user had Pinokio Anywhere switched on.
50
+ async start() {
51
+ const anywhere = this.kernel.anywhere
52
+ if (!anywhere || typeof anywhere.autostart !== "function") {
53
+ return
54
+ }
55
+ // not awaited: a slow or broken daemon must not hold up the rest of bin init
56
+ anywhere.autostart().catch((e) => {
57
+ console.log("[anywhere] autostart failed", e && e.message ? e.message : e)
58
+ })
59
+ }
60
+ async uninstall(req, ondata) {
61
+ ondata({ raw: "stopping Pinokio Anywhere\r\n" })
62
+ if (this.kernel.anywhere && typeof this.kernel.anywhere.stop === "function") {
63
+ await this.kernel.anywhere.stop().catch(() => {})
64
+ }
65
+ ondata({ raw: "cleaning up\r\n" })
66
+ await this.kernel.bin.exec({
67
+ message: `conda remove -y tailscaled`,
68
+ }, ondata)
69
+ ondata({ raw: "finished cleaning up\r\n" })
70
+ }
71
+ }
72
+ module.exports = Tailscaled
package/kernel/bin/uv.js CHANGED
@@ -1,4 +1,4 @@
1
- const UV_VERSION = "0.11.23"
1
+ const UV_VERSION = "0.13.4"
2
2
 
3
3
  const normalizeVersion = (version) => {
4
4
  const match = String(version || "").match(/^\d+\.\d+\.\d+/)