pinokiod 8.2.21 → 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.
- package/kernel/cert_env.js +132 -0
- package/kernel/connect/providers/huggingface/index.js +8 -3
- package/kernel/shell.js +50 -8
- package/kernel/shells.js +3 -3
- package/package.json +1 -1
- package/server/index.js +3 -2
- package/server/public/remote-control.svg +13 -0
- package/server/public/style.css +5 -0
- package/server/views/anywhere.ejs +19 -18
- package/server/views/partials/main_sidebar.ejs +2 -2
- package/test/anywhere-api.test.js +2 -0
- package/test/cert-env.test.js +315 -0
- package/test/huggingface-token-validity.test.js +142 -2
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const { randomUUID } = require('crypto')
|
|
4
|
+
|
|
5
|
+
// The managed CA bundle: the conda-forge ca-certificates package inside Miniforge.
|
|
6
|
+
// Every app's Python HTTPS traffic is pointed at this one file so a revoked root is
|
|
7
|
+
// retired for all apps, old or new, by updating this file alone.
|
|
8
|
+
function managedCertPaths(homedir, platform = process.platform) {
|
|
9
|
+
const dir = platform === 'win32'
|
|
10
|
+
? path.resolve(homedir, 'bin', 'miniforge', 'Library', 'ssl')
|
|
11
|
+
: path.resolve(homedir, 'bin', 'miniforge', 'ssl')
|
|
12
|
+
return { dir, file: path.resolve(dir, 'cacert.pem') }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function findEnvKey(env, key) {
|
|
16
|
+
const wanted = key.toLowerCase()
|
|
17
|
+
return Object.keys(env).find(envKey => envKey.toLowerCase() === wanted)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function hasEnvValue(env, key) {
|
|
21
|
+
const envKey = findEnvKey(env, key)
|
|
22
|
+
return !!envKey && typeof env[envKey] === 'string' && env[envKey].trim().length > 0
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function setEnvValue(env, key, value) {
|
|
26
|
+
env[findEnvKey(env, key) || key] = value
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Defaults for the three readers that otherwise fall back to a private certifi copy:
|
|
30
|
+
// httpx and Python's ssl module (SSL_CERT_FILE), requests (REQUESTS_CA_BUNDLE) and pip (PIP_CERT).
|
|
31
|
+
// A default is applied only when none of its related variables is set, because the readers
|
|
32
|
+
// rank them: httpx prefers SSL_CERT_FILE over SSL_CERT_DIR and requests prefers
|
|
33
|
+
// REQUESTS_CA_BUNDLE over CURL_CA_BUNDLE, so defaulting one would silently override a
|
|
34
|
+
// custom setting of the other.
|
|
35
|
+
function applyManagedCertEnv(env, { file, dir, platform = process.platform }) {
|
|
36
|
+
if (!hasEnvValue(env, 'SSL_CERT_FILE') && !hasEnvValue(env, 'SSL_CERT_DIR')) {
|
|
37
|
+
setEnvValue(env, 'SSL_CERT_FILE', file)
|
|
38
|
+
if (platform === 'win32') {
|
|
39
|
+
// conda-forge OpenSSL on Windows points SSL_CERT_DIR at an empty directory, which uv treats
|
|
40
|
+
// as an explicit (empty) certificate source. Give it the directory that holds the bundle.
|
|
41
|
+
setEnvValue(env, 'SSL_CERT_DIR', dir)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (!hasEnvValue(env, 'REQUESTS_CA_BUNDLE') && !hasEnvValue(env, 'CURL_CA_BUNDLE')) {
|
|
45
|
+
setEnvValue(env, 'REQUESTS_CA_BUNDLE', file)
|
|
46
|
+
}
|
|
47
|
+
if (!hasEnvValue(env, 'PIP_CERT')) {
|
|
48
|
+
setEnvValue(env, 'PIP_CERT', file)
|
|
49
|
+
}
|
|
50
|
+
return env
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// pip carries its own certifi copy and, from 24.2 on, loads it no matter what the environment
|
|
54
|
+
// says. The only way to retire a root from an existing environment's pip is to replace that file.
|
|
55
|
+
async function pipVendoredBundles(prefix, platform = process.platform) {
|
|
56
|
+
const sitePackages = []
|
|
57
|
+
if (platform === 'win32') {
|
|
58
|
+
sitePackages.push(path.join(prefix, 'Lib', 'site-packages'))
|
|
59
|
+
} else {
|
|
60
|
+
const lib = path.join(prefix, 'lib')
|
|
61
|
+
const entries = await fs.promises.readdir(lib).catch(() => [])
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
if (/^python\d/.test(entry)) sitePackages.push(path.join(lib, entry, 'site-packages'))
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return sitePackages.map(dir => path.join(dir, 'pip', '_vendor', 'certifi', 'cacert.pem'))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const bundleCache = new Map()
|
|
70
|
+
async function readManagedBundle(file) {
|
|
71
|
+
const stat = await fs.promises.stat(file)
|
|
72
|
+
const cached = bundleCache.get(file)
|
|
73
|
+
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) return cached.data
|
|
74
|
+
const data = await fs.promises.readFile(file)
|
|
75
|
+
bundleCache.set(file, { mtimeMs: stat.mtimeMs, size: stat.size, data })
|
|
76
|
+
return data
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Replaces pip's vendored cacert.pem under `prefix` with the managed bundle when they differ.
|
|
80
|
+
// The file is published with write-to-temp + rename, never edited in place: in conda
|
|
81
|
+
// environments pip's files are hardlinked from the package cache, and editing in place would
|
|
82
|
+
// change the cached copy too. Returns the paths that were replaced.
|
|
83
|
+
async function repairPipVendoredBundle(prefix, bundleFile, platform = process.platform) {
|
|
84
|
+
const managed = await readManagedBundle(bundleFile).catch(() => null)
|
|
85
|
+
if (!managed) return []
|
|
86
|
+
const repaired = []
|
|
87
|
+
for (const target of await pipVendoredBundles(prefix, platform)) {
|
|
88
|
+
const stat = await fs.promises.stat(target).catch(() => null)
|
|
89
|
+
if (stat && !stat.isFile()) continue
|
|
90
|
+
if (!stat) {
|
|
91
|
+
// No pip here: nothing to do. pip present but its bundle gone (the user deleted a file
|
|
92
|
+
// Pinokio could not replace): write a fresh one, or pip fails on every network call.
|
|
93
|
+
const certifiDir = await fs.promises.stat(path.dirname(target)).catch(() => null)
|
|
94
|
+
if (!certifiDir || !certifiDir.isDirectory()) continue
|
|
95
|
+
} else if (stat.size === managed.length) {
|
|
96
|
+
const current = await fs.promises.readFile(target).catch(() => null)
|
|
97
|
+
if (current && current.equals(managed)) continue
|
|
98
|
+
}
|
|
99
|
+
const temporary = path.join(path.dirname(target), `.cacert-${randomUUID()}.tmp`)
|
|
100
|
+
try {
|
|
101
|
+
await fs.promises.writeFile(temporary, managed, { mode: stat ? stat.mode & 0o777 : 0o644 })
|
|
102
|
+
await fs.promises.rename(temporary, target)
|
|
103
|
+
repaired.push(target)
|
|
104
|
+
} catch (error) {
|
|
105
|
+
// The caller decides what a failed replacement means; it needs to know which file.
|
|
106
|
+
error.target = target
|
|
107
|
+
error.prefix = prefix
|
|
108
|
+
throw error
|
|
109
|
+
} finally {
|
|
110
|
+
await fs.promises.rm(temporary, { force: true }).catch(() => {})
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return repaired
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// An in-app link to a folder under the Pinokio home: the app file explorer for anything under
|
|
117
|
+
// api/, the home directory listing for everything else. Both are served by the daemon, so the
|
|
118
|
+
// link works from any client, including a browser on another machine.
|
|
119
|
+
function explorerHref(homedir, dir) {
|
|
120
|
+
const relative = path.relative(path.resolve(homedir), path.resolve(dir))
|
|
121
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null
|
|
122
|
+
const segments = relative.split(path.sep).map(encodeURIComponent)
|
|
123
|
+
return segments[0] === 'api' ? `/${segments.join('/')}` : `/files/${segments.join('/')}`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = {
|
|
127
|
+
managedCertPaths,
|
|
128
|
+
applyManagedCertEnv,
|
|
129
|
+
pipVendoredBundles,
|
|
130
|
+
repairPipVendoredBundle,
|
|
131
|
+
explorerHref,
|
|
132
|
+
}
|
|
@@ -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/shell.js
CHANGED
|
@@ -23,6 +23,8 @@ const ShellStateSync = require('./shell_state_sync')
|
|
|
23
23
|
const ShellRunTemplate = require('./api/shell_run_template')
|
|
24
24
|
const { PYTHON_INSTALL_SPEC, PIP_WHEEL, PIP_SEED_SCRIPT } = require('./bin/conda-pins')
|
|
25
25
|
const CondaRuntimeGuard = require('./shell_conda_runtime_guard')
|
|
26
|
+
const { managedCertPaths, applyManagedCertEnv, repairPipVendoredBundle, explorerHref } = require('./cert_env')
|
|
27
|
+
const escapeHtml = require('escape-html')
|
|
26
28
|
const home = os.homedir()
|
|
27
29
|
const WINDOWS_CMD_CONDA_COMMAND_PATTERN = /(?:^|[&|()]\s*)@?\s*(?:call\s+)?(?:"[^"]*[\\/]conda(?:\.bat|\.exe)?"|(?:[a-z]:)?[^&|()\s"]*[\\/]conda(?:\.bat|\.exe)?|conda(?:\.bat|\.exe)?)\s+/i
|
|
28
30
|
|
|
@@ -307,15 +309,14 @@ class Shell {
|
|
|
307
309
|
setDefaultEnvValue(this.env, "HF_HUB_DISABLE_UPDATE_CHECK", "1")
|
|
308
310
|
setDefaultEnvValue(this.env, "HF_TOKEN_PATH", this.kernel.envs?.HF_TOKEN_PATH || path.resolve(this.kernel.homedir, "cache", "HF_AUTH", "token"))
|
|
309
311
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
setDefaultEnvValue(this.env, "SSL_CERT_DIR", managedSslDir)
|
|
317
|
-
}
|
|
312
|
+
// Point every app's Python HTTPS trust (httpx, requests, pip, the ssl module) at the
|
|
313
|
+
// managed CA bundle instead of the certifi copy frozen inside each environment.
|
|
314
|
+
const managedCert = managedCertPaths(this.kernel.homedir, this.platform)
|
|
315
|
+
if (await fs.promises.access(managedCert.file).then(() => true).catch(() => false)) {
|
|
316
|
+
applyManagedCertEnv(this.env, { ...managedCert, platform: this.platform })
|
|
317
|
+
}
|
|
318
318
|
|
|
319
|
+
if (this.platform === "win32") {
|
|
319
320
|
// Hugging Face file symlinks regularly fail on non-admin Windows setups.
|
|
320
321
|
// Default to no-symlink cache mode unless the user/app explicitly overrides it.
|
|
321
322
|
setDefaultEnvValue(this.env, "HF_HUB_DISABLE_SYMLINKS", "1")
|
|
@@ -945,6 +946,44 @@ class Shell {
|
|
|
945
946
|
exists(abspath) {
|
|
946
947
|
return new Promise(r=>fs.access(abspath, fs.constants.F_OK, e => r(!e)))
|
|
947
948
|
}
|
|
949
|
+
// pip ignores the certificate environment for the certifi copy it carries, so an existing
|
|
950
|
+
// environment's pip is brought in line by replacing that one file with the managed bundle.
|
|
951
|
+
// Runs for the environment being activated only; a match costs one file read.
|
|
952
|
+
// A replacement that fails stops the launch: the app must not run against a stale list
|
|
953
|
+
// without the user knowing. They are told which file to delete, and relaunching retries.
|
|
954
|
+
async repairPipCertBundle(prefix) {
|
|
955
|
+
const managedCert = managedCertPaths(this.kernel.homedir, this.platform)
|
|
956
|
+
let repaired
|
|
957
|
+
try {
|
|
958
|
+
repaired = await repairPipVendoredBundle(prefix, managedCert.file, this.platform)
|
|
959
|
+
} catch (error) {
|
|
960
|
+
const target = error.target || prefix
|
|
961
|
+
const reason = error.message
|
|
962
|
+
const message = `Pinokio could not replace pip's certificate file for this app (${reason}). Delete this file, then launch the app again: ${target}`
|
|
963
|
+
if (typeof this.ondata === "function") {
|
|
964
|
+
const href = explorerHref(this.kernel.homedir, path.dirname(target))
|
|
965
|
+
this.ondata({
|
|
966
|
+
html: [
|
|
967
|
+
`<b>Certificate update blocked</b>`,
|
|
968
|
+
`Pinokio could not replace pip's certificate file for this app (${escapeHtml(reason)}).`,
|
|
969
|
+
`Delete this file, then launch the app again:`,
|
|
970
|
+
`<code>${escapeHtml(target)}</code>`,
|
|
971
|
+
...(href ? [`Click to open the folder.`] : []),
|
|
972
|
+
].join("<br>"),
|
|
973
|
+
...(href ? { href, target: "self" } : {}),
|
|
974
|
+
type: "error",
|
|
975
|
+
}, "notify")
|
|
976
|
+
}
|
|
977
|
+
const blocked = new Error(message)
|
|
978
|
+
blocked.path = target
|
|
979
|
+
blocked.cause = error
|
|
980
|
+
throw blocked
|
|
981
|
+
}
|
|
982
|
+
for (const target of repaired) {
|
|
983
|
+
console.log(`[cert] replaced pip's vendored CA bundle: ${target}`)
|
|
984
|
+
}
|
|
985
|
+
return repaired
|
|
986
|
+
}
|
|
948
987
|
build (params) {
|
|
949
988
|
if (params.message) {
|
|
950
989
|
if (typeof params.message === "string") {
|
|
@@ -1125,6 +1164,7 @@ class Shell {
|
|
|
1125
1164
|
: this.quoteArgForShell(env_path)
|
|
1126
1165
|
let env_exists = await this.exists(env_path)
|
|
1127
1166
|
if (env_exists) {
|
|
1167
|
+
await this.repairPipCertBundle(env_path)
|
|
1128
1168
|
conda_activation = [
|
|
1129
1169
|
conda_hook,
|
|
1130
1170
|
// timeout,
|
|
@@ -1165,6 +1205,7 @@ class Shell {
|
|
|
1165
1205
|
let env_path = path.resolve(envs_path, conda_name)
|
|
1166
1206
|
let env_exists = await this.exists(env_path)
|
|
1167
1207
|
if (env_exists) {
|
|
1208
|
+
await this.repairPipCertBundle(env_path)
|
|
1168
1209
|
conda_activation = [
|
|
1169
1210
|
conda_hook,
|
|
1170
1211
|
// timeout,
|
|
@@ -1366,6 +1407,7 @@ class Shell {
|
|
|
1366
1407
|
let deactivate_path = (windowsBashVenv ? "deactivate" : (this.platform === 'win32' ? path.resolve(env_path, "Scripts", "deactivate") : "deactivate"))
|
|
1367
1408
|
let env_exists = await this.exists(env_path)
|
|
1368
1409
|
if (env_exists) {
|
|
1410
|
+
await this.repairPipCertBundle(env_path)
|
|
1369
1411
|
if (use_uv) {
|
|
1370
1412
|
venv_activation = [
|
|
1371
1413
|
// `python -m venv --upgrade ${env_path}`,
|
package/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
package/server/index.js
CHANGED
|
@@ -1049,7 +1049,8 @@ class Server {
|
|
|
1049
1049
|
const dnsName = anywhere && typeof anywhere.dnsNameFrom === "function" ? anywhere.dnsNameFrom(anywhere.lastStatus) : null
|
|
1050
1050
|
return !!dnsName && parsed.protocol === "https:" && host === dnsName.toLowerCase()
|
|
1051
1051
|
}
|
|
1052
|
-
// the app's menu as launch targets: scripts and web addresses, never interface links
|
|
1052
|
+
// the app's menu as launch targets: scripts and web addresses, never interface links;
|
|
1053
|
+
// text is plain: a launcher may write HTML, which the desktop renders and the phone does not
|
|
1053
1054
|
async anywhereMenu(api, repoPath) {
|
|
1054
1055
|
const items = []
|
|
1055
1056
|
for (const { item, target } of await launcherMenuTargets(api, repoPath)) {
|
|
@@ -1057,7 +1058,7 @@ class Server {
|
|
|
1057
1058
|
if (!web && !/\.(js|json)$/i.test(target.uri)) continue
|
|
1058
1059
|
if (isInterfaceLink(target.input)) continue
|
|
1059
1060
|
items.push({
|
|
1060
|
-
text: item.text || "",
|
|
1061
|
+
text: String(item.text || "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(),
|
|
1061
1062
|
icon: item.icon || "",
|
|
1062
1063
|
href: target.href,
|
|
1063
1064
|
default: !!item.default,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
|
|
2
|
+
<defs>
|
|
3
|
+
<mask id="cursor-gap" maskUnits="userSpaceOnUse" x="0" y="0" width="40" height="40">
|
|
4
|
+
<rect width="40" height="40" fill="white"/>
|
|
5
|
+
<path d="M23 17V35L28 30L32 38L36 36L32 28L39 27Z" fill="black" stroke="black" stroke-width="3" stroke-linejoin="round"/>
|
|
6
|
+
</mask>
|
|
7
|
+
</defs>
|
|
8
|
+
<g mask="url(#cursor-gap)">
|
|
9
|
+
<path fill-rule="evenodd" d="M5 5H33A3 3 0 0 1 36 8V26A3 3 0 0 1 33 29H5A3 3 0 0 1 2 26V8A3 3 0 0 1 5 5ZM6 9V25H32V9Z"/>
|
|
10
|
+
<path d="M17 29H22V34H29V38H10V34H17Z"/>
|
|
11
|
+
</g>
|
|
12
|
+
<path d="M23 17V35L28 30L32 38L36 36L32 28L39 27Z"/>
|
|
13
|
+
</svg>
|
package/server/public/style.css
CHANGED
|
@@ -1598,6 +1598,11 @@ body.dark .main-sidebar .main-sidebar-status-badge[data-state="on"] {
|
|
|
1598
1598
|
-webkit-mask: url('/pinokio-mark.svg') center / 14px 14px no-repeat;
|
|
1599
1599
|
mask: url('/pinokio-mark.svg') center / 14px 14px no-repeat;
|
|
1600
1600
|
}
|
|
1601
|
+
.main-sidebar .remote-control-icon {
|
|
1602
|
+
background: currentColor;
|
|
1603
|
+
-webkit-mask: url('/remote-control.svg') center / contain no-repeat;
|
|
1604
|
+
mask: url('/remote-control.svg') center / contain no-repeat;
|
|
1605
|
+
}
|
|
1601
1606
|
@media only screen and (min-width: 601px) {
|
|
1602
1607
|
body.main-sidebar-page {
|
|
1603
1608
|
display: flex;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<html>
|
|
2
2
|
<head>
|
|
3
|
-
<title>Pinokio
|
|
3
|
+
<title>Pinokio Remote</title>
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
|
6
6
|
<% if (locals.anywhere_host) { %>
|
|
@@ -132,8 +132,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
132
132
|
<section class='task-shell anywhere-shell'>
|
|
133
133
|
<header class='task-shell-header'>
|
|
134
134
|
<div class='task-shell-header-main'>
|
|
135
|
-
<h1 class='task-title'>
|
|
136
|
-
<p class='task-description'>
|
|
135
|
+
<h1 class='task-title'>Remote</h1>
|
|
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'>
|
|
@@ -146,8 +146,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
146
146
|
|
|
147
147
|
<div class='anywhere-state' data-state='off'>
|
|
148
148
|
<div class='anywhere-card'>
|
|
149
|
-
<h2>
|
|
150
|
-
<p>Turn it on to
|
|
149
|
+
<h2>Remote access is off</h2>
|
|
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>
|
|
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') })
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
<div class='main-sidebar-section' aria-label="Configure">
|
|
41
41
|
<div class="main-sidebar-section-title">Configure</div>
|
|
42
42
|
<a href="/network" class="tab <%= sidebarSelected === 'network' ? 'selected' : '' %>" data-main-sidebar-home-server-tab><i class="fa-solid fa-wifi"></i><div class='caption'>Home Server</div><span class="main-sidebar-status-badge" data-main-sidebar-home-server-status aria-hidden="true" hidden></span></a>
|
|
43
|
-
<a href="/anywhere" class="tab <%= sidebarSelected === 'anywhere' ? 'selected' : '' %>" data-main-sidebar-anywhere-tab><
|
|
43
|
+
<a href="/anywhere" class="tab <%= sidebarSelected === 'anywhere' ? 'selected' : '' %>" data-main-sidebar-anywhere-tab><span class="main-sidebar-action-icon remote-control-icon" aria-hidden="true"></span><div class='caption'>Remote</div><span class="main-sidebar-status-badge" data-main-sidebar-anywhere-status aria-hidden="true" hidden></span></a>
|
|
44
44
|
<a class="tab <%= sidebarSelected === 'autolaunch' ? 'selected' : '' %>" href="/autolaunch"><i class="fa-solid fa-power-off"></i><div class='caption'>Autolaunch</div></a>
|
|
45
45
|
<a href="/connect" class="tab <%= sidebarSelected === 'connect' ? 'selected' : '' %>"><i class="fa-solid fa-plug"></i><div class='caption'>Login</div></a>
|
|
46
46
|
<a class="tab <%= sidebarSelected === 'settings' ? 'selected' : '' %>" href="/home?mode=settings"><i class="fa-solid fa-gear"></i><div class='caption'>Settings</div></a>
|
|
@@ -140,7 +140,7 @@
|
|
|
140
140
|
badge.textContent = label;
|
|
141
141
|
badge.hidden = false;
|
|
142
142
|
badge.dataset.state = on ? "on" : "off";
|
|
143
|
-
tab.setAttribute("title", `
|
|
143
|
+
tab.setAttribute("title", `Remote ${label}`);
|
|
144
144
|
};
|
|
145
145
|
document.addEventListener("pinokio:anywhere-status", (event) => {
|
|
146
146
|
const detail = event && event.detail ? event.detail : {};
|
|
@@ -164,6 +164,7 @@ const menu = [
|
|
|
164
164
|
{ text: 'Start (low VRAM)', href: 'start.js', params: { lowvram: true } },
|
|
165
165
|
] },
|
|
166
166
|
{ text: 'Web UI', icon: 'fa-solid fa-rocket', href: 'http://127.0.0.1:7860' },
|
|
167
|
+
{ text: '<div><strong>Tunnel</strong>\n<div>Start first, then run again.</div></div>', href: 'tunnel.js' },
|
|
167
168
|
{ text: 'Edit start', href: 'start.js?fs=view' },
|
|
168
169
|
{ text: 'View source', href: 'start.js?mode=source' },
|
|
169
170
|
{ text: 'Models', href: 'models?fs=true' },
|
|
@@ -178,6 +179,7 @@ test('launch returns the flattened launchable menu when there is no default', as
|
|
|
178
179
|
{ text: 'Start', icon: '', href: 'start.js' },
|
|
179
180
|
{ text: 'Start (low VRAM)', icon: '', href: 'start.js?lowvram=true' },
|
|
180
181
|
{ text: 'Web UI', icon: 'fa-solid fa-rocket', href: 'http://127.0.0.1:7860' },
|
|
182
|
+
{ text: 'Tunnel Start first, then run again.', icon: '', href: 'tunnel.js' },
|
|
181
183
|
] } })
|
|
182
184
|
assert.deepEqual(h.calls.process, [])
|
|
183
185
|
})
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
const assert = require('node:assert/strict')
|
|
2
|
+
const fs = require('node:fs/promises')
|
|
3
|
+
const os = require('node:os')
|
|
4
|
+
const path = require('node:path')
|
|
5
|
+
const test = require('node:test')
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
managedCertPaths,
|
|
9
|
+
applyManagedCertEnv,
|
|
10
|
+
pipVendoredBundles,
|
|
11
|
+
repairPipVendoredBundle,
|
|
12
|
+
} = require('../kernel/cert_env')
|
|
13
|
+
const Shell = require('../kernel/shell')
|
|
14
|
+
|
|
15
|
+
const MANAGED = '# managed bundle\n-----BEGIN CERTIFICATE-----\nnew\n-----END CERTIFICATE-----\n'
|
|
16
|
+
const STALE = '# stale bundle\n-----BEGIN CERTIFICATE-----\nold\n-----END CERTIFICATE-----\n'
|
|
17
|
+
|
|
18
|
+
async function tempRoot(t, prefix) {
|
|
19
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix))
|
|
20
|
+
t.after(() => fs.rm(root, { recursive: true, force: true }).catch(() => {}))
|
|
21
|
+
return root
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function managedBundle(t, platform) {
|
|
25
|
+
const root = await tempRoot(t, 'pinokio-cert-env-')
|
|
26
|
+
const paths = managedCertPaths(root, platform)
|
|
27
|
+
await fs.mkdir(paths.dir, { recursive: true })
|
|
28
|
+
await fs.writeFile(paths.file, MANAGED)
|
|
29
|
+
return { root, ...paths }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function createKernel(root) {
|
|
33
|
+
return {
|
|
34
|
+
homedir: root,
|
|
35
|
+
bracketedPasteSupport: {},
|
|
36
|
+
connect: { keys: async () => null },
|
|
37
|
+
envs: {},
|
|
38
|
+
exists: async filepath => fs.access(filepath).then(() => true).catch(() => false),
|
|
39
|
+
which: () => null,
|
|
40
|
+
path: (...parts) => path.join(root, ...parts),
|
|
41
|
+
bin: { path: (...parts) => path.join(root, 'bin', ...parts) },
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test('managedCertPaths follows the Miniforge layout per platform', () => {
|
|
46
|
+
assert.equal(managedCertPaths('/home/u/pinokio', 'darwin').file, path.resolve('/home/u/pinokio/bin/miniforge/ssl/cacert.pem'))
|
|
47
|
+
assert.equal(managedCertPaths('/home/u/pinokio', 'linux').file, path.resolve('/home/u/pinokio/bin/miniforge/ssl/cacert.pem'))
|
|
48
|
+
assert.equal(managedCertPaths('C:\\pinokio', 'win32').file, path.resolve('C:\\pinokio', 'bin', 'miniforge', 'Library', 'ssl', 'cacert.pem'))
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('applyManagedCertEnv points httpx, requests and pip at the managed bundle', () => {
|
|
52
|
+
const env = applyManagedCertEnv({}, { file: '/m/cacert.pem', dir: '/m', platform: 'darwin' })
|
|
53
|
+
assert.equal(env.SSL_CERT_FILE, '/m/cacert.pem')
|
|
54
|
+
assert.equal(env.REQUESTS_CA_BUNDLE, '/m/cacert.pem')
|
|
55
|
+
assert.equal(env.PIP_CERT, '/m/cacert.pem')
|
|
56
|
+
assert.equal(env.SSL_CERT_DIR, undefined, 'SSL_CERT_DIR stays a Windows-only uv workaround')
|
|
57
|
+
assert.equal(env.CURL_CA_BUNDLE, undefined, 'curl keeps its own trust source')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('applyManagedCertEnv also gives Windows uv a certificate directory', () => {
|
|
61
|
+
const env = applyManagedCertEnv({}, { file: 'C:\\m\\cacert.pem', dir: 'C:\\m', platform: 'win32' })
|
|
62
|
+
assert.equal(env.SSL_CERT_FILE, 'C:\\m\\cacert.pem')
|
|
63
|
+
assert.equal(env.SSL_CERT_DIR, 'C:\\m')
|
|
64
|
+
assert.equal(env.REQUESTS_CA_BUNDLE, 'C:\\m\\cacert.pem')
|
|
65
|
+
assert.equal(env.PIP_CERT, 'C:\\m\\cacert.pem')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('applyManagedCertEnv never overrides a custom trust setting or its higher-priority sibling', () => {
|
|
69
|
+
const managed = { file: '/m/cacert.pem', dir: '/m', platform: 'darwin' }
|
|
70
|
+
|
|
71
|
+
// httpx prefers SSL_CERT_FILE over SSL_CERT_DIR: a custom directory must not be masked.
|
|
72
|
+
const dirOnly = applyManagedCertEnv({ SSL_CERT_DIR: '/corp/certs' }, managed)
|
|
73
|
+
assert.equal(dirOnly.SSL_CERT_FILE, undefined)
|
|
74
|
+
assert.equal(dirOnly.SSL_CERT_DIR, '/corp/certs')
|
|
75
|
+
assert.equal(dirOnly.REQUESTS_CA_BUNDLE, '/m/cacert.pem')
|
|
76
|
+
|
|
77
|
+
// requests prefers REQUESTS_CA_BUNDLE over CURL_CA_BUNDLE: a custom curl bundle must not be masked.
|
|
78
|
+
const curlOnly = applyManagedCertEnv({ CURL_CA_BUNDLE: '/corp/curl.pem' }, managed)
|
|
79
|
+
assert.equal(curlOnly.REQUESTS_CA_BUNDLE, undefined)
|
|
80
|
+
assert.equal(curlOnly.CURL_CA_BUNDLE, '/corp/curl.pem')
|
|
81
|
+
assert.equal(curlOnly.SSL_CERT_FILE, '/m/cacert.pem')
|
|
82
|
+
|
|
83
|
+
const custom = applyManagedCertEnv({ SSL_CERT_FILE: '/corp/a.pem', REQUESTS_CA_BUNDLE: '/corp/b.pem', PIP_CERT: '/corp/c.pem' }, managed)
|
|
84
|
+
assert.equal(custom.SSL_CERT_FILE, '/corp/a.pem')
|
|
85
|
+
assert.equal(custom.REQUESTS_CA_BUNDLE, '/corp/b.pem')
|
|
86
|
+
assert.equal(custom.PIP_CERT, '/corp/c.pem')
|
|
87
|
+
|
|
88
|
+
// Empty values count as unset, and the existing key casing is reused (Windows env is case-insensitive).
|
|
89
|
+
const empty = applyManagedCertEnv({ ssl_cert_file: '', Pip_Cert: ' ' }, { ...managed, platform: 'win32' })
|
|
90
|
+
assert.equal(empty.ssl_cert_file, '/m/cacert.pem')
|
|
91
|
+
assert.equal(empty.SSL_CERT_FILE, undefined)
|
|
92
|
+
assert.equal(empty.Pip_Cert, '/m/cacert.pem')
|
|
93
|
+
assert.equal(empty.PIP_CERT, undefined)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('pipVendoredBundles finds pip inside venv and conda prefixes on every platform', async t => {
|
|
97
|
+
const root = await tempRoot(t, 'pinokio-pip-bundles-')
|
|
98
|
+
const posix = path.join(root, 'env')
|
|
99
|
+
await fs.mkdir(path.join(posix, 'lib', 'python3.10', 'site-packages'), { recursive: true })
|
|
100
|
+
await fs.mkdir(path.join(posix, 'lib', 'python3.12', 'site-packages'), { recursive: true })
|
|
101
|
+
await fs.mkdir(path.join(posix, 'lib', 'pkgconfig'), { recursive: true })
|
|
102
|
+
assert.deepEqual(await pipVendoredBundles(posix, 'linux'), [
|
|
103
|
+
path.join(posix, 'lib', 'python3.10', 'site-packages', 'pip', '_vendor', 'certifi', 'cacert.pem'),
|
|
104
|
+
path.join(posix, 'lib', 'python3.12', 'site-packages', 'pip', '_vendor', 'certifi', 'cacert.pem'),
|
|
105
|
+
])
|
|
106
|
+
assert.deepEqual(await pipVendoredBundles(path.join(root, 'win'), 'win32'), [
|
|
107
|
+
path.join(root, 'win', 'Lib', 'site-packages', 'pip', '_vendor', 'certifi', 'cacert.pem'),
|
|
108
|
+
])
|
|
109
|
+
assert.deepEqual(await pipVendoredBundles(path.join(root, 'missing'), 'linux'), [])
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('repairPipVendoredBundle replaces a stale pip bundle once and leaves everything else alone', async t => {
|
|
113
|
+
const managed = await managedBundle(t, 'linux')
|
|
114
|
+
const prefix = path.join(managed.root, 'api', 'app', 'env')
|
|
115
|
+
const certifiDir = path.join(prefix, 'lib', 'python3.10', 'site-packages', 'pip', '_vendor', 'certifi')
|
|
116
|
+
const target = path.join(certifiDir, 'cacert.pem')
|
|
117
|
+
const sibling = path.join(certifiDir, 'core.py')
|
|
118
|
+
await fs.mkdir(certifiDir, { recursive: true })
|
|
119
|
+
await fs.writeFile(target, STALE, { mode: 0o600 })
|
|
120
|
+
await fs.writeFile(sibling, 'print("untouched")\n')
|
|
121
|
+
|
|
122
|
+
assert.deepEqual(await repairPipVendoredBundle(prefix, managed.file, 'linux'), [target])
|
|
123
|
+
assert.equal(await fs.readFile(target, 'utf8'), MANAGED)
|
|
124
|
+
if (process.platform !== 'win32') {
|
|
125
|
+
assert.equal((await fs.stat(target)).mode & 0o777, 0o600, 'file mode is preserved')
|
|
126
|
+
}
|
|
127
|
+
assert.equal(await fs.readFile(sibling, 'utf8'), 'print("untouched")\n')
|
|
128
|
+
assert.deepEqual(await fs.readdir(certifiDir), ['cacert.pem', 'core.py'], 'no temporary files are left behind')
|
|
129
|
+
|
|
130
|
+
// Second activation: the bundle matches, nothing is written.
|
|
131
|
+
const before = await fs.stat(target)
|
|
132
|
+
assert.deepEqual(await repairPipVendoredBundle(prefix, managed.file, 'linux'), [])
|
|
133
|
+
assert.equal((await fs.stat(target)).mtimeMs, before.mtimeMs)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('repairPipVendoredBundle is a no-op without pip or without a managed bundle', async t => {
|
|
137
|
+
const managed = await managedBundle(t, 'linux')
|
|
138
|
+
const uvVenv = path.join(managed.root, 'uv-env')
|
|
139
|
+
await fs.mkdir(path.join(uvVenv, 'lib', 'python3.11', 'site-packages'), { recursive: true })
|
|
140
|
+
assert.deepEqual(await repairPipVendoredBundle(uvVenv, managed.file, 'linux'), [])
|
|
141
|
+
|
|
142
|
+
const prefix = path.join(managed.root, 'env')
|
|
143
|
+
const target = path.join(prefix, 'lib', 'python3.10', 'site-packages', 'pip', '_vendor', 'certifi', 'cacert.pem')
|
|
144
|
+
await fs.mkdir(path.dirname(target), { recursive: true })
|
|
145
|
+
await fs.writeFile(target, STALE)
|
|
146
|
+
assert.deepEqual(await repairPipVendoredBundle(prefix, path.join(managed.root, 'nope.pem'), 'linux'), [])
|
|
147
|
+
assert.equal(await fs.readFile(target, 'utf8'), STALE)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test('Shell.init_env applies the managed certificate defaults on every platform when the bundle exists', async t => {
|
|
151
|
+
for (const platform of ['darwin', 'linux', 'win32']) {
|
|
152
|
+
const managed = await managedBundle(t, platform)
|
|
153
|
+
await fs.mkdir(path.join(managed.root, 'api'), { recursive: true })
|
|
154
|
+
const shell = new Shell(createKernel(managed.root))
|
|
155
|
+
shell.platform = platform
|
|
156
|
+
await shell.init_env({ path: process.cwd(), env: {} })
|
|
157
|
+
assert.equal(shell.env.SSL_CERT_FILE, managed.file, platform)
|
|
158
|
+
assert.equal(shell.env.REQUESTS_CA_BUNDLE, managed.file, platform)
|
|
159
|
+
assert.equal(shell.env.PIP_CERT, managed.file, platform)
|
|
160
|
+
assert.equal(shell.env.SSL_CERT_DIR, platform === 'win32' ? managed.dir : undefined, platform)
|
|
161
|
+
|
|
162
|
+
const custom = new Shell(createKernel(managed.root))
|
|
163
|
+
custom.platform = platform
|
|
164
|
+
await custom.init_env({ path: process.cwd(), env: { CURL_CA_BUNDLE: '/corp/curl.pem', SSL_CERT_DIR: '/corp/certs' } })
|
|
165
|
+
assert.equal(custom.env.SSL_CERT_FILE, undefined, platform)
|
|
166
|
+
assert.equal(custom.env.REQUESTS_CA_BUNDLE, undefined, platform)
|
|
167
|
+
assert.equal(custom.env.PIP_CERT, managed.file, platform)
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('Shell.init_env sets nothing when the managed bundle is absent', async t => {
|
|
172
|
+
const root = await tempRoot(t, 'pinokio-cert-env-missing-')
|
|
173
|
+
await fs.mkdir(path.join(root, 'api'), { recursive: true })
|
|
174
|
+
const shell = new Shell(createKernel(root))
|
|
175
|
+
shell.platform = 'darwin'
|
|
176
|
+
await shell.init_env({ path: process.cwd(), env: {} })
|
|
177
|
+
assert.equal(shell.env.SSL_CERT_FILE, undefined)
|
|
178
|
+
assert.equal(shell.env.REQUESTS_CA_BUNDLE, undefined)
|
|
179
|
+
assert.equal(shell.env.PIP_CERT, undefined)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('Shell.activate repairs pip in the venv and custom conda env being activated, never in base', async t => {
|
|
183
|
+
const managed = await managedBundle(t, 'linux')
|
|
184
|
+
const kernel = createKernel(managed.root)
|
|
185
|
+
const app = path.join(managed.root, 'api', 'app')
|
|
186
|
+
const stalePip = async prefix => {
|
|
187
|
+
const target = path.join(prefix, 'lib', 'python3.10', 'site-packages', 'pip', '_vendor', 'certifi', 'cacert.pem')
|
|
188
|
+
await fs.mkdir(path.dirname(target), { recursive: true })
|
|
189
|
+
await fs.writeFile(target, STALE)
|
|
190
|
+
return target
|
|
191
|
+
}
|
|
192
|
+
const venv = await stalePip(path.join(app, 'env'))
|
|
193
|
+
const condaByPath = await stalePip(path.join(app, 'conda-env'))
|
|
194
|
+
const condaByName = await stalePip(kernel.bin.path('miniforge', 'envs', 'custom'))
|
|
195
|
+
const base = await stalePip(kernel.bin.path('miniforge'))
|
|
196
|
+
await fs.mkdir(path.join(app, 'env', 'bin'), { recursive: true })
|
|
197
|
+
|
|
198
|
+
const shell = new Shell(kernel)
|
|
199
|
+
shell.platform = 'linux'
|
|
200
|
+
shell.shell = 'bash'
|
|
201
|
+
shell.env = {}
|
|
202
|
+
shell.conda_hook = () => 'conda hook'
|
|
203
|
+
shell.condaRuntimeGuard = null
|
|
204
|
+
|
|
205
|
+
await shell.activate({ path: app, venv: 'env', message: ['true'] })
|
|
206
|
+
assert.equal(await fs.readFile(venv, 'utf8'), MANAGED)
|
|
207
|
+
assert.equal(await fs.readFile(base, 'utf8'), STALE, 'base is managed by the runtime seed, not by activation')
|
|
208
|
+
|
|
209
|
+
await shell.activate({ path: app, conda: { path: 'conda-env' }, message: ['true'] })
|
|
210
|
+
assert.equal(await fs.readFile(condaByPath, 'utf8'), MANAGED)
|
|
211
|
+
|
|
212
|
+
await shell.activate({ path: app, conda: { name: 'custom' }, message: ['true'] })
|
|
213
|
+
assert.equal(await fs.readFile(condaByName, 'utf8'), MANAGED)
|
|
214
|
+
assert.equal(await fs.readFile(base, 'utf8'), STALE)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
const { explorerHref } = require('../kernel/cert_env')
|
|
218
|
+
|
|
219
|
+
test('explorerHref links into the app file explorer under api/ and the home listing elsewhere', () => {
|
|
220
|
+
const home = path.resolve('/home/u/pinokio')
|
|
221
|
+
assert.equal(explorerHref(home, path.join(home, 'api', 'my app.git', 'app', 'env', 'lib', 'python3.10', 'site-packages', 'pip', '_vendor', 'certifi')),
|
|
222
|
+
'/api/my%20app.git/app/env/lib/python3.10/site-packages/pip/_vendor/certifi')
|
|
223
|
+
assert.equal(explorerHref(home, path.join(home, 'bin', 'miniforge', 'envs', 'custom', 'lib', 'python3.10', 'site-packages', 'pip', '_vendor', 'certifi')),
|
|
224
|
+
'/files/bin/miniforge/envs/custom/lib/python3.10/site-packages/pip/_vendor/certifi')
|
|
225
|
+
assert.equal(explorerHref(home, '/elsewhere/env'), null)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
const canTestPermissions = process.platform !== 'win32' && typeof process.getuid === 'function' && process.getuid() !== 0
|
|
229
|
+
|
|
230
|
+
test('a pip bundle that cannot be replaced blocks the launch, names the file, and notifies the app page', { skip: !canTestPermissions && 'needs a non-root POSIX user' }, async t => {
|
|
231
|
+
const managed = await managedBundle(t, 'linux')
|
|
232
|
+
const kernel = createKernel(managed.root)
|
|
233
|
+
const app = path.join(managed.root, 'api', 'demo.git')
|
|
234
|
+
const prefix = path.join(app, 'app', 'env')
|
|
235
|
+
const certifiDir = path.join(prefix, 'lib', 'python3.10', 'site-packages', 'pip', '_vendor', 'certifi')
|
|
236
|
+
const target = path.join(certifiDir, 'cacert.pem')
|
|
237
|
+
await fs.mkdir(certifiDir, { recursive: true })
|
|
238
|
+
await fs.writeFile(target, STALE)
|
|
239
|
+
await fs.chmod(certifiDir, 0o500)
|
|
240
|
+
t.after(() => fs.chmod(certifiDir, 0o700).catch(() => {}))
|
|
241
|
+
|
|
242
|
+
const shell = new Shell(kernel)
|
|
243
|
+
shell.platform = 'linux'
|
|
244
|
+
const packets = []
|
|
245
|
+
shell.ondata = (stream, type) => packets.push({ stream, type })
|
|
246
|
+
|
|
247
|
+
await assert.rejects(() => shell.repairPipCertBundle(prefix), error => {
|
|
248
|
+
assert.match(error.message, /could not replace pip's certificate file/)
|
|
249
|
+
assert.match(error.message, /Delete this file, then launch the app again/)
|
|
250
|
+
assert.ok(error.message.includes(target), 'the message names the file to delete')
|
|
251
|
+
assert.equal(error.path, target)
|
|
252
|
+
return true
|
|
253
|
+
})
|
|
254
|
+
assert.equal(await fs.readFile(target, 'utf8'), STALE, 'the original file is left untouched')
|
|
255
|
+
assert.deepEqual(await fs.readdir(certifiDir), ['cacert.pem'], 'no temporary file is left behind')
|
|
256
|
+
|
|
257
|
+
assert.equal(packets.length, 1)
|
|
258
|
+
assert.equal(packets[0].type, 'notify')
|
|
259
|
+
const notice = packets[0].stream
|
|
260
|
+
assert.equal(notice.type, 'error')
|
|
261
|
+
assert.equal(notice.target, 'self')
|
|
262
|
+
assert.equal(notice.href, '/api/demo.git/app/env/lib/python3.10/site-packages/pip/_vendor/certifi')
|
|
263
|
+
assert.ok(notice.html.includes('Delete this file, then launch the app again'))
|
|
264
|
+
assert.ok(notice.html.includes(escapeForHtml(target)))
|
|
265
|
+
|
|
266
|
+
// The user deletes the file and launches again: the fresh bundle is written and the launch proceeds.
|
|
267
|
+
await fs.chmod(certifiDir, 0o700)
|
|
268
|
+
await fs.rm(target)
|
|
269
|
+
assert.deepEqual(await shell.repairPipCertBundle(prefix), [target])
|
|
270
|
+
assert.equal(await fs.readFile(target, 'utf8'), MANAGED)
|
|
271
|
+
if (process.platform !== 'win32') {
|
|
272
|
+
assert.equal((await fs.stat(target)).mode & 0o777, 0o644, 'a recreated file gets a normal mode')
|
|
273
|
+
}
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
test('repairPipVendoredBundle recreates a deleted bundle when pip is present, and ignores an absent pip', async t => {
|
|
277
|
+
const managed = await managedBundle(t, 'linux')
|
|
278
|
+
const prefix = path.join(managed.root, 'env')
|
|
279
|
+
const certifiDir = path.join(prefix, 'lib', 'python3.10', 'site-packages', 'pip', '_vendor', 'certifi')
|
|
280
|
+
const target = path.join(certifiDir, 'cacert.pem')
|
|
281
|
+
await fs.mkdir(certifiDir, { recursive: true })
|
|
282
|
+
assert.deepEqual(await repairPipVendoredBundle(prefix, managed.file, 'linux'), [target])
|
|
283
|
+
assert.equal(await fs.readFile(target, 'utf8'), MANAGED)
|
|
284
|
+
|
|
285
|
+
const noPip = path.join(managed.root, 'nopip')
|
|
286
|
+
await fs.mkdir(path.join(noPip, 'lib', 'python3.10', 'site-packages'), { recursive: true })
|
|
287
|
+
assert.deepEqual(await repairPipVendoredBundle(noPip, managed.file, 'linux'), [])
|
|
288
|
+
assert.equal(await fs.stat(path.join(noPip, 'lib', 'python3.10', 'site-packages', 'pip')).catch(() => null), null, 'nothing is created where pip is absent')
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
test('Shell.activate stops the step when the pip bundle cannot be replaced', { skip: !canTestPermissions && 'needs a non-root POSIX user' }, async t => {
|
|
292
|
+
const managed = await managedBundle(t, 'linux')
|
|
293
|
+
const kernel = createKernel(managed.root)
|
|
294
|
+
const app = path.join(managed.root, 'api', 'demo.git')
|
|
295
|
+
const certifiDir = path.join(app, 'env', 'lib', 'python3.10', 'site-packages', 'pip', '_vendor', 'certifi')
|
|
296
|
+
await fs.mkdir(certifiDir, { recursive: true })
|
|
297
|
+
await fs.mkdir(path.join(app, 'env', 'bin'), { recursive: true })
|
|
298
|
+
await fs.writeFile(path.join(certifiDir, 'cacert.pem'), STALE)
|
|
299
|
+
await fs.chmod(certifiDir, 0o500)
|
|
300
|
+
t.after(() => fs.chmod(certifiDir, 0o700).catch(() => {}))
|
|
301
|
+
|
|
302
|
+
const shell = new Shell(kernel)
|
|
303
|
+
shell.platform = 'linux'
|
|
304
|
+
shell.shell = 'bash'
|
|
305
|
+
shell.env = {}
|
|
306
|
+
shell.ondata = () => {}
|
|
307
|
+
shell.conda_hook = () => 'conda hook'
|
|
308
|
+
shell.condaRuntimeGuard = null
|
|
309
|
+
await assert.rejects(() => shell.activate({ path: app, venv: 'env', message: ['true'] }), /could not replace pip's certificate file/)
|
|
310
|
+
await fs.chmod(certifiDir, 0o700)
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
function escapeForHtml(value) {
|
|
314
|
+
return require('escape-html')(value)
|
|
315
|
+
}
|
|
@@ -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)
|