pinokiod 8.2.22 → 8.2.24

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.
@@ -1,6 +1,6 @@
1
1
  const fs = require('fs')
2
2
  const path = require('path')
3
- const { randomUUID } = require('crypto')
3
+ const { randomUUID, createHash } = require('crypto')
4
4
 
5
5
  // The managed CA bundle: the conda-forge ca-certificates package inside Miniforge.
6
6
  // Every app's Python HTTPS traffic is pointed at this one file so a revoked root is
@@ -83,25 +83,39 @@ async function readManagedBundle(file) {
83
83
  async function repairPipVendoredBundle(prefix, bundleFile, platform = process.platform) {
84
84
  const managed = await readManagedBundle(bundleFile).catch(() => null)
85
85
  if (!managed) return []
86
+ const sha256 = data => createHash('sha256').update(data).digest('hex')
86
87
  const repaired = []
87
88
  for (const target of await pipVendoredBundles(prefix, platform)) {
88
89
  const stat = await fs.promises.stat(target).catch(() => null)
89
90
  if (stat && !stat.isFile()) continue
91
+ const current = stat ? await fs.promises.readFile(target).catch(() => null) : null
90
92
  if (!stat) {
91
93
  // No pip here: nothing to do. pip present but its bundle gone (the user deleted a file
92
94
  // Pinokio could not replace): write a fresh one, or pip fails on every network call.
93
95
  const certifiDir = await fs.promises.stat(path.dirname(target)).catch(() => null)
94
96
  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
97
+ } else if (current && current.equals(managed)) {
98
+ console.log(`[cert] pip's vendored CA bundle already current: ${target}`, JSON.stringify({
99
+ timestamp: new Date().toISOString(), size_bytes: current.length, sha256: sha256(current),
100
+ }))
101
+ continue
98
102
  }
99
103
  const temporary = path.join(path.dirname(target), `.cacert-${randomUUID()}.tmp`)
100
104
  try {
101
105
  await fs.promises.writeFile(temporary, managed, { mode: stat ? stat.mode & 0o777 : 0o644 })
102
106
  await fs.promises.rename(temporary, target)
107
+ console.log(`[cert] replaced pip's vendored CA bundle: ${target}`, JSON.stringify({
108
+ timestamp: new Date().toISOString(),
109
+ before_size_bytes: current ? current.length : stat ? stat.size : null,
110
+ after_size_bytes: managed.length,
111
+ before_sha256: current ? sha256(current) : null,
112
+ after_sha256: sha256(managed),
113
+ }))
103
114
  repaired.push(target)
104
115
  } catch (error) {
116
+ console.log(`[cert] could not replace pip's vendored CA bundle: ${target}`, JSON.stringify({
117
+ timestamp: new Date().toISOString(), error: error.message,
118
+ }))
105
119
  // The caller decides what a failed replacement means; it needs to know which file.
106
120
  error.target = target
107
121
  error.prefix = prefix
@@ -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
- if (this.kernel.envs && error.tokenPath === this.defaultTokenPath()) {
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() {
@@ -623,8 +623,8 @@ const init_folders = async (homedir, kernel) => {
623
623
  let is_relative = val.startsWith("./")
624
624
  if (is_absolute || is_relative) {
625
625
 
626
- // skip condarc and pipconfig => special case
627
- if (["PIP_CONFIG_FILE", "CONDARC", "HF_TOKEN_PATH"].includes(key)) {
626
+ // skip values that name a file, not a folder: config files and CA bundles
627
+ if (["PIP_CONFIG_FILE", "CONDARC", "HF_TOKEN_PATH", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "PIP_CERT"].includes(kernel.platform === "win32" ? key.toUpperCase() : key)) {
628
628
  continue
629
629
  }
630
630
  // it's a path
package/kernel/shell.js CHANGED
@@ -979,9 +979,6 @@ class Shell {
979
979
  blocked.cause = error
980
980
  throw blocked
981
981
  }
982
- for (const target of repaired) {
983
- console.log(`[cert] replaced pip's vendored CA bundle: ${target}`)
984
- }
985
982
  return repaired
986
983
  }
987
984
  build (params) {
package/kernel/shells.js CHANGED
@@ -47,8 +47,8 @@ class Shells {
47
47
  }
48
48
  */
49
49
  async init() {
50
- const hfDisconnected = !this.kernel.envs?.HF_TOKEN_PATH &&
51
- !(await this.kernel.connect.connected("huggingface", { timeout: 5000 }))
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 (hfDisconnected && sh.env.HF_TOKEN_PATH === this.kernel.path("cache", "HF_AUTH", "token")) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.2.22",
3
+ "version": "8.2.24",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -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'>Open this Pinokio and its running apps from your phone or another computer, anywhere, over a private encrypted network.</p>
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 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>
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. 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. 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 on your phone.</p>
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 your phone</h2>
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> waiting for a phone to join…</div>
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 the <b>Tailscale</b> app on your phone (App Store / Play Store).</li>
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>Scan the QR code, or open the address above in your phone's browser. Use <i>Add to Home Screen</i> for an app-like icon.</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
- if (current.mobilePeerPresent) {
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> phone ${current.mobilePeerOnline ? 'connected' : 'joined (currently offline)'}`
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> waiting for a phone to join…`
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 your phone with the same account.</p>`
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 Anywhere status (${response.status})`)
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 phone will no longer be able to reach this Pinokio until you sign in again.',
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') })
@@ -310,6 +310,62 @@ test('Shell.activate stops the step when the pip bundle cannot be replaced', { s
310
310
  await fs.chmod(certifiDir, 0o700)
311
311
  })
312
312
 
313
+ test('init_folders never turns a certificate file path into a directory', async t => {
314
+ const Environment = require('../kernel/environment')
315
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'cert-env-folders-'))
316
+ t.after(() => fs.rm(root, { recursive: true, force: true }).catch(() => {}))
317
+ const app = path.join(root, 'api', 'demo.git')
318
+ await fs.mkdir(app, { recursive: true })
319
+ const lines = [
320
+ `SSL_CERT_FILE=${path.join(root, 'custom', 'ssl.pem')}`,
321
+ `REQUESTS_CA_BUNDLE=${path.join(root, 'custom', 'requests.pem')}`,
322
+ `CURL_CA_BUNDLE=${path.join(root, 'custom', 'curl.pem')}`,
323
+ `PIP_CERT=${path.join(root, 'custom', 'pip.pem')}`,
324
+ `HF_HOME=${path.join(root, 'custom', 'hf')}`,
325
+ ]
326
+ await fs.writeFile(path.join(app, 'ENVIRONMENT'), lines.join('\n') + '\n')
327
+
328
+ await Environment.init_folders(app, createKernel(root))
329
+
330
+ for (const name of ['ssl.pem', 'requests.pem', 'curl.pem', 'pip.pem']) {
331
+ assert.equal(await fs.stat(path.join(root, 'custom', name)).catch(() => null), null, `${name} must not be created as a folder`)
332
+ }
333
+ assert.ok((await fs.stat(path.join(root, 'custom', 'hf'))).isDirectory(), 'ordinary path values are still created as folders')
334
+ })
335
+
336
+ test('init_folders handles Windows certificate key casing without changing POSIX key semantics', async t => {
337
+ const Environment = require('../kernel/environment')
338
+ for (const platform of ['win32', 'linux']) {
339
+ for (const casing of ['upper', 'lower', 'mixed']) {
340
+ await t.test(`${platform} ${casing}`, async t => {
341
+ const root = await tempRoot(t, 'cert-env-case-')
342
+ const app = path.join(root, 'api', 'demo.git')
343
+ await fs.mkdir(app, { recursive: true })
344
+ const keys = ['SSL_CERT_FILE', 'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE', 'PIP_CERT']
345
+ const lines = keys.map((key, index) => {
346
+ if (casing === 'lower') key = key.toLowerCase()
347
+ if (casing === 'mixed') key = key[0] + key.slice(1).toLowerCase()
348
+ return `${key}=${path.join(root, 'custom', `${index}.pem`)}`
349
+ })
350
+ lines.push(`HF_HOME=${path.join(root, 'custom', 'hf')}`)
351
+ await fs.writeFile(path.join(app, 'ENVIRONMENT'), lines.join('\n') + '\n')
352
+ const kernel = createKernel(root)
353
+ kernel.platform = platform
354
+ await Environment.init_folders(app, kernel)
355
+ for (let index = 0; index < keys.length; index++) {
356
+ const stat = await fs.stat(path.join(root, 'custom', `${index}.pem`)).catch(() => null)
357
+ if (platform === 'win32' || casing === 'upper') {
358
+ assert.equal(stat, null, 'recognized certificate files must not become folders')
359
+ } else {
360
+ assert.ok(stat.isDirectory(), 'nonstandard POSIX keys retain ordinary folder handling')
361
+ }
362
+ }
363
+ assert.ok((await fs.stat(path.join(root, 'custom', 'hf'))).isDirectory())
364
+ })
365
+ }
366
+ }
367
+ })
368
+
313
369
  function escapeForHtml(value) {
314
370
  return require('escape-html')(value)
315
371
  }
@@ -0,0 +1,97 @@
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 { createHash } = require('node:crypto')
6
+ const test = require('node:test')
7
+ const { repairPipVendoredBundle } = require('../kernel/cert_env')
8
+
9
+ const sha256 = data => createHash('sha256').update(data).digest('hex')
10
+ const MANAGED = 'new certificate bundle\n'
11
+ const STALE = 'old certificate bundle\n' // Equal sizes must still produce different hashes.
12
+
13
+ async function fixture(t, platform = 'linux') {
14
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-cert-log-'))
15
+ t.after(() => fs.rm(root, { recursive: true, force: true }))
16
+ const prefix = path.join(root, 'env')
17
+ const site = platform === 'win32' ? 'Lib/site-packages' : 'lib/python3.10/site-packages'
18
+ const target = path.join(prefix, site, 'pip/_vendor/certifi/cacert.pem')
19
+ const managed = path.join(root, 'managed.pem')
20
+ await fs.mkdir(path.dirname(target), { recursive: true })
21
+ await fs.writeFile(target, STALE)
22
+ await fs.writeFile(managed, MANAGED)
23
+ const messages = []
24
+ t.mock.method(console, 'log', (...args) => messages.push(args))
25
+ return { root, prefix, target, managed, messages }
26
+ }
27
+
28
+ test('console logs contain independently verifiable evidence even for equal-size replacements', async t => {
29
+ for (const platform of ['linux', 'win32']) {
30
+ await t.test(platform, async t => {
31
+ const f = await fixture(t, platform)
32
+ assert.deepEqual(await repairPipVendoredBundle(f.prefix, f.managed, platform), [f.target])
33
+ assert.equal(f.messages.length, 1)
34
+ assert.ok(f.messages[0][0].includes(f.target))
35
+ const record = JSON.parse(f.messages[0][1])
36
+ assert.equal(record.before_size_bytes, Buffer.byteLength(STALE))
37
+ assert.equal(record.after_size_bytes, record.before_size_bytes)
38
+ assert.equal(record.before_sha256, sha256(STALE))
39
+ assert.equal(record.after_sha256, sha256(await fs.readFile(f.target)))
40
+ assert.notEqual(record.before_sha256, record.after_sha256)
41
+ assert.equal(new Date(record.timestamp).toISOString(), record.timestamp)
42
+ const mtime = (await fs.stat(f.target)).mtimeMs
43
+ assert.deepEqual(await repairPipVendoredBundle(f.prefix, f.managed, platform), [])
44
+ assert.equal((await fs.stat(f.target)).mtimeMs, mtime)
45
+ assert.ok(f.messages[1][0].includes('already current'))
46
+ assert.equal(JSON.parse(f.messages[1][1]).sha256, record.after_sha256)
47
+ })
48
+ }
49
+ })
50
+
51
+ test('a created PEM logs a missing baseline and the written size/hash', async t => {
52
+ const f = await fixture(t)
53
+ await fs.rm(f.target)
54
+ await repairPipVendoredBundle(f.prefix, f.managed, 'linux')
55
+ const record = JSON.parse(f.messages[0][1])
56
+ assert.equal(record.before_size_bytes, null)
57
+ assert.equal(record.before_sha256, null)
58
+ assert.equal(record.after_size_bytes, Buffer.byteLength(MANAGED))
59
+ assert.equal(record.after_sha256, sha256(MANAGED))
60
+ })
61
+
62
+ test('a failed rename logs failure and leaves the stale file and no temp file', async t => {
63
+ const f = await fixture(t)
64
+ t.mock.method(fs, 'rename', async () => { throw Object.assign(new Error('file locked'), { code: 'EPERM' }) })
65
+ await assert.rejects(repairPipVendoredBundle(f.prefix, f.managed, 'linux'), /file locked/)
66
+ assert.equal(f.messages.length, 1)
67
+ assert.equal(JSON.parse(f.messages[0][1]).error, 'file locked')
68
+ assert.equal(await fs.readFile(f.target, 'utf8'), STALE)
69
+ assert.deepEqual(await fs.readdir(path.dirname(f.target)), ['cacert.pem'])
70
+ })
71
+
72
+ test('logging a successful replacement adds no verification read or launch failure', async t => {
73
+ const f = await fixture(t)
74
+ const read = fs.readFile.bind(fs)
75
+ const rename = fs.rename.bind(fs)
76
+ let renamed = false
77
+ let reread = false
78
+ t.mock.method(fs, 'rename', async (...args) => {
79
+ await rename(...args)
80
+ renamed = true
81
+ })
82
+ t.mock.method(fs, 'readFile', async (file, ...args) => {
83
+ if (file === f.target && renamed) {
84
+ reread = true
85
+ throw Object.assign(new Error('reread denied'), { code: 'EACCES' })
86
+ }
87
+ return read(file, ...args)
88
+ })
89
+ assert.deepEqual(await repairPipVendoredBundle(f.prefix, f.managed, 'linux'), [f.target])
90
+ const record = JSON.parse(f.messages[0][1])
91
+ assert.equal(reread, false)
92
+ assert.equal(record.after_size_bytes, Buffer.byteLength(MANAGED))
93
+ assert.equal(record.after_sha256, sha256(MANAGED))
94
+ assert.equal(Object.hasOwn(record, 'verified'), false)
95
+ assert.equal(await read(f.target, 'utf8'), MANAGED)
96
+ assert.deepEqual(await fs.readdir(path.dirname(f.target)), ['cacert.pem'])
97
+ })
@@ -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)
@@ -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.