pinokiod 8.2.22 → 8.2.23
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.
|
@@ -253,12 +253,17 @@ class Huggingface {
|
|
|
253
253
|
if (this.kernel.envs && result.env.HF_TOKEN_PATH === this.defaultTokenPath()) {
|
|
254
254
|
this.kernel.envs.HF_TOKEN_PATH = result.env.HF_TOKEN_PATH
|
|
255
255
|
}
|
|
256
|
-
return true
|
|
256
|
+
return options.details === true ? { connected: true, invalid: false } : true
|
|
257
257
|
} catch (error) {
|
|
258
|
-
|
|
258
|
+
// Match the pinned hf CLI's authentication errors, not arbitrary command failures.
|
|
259
|
+
const invalid = !error.killed && (
|
|
260
|
+
(error.code === 1 && /^Error: (?:Invalid user token\.[^\r\n]*|Not logged in)\r?$/m.test(stripAnsi(error.stderr))) ||
|
|
261
|
+
(typeof error.path === "string" && error.path === error.tokenPath && ["EISDIR", "ENOTDIR", "ENOTEMPTY"].includes(error.code))
|
|
262
|
+
)
|
|
263
|
+
if (invalid && this.kernel.envs && error.tokenPath === this.defaultTokenPath()) {
|
|
259
264
|
this.kernel.envs.HF_TOKEN_PATH = this.kernel.path("cache", "HF_AUTH", "anonymous")
|
|
260
265
|
}
|
|
261
|
-
return false
|
|
266
|
+
return options.details === true ? { connected: false, invalid } : false
|
|
262
267
|
}
|
|
263
268
|
}
|
|
264
269
|
async profile() {
|
package/kernel/shells.js
CHANGED
|
@@ -47,8 +47,8 @@ class Shells {
|
|
|
47
47
|
}
|
|
48
48
|
*/
|
|
49
49
|
async init() {
|
|
50
|
-
const
|
|
51
|
-
|
|
50
|
+
const hfInvalid = !this.kernel.envs?.HF_TOKEN_PATH &&
|
|
51
|
+
(await this.kernel.connect.connected("huggingface", { timeout: 5000, details: true }))?.invalid === true
|
|
52
52
|
|
|
53
53
|
// iterate through all the envs
|
|
54
54
|
let sh = new Shell(this.kernel)
|
|
@@ -61,7 +61,7 @@ class Shells {
|
|
|
61
61
|
await sh.init_env({
|
|
62
62
|
env: this.kernel.bin.envs({}),
|
|
63
63
|
})
|
|
64
|
-
if (
|
|
64
|
+
if (hfInvalid && sh.env.HF_TOKEN_PATH === this.kernel.path("cache", "HF_AUTH", "token")) {
|
|
65
65
|
sh.env.HF_TOKEN_PATH = this.kernel.path("cache", "HF_AUTH", "anonymous")
|
|
66
66
|
}
|
|
67
67
|
|
package/package.json
CHANGED
|
@@ -133,7 +133,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
133
133
|
<header class='task-shell-header'>
|
|
134
134
|
<div class='task-shell-header-main'>
|
|
135
135
|
<h1 class='task-title'>Remote</h1>
|
|
136
|
-
<p class='task-description'>
|
|
136
|
+
<p class='task-description'>Access this Pinokio and its running apps from another computer, phone, or tablet over a private encrypted network.</p>
|
|
137
137
|
</div>
|
|
138
138
|
<div class='task-header-actions'>
|
|
139
139
|
<div class='anywhere-switch' id='anywhere-switch' data-on='false'>
|
|
@@ -147,7 +147,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
147
147
|
<div class='anywhere-state' data-state='off'>
|
|
148
148
|
<div class='anywhere-card'>
|
|
149
149
|
<h2>Remote access is off</h2>
|
|
150
|
-
<p>Turn it on to
|
|
150
|
+
<p>Turn it on to access this Pinokio from another device. 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>
|
|
153
153
|
</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>Remote access uses <b>Tailscale</b>, a free private network. Sign in with a <b>personal</b> Google, Apple, GitHub or Microsoft account.
|
|
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. Use the same account on every device you want to connect.</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>
|
|
@@ -179,7 +179,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
179
179
|
<div class='anywhere-state' data-state='https'>
|
|
180
180
|
<div class='anywhere-card'>
|
|
181
181
|
<h2>One more click: enable HTTPS</h2>
|
|
182
|
-
<p id='anywhere-approval-text'>Tailscale needs your permission once to issue HTTPS certificates for your network. Apps need HTTPS for things like camera and microphone access
|
|
182
|
+
<p id='anywhere-approval-text'>Tailscale needs your permission once to issue HTTPS certificates for your network. Apps need HTTPS for things like camera and microphone access in your browser.</p>
|
|
183
183
|
<p><a class='anywhere-btn' id='anywhere-approval-link' href='#' target='_blank' rel='noopener'><i class="fa-solid fa-lock"></i> Enable HTTPS</a>
|
|
184
184
|
<a class='anywhere-btn secondary' href='https://login.tailscale.com/admin/dns' target='_blank' rel='noopener'>Open DNS settings</a></p>
|
|
185
185
|
<p class='anywhere-muted'>Waiting for HTTPS to be enabled… (in the admin console: DNS → HTTPS Certificates → Enable)</p>
|
|
@@ -197,19 +197,20 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
197
197
|
<div class='anywhere-state' data-state='ready'>
|
|
198
198
|
<div class='anywhere-grid'>
|
|
199
199
|
<div class='anywhere-card'>
|
|
200
|
-
<h2>Connect
|
|
200
|
+
<h2>Connect another device</h2>
|
|
201
201
|
<div class='anywhere-qr'>
|
|
202
202
|
<img id='anywhere-qr' alt='QR code to connect' src=''>
|
|
203
203
|
<div class='anywhere-address'><a id='anywhere-address' href='#' target='_blank' rel='noopener'></a></div>
|
|
204
204
|
<div class='anywhere-muted'>signed in as <b id='anywhere-account'></b></div>
|
|
205
|
-
<div class='anywhere-waiting' id='anywhere-waiting'><i class="fa-solid fa-circle-notch fa-spin"></i>
|
|
205
|
+
<div class='anywhere-waiting' id='anywhere-waiting'><i class="fa-solid fa-circle-notch fa-spin"></i> Waiting for another device to join…</div>
|
|
206
206
|
</div>
|
|
207
207
|
<h3>Steps</h3>
|
|
208
208
|
<ol class='anywhere-steps'>
|
|
209
|
-
<li>Install
|
|
209
|
+
<li>Install <b>Tailscale</b> on the computer, phone, or tablet you want to connect from.</li>
|
|
210
210
|
<li>Sign in with <b class='anywhere-account-inline'></b> and turn the connection on.</li>
|
|
211
|
-
<li>
|
|
211
|
+
<li>On that device, scan the QR code or open the address above in a browser.</li>
|
|
212
212
|
</ol>
|
|
213
|
+
<p class='anywhere-muted'>Bookmark the address for quick access. On a phone or tablet, you can also use <i>Add to Home Screen</i>.</p>
|
|
213
214
|
</div>
|
|
214
215
|
<div>
|
|
215
216
|
<div class='anywhere-card'>
|
|
@@ -326,12 +327,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
326
327
|
document.getElementById('anywhere-account').textContent = email
|
|
327
328
|
for (const el of document.querySelectorAll('.anywhere-account-inline')) el.textContent = email
|
|
328
329
|
const waiting = document.getElementById('anywhere-waiting')
|
|
329
|
-
|
|
330
|
+
const peers = Array.isArray(current.peers) ? current.peers : []
|
|
331
|
+
if (peers.length > 0) {
|
|
330
332
|
waiting.classList.add('joined')
|
|
331
|
-
waiting.innerHTML = `<i class="fa-solid fa-check"></i>
|
|
333
|
+
waiting.innerHTML = `<i class="fa-solid fa-check"></i> ${peers.some((peer) => peer.online) ? 'Another device is online' : 'No other devices are online'}`
|
|
332
334
|
} else {
|
|
333
335
|
waiting.classList.remove('joined')
|
|
334
|
-
waiting.innerHTML = `<i class="fa-solid fa-circle-notch fa-spin"></i>
|
|
336
|
+
waiting.innerHTML = `<i class="fa-solid fa-circle-notch fa-spin"></i> Waiting for another device to join…`
|
|
335
337
|
}
|
|
336
338
|
const apps = Array.isArray(current.apps) ? current.apps : []
|
|
337
339
|
document.getElementById('anywhere-apps').innerHTML = apps.length === 0
|
|
@@ -346,9 +348,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
346
348
|
</div>`).join('')
|
|
347
349
|
const serve = Array.isArray(current.serve) ? current.serve : []
|
|
348
350
|
document.getElementById('anywhere-ports').innerHTML = serve.length === 0 ? '—' : serve.map((entry) => `${esc(entry.name || (entry.port === 443 ? 'Pinokio' : ''))} <a class='remote' href='${esc(entry.url)}' target='_blank' rel='noopener'>${esc(entry.url)}</a> → ${esc(entry.target)}`).join('<br>')
|
|
349
|
-
const peers = Array.isArray(current.peers) ? current.peers : []
|
|
350
351
|
document.getElementById('anywhere-peers').innerHTML = peers.length === 0
|
|
351
|
-
? `<p class='anywhere-muted'>No other devices yet. Sign in to Tailscale on
|
|
352
|
+
? `<p class='anywhere-muted'>No other devices yet. Sign in to Tailscale on another device with the same account.</p>`
|
|
352
353
|
: peers.map((peer) => `
|
|
353
354
|
<div class='anywhere-row'>
|
|
354
355
|
<span class='anywhere-dot ${peer.online ? 'online' : ''}'></span>
|
|
@@ -368,7 +369,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
368
369
|
const poll = async () => {
|
|
369
370
|
try {
|
|
370
371
|
const response = await fetch('/anywhere/status', { cache: 'no-store' })
|
|
371
|
-
if (!response.ok) throw new Error(`Could not refresh
|
|
372
|
+
if (!response.ok) throw new Error(`Could not refresh Remote status (${response.status})`)
|
|
372
373
|
render(await response.json())
|
|
373
374
|
} catch (e) {
|
|
374
375
|
render(Object.assign({}, current, { phase: 'error', error: e.message }))
|
|
@@ -405,7 +406,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
405
406
|
if (action === 'logout') {
|
|
406
407
|
Swal.fire({
|
|
407
408
|
title: 'Sign out of Tailscale?',
|
|
408
|
-
text: 'Your
|
|
409
|
+
text: 'Your other devices will no longer be able to reach this Pinokio until you sign in again.',
|
|
409
410
|
showCancelButton: true,
|
|
410
411
|
confirmButtonText: 'Sign out'
|
|
411
412
|
}).then((result) => { if (result.isConfirmed) act('logout') })
|
|
@@ -5,6 +5,7 @@ const path = require('node:path')
|
|
|
5
5
|
const test = require('node:test')
|
|
6
6
|
|
|
7
7
|
const HF = require('../kernel/api/hf')
|
|
8
|
+
const Connect = require('../kernel/connect')
|
|
8
9
|
const HuggingfaceConnect = require('../kernel/connect/providers/huggingface')
|
|
9
10
|
const Shell = require('../kernel/shell')
|
|
10
11
|
const Shells = require('../kernel/shells')
|
|
@@ -18,6 +19,31 @@ function createKernel(root) {
|
|
|
18
19
|
}
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
const connectionFailures = [
|
|
23
|
+
{ name: 'revoked token', invalid: true, stderr: 'Error: Invalid user token. The token stored is invalid.\n' },
|
|
24
|
+
{ name: 'missing token', invalid: true, stderr: 'Error: Not logged in\r\n' },
|
|
25
|
+
{ name: 'colored authentication error', invalid: true, stderr: '\x1b[31mError: Invalid user token.\x1b[0m\r\n' },
|
|
26
|
+
{ name: 'unusable CLI path', invalid: false, code: 'ENOTDIR', syscall: 'spawn' },
|
|
27
|
+
{ name: 'timeout', invalid: false, code: null, killed: true },
|
|
28
|
+
{ name: 'timeout after authentication output', invalid: false, killed: true, stderr: 'Error: Invalid user token.\n' },
|
|
29
|
+
{ name: 'network error', invalid: false, stderr: 'Error: Connection refused\n' },
|
|
30
|
+
{ name: 'rate limit', invalid: false, stderr: "Error: You've hit the rate limit for the /whoami-v2 endpoint.\n" },
|
|
31
|
+
{ name: 'server error', invalid: false, stderr: 'Error: 500 Server Error\n' },
|
|
32
|
+
{ name: 'permission error', invalid: false, stderr: 'Error: 403 Forbidden\n' },
|
|
33
|
+
{ name: 'missing CLI', invalid: false, code: 'ENOENT' },
|
|
34
|
+
{ name: 'unrecognized CLI error', invalid: false, stderr: 'Error: Unexpected failure\n' },
|
|
35
|
+
{ name: 'incidental authentication text', invalid: false, stderr: 'Warning: Previous output: Error: Invalid user token.\n' }
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
function connectionError(fixture, tokenPath) {
|
|
39
|
+
return Object.assign(new Error(fixture.name), {
|
|
40
|
+
code: 1,
|
|
41
|
+
stderr: '',
|
|
42
|
+
...fixture,
|
|
43
|
+
tokenPath
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
21
47
|
test('hf.login does not accept an invalid saved token', async () => {
|
|
22
48
|
const context = {
|
|
23
49
|
parentPath: '/pinokio/api/test/login.js',
|
|
@@ -68,11 +94,123 @@ test('whoami updates only the managed shared token path', async () => {
|
|
|
68
94
|
|
|
69
95
|
provider.runHf = async () => {
|
|
70
96
|
const error = new Error('invalid token')
|
|
97
|
+
error.code = 1
|
|
98
|
+
error.stderr = 'Error: Invalid user token. The token stored is invalid.\n'
|
|
71
99
|
error.tokenPath = tokenPath
|
|
72
100
|
throw error
|
|
73
101
|
}
|
|
74
102
|
assert.equal(await provider.connected(), false)
|
|
75
103
|
assert.equal(kernel.envs.HF_TOKEN_PATH, anonymousPath)
|
|
104
|
+
|
|
105
|
+
provider.runHf = async () => {
|
|
106
|
+
throw connectionError(connectionFailures[0], customContext.env.HF_TOKEN_PATH)
|
|
107
|
+
}
|
|
108
|
+
kernel.envs.HF_TOKEN_PATH = tokenPath
|
|
109
|
+
assert.equal(await provider.connected({}, customContext), false)
|
|
110
|
+
assert.equal(kernel.envs.HF_TOKEN_PATH, tokenPath)
|
|
111
|
+
|
|
112
|
+
provider.runHf = async () => ({ env: { HF_TOKEN_PATH: tokenPath } })
|
|
113
|
+
kernel.envs.HF_TOKEN_PATH = anonymousPath
|
|
114
|
+
assert.deepEqual(await provider.connected({ details: true }), { connected: true, invalid: false })
|
|
115
|
+
assert.equal(kernel.envs.HF_TOKEN_PATH, tokenPath)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('only confirmed authentication failures change shared credential selection', async (t) => {
|
|
119
|
+
const root = path.join(os.tmpdir(), 'pinokio-hf-failure-states')
|
|
120
|
+
const tokenPath = path.join(root, 'cache', 'HF_AUTH', 'token')
|
|
121
|
+
const anonymousPath = path.join(root, 'cache', 'HF_AUTH', 'anonymous')
|
|
122
|
+
for (const fixture of connectionFailures) {
|
|
123
|
+
await t.test(fixture.name, async () => {
|
|
124
|
+
for (const initialPath of [tokenPath, anonymousPath]) {
|
|
125
|
+
const kernel = createKernel(root)
|
|
126
|
+
kernel.envs = { HF_TOKEN_PATH: initialPath }
|
|
127
|
+
const provider = new HuggingfaceConnect(kernel, {})
|
|
128
|
+
provider.runHf = async () => { throw connectionError(fixture, tokenPath) }
|
|
129
|
+
|
|
130
|
+
assert.deepEqual(await provider.connected({ details: true }), {
|
|
131
|
+
connected: false,
|
|
132
|
+
invalid: fixture.invalid
|
|
133
|
+
})
|
|
134
|
+
assert.equal(kernel.envs.HF_TOKEN_PATH, fixture.invalid ? anonymousPath : initialPath)
|
|
135
|
+
assert.equal(await provider.connected(), false)
|
|
136
|
+
assert.equal(kernel.envs.HF_TOKEN_PATH, fixture.invalid ? anonymousPath : initialPath)
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('startup propagates only confirmed shared authentication failures to app shells', async (t) => {
|
|
143
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-hf-startup-'))
|
|
144
|
+
const tokenPath = path.join(root, 'cache', 'HF_AUTH', 'token')
|
|
145
|
+
const anonymousPath = path.join(root, 'cache', 'HF_AUTH', 'anonymous')
|
|
146
|
+
await fs.writeFile(path.join(root, 'ENVIRONMENT'), 'TEST_FIXTURE=1\n')
|
|
147
|
+
await fs.mkdir(path.dirname(tokenPath), { recursive: true })
|
|
148
|
+
await fs.writeFile(tokenPath, 'synthetic-token')
|
|
149
|
+
await fs.writeFile(path.join(path.dirname(tokenPath), 'stored_tokens'), 'synthetic-refresh-state')
|
|
150
|
+
try {
|
|
151
|
+
for (const fixture of [{ name: 'valid token', connected: true }, ...connectionFailures]) {
|
|
152
|
+
await t.test(fixture.name, async () => {
|
|
153
|
+
const kernel = createKernel(root)
|
|
154
|
+
kernel.bin = { envs: () => ({}) }
|
|
155
|
+
kernel.bracketedPasteSupport = {}
|
|
156
|
+
kernel.which = () => null
|
|
157
|
+
kernel.connect = new Connect(kernel)
|
|
158
|
+
let checks = 0
|
|
159
|
+
kernel.connect.clients.huggingface.runHf = async () => {
|
|
160
|
+
checks++
|
|
161
|
+
if (fixture.connected) return { env: { HF_TOKEN_PATH: tokenPath } }
|
|
162
|
+
throw connectionError(fixture, tokenPath)
|
|
163
|
+
}
|
|
164
|
+
const shells = new Shells(kernel)
|
|
165
|
+
shells.ensureBracketedPasteSupport = async () => {}
|
|
166
|
+
await shells.init()
|
|
167
|
+
const expectedPath = fixture.invalid ? anonymousPath : tokenPath
|
|
168
|
+
assert.equal(kernel.envs.HF_TOKEN_PATH, expectedPath)
|
|
169
|
+
await shells.init()
|
|
170
|
+
assert.equal(checks, 1)
|
|
171
|
+
assert.equal(kernel.envs.HF_TOKEN_PATH, expectedPath)
|
|
172
|
+
|
|
173
|
+
const appShell = new Shell(kernel)
|
|
174
|
+
await appShell.init_env({ env: {} })
|
|
175
|
+
assert.equal(appShell.env.HF_TOKEN_PATH, expectedPath)
|
|
176
|
+
assert.equal(await fs.readFile(tokenPath, 'utf8'), 'synthetic-token')
|
|
177
|
+
assert.equal(await fs.readFile(path.join(path.dirname(tokenPath), 'stored_tokens'), 'utf8'), 'synthetic-refresh-state')
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
} finally {
|
|
181
|
+
await fs.rm(root, { recursive: true, force: true })
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('a broken CLI executable path does not disable saved credentials at startup', async () => {
|
|
186
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-hf-cli-path-'))
|
|
187
|
+
const tokenPath = path.join(root, 'cache', 'HF_AUTH', 'token')
|
|
188
|
+
try {
|
|
189
|
+
await fs.writeFile(path.join(root, 'ENVIRONMENT'), 'TEST_FIXTURE=1\n')
|
|
190
|
+
await fs.mkdir(path.dirname(tokenPath), { recursive: true })
|
|
191
|
+
await fs.writeFile(tokenPath, 'synthetic-token')
|
|
192
|
+
const brokenCliParent = path.join(root, 'cli-parent')
|
|
193
|
+
await fs.writeFile(brokenCliParent, 'file instead of directory')
|
|
194
|
+
const kernel = createKernel(root)
|
|
195
|
+
kernel.bin = { envs: () => ({}) }
|
|
196
|
+
kernel.bracketedPasteSupport = {}
|
|
197
|
+
kernel.which = () => null
|
|
198
|
+
kernel.connect = new Connect(kernel)
|
|
199
|
+
const provider = kernel.connect.clients.huggingface
|
|
200
|
+
provider.hfPath = () => path.join(brokenCliParent, 'hf')
|
|
201
|
+
assert.deepEqual(await provider.connected({ details: true, timeout: 1000 }), {
|
|
202
|
+
connected: false,
|
|
203
|
+
invalid: false
|
|
204
|
+
})
|
|
205
|
+
const shells = new Shells(kernel)
|
|
206
|
+
shells.ensureBracketedPasteSupport = async () => {}
|
|
207
|
+
await shells.init()
|
|
208
|
+
assert.equal(kernel.envs.HF_TOKEN_PATH, tokenPath)
|
|
209
|
+
assert.equal(await fs.readFile(tokenPath, 'utf8'), 'synthetic-token')
|
|
210
|
+
assert.equal(await fs.readFile(brokenCliParent, 'utf8'), 'file instead of directory')
|
|
211
|
+
} finally {
|
|
212
|
+
await fs.rm(root, { recursive: true, force: true })
|
|
213
|
+
}
|
|
76
214
|
})
|
|
77
215
|
|
|
78
216
|
test('provider login ignores only Pinokio\'s inherited anonymous path', async () => {
|
|
@@ -108,9 +246,11 @@ test('invalid managed tokens are replaced once without overriding apps', async (
|
|
|
108
246
|
kernel.bracketedPasteSupport = {}
|
|
109
247
|
kernel.which = () => null
|
|
110
248
|
kernel.connect = {
|
|
111
|
-
connected: async () => {
|
|
249
|
+
connected: async (provider, options) => {
|
|
250
|
+
assert.equal(provider, 'huggingface')
|
|
251
|
+
assert.deepEqual(options, { timeout: 5000, details: true })
|
|
112
252
|
checks++
|
|
113
|
-
return false
|
|
253
|
+
return { connected: false, invalid: true }
|
|
114
254
|
}
|
|
115
255
|
}
|
|
116
256
|
const shells = new Shells(kernel)
|
package/docs/SECURITY_TODO.md
DELETED
|
@@ -1,126 +0,0 @@
|
|
|
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.
|