@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@venturewild/workspace",
3
- "version": "0.6.8",
3
+ "version": "0.6.9",
4
4
  "description": "Claude Code Web — Replit/Lovable-style chat-first browser UI that wraps the AI agent already installed on your machine.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -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 is a HOST action the folder lives on THIS machine
2047
- // so it's refused for remote/tunnel requests; entering an existing one works
2048
- // from anywhere. Gated on `fileTree` (owner-level: never expose the host's other
2049
- // projects to a shared-link viewer/client). Distinct origin from the bmo-sync
2050
- // rails `/api/workspaces/*` (those live on the sync server).
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 stays HOST-only: it would let a remote
2135
- // session reach into the host's disk (design §5.3 a host never exposes disk
2136
- // outside the shared folder). Create-by-NAME is allowed for the authenticated
2137
- // owner from ANY device: the folder is created on THIS serving host
2138
- // (~/Workspaces/<name>), which identity-first routing guarantees is the
2139
- // member's OWN host (§5.1 "authorization = membership, not device-binding";
2140
- // §5.2 "a member's every device their own host"). `fileTree` above already
2141
- // limits this to owner-level never a shared-link viewer.
2142
- if (dir && !isHostRequest(c)) {
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
+ }