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.
- package/Dockerfile +3 -2
- package/kernel/anywhere/index.js +706 -0
- package/kernel/anywhere/localapi.js +147 -0
- package/kernel/anywhere/ports.js +51 -0
- package/kernel/anywhere/serve.js +95 -0
- package/kernel/bin/bun.js +1 -1
- package/kernel/bin/conda-meta.js +15 -0
- package/kernel/bin/conda-pins.js +22 -0
- package/kernel/bin/conda.js +66 -5
- package/kernel/bin/index.js +4 -3
- package/kernel/bin/node.js +1 -1
- package/kernel/bin/setup.js +29 -0
- package/kernel/bin/tailscaled.js +72 -0
- package/kernel/bin/uv.js +1 -1
- package/kernel/index.js +15 -0
- package/kernel/shell.js +8 -1
- package/package.json +1 -1
- package/server/index.js +123 -0
- package/server/public/anywhere.webmanifest +1 -0
- package/server/views/anywhere.ejs +436 -0
- package/server/views/app.ejs +3 -1
- package/server/views/index.ejs +6 -0
- package/server/views/partials/main_sidebar.ejs +39 -0
- package/test/anywhere.test.js +461 -0
- package/test/conda-bin.test.js +126 -8
- package/test/node-bin.test.js +3 -3
- package/test/shell-run-template.test.js +46 -0
- package/test/uv-bin.test.js +5 -5
|
@@ -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
package/kernel/bin/conda-meta.js
CHANGED
|
@@ -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
|
}
|
package/kernel/bin/conda-pins.js
CHANGED
|
@@ -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,
|
package/kernel/bin/conda.js
CHANGED
|
@@ -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.
|
|
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(
|
package/kernel/bin/index.js
CHANGED
|
@@ -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
|
package/kernel/bin/node.js
CHANGED
|
@@ -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.
|
|
5
|
+
const NODE_VERSION = "24.22.1"
|
|
6
6
|
const PNPM_VERSION = "11.9.0"
|
|
7
7
|
|
|
8
8
|
const normalizeVersion = (version) => {
|
package/kernel/bin/setup.js
CHANGED
|
@@ -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