pinokiod 7.2.6 → 7.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,469 @@
1
+ const express = require("express")
2
+ const fs = require("fs")
3
+ const path = require("path")
4
+ const axios = require("axios")
5
+ const FormData = require("form-data")
6
+ const mime = require("mime-types")
7
+
8
+ const DEFAULT_MAX_FILES = 10
9
+ const DEFAULT_MAX_FILE_BYTES = 25 * 1024 * 1024
10
+
11
+ const asyncHandler = (fn) => (req, res, next) => {
12
+ Promise.resolve(fn(req, res, next)).catch(next)
13
+ }
14
+
15
+ const escapeHtml = (value) => String(value || "")
16
+ .replace(/&/g, "&")
17
+ .replace(/</g, "&lt;")
18
+ .replace(/>/g, "&gt;")
19
+ .replace(/"/g, "&quot;")
20
+
21
+ function renderMessage(res, status, title, message) {
22
+ res.status(status).send(`<!doctype html>
23
+ <html>
24
+ <head>
25
+ <meta charset="utf-8">
26
+ <title>${escapeHtml(title)}</title>
27
+ <style>
28
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 40px; color: #111827; }
29
+ .box { max-width: 680px; border: 1px solid #d1d5db; border-radius: 8px; padding: 18px; }
30
+ h1 { margin: 0 0 8px; font-size: 22px; }
31
+ p { margin: 0; color: #4b5563; line-height: 1.5; }
32
+ </style>
33
+ </head>
34
+ <body>
35
+ <div class="box">
36
+ <h1>${escapeHtml(title)}</h1>
37
+ <p>${escapeHtml(message)}</p>
38
+ </div>
39
+ </body>
40
+ </html>`)
41
+ }
42
+
43
+ function renderImportLauncher(res, { authorizeUrl, draftId, registryOrigin, autoOpen }) {
44
+ res.status(200).send(`<!doctype html>
45
+ <html>
46
+ <head>
47
+ <meta charset="utf-8">
48
+ <title>Import draft</title>
49
+ <style>
50
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 40px; color: #111827; background: #f8fafc; }
51
+ .box { max-width: 680px; border: 1px solid #d1d5db; border-radius: 8px; padding: 18px; background: white; box-shadow: 0 16px 42px rgba(15, 23, 42, 0.08); }
52
+ h1 { margin: 0 0 8px; font-size: 22px; }
53
+ p { margin: 0 0 14px; color: #4b5563; line-height: 1.5; }
54
+ button, a.button { display: inline-flex; align-items: center; justify-content: center; min-height: 34px; border: 1px solid #111827; border-radius: 6px; background: #111827; color: white; padding: 7px 12px; font-weight: 700; text-decoration: none; cursor: pointer; }
55
+ .muted { color: #6b7280; font-size: 13px; }
56
+ </style>
57
+ </head>
58
+ <body>
59
+ <div class="box">
60
+ <h1>Import draft</h1>
61
+ <p id="status">${autoOpen ? "Opening the registry authorization window..." : "Click Open registry to authorize the import."}</p>
62
+ <button id="open" type="button">Open registry</button>
63
+ <div class="muted" style="margin-top:12px;">Keep this Pinokio window open until the registry draft editor opens.</div>
64
+ </div>
65
+ <script>
66
+ window.__PINOKIO_DRAFT_IMPORT_VERSION = "metadata-b64";
67
+ const authorizeUrl = ${JSON.stringify(authorizeUrl)};
68
+ const draftId = ${JSON.stringify(draftId)};
69
+ const registryOrigin = ${JSON.stringify(registryOrigin)};
70
+ const autoOpen = ${JSON.stringify(Boolean(autoOpen))};
71
+ const statusEl = document.getElementById("status");
72
+ const openButton = document.getElementById("open");
73
+ let registryWindow = null;
74
+
75
+ function setStatus(message) {
76
+ statusEl.textContent = message;
77
+ }
78
+
79
+ function openRegistry() {
80
+ registryWindow = window.open(authorizeUrl, "pinokioRegistryDraftImport", "popup,width=760,height=820");
81
+ if (!registryWindow) {
82
+ setStatus("The registry window was blocked. Click Open registry to continue.");
83
+ } else {
84
+ setStatus("Authorize the import in the registry window.");
85
+ }
86
+ }
87
+
88
+ async function completeImport(payload) {
89
+ setStatus("Uploading draft to the registry...");
90
+ const response = await fetch("/registry/draft-import/complete", {
91
+ method: "POST",
92
+ headers: { "Content-Type": "application/json" },
93
+ body: JSON.stringify({
94
+ draft: draftId,
95
+ token: payload.token,
96
+ registry: payload.registry,
97
+ app: payload.app || ""
98
+ })
99
+ });
100
+ const data = await response.json().catch(() => null);
101
+ if (!response.ok || !data || !data.editUrl) {
102
+ const detail = data && data.status ? " (" + data.status + ")" : "";
103
+ throw new Error(((data && data.error) || "Import failed.") + detail);
104
+ }
105
+ try {
106
+ if (registryWindow && !registryWindow.closed) registryWindow.close();
107
+ } catch (_) {}
108
+ window.location.href = data.editUrl;
109
+ }
110
+
111
+ window.addEventListener("message", (event) => {
112
+ if (event.origin !== registryOrigin) return;
113
+ const payload = event.data || {};
114
+ if (payload.type !== "pinokio:draft-import-token" || !payload.token || !payload.registry) return;
115
+ completeImport(payload).catch((error) => {
116
+ setStatus(error && error.message ? error.message : "Import failed.");
117
+ });
118
+ });
119
+
120
+ openButton.addEventListener("click", openRegistry);
121
+ if (autoOpen) {
122
+ window.setTimeout(openRegistry, 100);
123
+ }
124
+ </script>
125
+ </body>
126
+ </html>`)
127
+ }
128
+
129
+ function normalizeRegistryBase(raw, fallback) {
130
+ const value = String(raw || fallback || "").trim()
131
+ if (!value) return ""
132
+ try {
133
+ const url = new URL(value)
134
+ if (url.protocol !== "https:" && url.protocol !== "http:") return ""
135
+ url.hash = ""
136
+ url.search = ""
137
+ return url.toString().replace(/\/$/, "")
138
+ } catch (_) {
139
+ return ""
140
+ }
141
+ }
142
+
143
+ function requestOrigin(req) {
144
+ const host = req.get("host") || "localhost:42000"
145
+ return `${req.protocol || "http"}://${host}`
146
+ }
147
+
148
+ function isExternalRef(value) {
149
+ return /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(value)
150
+ }
151
+
152
+ function normalizeMarkdownRef(value) {
153
+ const raw = String(value || "").trim().replace(/^<|>$/g, "")
154
+ if (!raw || raw.includes("\0") || isExternalRef(raw) || path.isAbsolute(raw)) {
155
+ return ""
156
+ }
157
+ const withoutHash = raw.split("#")[0]
158
+ const withoutQuery = withoutHash.split("?")[0]
159
+ if (!withoutQuery) {
160
+ return ""
161
+ }
162
+ let decoded = withoutQuery
163
+ try {
164
+ decoded = decodeURIComponent(withoutQuery)
165
+ } catch (_) {
166
+ }
167
+ const normalized = path.posix.normalize(decoded.replace(/\\/g, "/"))
168
+ if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) {
169
+ return ""
170
+ }
171
+ return normalized
172
+ }
173
+
174
+ function collectMarkdownRefs(markdown) {
175
+ const refs = []
176
+ const seen = new Set()
177
+ const patterns = [
178
+ /!\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g,
179
+ /\[(?:video|audio|media|image|screenshot|file|asset)[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/gi,
180
+ /\[[^\]]+]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g
181
+ ]
182
+ for (const pattern of patterns) {
183
+ let match = null
184
+ while ((match = pattern.exec(markdown))) {
185
+ const ref = normalizeMarkdownRef(match[1])
186
+ if (!ref || seen.has(ref)) continue
187
+ seen.add(ref)
188
+ refs.push(ref)
189
+ }
190
+ }
191
+ return refs
192
+ }
193
+
194
+ function extractTitleAndBody(markdown, fallbackTitle) {
195
+ const lines = String(markdown || "").split(/\r?\n/)
196
+ for (let i = 0; i < lines.length; i += 1) {
197
+ const match = lines[i].match(/^#\s+(.+?)\s*#*\s*$/)
198
+ if (!match || !match[1]) continue
199
+ const title = match[1].replace(/\s+/g, " ").trim().slice(0, 160)
200
+ const bodyLines = [...lines.slice(0, i), ...lines.slice(i + 1)]
201
+ while (bodyLines.length && !bodyLines[0].trim()) bodyLines.shift()
202
+ return { title: title || fallbackTitle, body: bodyLines.join("\n").trim() }
203
+ }
204
+ return { title: fallbackTitle, body: String(markdown || "").trim() }
205
+ }
206
+
207
+ async function findDraftById(drafts, id) {
208
+ const normalized = String(id || "").trim()
209
+ if (!normalized) return null
210
+ const items = await drafts.listPending({})
211
+ return (items || []).find((item) => item && item.id === normalized) || null
212
+ }
213
+
214
+ async function describeMedia(markdown, baseDir) {
215
+ const refs = collectMarkdownRefs(markdown)
216
+ const media = []
217
+ for (const ref of refs) {
218
+ const filePath = path.resolve(baseDir, ref)
219
+ const relative = path.relative(baseDir, filePath)
220
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) continue
221
+ const stats = await fs.promises.stat(filePath).catch(() => null)
222
+ if (!stats || !stats.isFile()) {
223
+ media.push({ ref, path: filePath, exists: false, bytes: 0 })
224
+ continue
225
+ }
226
+ media.push({ ref, path: filePath, exists: true, bytes: stats.size })
227
+ }
228
+ return media
229
+ }
230
+
231
+ async function buildDraftBundle(item, query = {}) {
232
+ const markdown = await fs.promises.readFile(item.postPath, "utf8")
233
+ const resultDir = path.dirname(item.postPath)
234
+ const titleFallback = item.title || (item.workspaceName ? `Draft for ${item.workspaceName}` : "Draft")
235
+ const { title, body } = extractTitleAndBody(markdown, titleFallback)
236
+ const media = await describeMedia(markdown, resultDir)
237
+ return {
238
+ title,
239
+ body,
240
+ appSlug: String(query.app || "").trim(),
241
+ media
242
+ }
243
+ }
244
+
245
+ function preflightBundle(bundle, options = {}) {
246
+ const maxFiles = Number(options.maxFiles || DEFAULT_MAX_FILES)
247
+ const maxFileBytes = Number(options.maxFileBytes || DEFAULT_MAX_FILE_BYTES)
248
+ if (!bundle.title) {
249
+ return "Draft title is missing."
250
+ }
251
+ if (bundle.media.length > maxFiles) {
252
+ return `Draft has ${bundle.media.length} media files. The registry limit is ${maxFiles}.`
253
+ }
254
+ const missing = bundle.media.filter((item) => !item.exists)
255
+ if (missing.length > 0) {
256
+ return `Draft references missing media: ${missing.map((item) => item.ref).join(", ")}`
257
+ }
258
+ const oversized = bundle.media.find((item) => item.bytes > maxFileBytes)
259
+ if (oversized) {
260
+ return `Media file is too large: ${oversized.ref}. The per-file limit is ${Math.round(maxFileBytes / 1024 / 1024)} MB.`
261
+ }
262
+ return ""
263
+ }
264
+
265
+ async function uploadBundle(registryBase, token, bundle) {
266
+ const form = new FormData()
267
+ const metadata = JSON.stringify({
268
+ title: bundle.title,
269
+ body: bundle.body,
270
+ app: bundle.appSlug || "",
271
+ media: bundle.media.map((item) => ({ path: item.ref }))
272
+ })
273
+ form.append("metadata_b64", Buffer.from(metadata, "utf8").toString("base64"))
274
+ for (const item of bundle.media) {
275
+ form.append("files", fs.createReadStream(item.path), {
276
+ filename: path.basename(item.ref),
277
+ contentType: mime.lookup(item.path) || "application/octet-stream",
278
+ knownLength: item.bytes
279
+ })
280
+ }
281
+ const endpoint = `${registryBase}/registry-bridge/draft-imports`
282
+ const headers = {
283
+ Authorization: `Bearer ${token}`,
284
+ ...form.getHeaders()
285
+ }
286
+ const contentLength = await new Promise((resolve) => {
287
+ form.getLength((error, length) => resolve(error ? null : length))
288
+ })
289
+ if (Number.isFinite(contentLength)) {
290
+ headers["Content-Length"] = contentLength
291
+ }
292
+ console.log("[draft-import] request", {
293
+ endpoint,
294
+ media: bundle.media.length,
295
+ contentLength: Number.isFinite(contentLength) ? contentLength : null
296
+ })
297
+ const response = await axios.post(endpoint, form, {
298
+ timeout: 180000,
299
+ maxContentLength: Infinity,
300
+ maxBodyLength: Infinity,
301
+ validateStatus: () => true,
302
+ headers
303
+ })
304
+ if (response.status < 200 || response.status >= 300) {
305
+ const error = new Error(
306
+ response.data && response.data.error
307
+ ? String(response.data.error)
308
+ : `Registry upload failed with status ${response.status}.`
309
+ )
310
+ error.status = response.status
311
+ error.registryEndpoint = endpoint
312
+ error.responseData = response.data
313
+ throw error
314
+ }
315
+ return response.data || {}
316
+ }
317
+
318
+ async function uploadDraftFromRequest(drafts, query, token, registryBase, options = {}) {
319
+ const item = await findDraftById(drafts, query.draft)
320
+ if (!item) {
321
+ const error = new Error("The local draft is no longer available.")
322
+ error.status = 404
323
+ throw error
324
+ }
325
+ const bundle = await buildDraftBundle(item, query)
326
+ const problem = preflightBundle(bundle, options)
327
+ if (problem) {
328
+ const error = new Error(problem)
329
+ error.status = 400
330
+ throw error
331
+ }
332
+ return uploadBundle(registryBase, token, bundle)
333
+ }
334
+
335
+ function registerDraftImportRoutes(app, options = {}) {
336
+ const drafts = options.drafts
337
+ if (!drafts) {
338
+ throw new Error("drafts is required")
339
+ }
340
+ const defaultRegistryUrl = options.defaultRegistryUrl || "https://beta.pinokio.co"
341
+ const router = express.Router()
342
+
343
+ router.get("/registry/draft-import/authorize-url", asyncHandler(async (req, res) => {
344
+ const item = await findDraftById(drafts, req.query.draft)
345
+ if (!item) {
346
+ return res.status(404).json({ error: "The local draft is no longer available." })
347
+ }
348
+ const bundle = await buildDraftBundle(item, req.query)
349
+ const problem = preflightBundle(bundle, options)
350
+ if (problem) {
351
+ return res.status(400).json({ error: problem })
352
+ }
353
+ const registryBase = normalizeRegistryBase(req.query.registry, defaultRegistryUrl)
354
+ if (!registryBase) {
355
+ return res.status(400).json({ error: "The registry URL is invalid." })
356
+ }
357
+ const authorizeUrl = new URL("/draft-import/authorize", registryBase)
358
+ authorizeUrl.searchParams.set("handoff", "post_message")
359
+ authorizeUrl.searchParams.set("origin", requestOrigin(req))
360
+ authorizeUrl.searchParams.set("wait", "1")
361
+ if (bundle.appSlug) authorizeUrl.searchParams.set("app", bundle.appSlug)
362
+ res.setHeader("Cache-Control", "no-store")
363
+ return res.json({
364
+ draftId: item.id,
365
+ authorizeUrl: authorizeUrl.toString(),
366
+ registryOrigin: new URL(registryBase).origin
367
+ })
368
+ }))
369
+
370
+ router.get("/registry/draft-import/start", asyncHandler(async (req, res) => {
371
+ const item = await findDraftById(drafts, req.query.draft)
372
+ if (!item) {
373
+ return renderMessage(res, 404, "Draft not found", "The local draft is no longer available.")
374
+ }
375
+ const bundle = await buildDraftBundle(item, req.query)
376
+ const problem = preflightBundle(bundle, options)
377
+ if (problem) {
378
+ return renderMessage(res, 400, "Draft is not ready", problem)
379
+ }
380
+ const registryBase = normalizeRegistryBase(req.query.registry, defaultRegistryUrl)
381
+ if (!registryBase) {
382
+ return renderMessage(res, 400, "Registry unavailable", "The registry URL is invalid.")
383
+ }
384
+ const authorizeUrl = new URL("/draft-import/authorize", registryBase)
385
+ authorizeUrl.searchParams.set("handoff", "post_message")
386
+ authorizeUrl.searchParams.set("origin", requestOrigin(req))
387
+ if (bundle.appSlug) authorizeUrl.searchParams.set("app", bundle.appSlug)
388
+ res.setHeader("Cache-Control", "no-store")
389
+ res.setHeader("Cross-Origin-Opener-Policy", "unsafe-none")
390
+ return renderImportLauncher(res, {
391
+ authorizeUrl: authorizeUrl.toString(),
392
+ draftId: item.id,
393
+ registryOrigin: new URL(registryBase).origin,
394
+ autoOpen: req.query.auto === "1"
395
+ })
396
+ }))
397
+
398
+ router.post("/registry/draft-import/complete", asyncHandler(async (req, res) => {
399
+ const token = String(req.body && req.body.token || "").trim()
400
+ if (!token) {
401
+ return res.status(400).json({ error: "Missing registry token." })
402
+ }
403
+ const registryBase = normalizeRegistryBase(req.body && req.body.registry, defaultRegistryUrl)
404
+ if (!registryBase) {
405
+ return res.status(400).json({ error: "The registry URL is invalid." })
406
+ }
407
+ try {
408
+ console.log("[draft-import] uploading", {
409
+ draft: req.body && req.body.draft,
410
+ registry: registryBase,
411
+ app: req.body && req.body.app ? String(req.body.app) : ""
412
+ })
413
+ const result = await uploadDraftFromRequest(
414
+ drafts,
415
+ { draft: req.body && req.body.draft, app: req.body && req.body.app },
416
+ token,
417
+ registryBase,
418
+ options
419
+ )
420
+ if (result && result.editUrl) {
421
+ return res.json({ ok: true, editUrl: String(result.editUrl) })
422
+ }
423
+ return res.json({ ok: true, editUrl: registryBase })
424
+ } catch (error) {
425
+ const response = error && error.response
426
+ const status = response && response.status ? response.status : (error && error.status ? error.status : 500)
427
+ const endpoint = error && error.registryEndpoint ? error.registryEndpoint : `${registryBase}/registry-bridge/draft-imports`
428
+ const responseData = response && response.data ? response.data : (error && error.responseData ? error.responseData : null)
429
+ console.warn("[draft-import] upload failed", {
430
+ status,
431
+ endpoint,
432
+ error: error && error.message ? error.message : "Upload failed.",
433
+ response: typeof responseData === "string" ? responseData.slice(0, 500) : responseData
434
+ })
435
+ const message = response && response.data && response.data.error
436
+ ? response.data.error
437
+ : (error && error.message ? error.message : "Upload failed.")
438
+ return res.status(status).json({ error: message, status, endpoint })
439
+ }
440
+ }))
441
+
442
+ router.get("/registry/draft-import/callback", asyncHandler(async (req, res) => {
443
+ const token = String(req.query.token || "").trim()
444
+ if (!token) {
445
+ return renderMessage(res, 400, "Missing token", "The registry did not return an import token.")
446
+ }
447
+ const registryBase = normalizeRegistryBase(req.query.registry, defaultRegistryUrl)
448
+ if (!registryBase) {
449
+ return renderMessage(res, 400, "Registry unavailable", "The registry URL is invalid.")
450
+ }
451
+ try {
452
+ const result = await uploadDraftFromRequest(drafts, req.query, token, registryBase, options)
453
+ if (result && result.editUrl) {
454
+ return res.redirect(String(result.editUrl))
455
+ }
456
+ return renderMessage(res, 200, "Draft imported", "The registry accepted the draft.")
457
+ } catch (error) {
458
+ const response = error && error.response
459
+ const message = response && response.data && response.data.error
460
+ ? response.data.error
461
+ : (error && error.message ? error.message : "Upload failed.")
462
+ return renderMessage(res, response && response.status ? response.status : 500, "Import failed", message)
463
+ }
464
+ }))
465
+
466
+ app.use(router)
467
+ }
468
+
469
+ module.exports = registerDraftImportRoutes
@@ -0,0 +1,44 @@
1
+ const express = require("express")
2
+
3
+ function registerWorkspacesRoutes(app, options = {}) {
4
+ const {
5
+ workspaceCatalog,
6
+ composePeerAccessPayload,
7
+ getTheme,
8
+ getPeers,
9
+ getCurrentHost,
10
+ getPortal,
11
+ } = options
12
+
13
+ if (!workspaceCatalog) {
14
+ throw new Error("workspaceCatalog is required")
15
+ }
16
+
17
+ const router = express.Router()
18
+
19
+ router.get("/workspaces", async (req, res, next) => {
20
+ try {
21
+ const catalog = await workspaceCatalog.list({ sort: req.query.sort })
22
+ res.render("workspaces", {
23
+ title: "Workspaces",
24
+ sidebarSelected: "workspaces",
25
+ workspaceCatalog: catalog,
26
+ theme: getTheme ? getTheme(req) : null,
27
+ peers: getPeers ? getPeers() : [],
28
+ currentHost: getCurrentHost ? getCurrentHost(req) : null,
29
+ portal: getPortal ? getPortal(req) : null,
30
+ peerAccess: composePeerAccessPayload ? composePeerAccessPayload(req) : null,
31
+ })
32
+ } catch (err) {
33
+ next(err)
34
+ }
35
+ })
36
+
37
+ router.get("/activity", (req, res) => {
38
+ res.redirect("/workspaces")
39
+ })
40
+
41
+ app.use(router)
42
+ }
43
+
44
+ module.exports = registerWorkspacesRoutes
package/server/socket.js CHANGED
@@ -284,7 +284,7 @@ class Socket {
284
284
  } else {
285
285
  let buf = this.buffer[id]
286
286
  let sh = this.active_shell[id]
287
- this.subscribe(ws, id, buf, sh)
287
+ this.subscribe(ws, id, buf, sh, req)
288
288
  if (req.mode !== "listen") {
289
289
  // Run only if currently not running
290
290
  if (!this.parent.kernel.api.running[id]) {
@@ -308,7 +308,7 @@ class Socket {
308
308
  if (req.id) {
309
309
  let buf = this.buffer[req.id]
310
310
  let sh = this.active_shell[req.id]
311
- this.subscribe(ws, req.id, buf, sh)
311
+ this.subscribe(ws, req.id, buf, sh, req)
312
312
  if (req.mode === "listen") {
313
313
  return
314
314
  }
@@ -345,13 +345,7 @@ class Socket {
345
345
  // Mark local client sockets by IP matching any local address
346
346
  try {
347
347
  const ip = ws._ip || ''
348
- const isLocal = (addr) => {
349
- if (!addr || typeof addr !== 'string') return false
350
- if (this.localAddresses.has(addr)) return true
351
- const v = addr.trim().toLowerCase()
352
- return v.startsWith('::ffff:127.') || v.startsWith('127.')
353
- }
354
- ws._isLocalClient = isLocal(ip)
348
+ ws._isLocalClient = this.isLocalAddress(ip)
355
349
  if (ws._isLocalClient && ws._deviceId) {
356
350
  this.localDeviceIds.add(ws._deviceId)
357
351
  }
@@ -431,18 +425,22 @@ class Socket {
431
425
  this.old_buffer = structuredClone(this.buffer)
432
426
  }, 5000)
433
427
  }
434
- subscribe(ws, id, buf, sh) {
428
+ subscribe(ws, id, buf, sh, req = {}) {
435
429
  let resolvedShellId = sh || null
436
430
  let resolvedState = buf
437
431
  let hasState = typeof resolvedState === "string" ? resolvedState.length > 0 : Boolean(resolvedState)
432
+ let resolvedShell = null
438
433
  if ((!resolvedShellId || !hasState) && this.parent && this.parent.kernel && this.parent.kernel.shell && Array.isArray(this.parent.kernel.shell.shells)) {
439
- const groupedShell = this.parent.kernel.shell.shells.find((candidate) => {
434
+ const directShell = this.parent.kernel.shell.get(id)
435
+ const liveDirectShell = directShell && directShell.done !== true ? directShell : null
436
+ const groupedShell = liveDirectShell || this.parent.kernel.shell.shells.find((candidate) => {
440
437
  return candidate
441
438
  && candidate.done !== true
442
439
  && typeof candidate.group === "string"
443
440
  && candidate.group === id
444
441
  })
445
442
  if (groupedShell) {
443
+ resolvedShell = groupedShell
446
444
  if (!resolvedShellId) {
447
445
  resolvedShellId = groupedShell.id
448
446
  }
@@ -455,6 +453,12 @@ class Socket {
455
453
  }
456
454
  }
457
455
  }
456
+ if (!resolvedShell && resolvedShellId && this.parent && this.parent.kernel && this.parent.kernel.shell) {
457
+ resolvedShell = this.parent.kernel.shell.get(resolvedShellId)
458
+ }
459
+ if (resolvedShell && req && req.input) {
460
+ resolvedShell.input = true
461
+ }
458
462
  if (this.parent.kernel.api.running[id] || resolvedShellId || hasState) {
459
463
  ws.send(JSON.stringify({
460
464
  type: "connect",
@@ -652,6 +656,13 @@ class Socket {
652
656
  return this.localDeviceIds.has(deviceId)
653
657
  }
654
658
 
659
+ isLocalAddress(addr) {
660
+ if (!addr || typeof addr !== 'string') return false
661
+ if (this.localAddresses.has(addr)) return true
662
+ const v = addr.trim().toLowerCase()
663
+ return v === 'localhost' || v === '::1' || v.startsWith('::ffff:127.') || v.startsWith('127.')
664
+ }
665
+
655
666
  ensureNotificationBridge() {
656
667
  if (this.notificationBridgeDispose) {
657
668
  return
@@ -5222,6 +5222,9 @@ header.navheader .mode-selector .community-mode-toggle {
5222
5222
  <a class='btn mobile-sheet-action' href="/home" aria-label="Home" title="Home">
5223
5223
  <i class="fa-solid fa-house"></i>
5224
5224
  </a>
5225
+ <a class='btn mobile-sheet-action' href="/workspaces" aria-label="Workspaces" title="Workspaces">
5226
+ <i class="fa-solid fa-folder-tree"></i>
5227
+ </a>
5225
5228
  <button type='button' class='btn mobile-sheet-action' data-mobile-proxy="#refresh-page" data-mobile-close-menu="true" aria-label="Refresh" title="Refresh">
5226
5229
  <i class="fa-solid fa-rotate-right"></i>
5227
5230
  </button>
@@ -5290,6 +5293,13 @@ header.navheader .mode-selector .community-mode-toggle {
5290
5293
  </div>
5291
5294
  </div>
5292
5295
  <div class='menu-actions'>
5296
+ <a id='workspaces-tab' href="/workspaces" class="btn header-item" data-tippy-content="Workspaces">
5297
+ <div class='tab'>
5298
+ <i class="fa-solid fa-folder-tree menu-action-leading-icon"></i>
5299
+ <div class='display'>Workspaces</div>
5300
+ <div class='flexible'></div>
5301
+ </div>
5302
+ </a>
5293
5303
  <% if (type === 'run') { %>
5294
5304
  <button type='button' id='ask-ai-tab' class="btn header-item" data-static="ask-ai" data-workspace="<%=name%>" data-workspace-cwd="<%=path%>" data-ask-ai-trigger="true" data-tippy-content="Ask AI">
5295
5305
  <div class='tab'>
@@ -5605,6 +5615,9 @@ header.navheader .mode-selector .community-mode-toggle {
5605
5615
  <a class='btn mobile-sheet-action' href="/home" aria-label="Home" title="Home">
5606
5616
  <i class="fa-solid fa-house"></i>
5607
5617
  </a>
5618
+ <a class='btn mobile-sheet-action' href="/workspaces" aria-label="Workspaces" title="Workspaces">
5619
+ <i class="fa-solid fa-folder-tree"></i>
5620
+ </a>
5608
5621
  <button type='button' class='btn mobile-sheet-action' data-mobile-proxy="#refresh-page" data-mobile-close-menu="true" aria-label="Refresh" title="Refresh">
5609
5622
  <i class="fa-solid fa-rotate-right"></i>
5610
5623
  </button>
@@ -27,6 +27,7 @@
27
27
  </button>
28
28
  </div>
29
29
  <a href="/home" class="tab <%= sidebarSelected === 'home' ? 'selected' : '' %>" data-tippy-content="This machine"><i class='fas fa-laptop-code'></i><div class='caption'>This machine</div></a>
30
+ <a href="/workspaces" class="tab <%= sidebarSelected === 'workspaces' ? 'selected' : '' %>" data-tippy-content="Workspaces"><i class="fa-solid fa-folder-tree"></i><div class='caption'>Workspaces</div></a>
30
31
  <a href="/network" class="tab <%= sidebarSelected === 'network' ? 'selected' : '' %>" data-tippy-content="Local network"><i class="fa-solid fa-wifi"></i><div class='caption'>Local network</div></a>
31
32
  <% if (sidebarList.length > 0) { %>
32
33
  <% sidebarList.forEach(({ host, name, platform }) => { %>