@venturewild/workspace 0.3.3 → 0.3.5

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.3.3",
3
+ "version": "0.3.5",
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": {
@@ -255,12 +255,16 @@ export class AutoUpdater {
255
255
  /**
256
256
  * One auto-update cycle, called on the supervisor's slow timer. Self-rate-limits
257
257
  * via dueForCheck so the timer cadence and the check interval are independent.
258
- * Returns a short status string (exposed for tests/logging).
258
+ * `force` bypasses dueForCheck used by the supervisor's post-boot kick so a
259
+ * RESTART always checks for updates immediately (a reboot is a rare, deliberate
260
+ * signal; without this a reboot within the 6h window never re-checked — why a
261
+ * sleepy/rebooted Mac could sit on a stale version). Returns a short status
262
+ * string (exposed for tests/logging).
259
263
  */
260
- async tick() {
264
+ async tick({ force = false } = {}) {
261
265
  if (this.inProgress) return 'busy';
262
266
  if (!this.enabled()) return 'disabled';
263
- if (!this.dueForCheck()) return 'not-due';
267
+ if (!force && !this.dueForCheck()) return 'not-due';
264
268
  this.inProgress = true;
265
269
  try {
266
270
  touchLastCheck(this.globalDir, this.nowImpl());
@@ -65,6 +65,9 @@ export class DaemonSupervisor {
65
65
  this.globalDir = globalDir;
66
66
  this.pidFile = path.join(globalDir, 'daemon.pid');
67
67
  this.logFile = path.join(globalDir, 'daemon.log');
68
+ // Serializes concurrent ensureRunning() spawns (server + supervisor) so only
69
+ // one daemon is started — prevents the duplicate-daemon :8320 bind conflict.
70
+ this.spawnLockFile = path.join(globalDir, 'daemon-spawn.lock');
68
71
  this.resolveBinary = resolveBinary;
69
72
  this.spawnImpl = spawnImpl;
70
73
  this.fetchImpl = fetchImpl;
@@ -89,6 +92,14 @@ export class DaemonSupervisor {
89
92
  /**
90
93
  * Start the daemon unless it is already up. Idempotent and best-effort —
91
94
  * the result is reported, never thrown.
95
+ *
96
+ * Concurrency-safe: the daemon is ensured by BOTH the server (its DaemonBridge)
97
+ * and the always-on supervisor (daemonTick). At boot neither sees the daemon up
98
+ * yet, so without a guard they'd BOTH spawn → two daemons fighting over :8320
99
+ * (`Address already in use`) and both opening proxy links → the relay evicts one
100
+ * → reconnect churn. An atomic cross-process spawn lock serializes the decision:
101
+ * the winner re-checks health then spawns; the loser waits for the winner's
102
+ * daemon instead of spawning its own.
92
103
  * @returns {Promise<{started:boolean, alreadyRunning?:boolean, pid?:number,
93
104
  * error?:string}>}
94
105
  */
@@ -96,7 +107,50 @@ export class DaemonSupervisor {
96
107
  if ((await this.health()).running) {
97
108
  return { started: false, alreadyRunning: true };
98
109
  }
99
- return this.spawnDaemon();
110
+ if (!this.acquireSpawnLock()) {
111
+ // Another process (server bridge / always-on supervisor) is spawning —
112
+ // wait for ITS daemon rather than racing in a second one.
113
+ const up = await this.waitForHealthy(4000);
114
+ return up ? { started: false, alreadyRunning: true } : { started: false, error: 'spawn-in-progress' };
115
+ }
116
+ try {
117
+ // Re-check under the lock: the other caller may have just brought it up.
118
+ if ((await this.health()).running) {
119
+ return { started: false, alreadyRunning: true };
120
+ }
121
+ return this.spawnDaemon();
122
+ } finally {
123
+ this.releaseSpawnLock();
124
+ }
125
+ }
126
+
127
+ /** Is a pid alive? EPERM ("exists, not ours") still counts as alive. */
128
+ pidAlive(pid) {
129
+ try { this.killImpl(pid, 0); return true; } catch (e) { return !!(e && e.code === 'EPERM'); }
130
+ }
131
+
132
+ /**
133
+ * Atomic, cross-process spawn lock (shared globalDir, so it serializes the
134
+ * server and the supervisor). Returns true iff we hold it. A stale lock whose
135
+ * recorded pid is dead is taken over — a crash mid-spawn can't wedge it.
136
+ */
137
+ acquireSpawnLock() {
138
+ try { mkdirSync(this.globalDir, { recursive: true }); } catch { /* surfaced below */ }
139
+ try {
140
+ writeFileSync(this.spawnLockFile, String(process.pid), { flag: 'wx' }); // atomic exclusive create
141
+ return true;
142
+ } catch {
143
+ let holder = null;
144
+ try { holder = Number(readFileSync(this.spawnLockFile, 'utf8').trim()); } catch { /* unreadable */ }
145
+ if (holder && this.pidAlive(holder)) return false; // a live caller is spawning
146
+ try { writeFileSync(this.spawnLockFile, String(process.pid)); return true; } catch { return false; }
147
+ }
148
+ }
149
+
150
+ releaseSpawnLock() {
151
+ try {
152
+ if (Number(readFileSync(this.spawnLockFile, 'utf8').trim()) === process.pid) unlinkSync(this.spawnLockFile);
153
+ } catch { /* already gone */ }
100
154
  }
101
155
 
102
156
  /** Spawn the daemon detached + window-hidden, logging to `daemon.log`. */
@@ -0,0 +1,87 @@
1
+ // Google sign-in vouch (req 2) — the LOCAL-server half of "Sign in with Google".
2
+ //
3
+ // Why a vouch at all: Google's OAuth redirect URI must be a single, pre-registered
4
+ // HTTPS URL, and the code exchange needs the client SECRET — neither can live on a
5
+ // per-user, behind-the-tunnel local server. So bmo-sync (the central relay, which
6
+ // already holds the account registry) owns the Google flow: it exchanges the code,
7
+ // reads the user's verified email, checks it against the workspace's OWNER email,
8
+ // and — only on a match — mints a short-lived VOUCH that it hands back to the local
9
+ // server via `<slug>.venturewild.llc/?gv=<vouch>`. The local server verifies the
10
+ // vouch and mints its own durable device token (reusing the same machinery as a
11
+ // device-approval), so the cookie/role/revocation story is unchanged.
12
+ //
13
+ // The trust anchor is a secret BOTH sides already share, with NO new key to
14
+ // distribute: the account token. bmo-sync stores `sha256(account_token)` in hex
15
+ // (accounts.account_token_hash); the local server has the raw account token and
16
+ // derives the same hex. That hex is the HS256 signing key. A third party can't
17
+ // forge a vouch without the account token (or bmo-sync's at-rest hash), and the
18
+ // local server already trusts bmo-sync (it routes all its traffic), so vouching
19
+ // for identity adds no party that wasn't already in the trust base.
20
+
21
+ import { SignJWT, jwtVerify } from 'jose';
22
+ import { createHash } from 'node:crypto';
23
+
24
+ export const VOUCH_AUDIENCE = 'wild-workspace-google-vouch';
25
+ export const VOUCH_ISSUER = 'bmo-sync';
26
+ // Vouches are single-hop and consumed immediately on the return redirect, so the
27
+ // lifetime is tiny — long enough to survive the 302 + the SPA's POST, no more.
28
+ export const VOUCH_TTL_SECONDS = 120;
29
+
30
+ /**
31
+ * The HS256 key shared with bmo-sync: the lowercase-hex sha256 of the account
32
+ * token, exactly as bmo-sync stores it in `accounts.account_token_hash`
33
+ * (routes/slug.rs::hash_token = `hex::encode(Sha256::digest(token))`). Returns
34
+ * null when the install has no account token (not slug-linked → no Google sign-in).
35
+ */
36
+ export function vouchKey(accountToken) {
37
+ if (!accountToken) return null;
38
+ const hex = createHash('sha256').update(String(accountToken)).digest('hex');
39
+ return new TextEncoder().encode(hex);
40
+ }
41
+
42
+ /**
43
+ * Mint a Google vouch (HS256). This is the contract bmo-sync implements in Rust;
44
+ * we keep a JS minter so the local-server verifier can be tested round-trip and
45
+ * the claim shape has one source of truth. NOT used in production by the server.
46
+ */
47
+ export async function mintGoogleVouch({ accountToken, email, slug, nowSec = Math.floor(Date.now() / 1000), ttlSeconds = VOUCH_TTL_SECONDS }) {
48
+ const key = vouchKey(accountToken);
49
+ if (!key) throw new Error('no account token');
50
+ return new SignJWT({ email: String(email), slug: slug ? String(slug) : undefined })
51
+ .setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
52
+ .setIssuer(VOUCH_ISSUER)
53
+ .setAudience(VOUCH_AUDIENCE)
54
+ .setIssuedAt(nowSec)
55
+ .setExpirationTime(nowSec + ttlSeconds)
56
+ .sign(key);
57
+ }
58
+
59
+ /**
60
+ * Verify a Google vouch against this install's account token. Returns
61
+ * `{ ok: true, email, slug }` or `{ ok: false, reason }`. Never throws. The caller
62
+ * still enforces that `email` matches the configured owner email + `slug` matches
63
+ * this install — the vouch only proves "bmo-sync verified this Google identity for
64
+ * this account", not "this is the owner".
65
+ */
66
+ export async function verifyGoogleVouch(token, accountToken) {
67
+ const key = vouchKey(accountToken);
68
+ if (!token || !key) return { ok: false, reason: 'no-token-or-key' };
69
+ try {
70
+ const { payload } = await jwtVerify(token, key, {
71
+ issuer: VOUCH_ISSUER,
72
+ audience: VOUCH_AUDIENCE,
73
+ });
74
+ if (!payload.email || typeof payload.email !== 'string') {
75
+ return { ok: false, reason: 'no-email' };
76
+ }
77
+ return { ok: true, email: payload.email, slug: typeof payload.slug === 'string' ? payload.slug : null };
78
+ } catch (e) {
79
+ // jose throws on bad signature / expiry / aud-iss mismatch — all "not valid".
80
+ return { ok: false, reason: e?.code || 'invalid' };
81
+ }
82
+ }
83
+
84
+ /** Case-insensitive owner-email match (Google emails are case-insensitive). */
85
+ export function emailMatches(a, b) {
86
+ return Boolean(a) && Boolean(b) && String(a).trim().toLowerCase() === String(b).trim().toLowerCase();
87
+ }
@@ -30,6 +30,7 @@ import {
30
30
  TokenRegistry,
31
31
  } from './share.mjs';
32
32
  import { PairingStore } from './pairing.mjs';
33
+ import { verifyGoogleVouch, emailMatches } from './google-vouch.mjs';
33
34
  import { listDir, readFile, fullTree, workspaceSummary, safeResolve } from './fs.mjs';
34
35
  import { InboxWatcher } from './inbox.mjs';
35
36
  import { ActivityBus } from './activity.mjs';
@@ -588,6 +589,14 @@ export async function createServer(overrides = {}) {
588
589
  return attrs.join('; ');
589
590
  }
590
591
 
592
+ // The host-owner loopback grant (req 1) is only sound when the server is BOUND
593
+ // to loopback — then the port is reachable solely by on-host processes, so the
594
+ // (client-controlled) Host header can't be spoofed from the network. On a
595
+ // 0.0.0.0 / public bind we must NOT trust loopback-looking headers; tokens only.
596
+ const isLocalBind = ['127.0.0.1', 'localhost', '::1', '[::1]'].includes(
597
+ String(config.host || '127.0.0.1').toLowerCase(),
598
+ );
599
+
591
600
  // --- auth + role resolution ---
592
601
  async function resolveRole(c) {
593
602
  // 1. Authorization: Bearer — the only path that may carry the operator token.
@@ -619,7 +628,16 @@ export async function createServer(overrides = {}) {
619
628
  if (!config.publicMode) {
620
629
  return { role: ROLES.PARTNER, sub: 'local-partner', source: 'localhost' };
621
630
  }
622
- // Public mode with no valid token: deny. No anonymous viewer access —
631
+ // Host-machine owner over GENUINE loopback (req 1: "same machine = no
632
+ // approval"). The owner opens localhost on the box that runs the workspace and
633
+ // is in — no device-approval. A tunneled visitor can never reach this: the
634
+ // relay stamps x-forwarded-host on every tunneled request (rejected by
635
+ // loopbackHeaders) and the server binds loopback. The only thing trusted here
636
+ // is an on-host process, which already holds the owner's files + secrets.
637
+ if (isLocalBind && isGenuineLoopback(c)) {
638
+ return { role: ROLES.PARTNER, sub: 'local-owner', source: 'loopback' };
639
+ }
640
+ // Public mode, not local, no valid token: deny. No anonymous viewer access —
623
641
  // a share JWT or the partner token is required. (Concern C1.)
624
642
  return { role: null, sub: 'anon', source: 'unauth', denied: true };
625
643
  }
@@ -692,6 +710,10 @@ export async function createServer(overrides = {}) {
692
710
  // request can reach it; the handler itself 404s anything that isn't real
693
711
  // loopback, so a public visitor can never use it.
694
712
  '/api/auth/bootstrap',
713
+ // Google sign-in return (req 2). A browser coming back from Google has no
714
+ // workspace token yet — it carries a bmo-sync vouch, which the handler
715
+ // verifies (an invalid vouch is rejected there), so being public is safe.
716
+ '/api/auth/google',
695
717
  ]);
696
718
  app.use('*', async (c, next) => {
697
719
  const session = await resolveRole(c);
@@ -760,14 +782,22 @@ export async function createServer(overrides = {}) {
760
782
  // with no forwarding headers + a loopback Host. (B1 — basis for first-device
761
783
  // bootstrap. A local process already has the user's filesystem + secrets, so
762
784
  // trusting it grants nothing it didn't already have.)
763
- function isGenuineLoopback(c) {
764
- const h = (n) => c.req.header(n);
765
- if (h('x-forwarded-for') || h('x-forwarded-host') || h('x-forwarded-proto') || h('x-real-ip')) {
785
+ // Shared loopback predicate over a header getter — reused by the Hono path
786
+ // (isGenuineLoopback) and the raw-Node WS upgrade path (req.headers). A GENUINE
787
+ // local request carries NONE of the relay's forwarding headers (bmo-sync strips
788
+ // any client-supplied X-Forwarded-*/X-Real-IP and stamps its own on every
789
+ // tunneled request, so they can't be faked from the internet) and has a loopback
790
+ // Host. The server binds loopback, so only an on-host process can produce this.
791
+ function loopbackHeaders(get) {
792
+ if (get('x-forwarded-for') || get('x-forwarded-host') || get('x-forwarded-proto') || get('x-real-ip')) {
766
793
  return false;
767
794
  }
768
- const hostname = String(h('host') || '').toLowerCase().split(':')[0];
795
+ const hostname = String(get('host') || '').toLowerCase().split(':')[0];
769
796
  return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '::1' || hostname === '[::1]';
770
797
  }
798
+ function isGenuineLoopback(c) {
799
+ return loopbackHeaders((n) => c.req.header(n));
800
+ }
771
801
 
772
802
  // --- auth: first-device bootstrap (B1) -------------------------------------
773
803
  // The everyday problem: a brand-new owner runs `wild-workspace` and wants to
@@ -862,6 +892,47 @@ export async function createServer(overrides = {}) {
862
892
  return c.json({ ok: true, role: session.role, cookie: true });
863
893
  });
864
894
 
895
+ // Google sign-in return (req 2). bmo-sync ran the Google OAuth centrally, checked
896
+ // the verified email against THIS account's owner, and handed the browser back a
897
+ // short-lived vouch via <slug>.venturewild.llc/?gv=<vouch>. The SPA POSTs it here;
898
+ // we verify it against the shared account secret, re-confirm the owner email +
899
+ // slug, then mint the SAME durable, individually-revocable device token a device
900
+ // approval would — so any device the owner signs into with Google is in, no
901
+ // second-device approval and no nag. Public-allowlisted: the vouch IS the
902
+ // credential, so an invalid one is rejected here (401/403), never trusted.
903
+ app.post('/api/auth/google', async (c) => {
904
+ // accountToken is kept top-level on config (out of the broadcast config.account).
905
+ if (!config.accountToken) {
906
+ return c.json({ error: 'not-linked' }, 400); // not slug-linked → no Google sign-in
907
+ }
908
+ let body = {};
909
+ try { body = await c.req.json(); } catch { /* empty body ok */ }
910
+ const vouch = typeof body.vouch === 'string' ? body.vouch : c.req.query('gv');
911
+ const v = await verifyGoogleVouch(vouch, config.accountToken);
912
+ if (!v.ok) {
913
+ log('[auth]', `google vouch rejected: ${v.reason}`);
914
+ return c.json({ error: 'invalid-vouch' }, 401);
915
+ }
916
+ // The vouch proves "bmo-sync verified this Google identity for this account".
917
+ // Re-enforce that it's the OWNER of THIS install (email + slug) so a vouch
918
+ // minted for another workspace can't be replayed here.
919
+ if (!emailMatches(v.email, config.account.email)) {
920
+ log('[auth]', `google vouch email mismatch (${v.email})`);
921
+ return c.json({ error: 'not-owner' }, 403);
922
+ }
923
+ if (v.slug && config.account.slug && v.slug !== config.account.slug) {
924
+ return c.json({ error: 'wrong-workspace' }, 403);
925
+ }
926
+ const device = await mintDeviceToken({ secret: config.shareSecret, workspaceId: config.workspaceId });
927
+ tokenRegistry.add({ ...device, kind: 'device', label: `Google (${v.email})`, createdAt: Date.now() });
928
+ const now = Math.floor(Date.now() / 1000);
929
+ const maxAge = Math.max(60, device.exp - now);
930
+ c.header('Set-Cookie', authCookieAttrs(device.token, maxAge));
931
+ auditAction(c, 'google-signin', `email=${v.email} sub=${device.sub}`);
932
+ log('[auth]', `google sign-in → durable device sub=${device.sub} email=${v.email} ttl=${maxAge}s`);
933
+ return c.json({ ok: true, role: 'partner', cookie: true });
934
+ });
935
+
865
936
  app.post('/api/auth/logout', (c) => {
866
937
  c.header('Set-Cookie', authCookieAttrs('', 0));
867
938
  return c.json({ ok: true });
@@ -2078,6 +2149,11 @@ export async function createServer(overrides = {}) {
2078
2149
  } else if (!cookieToken && !tokenFromQuery && !config.publicMode) {
2079
2150
  role = ROLES.PARTNER;
2080
2151
  sub = 'local-partner';
2152
+ } else if (!cookieToken && !tokenFromQuery && isLocalBind && loopbackHeaders((n) => req.headers[n])) {
2153
+ // Host-machine owner over genuine loopback (req 1) — mirror resolveRole so
2154
+ // the chat WS works for the local owner with no token, same as the SPA.
2155
+ role = ROLES.PARTNER;
2156
+ sub = 'local-owner';
2081
2157
  }
2082
2158
  // Deny: public mode with no token, or any invalid/revoked token. An
2083
2159
  // invalid token must NOT silently fall back to partner. (Concern C1.)
@@ -503,9 +503,9 @@ export class WorkspaceSupervisor {
503
503
  });
504
504
  }
505
505
 
506
- runUpdateTick() {
506
+ runUpdateTick(opts = {}) {
507
507
  if (!this.autoUpdater) return;
508
- this.autoUpdater.tick()
508
+ this.autoUpdater.tick(opts)
509
509
  .then((r) => { if (r && !['not-due', 'disabled', 'up-to-date', 'busy'].includes(r)) this.log(`auto-update tick: ${r}`); })
510
510
  .catch((e) => this.log(`auto-update error: ${e?.message || e}`));
511
511
  }
@@ -606,7 +606,7 @@ export class WorkspaceSupervisor {
606
606
  process.on('exit', () => this.releaseLock());
607
607
  process.on('SIGTERM', () => process.exit(0));
608
608
  process.on('SIGINT', () => process.exit(0));
609
- this.log(`supervisor start pid=${process.pid} watching http://127.0.0.1:${this.port}/api/health (workspace=${this.workspaceDir})`);
609
+ this.log(`supervisor start pid=${process.pid} v${this.selfVersion || '?'} watching http://127.0.0.1:${this.port}/api/health (workspace=${this.workspaceDir})`);
610
610
  this.timer = setInterval(() => { this.tick().catch((e) => this.log(`tick error: ${e?.message || e}`)); }, this.pollMs);
611
611
  this.tick().catch((e) => this.log(`tick error: ${e?.message || e}`));
612
612
 
@@ -617,7 +617,10 @@ export class WorkspaceSupervisor {
617
617
  this.autoUpdater = u;
618
618
  this.updateTimer = setInterval(() => this.runUpdateTick(), this.updatePollMs);
619
619
  if (this.updateTimer.unref) this.updateTimer.unref();
620
- const kick = setTimeout(() => this.runUpdateTick(), 60_000);
620
+ // FORCE the first post-boot check (bypass the 6h dueForCheck): a restart
621
+ // should always pull the latest, so a rebooted/woken machine can't sit on
622
+ // a stale version just because it last checked <6h ago.
623
+ const kick = setTimeout(() => this.runUpdateTick({ force: true }), 60_000);
621
624
  if (kick.unref) kick.unref();
622
625
  }).catch((e) => this.log(`auto-update init error: ${e?.message || e}`));
623
626
  }