@venturewild/workspace 0.6.8 → 0.6.9
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/package.json
CHANGED
package/server/src/index.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { serve } from '@hono/node-server';
|
|
|
10
10
|
import { WebSocketServer } from 'ws';
|
|
11
11
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync } from 'node:fs';
|
|
12
12
|
import path from 'node:path';
|
|
13
|
+
import os from 'node:os';
|
|
13
14
|
import url from 'node:url';
|
|
14
15
|
import {
|
|
15
16
|
buildConfig,
|
|
@@ -32,6 +33,7 @@ import {
|
|
|
32
33
|
import { PairingStore } from './pairing.mjs';
|
|
33
34
|
import { verifyGoogleVouch, emailMatches } from './google-vouch.mjs';
|
|
34
35
|
import { listDir, readFile, fullTree, workspaceSummary, safeResolve } from './fs.mjs';
|
|
36
|
+
import { browseDir, browseRoots } from './lobby-browse.mjs';
|
|
35
37
|
import { InboxWatcher } from './inbox.mjs';
|
|
36
38
|
import { ActivityBus } from './activity.mjs';
|
|
37
39
|
import { createWorkspacePresence } from './workspace-presence.mjs';
|
|
@@ -2043,11 +2045,13 @@ export async function createServer(overrides = {}) {
|
|
|
2043
2045
|
// --- lobby: the per-install workspace list (lobby M1) ---
|
|
2044
2046
|
// One install holds many local workspaces; the lobby lists them and the user
|
|
2045
2047
|
// picks one to enter (the client then sends X-Workspace-Id to re-root the agent,
|
|
2046
|
-
// files, and chat). Creating
|
|
2047
|
-
//
|
|
2048
|
-
//
|
|
2049
|
-
//
|
|
2050
|
-
//
|
|
2048
|
+
// files, and chat). Creating/opening a workspace targets THIS serving machine,
|
|
2049
|
+
// which identity-first routing guarantees is the owner's OWN host — so the authed
|
|
2050
|
+
// OWNER may create-by-name AND open-an-existing-folder from any device (0.6.7 +
|
|
2051
|
+
// the 2026-06-18 open-by-path fix); entering an existing one works from anywhere.
|
|
2052
|
+
// Gated on `fileTree` (owner-level: never expose the host's other projects to a
|
|
2053
|
+
// shared-link viewer/client). Distinct origin from the bmo-sync rails
|
|
2054
|
+
// `/api/workspaces/*` (those live on the sync server).
|
|
2051
2055
|
const lobbyBootTime = Date.now();
|
|
2052
2056
|
function isHostRequest(c) {
|
|
2053
2057
|
const src = c.get('session')?.source;
|
|
@@ -2131,15 +2135,20 @@ export async function createServer(overrides = {}) {
|
|
|
2131
2135
|
const name = typeof body.name === 'string' ? body.name.trim() : '';
|
|
2132
2136
|
const dir = typeof body.dir === 'string' ? body.dir.trim() : '';
|
|
2133
2137
|
if (!name && !dir) return c.json({ error: 'name_or_dir_required' }, 400);
|
|
2134
|
-
// Open-an-existing-folder-by-PATH
|
|
2135
|
-
//
|
|
2136
|
-
//
|
|
2137
|
-
//
|
|
2138
|
-
//
|
|
2139
|
-
//
|
|
2140
|
-
//
|
|
2141
|
-
//
|
|
2142
|
-
|
|
2138
|
+
// Open-an-existing-folder-by-PATH: the authenticated OWNER may do this from ANY
|
|
2139
|
+
// device, exactly like create-by-name. The path resolves on THIS serving host,
|
|
2140
|
+
// which identity-first routing guarantees is the owner's OWN machine (§5.1
|
|
2141
|
+
// "authorize on membership, not device"; §5.2 "a member's every device → their
|
|
2142
|
+
// own host"), and the partner role already holds the file tree AND a terminal on
|
|
2143
|
+
// that host — so opening one of their own folders grants nothing new. The old
|
|
2144
|
+
// host-only gate conflated "remote owner" with "shared-link viewer": the
|
|
2145
|
+
// `fileTree` guard above already bars viewers/clients (same bug class as the
|
|
2146
|
+
// 0.6.7 create-by-name fix; supersedes the §5.3 host-only note for the owner).
|
|
2147
|
+
// The consented OPERATOR support channel is deliberately still NOT allowed to
|
|
2148
|
+
// open arbitrary host folders by path — that reaches beyond its diagnose/
|
|
2149
|
+
// remediate scope. So: the owner from anywhere, or the on-box host.
|
|
2150
|
+
const isOwner = c.get('role') === ROLES.PARTNER;
|
|
2151
|
+
if (dir && !isOwner && !isHostRequest(c)) {
|
|
2143
2152
|
return c.json(
|
|
2144
2153
|
{
|
|
2145
2154
|
error: 'host_only',
|
|
@@ -2164,6 +2173,33 @@ export async function createServer(overrides = {}) {
|
|
|
2164
2173
|
}
|
|
2165
2174
|
});
|
|
2166
2175
|
|
|
2176
|
+
// Folder picker for "open an existing folder": browse the SERVING host's
|
|
2177
|
+
// directories so the owner navigates + picks a folder instead of typing a path.
|
|
2178
|
+
// Gated identically to open-by-path — the authed OWNER from any device, or the
|
|
2179
|
+
// on-box host; never a viewer/client (`require` fileTree) and never the operator
|
|
2180
|
+
// (it would let a support session enumerate the whole host disk). The owner
|
|
2181
|
+
// already holds a terminal + file tree on its own host, so this is no new reach.
|
|
2182
|
+
app.get('/api/lobby/browse', (c) => {
|
|
2183
|
+
const forbidden = require(c, 'fileTree');
|
|
2184
|
+
if (forbidden) return forbidden;
|
|
2185
|
+
const isOwner = c.get('role') === ROLES.PARTNER;
|
|
2186
|
+
if (!isOwner && !isHostRequest(c)) {
|
|
2187
|
+
return c.json(
|
|
2188
|
+
{ error: 'host_only', message: 'Browsing folders is available to the workspace owner.' },
|
|
2189
|
+
403,
|
|
2190
|
+
);
|
|
2191
|
+
}
|
|
2192
|
+
const home = os.homedir();
|
|
2193
|
+
const workspacesHome = registryEnv.WILD_WORKSPACE_HOME || path.join(home, 'Workspaces');
|
|
2194
|
+
const reqPath = c.req.query('path');
|
|
2195
|
+
try {
|
|
2196
|
+
const listing = browseDir(reqPath && reqPath.trim() ? reqPath.trim() : home);
|
|
2197
|
+
return c.json({ ...listing, roots: browseRoots({ home, workspacesHome }) });
|
|
2198
|
+
} catch (e) {
|
|
2199
|
+
return c.json({ error: e.code || 'browse_failed', message: String(e.message || e) }, 400);
|
|
2200
|
+
}
|
|
2201
|
+
});
|
|
2202
|
+
|
|
2167
2203
|
app.post('/api/lobby/workspaces/:id/open', (c) => {
|
|
2168
2204
|
const forbidden = require(c, 'fileTree');
|
|
2169
2205
|
if (forbidden) return forbidden;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Folder picker backend for the lobby's "open an existing folder".
|
|
2
|
+
//
|
|
3
|
+
// Lists DIRECTORIES on the serving host so the owner can navigate + pick a folder
|
|
4
|
+
// to open as a workspace, instead of typing an absolute path (the bug Tuan hit:
|
|
5
|
+
// from the public URL there was no picker, only "name a new workspace", so an
|
|
6
|
+
// existing folder silently became a new empty one).
|
|
7
|
+
//
|
|
8
|
+
// Host-WIDE, NOT rooted to a workspace — so it is OWNER-gated at the route (the
|
|
9
|
+
// same identity gate as open-by-path). That's safe: the partner role already holds
|
|
10
|
+
// a terminal + file tree on its own host, so enumerating its own directories
|
|
11
|
+
// exposes nothing new. Directories only (you open a folder); hidden/dot entries
|
|
12
|
+
// skipped; an unreadable child is skipped rather than failing the whole listing.
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
function isDirSafe(p) {
|
|
18
|
+
try {
|
|
19
|
+
return fs.statSync(p).isDirectory();
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Quick-access roots offered beside the listing — only the ones that actually
|
|
26
|
+
// exist, de-duped by resolved path (Home/Desktop/Documents can coincide).
|
|
27
|
+
export function browseRoots({ home, workspacesHome } = {}) {
|
|
28
|
+
const candidates = [
|
|
29
|
+
{ name: 'Home', dir: home },
|
|
30
|
+
{ name: 'Desktop', dir: home && path.join(home, 'Desktop') },
|
|
31
|
+
{ name: 'Documents', dir: home && path.join(home, 'Documents') },
|
|
32
|
+
{ name: 'Workspaces', dir: workspacesHome },
|
|
33
|
+
];
|
|
34
|
+
const seen = new Set();
|
|
35
|
+
const out = [];
|
|
36
|
+
for (const r of candidates) {
|
|
37
|
+
if (!r.dir) continue;
|
|
38
|
+
const abs = path.resolve(r.dir);
|
|
39
|
+
if (seen.has(abs) || !isDirSafe(abs)) continue;
|
|
40
|
+
seen.add(abs);
|
|
41
|
+
out.push({ name: r.name, dir: abs });
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// List the immediate sub-directories of `target` (absolute path). Returns
|
|
47
|
+
// `{ path, parent, entries:[{name,dir}] }` — `parent` is null at a filesystem
|
|
48
|
+
// root (so the UI knows it can't go up). Throws an Error with `.code` for the
|
|
49
|
+
// route to map to a status: 'not_found' | 'not_a_directory' | 'unreadable'.
|
|
50
|
+
export function browseDir(target) {
|
|
51
|
+
if (typeof target !== 'string' || !target.trim()) {
|
|
52
|
+
const e = new Error('a folder path is required');
|
|
53
|
+
e.code = 'not_found';
|
|
54
|
+
throw e;
|
|
55
|
+
}
|
|
56
|
+
const abs = path.resolve(target);
|
|
57
|
+
let stat;
|
|
58
|
+
try {
|
|
59
|
+
stat = fs.statSync(abs);
|
|
60
|
+
} catch {
|
|
61
|
+
const e = new Error(`folder not found: ${abs}`);
|
|
62
|
+
e.code = 'not_found';
|
|
63
|
+
throw e;
|
|
64
|
+
}
|
|
65
|
+
if (!stat.isDirectory()) {
|
|
66
|
+
const e = new Error(`not a folder: ${abs}`);
|
|
67
|
+
e.code = 'not_a_directory';
|
|
68
|
+
throw e;
|
|
69
|
+
}
|
|
70
|
+
let dirents;
|
|
71
|
+
try {
|
|
72
|
+
dirents = fs.readdirSync(abs, { withFileTypes: true });
|
|
73
|
+
} catch (err) {
|
|
74
|
+
const e = new Error(String(err?.message || err));
|
|
75
|
+
e.code = 'unreadable';
|
|
76
|
+
throw e;
|
|
77
|
+
}
|
|
78
|
+
const entries = dirents
|
|
79
|
+
.filter((d) => {
|
|
80
|
+
if (d.name.startsWith('.')) return false; // hidden / dotfolders
|
|
81
|
+
if (d.isDirectory()) return true;
|
|
82
|
+
// Follow symlinks: a link to a directory is still a pickable folder.
|
|
83
|
+
if (d.isSymbolicLink()) return isDirSafe(path.join(abs, d.name));
|
|
84
|
+
return false;
|
|
85
|
+
})
|
|
86
|
+
.map((d) => ({ name: d.name, dir: path.join(abs, d.name) }))
|
|
87
|
+
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
|
|
88
|
+
const parent = path.dirname(abs);
|
|
89
|
+
return { path: abs, parent: parent !== abs ? parent : null, entries };
|
|
90
|
+
}
|