@venturewild/workspace 0.3.7 → 0.4.0

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.
Files changed (55) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +112 -112
  3. package/package.json +83 -83
  4. package/server/bin/wild-workspace.mjs +1096 -995
  5. package/server/src/account.mjs +114 -114
  6. package/server/src/agent-login.mjs +146 -146
  7. package/server/src/agent-readiness.mjs +200 -200
  8. package/server/src/agent.mjs +468 -468
  9. package/server/src/bazaar/core.mjs +579 -579
  10. package/server/src/bazaar/index.mjs +75 -75
  11. package/server/src/bazaar/mcp-server.mjs +328 -328
  12. package/server/src/bazaar/mock-tickup.mjs +97 -97
  13. package/server/src/bazaar/preview-server.mjs +95 -95
  14. package/server/src/bazaar/seed-recipes/customer-feedback-form/know-how.md +23 -23
  15. package/server/src/bazaar/seed-recipes/customer-feedback-form/recipe.json +24 -24
  16. package/server/src/bazaar/seed-recipes/landing-page-launch/know-how.md +29 -29
  17. package/server/src/bazaar/seed-recipes/landing-page-launch/recipe.json +25 -25
  18. package/server/src/bazaar/seed-recipes/personal-portfolio/know-how.md +21 -21
  19. package/server/src/bazaar/seed-recipes/personal-portfolio/recipe.json +24 -24
  20. package/server/src/bazaar/seed-recipes/receipt-sorter/know-how.md +31 -31
  21. package/server/src/bazaar/seed-recipes/receipt-sorter/recipe.json +25 -25
  22. package/server/src/bazaar/seed-recipes/tickup-hr-matching/know-how.md +79 -79
  23. package/server/src/bazaar/seed-recipes/tickup-hr-matching/recipe.json +32 -32
  24. package/server/src/canvas/core.mjs +446 -421
  25. package/server/src/canvas/index.mjs +42 -42
  26. package/server/src/canvas/mcp-server.mjs +253 -253
  27. package/server/src/canvas-rails.mjs +108 -0
  28. package/server/src/config.mjs +404 -404
  29. package/server/src/daemon-bin.mjs +110 -110
  30. package/server/src/daemon-supervisor.mjs +285 -285
  31. package/server/src/doctor.mjs +375 -375
  32. package/server/src/inbox.mjs +86 -86
  33. package/server/src/index.mjs +2766 -2475
  34. package/server/src/logpaths.mjs +98 -98
  35. package/server/src/observability.mjs +45 -45
  36. package/server/src/operator.mjs +92 -92
  37. package/server/src/pairing.mjs +137 -137
  38. package/server/src/service.mjs +515 -515
  39. package/server/src/session-reporter.mjs +201 -201
  40. package/server/src/settings.mjs +145 -145
  41. package/server/src/share.mjs +182 -182
  42. package/server/src/skills.mjs +213 -213
  43. package/server/src/supervisor.mjs +647 -647
  44. package/server/src/support-consent.mjs +133 -133
  45. package/server/src/sync.mjs +248 -248
  46. package/server/src/transcript.mjs +121 -121
  47. package/server/src/turn-mcp.mjs +46 -46
  48. package/server/src/usage.mjs +405 -405
  49. package/server/src/workspace-registry.mjs +225 -0
  50. package/server/src/workspaces.mjs +111 -0
  51. package/web/dist/assets/index-NXZN2LU2.css +1 -0
  52. package/web/dist/assets/index-PAS8Inwp.js +91 -0
  53. package/web/dist/index.html +2 -2
  54. package/web/dist/assets/index-BxRx8EsD.js +0 -91
  55. package/web/dist/assets/index-DoOPBr3s.css +0 -1
@@ -1,404 +1,404 @@
1
- // Central config + role definitions.
2
- // One UI permission-flagged per AR-19.
3
-
4
- import path from 'node:path';
5
- import os from 'node:os';
6
- import fs from 'node:fs';
7
- import crypto from 'node:crypto';
8
- import { fileURLToPath } from 'node:url';
9
-
10
- import { loadAccount } from './account.mjs';
11
- import { loadOperatorToken } from './operator.mjs';
12
- import { globalDir } from './logpaths.mjs';
13
-
14
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
-
16
- // Secrets that shipped as scaffold defaults. Forgeable — never allowed on a
17
- // non-localhost bind. Kept only so the startup guard can recognise them.
18
- export const WEAK_SECRETS = Object.freeze(
19
- new Set(['dev-partner-token', 'dev-share-secret-change-me']),
20
- );
21
-
22
- const LOCAL_HOSTS = Object.freeze(new Set(['127.0.0.1', 'localhost', '::1']));
23
-
24
- export function isLocalhost(host) {
25
- return LOCAL_HOSTS.has(host);
26
- }
27
-
28
- // The bmo-sync daemon's HTTP origin, derived from its WebSocket event-feed
29
- // URL so the two stay consistent when WILD_WORKSPACE_DAEMON_URL is overridden.
30
- function daemonHttpBase(wsUrl) {
31
- try {
32
- const u = new URL(wsUrl);
33
- return `${u.protocol === 'wss:' ? 'https:' : 'http:'}//${u.host}`;
34
- } catch {
35
- return 'http://127.0.0.1:8320';
36
- }
37
- }
38
-
39
- // Per-install secrets (partnerToken + shareSecret). They sign the owner's login
40
- // cookie, so they MUST be stable across restarts/upgrades — if they rotate, every
41
- // browser holding a cookie is silently logged out (the public URL then 401s until
42
- // the owner re-authenticates with a token). They live in the per-install GLOBAL
43
- // dir (~/.wild-workspace) — an ABSOLUTE path that never moves — NOT in the
44
- // workspace's `.wild-workspace`, which (a) is keyed off `process.cwd()` so it
45
- // shifts when the always-on supervisor relaunches from a different dir (the
46
- // real-world "logged out after upgrade" bug), and (b) is inside the synced
47
- // folder (CLAUDE.md rule #1: no system state in the synced workspace).
48
- //
49
- // A legacy in-workspace secrets.json is migrated forward (its tokens preserved,
50
- // so cookies issued before this version keep validating) and then removed from
51
- // the synced folder. Replaces the weak scaffold defaults so share tokens can't
52
- // be forged. (Concerns C1/C2.)
53
- function readSecretsFile(file) {
54
- try {
55
- const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
56
- if (parsed && parsed.partnerToken && parsed.shareSecret) {
57
- return {
58
- partnerToken: parsed.partnerToken,
59
- shareSecret: parsed.shareSecret,
60
- // Phase 4: the loopback support-channel secret (may be absent on installs
61
- // created before Phase 4 — loadOrCreateSecrets backfills it).
62
- supportChannelSecret: parsed.supportChannelSecret,
63
- };
64
- }
65
- } catch {
66
- // missing / unreadable / malformed
67
- }
68
- return null;
69
- }
70
-
71
- function loadOrCreateSecrets(dataDir, env = process.env) {
72
- const stablePath = path.join(globalDir(env), 'secrets.json');
73
- const legacyPath = path.join(dataDir, 'secrets.json');
74
-
75
- // 1. The stable per-install location wins.
76
- const stable = readSecretsFile(stablePath);
77
- if (stable) {
78
- // Backfill the Phase-4 support-channel secret on installs created earlier,
79
- // WITHOUT rotating the login-signing tokens (which would log everyone out).
80
- // Re-read + rewrite the raw file so any other fields are preserved.
81
- if (!stable.supportChannelSecret) {
82
- stable.supportChannelSecret = crypto.randomBytes(32).toString('base64url');
83
- try {
84
- const raw = JSON.parse(fs.readFileSync(stablePath, 'utf8'));
85
- raw.supportChannelSecret = stable.supportChannelSecret;
86
- fs.writeFileSync(stablePath, JSON.stringify(raw, null, 2), { mode: 0o600 });
87
- } catch {
88
- // can't persist (read-only fs?) — still usable for this run
89
- }
90
- }
91
- return stable;
92
- }
93
-
94
- // 2. Migrate a legacy in-workspace secrets file (preserve its tokens so
95
- // pre-existing login cookies keep validating); otherwise generate fresh.
96
- const legacy = readSecretsFile(legacyPath);
97
- const secrets = legacy || {
98
- partnerToken: crypto.randomBytes(24).toString('base64url'),
99
- shareSecret: crypto.randomBytes(32).toString('base64url'),
100
- };
101
- // The support-channel secret can be (re)generated freely — it's not used to
102
- // sign anything durable, just to authenticate the local daemon → server hop.
103
- if (!secrets.supportChannelSecret) {
104
- secrets.supportChannelSecret = crypto.randomBytes(32).toString('base64url');
105
- }
106
-
107
- // 3. Persist to the stable location.
108
- try {
109
- fs.mkdirSync(path.dirname(stablePath), { recursive: true });
110
- fs.writeFileSync(stablePath, JSON.stringify(secrets, null, 2), { mode: 0o600 });
111
- } catch {
112
- // can't persist (read-only fs?) — still use the secrets for this run
113
- }
114
-
115
- // 4. Best-effort: drop the legacy copy so the signing secret stops living in
116
- // the synced workspace folder. Only after a successful migration.
117
- if (legacy) {
118
- try {
119
- fs.rmSync(legacyPath, { force: true });
120
- } catch {
121
- // leave it; it's now inert (the stable copy takes precedence)
122
- }
123
- }
124
-
125
- return secrets;
126
- }
127
-
128
- // Refuse to start in public mode with a forgeable secret. (Concerns C1/C2.)
129
- export function assertSecureBinding(config) {
130
- if (!config.publicMode) return;
131
- if (WEAK_SECRETS.has(config.partnerToken) || WEAK_SECRETS.has(config.shareSecret)) {
132
- throw new Error(
133
- `Refusing to run in public mode with a default secret. ` +
134
- `Set WILD_WORKSPACE_PARTNER_TOKEN and WILD_WORKSPACE_SHARE_SECRET, ` +
135
- `or remove them so per-install secrets are generated.`,
136
- );
137
- }
138
- }
139
-
140
- export const ROLES = Object.freeze({
141
- PARTNER: 'partner',
142
- VIEWER: 'viewer',
143
- CLIENT: 'client',
144
- // The consented support/operator channel (off by default — see operator.mjs).
145
- OPERATOR: 'operator',
146
- });
147
-
148
- export const ROLE_CAPABILITIES = Object.freeze({
149
- partner: {
150
- chat: true,
151
- chatWrite: true,
152
- preview: true,
153
- fileTree: true,
154
- terminal: true,
155
- inbox: true,
156
- share: true,
157
- sync: true,
158
- deploy: true,
159
- requestChanges: false,
160
- operate: true, // the owner can also drive the operator allowlist locally
161
- },
162
- viewer: {
163
- chat: true,
164
- chatWrite: false,
165
- preview: true,
166
- fileTree: false,
167
- terminal: false,
168
- inbox: false,
169
- share: false,
170
- sync: false,
171
- deploy: false,
172
- requestChanges: false,
173
- operate: false,
174
- },
175
- client: {
176
- chat: true,
177
- chatWrite: true,
178
- preview: true,
179
- fileTree: false,
180
- terminal: false,
181
- inbox: false,
182
- share: false,
183
- sync: false,
184
- deploy: false,
185
- requestChanges: true,
186
- operate: false,
187
- },
188
- // Operator: remote diagnose + a curated remediation allowlist. Read-only on
189
- // chat (can SEE the conversation to help, cannot drive the agent — chatWrite
190
- // stays false), plus the `operate` capability the /api/operator/* routes gate
191
- // on. Reachable only with the dedicated operator token (operator.mjs).
192
- operator: {
193
- chat: true,
194
- chatWrite: false,
195
- preview: true,
196
- fileTree: true,
197
- terminal: false,
198
- inbox: false,
199
- share: false,
200
- sync: false,
201
- deploy: false,
202
- requestChanges: false,
203
- operate: true,
204
- },
205
- });
206
-
207
- export const DEFAULT_AGENTS = Object.freeze([
208
- {
209
- id: 'claude',
210
- binary: 'claude',
211
- label: 'Claude Code',
212
- description: 'Anthropic Claude Code',
213
- args: ['-p', '--output-format', 'stream-json', '--include-partial-messages', '--verbose'],
214
- streamFormat: 'claude-stream-json',
215
- },
216
- {
217
- id: 'gemini',
218
- binary: 'gemini',
219
- label: 'Gemini CLI',
220
- description: 'Google Gemini',
221
- args: ['-p'],
222
- streamFormat: 'text',
223
- },
224
- {
225
- id: 'glm',
226
- binary: 'glm',
227
- label: 'GLM (Z.AI)',
228
- description: 'GLM-4.6 via Z.AI',
229
- args: ['-p', '--permission-mode', 'bypassPermissions'],
230
- streamFormat: 'text',
231
- },
232
- {
233
- id: 'codex',
234
- binary: 'codex',
235
- label: 'Codex (OpenAI)',
236
- description: 'GPT-5 / o3 via Codex CLI',
237
- args: ['exec', '--skip-git-repo-check'],
238
- streamFormat: 'text',
239
- },
240
- ]);
241
-
242
- export function buildConfig(overrides = {}) {
243
- const env = overrides.env || process.env;
244
- const portOverride = overrides.port;
245
- const resolvedPort =
246
- typeof portOverride === 'number'
247
- ? portOverride
248
- : Number(env.WILD_WORKSPACE_PORT || 5173);
249
- const workspaceDir = path.resolve(
250
- overrides.workspaceDir || env.WILD_WORKSPACE_DIR || process.cwd(),
251
- );
252
- const dataDir = path.resolve(
253
- overrides.dataDir ||
254
- env.WILD_WORKSPACE_DATA_DIR ||
255
- path.join(workspaceDir, '.wild-workspace'),
256
- );
257
- // Lazy: only load/generate persisted secrets if neither an override nor an
258
- // env var supplies one — keeps tests that pass both from touching the fs.
259
- let _secrets = null;
260
- const secrets = () => (_secrets ??= loadOrCreateSecrets(dataDir, env));
261
- const host = overrides.host || env.WILD_WORKSPACE_HOST || '127.0.0.1';
262
- // Per-install bmo-sync account — null until the user runs `wild-workspace
263
- // login` with the payload from `workspace.venturewild.llc`. Its presence
264
- // upgrades the install to a real slug (shareBaseUrl flips to the user's
265
- // subdomain) and lights up the /api/session.account field for the UI.
266
- // Loaded BEFORE publicMode because a logged-in install is publicly reachable.
267
- const account =
268
- overrides.account === undefined ? loadAccount(dataDir) : overrides.account;
269
- // publicMode = "treat every request as untrusted". True for a non-localhost
270
- // bind, OR when WILD_WORKSPACE_PUBLIC=1 — needed when a tunnel (Cloudflare
271
- // etc.) forwards public traffic to a localhost-bound server, since the bind
272
- // address alone would otherwise look local. Drives the C1 auth posture.
273
- const publicMode =
274
- overrides.publicMode ??
275
- (env.WILD_WORKSPACE_PUBLIC === '1' ||
276
- !isLocalhost(host) ||
277
- // CRITICAL (C1): a slug-linked install is reachable from the public
278
- // internet via <slug>.venturewild.llc — the daemon forwards that traffic
279
- // to this server FROM 127.0.0.1, so a non-public server would auto-grant
280
- // `partner` (full RCE) to every anonymous visitor. Having an account token
281
- // ⟺ the tunnel is exposing this machine, so force public mode even when the
282
- // always-on supervisor (or a bare `wild-workspace`) launches without
283
- // WILD_WORKSPACE_PUBLIC=1. The local owner authenticates via the partner
284
- // token the launcher appends to the localhost URL (?t=, then S1 cookie).
285
- Boolean(account?.accountToken));
286
- // bmo-sync: the local daemon's event feed (a WebSocket URL).
287
- const daemonUrl =
288
- overrides.daemonUrl ||
289
- env.WILD_WORKSPACE_DAEMON_URL ||
290
- 'ws://127.0.0.1:8320/api/events';
291
- const accountShareBase = account?.slug
292
- ? `https://${account.slug}.venturewild.llc`
293
- : null;
294
- return {
295
- port: resolvedPort,
296
- host,
297
- publicMode,
298
- workspaceDir,
299
- dataDir,
300
- webDir:
301
- overrides.webDir ||
302
- env.WILD_WORKSPACE_WEB_DIR ||
303
- path.resolve(__dirname, '..', '..', 'web', 'dist'),
304
- openBrowser: overrides.openBrowser ?? env.WILD_WORKSPACE_NO_OPEN !== '1',
305
- shareBaseUrl:
306
- overrides.shareBaseUrl ||
307
- env.WILD_WORKSPACE_SHARE_BASE_URL ||
308
- accountShareBase ||
309
- `http://${host}:${resolvedPort}`,
310
- // The signed-in account (if any). Kept on the server-side config so
311
- // /api/session can expose the public bits (slug, email, accountId, displayName)
312
- // to the UI while accountToken stays here only.
313
- account: account
314
- ? {
315
- slug: account.slug,
316
- email: account.email,
317
- accountId: account.accountId,
318
- displayName: account.displayName,
319
- loggedInAt: account.loggedInAt,
320
- // accountToken is intentionally kept out of the broadcasted config
321
- // shape — it's read separately by code that needs to authenticate
322
- // against bmo-sync.
323
- }
324
- : null,
325
- accountToken: account?.accountToken || null,
326
- partnerToken:
327
- overrides.partnerToken ||
328
- env.WILD_WORKSPACE_PARTNER_TOKEN ||
329
- secrets().partnerToken,
330
- shareSecret:
331
- overrides.shareSecret ||
332
- env.WILD_WORKSPACE_SHARE_SECRET ||
333
- secrets().shareSecret,
334
- // Phase 4: authenticates the local bmo-sync daemon's loopback call to
335
- // POST /api/support/agent-task (the daemon reads it from ~/.wild-workspace/
336
- // secrets.json by the same convention). Never sent to the browser.
337
- supportChannelSecret:
338
- overrides.supportChannelSecret ||
339
- env.WILD_WORKSPACE_SUPPORT_SECRET ||
340
- secrets().supportChannelSecret,
341
- // The operator-channel token — null unless the user explicitly enabled the
342
- // channel (`wild-workspace operator enable`). Off by default. Server-side
343
- // only; never broadcast to the browser.
344
- operatorToken:
345
- overrides.operatorToken ??
346
- env.WILD_WORKSPACE_OPERATOR_TOKEN ??
347
- loadOperatorToken(dataDir),
348
- // RC1 hot-reload seam: the EXPLICIT token (override or env), with NO disk read.
349
- // When this is null the live auth path re-reads the token file on each request
350
- // (getOperatorToken) so `operator enable` takes effect with no restart; when a
351
- // test/env pins a token, that value stays authoritative. (See index.mjs.)
352
- operatorTokenExplicit:
353
- overrides.operatorToken ?? env.WILD_WORKSPACE_OPERATOR_TOKEN ?? null,
354
- workspaceId:
355
- overrides.workspaceId ||
356
- env.WILD_WORKSPACE_ID ||
357
- path.basename(workspaceDir) ||
358
- 'workspace',
359
- role: overrides.role || env.WILD_WORKSPACE_ROLE || ROLES.PARTNER,
360
- // bmo-sync daemon — a separate local process; the bridge retries quietly
361
- // when it is absent. daemonUrl is the WebSocket event feed; daemonHttpUrl
362
- // is the same origin's HTTP API (pair / detach / list).
363
- daemonUrl,
364
- daemonHttpUrl: daemonHttpBase(daemonUrl),
365
- // Auto-start the bmo-sync daemon when the server boots. On by default;
366
- // WILD_WORKSPACE_DAEMON_AUTOSTART=0 disables it. Forced off under the test
367
- // runner (VITEST / NODE_ENV=test) so the suite never spawns a real daemon.
368
- daemonAutostart:
369
- overrides.daemonAutostart ??
370
- (env.WILD_WORKSPACE_DAEMON_AUTOSTART !== '0' &&
371
- !env.VITEST &&
372
- env.NODE_ENV !== 'test'),
373
- // Central bmo-sync server (Fly.io). Used to redeem invites (via the
374
- // daemon) and — only when an admin key is set — to mint them.
375
- bmoSyncServerUrl:
376
- overrides.bmoSyncServerUrl ||
377
- env.WILD_WORKSPACE_BMO_SYNC_URL ||
378
- env.BMO_SYNC_URL ||
379
- 'https://sync.venturewild.llc',
380
- // Optional. Present only on an install that mints invites (the folder
381
- // owner). Absent installs can still redeem. Never sent to the browser.
382
- bmoSyncAdminKey:
383
- overrides.bmoSyncAdminKey ||
384
- env.BMO_SYNC_ADMIN_KEY ||
385
- env.WILD_WORKSPACE_BMO_ADMIN_KEY ||
386
- null,
387
- home: os.homedir(),
388
- nodeEnv: env.NODE_ENV || 'production',
389
- };
390
- }
391
-
392
- // Read from package.json (the single source of truth) so the reported version
393
- // can never drift from the published release — `npm version` bumps it for free.
394
- // This previously hardcoded '0.1.0', so every release (0.1.1/0.1.2/0.1.3) shipped
395
- // a stale version to `--version`, /api/health, doctor, and all telemetry.
396
- function readAppVersion() {
397
- try {
398
- const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '../../package.json'), 'utf8'));
399
- return pkg.version || '0.0.0';
400
- } catch {
401
- return '0.0.0';
402
- }
403
- }
404
- export const APP_VERSION = readAppVersion();
1
+ // Central config + role definitions.
2
+ // One UI permission-flagged per AR-19.
3
+
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import fs from 'node:fs';
7
+ import crypto from 'node:crypto';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ import { loadAccount } from './account.mjs';
11
+ import { loadOperatorToken } from './operator.mjs';
12
+ import { globalDir } from './logpaths.mjs';
13
+
14
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
+
16
+ // Secrets that shipped as scaffold defaults. Forgeable — never allowed on a
17
+ // non-localhost bind. Kept only so the startup guard can recognise them.
18
+ export const WEAK_SECRETS = Object.freeze(
19
+ new Set(['dev-partner-token', 'dev-share-secret-change-me']),
20
+ );
21
+
22
+ const LOCAL_HOSTS = Object.freeze(new Set(['127.0.0.1', 'localhost', '::1']));
23
+
24
+ export function isLocalhost(host) {
25
+ return LOCAL_HOSTS.has(host);
26
+ }
27
+
28
+ // The bmo-sync daemon's HTTP origin, derived from its WebSocket event-feed
29
+ // URL so the two stay consistent when WILD_WORKSPACE_DAEMON_URL is overridden.
30
+ function daemonHttpBase(wsUrl) {
31
+ try {
32
+ const u = new URL(wsUrl);
33
+ return `${u.protocol === 'wss:' ? 'https:' : 'http:'}//${u.host}`;
34
+ } catch {
35
+ return 'http://127.0.0.1:8320';
36
+ }
37
+ }
38
+
39
+ // Per-install secrets (partnerToken + shareSecret). They sign the owner's login
40
+ // cookie, so they MUST be stable across restarts/upgrades — if they rotate, every
41
+ // browser holding a cookie is silently logged out (the public URL then 401s until
42
+ // the owner re-authenticates with a token). They live in the per-install GLOBAL
43
+ // dir (~/.wild-workspace) — an ABSOLUTE path that never moves — NOT in the
44
+ // workspace's `.wild-workspace`, which (a) is keyed off `process.cwd()` so it
45
+ // shifts when the always-on supervisor relaunches from a different dir (the
46
+ // real-world "logged out after upgrade" bug), and (b) is inside the synced
47
+ // folder (CLAUDE.md rule #1: no system state in the synced workspace).
48
+ //
49
+ // A legacy in-workspace secrets.json is migrated forward (its tokens preserved,
50
+ // so cookies issued before this version keep validating) and then removed from
51
+ // the synced folder. Replaces the weak scaffold defaults so share tokens can't
52
+ // be forged. (Concerns C1/C2.)
53
+ function readSecretsFile(file) {
54
+ try {
55
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
56
+ if (parsed && parsed.partnerToken && parsed.shareSecret) {
57
+ return {
58
+ partnerToken: parsed.partnerToken,
59
+ shareSecret: parsed.shareSecret,
60
+ // Phase 4: the loopback support-channel secret (may be absent on installs
61
+ // created before Phase 4 — loadOrCreateSecrets backfills it).
62
+ supportChannelSecret: parsed.supportChannelSecret,
63
+ };
64
+ }
65
+ } catch {
66
+ // missing / unreadable / malformed
67
+ }
68
+ return null;
69
+ }
70
+
71
+ function loadOrCreateSecrets(dataDir, env = process.env) {
72
+ const stablePath = path.join(globalDir(env), 'secrets.json');
73
+ const legacyPath = path.join(dataDir, 'secrets.json');
74
+
75
+ // 1. The stable per-install location wins.
76
+ const stable = readSecretsFile(stablePath);
77
+ if (stable) {
78
+ // Backfill the Phase-4 support-channel secret on installs created earlier,
79
+ // WITHOUT rotating the login-signing tokens (which would log everyone out).
80
+ // Re-read + rewrite the raw file so any other fields are preserved.
81
+ if (!stable.supportChannelSecret) {
82
+ stable.supportChannelSecret = crypto.randomBytes(32).toString('base64url');
83
+ try {
84
+ const raw = JSON.parse(fs.readFileSync(stablePath, 'utf8'));
85
+ raw.supportChannelSecret = stable.supportChannelSecret;
86
+ fs.writeFileSync(stablePath, JSON.stringify(raw, null, 2), { mode: 0o600 });
87
+ } catch {
88
+ // can't persist (read-only fs?) — still usable for this run
89
+ }
90
+ }
91
+ return stable;
92
+ }
93
+
94
+ // 2. Migrate a legacy in-workspace secrets file (preserve its tokens so
95
+ // pre-existing login cookies keep validating); otherwise generate fresh.
96
+ const legacy = readSecretsFile(legacyPath);
97
+ const secrets = legacy || {
98
+ partnerToken: crypto.randomBytes(24).toString('base64url'),
99
+ shareSecret: crypto.randomBytes(32).toString('base64url'),
100
+ };
101
+ // The support-channel secret can be (re)generated freely — it's not used to
102
+ // sign anything durable, just to authenticate the local daemon → server hop.
103
+ if (!secrets.supportChannelSecret) {
104
+ secrets.supportChannelSecret = crypto.randomBytes(32).toString('base64url');
105
+ }
106
+
107
+ // 3. Persist to the stable location.
108
+ try {
109
+ fs.mkdirSync(path.dirname(stablePath), { recursive: true });
110
+ fs.writeFileSync(stablePath, JSON.stringify(secrets, null, 2), { mode: 0o600 });
111
+ } catch {
112
+ // can't persist (read-only fs?) — still use the secrets for this run
113
+ }
114
+
115
+ // 4. Best-effort: drop the legacy copy so the signing secret stops living in
116
+ // the synced workspace folder. Only after a successful migration.
117
+ if (legacy) {
118
+ try {
119
+ fs.rmSync(legacyPath, { force: true });
120
+ } catch {
121
+ // leave it; it's now inert (the stable copy takes precedence)
122
+ }
123
+ }
124
+
125
+ return secrets;
126
+ }
127
+
128
+ // Refuse to start in public mode with a forgeable secret. (Concerns C1/C2.)
129
+ export function assertSecureBinding(config) {
130
+ if (!config.publicMode) return;
131
+ if (WEAK_SECRETS.has(config.partnerToken) || WEAK_SECRETS.has(config.shareSecret)) {
132
+ throw new Error(
133
+ `Refusing to run in public mode with a default secret. ` +
134
+ `Set WILD_WORKSPACE_PARTNER_TOKEN and WILD_WORKSPACE_SHARE_SECRET, ` +
135
+ `or remove them so per-install secrets are generated.`,
136
+ );
137
+ }
138
+ }
139
+
140
+ export const ROLES = Object.freeze({
141
+ PARTNER: 'partner',
142
+ VIEWER: 'viewer',
143
+ CLIENT: 'client',
144
+ // The consented support/operator channel (off by default — see operator.mjs).
145
+ OPERATOR: 'operator',
146
+ });
147
+
148
+ export const ROLE_CAPABILITIES = Object.freeze({
149
+ partner: {
150
+ chat: true,
151
+ chatWrite: true,
152
+ preview: true,
153
+ fileTree: true,
154
+ terminal: true,
155
+ inbox: true,
156
+ share: true,
157
+ sync: true,
158
+ deploy: true,
159
+ requestChanges: false,
160
+ operate: true, // the owner can also drive the operator allowlist locally
161
+ },
162
+ viewer: {
163
+ chat: true,
164
+ chatWrite: false,
165
+ preview: true,
166
+ fileTree: false,
167
+ terminal: false,
168
+ inbox: false,
169
+ share: false,
170
+ sync: false,
171
+ deploy: false,
172
+ requestChanges: false,
173
+ operate: false,
174
+ },
175
+ client: {
176
+ chat: true,
177
+ chatWrite: true,
178
+ preview: true,
179
+ fileTree: false,
180
+ terminal: false,
181
+ inbox: false,
182
+ share: false,
183
+ sync: false,
184
+ deploy: false,
185
+ requestChanges: true,
186
+ operate: false,
187
+ },
188
+ // Operator: remote diagnose + a curated remediation allowlist. Read-only on
189
+ // chat (can SEE the conversation to help, cannot drive the agent — chatWrite
190
+ // stays false), plus the `operate` capability the /api/operator/* routes gate
191
+ // on. Reachable only with the dedicated operator token (operator.mjs).
192
+ operator: {
193
+ chat: true,
194
+ chatWrite: false,
195
+ preview: true,
196
+ fileTree: true,
197
+ terminal: false,
198
+ inbox: false,
199
+ share: false,
200
+ sync: false,
201
+ deploy: false,
202
+ requestChanges: false,
203
+ operate: true,
204
+ },
205
+ });
206
+
207
+ export const DEFAULT_AGENTS = Object.freeze([
208
+ {
209
+ id: 'claude',
210
+ binary: 'claude',
211
+ label: 'Claude Code',
212
+ description: 'Anthropic Claude Code',
213
+ args: ['-p', '--output-format', 'stream-json', '--include-partial-messages', '--verbose'],
214
+ streamFormat: 'claude-stream-json',
215
+ },
216
+ {
217
+ id: 'gemini',
218
+ binary: 'gemini',
219
+ label: 'Gemini CLI',
220
+ description: 'Google Gemini',
221
+ args: ['-p'],
222
+ streamFormat: 'text',
223
+ },
224
+ {
225
+ id: 'glm',
226
+ binary: 'glm',
227
+ label: 'GLM (Z.AI)',
228
+ description: 'GLM-4.6 via Z.AI',
229
+ args: ['-p', '--permission-mode', 'bypassPermissions'],
230
+ streamFormat: 'text',
231
+ },
232
+ {
233
+ id: 'codex',
234
+ binary: 'codex',
235
+ label: 'Codex (OpenAI)',
236
+ description: 'GPT-5 / o3 via Codex CLI',
237
+ args: ['exec', '--skip-git-repo-check'],
238
+ streamFormat: 'text',
239
+ },
240
+ ]);
241
+
242
+ export function buildConfig(overrides = {}) {
243
+ const env = overrides.env || process.env;
244
+ const portOverride = overrides.port;
245
+ const resolvedPort =
246
+ typeof portOverride === 'number'
247
+ ? portOverride
248
+ : Number(env.WILD_WORKSPACE_PORT || 5173);
249
+ const workspaceDir = path.resolve(
250
+ overrides.workspaceDir || env.WILD_WORKSPACE_DIR || process.cwd(),
251
+ );
252
+ const dataDir = path.resolve(
253
+ overrides.dataDir ||
254
+ env.WILD_WORKSPACE_DATA_DIR ||
255
+ path.join(workspaceDir, '.wild-workspace'),
256
+ );
257
+ // Lazy: only load/generate persisted secrets if neither an override nor an
258
+ // env var supplies one — keeps tests that pass both from touching the fs.
259
+ let _secrets = null;
260
+ const secrets = () => (_secrets ??= loadOrCreateSecrets(dataDir, env));
261
+ const host = overrides.host || env.WILD_WORKSPACE_HOST || '127.0.0.1';
262
+ // Per-install bmo-sync account — null until the user runs `wild-workspace
263
+ // login` with the payload from `workspace.venturewild.llc`. Its presence
264
+ // upgrades the install to a real slug (shareBaseUrl flips to the user's
265
+ // subdomain) and lights up the /api/session.account field for the UI.
266
+ // Loaded BEFORE publicMode because a logged-in install is publicly reachable.
267
+ const account =
268
+ overrides.account === undefined ? loadAccount(dataDir) : overrides.account;
269
+ // publicMode = "treat every request as untrusted". True for a non-localhost
270
+ // bind, OR when WILD_WORKSPACE_PUBLIC=1 — needed when a tunnel (Cloudflare
271
+ // etc.) forwards public traffic to a localhost-bound server, since the bind
272
+ // address alone would otherwise look local. Drives the C1 auth posture.
273
+ const publicMode =
274
+ overrides.publicMode ??
275
+ (env.WILD_WORKSPACE_PUBLIC === '1' ||
276
+ !isLocalhost(host) ||
277
+ // CRITICAL (C1): a slug-linked install is reachable from the public
278
+ // internet via <slug>.venturewild.llc — the daemon forwards that traffic
279
+ // to this server FROM 127.0.0.1, so a non-public server would auto-grant
280
+ // `partner` (full RCE) to every anonymous visitor. Having an account token
281
+ // ⟺ the tunnel is exposing this machine, so force public mode even when the
282
+ // always-on supervisor (or a bare `wild-workspace`) launches without
283
+ // WILD_WORKSPACE_PUBLIC=1. The local owner authenticates via the partner
284
+ // token the launcher appends to the localhost URL (?t=, then S1 cookie).
285
+ Boolean(account?.accountToken));
286
+ // bmo-sync: the local daemon's event feed (a WebSocket URL).
287
+ const daemonUrl =
288
+ overrides.daemonUrl ||
289
+ env.WILD_WORKSPACE_DAEMON_URL ||
290
+ 'ws://127.0.0.1:8320/api/events';
291
+ const accountShareBase = account?.slug
292
+ ? `https://${account.slug}.venturewild.llc`
293
+ : null;
294
+ return {
295
+ port: resolvedPort,
296
+ host,
297
+ publicMode,
298
+ workspaceDir,
299
+ dataDir,
300
+ webDir:
301
+ overrides.webDir ||
302
+ env.WILD_WORKSPACE_WEB_DIR ||
303
+ path.resolve(__dirname, '..', '..', 'web', 'dist'),
304
+ openBrowser: overrides.openBrowser ?? env.WILD_WORKSPACE_NO_OPEN !== '1',
305
+ shareBaseUrl:
306
+ overrides.shareBaseUrl ||
307
+ env.WILD_WORKSPACE_SHARE_BASE_URL ||
308
+ accountShareBase ||
309
+ `http://${host}:${resolvedPort}`,
310
+ // The signed-in account (if any). Kept on the server-side config so
311
+ // /api/session can expose the public bits (slug, email, accountId, displayName)
312
+ // to the UI while accountToken stays here only.
313
+ account: account
314
+ ? {
315
+ slug: account.slug,
316
+ email: account.email,
317
+ accountId: account.accountId,
318
+ displayName: account.displayName,
319
+ loggedInAt: account.loggedInAt,
320
+ // accountToken is intentionally kept out of the broadcasted config
321
+ // shape — it's read separately by code that needs to authenticate
322
+ // against bmo-sync.
323
+ }
324
+ : null,
325
+ accountToken: account?.accountToken || null,
326
+ partnerToken:
327
+ overrides.partnerToken ||
328
+ env.WILD_WORKSPACE_PARTNER_TOKEN ||
329
+ secrets().partnerToken,
330
+ shareSecret:
331
+ overrides.shareSecret ||
332
+ env.WILD_WORKSPACE_SHARE_SECRET ||
333
+ secrets().shareSecret,
334
+ // Phase 4: authenticates the local bmo-sync daemon's loopback call to
335
+ // POST /api/support/agent-task (the daemon reads it from ~/.wild-workspace/
336
+ // secrets.json by the same convention). Never sent to the browser.
337
+ supportChannelSecret:
338
+ overrides.supportChannelSecret ||
339
+ env.WILD_WORKSPACE_SUPPORT_SECRET ||
340
+ secrets().supportChannelSecret,
341
+ // The operator-channel token — null unless the user explicitly enabled the
342
+ // channel (`wild-workspace operator enable`). Off by default. Server-side
343
+ // only; never broadcast to the browser.
344
+ operatorToken:
345
+ overrides.operatorToken ??
346
+ env.WILD_WORKSPACE_OPERATOR_TOKEN ??
347
+ loadOperatorToken(dataDir),
348
+ // RC1 hot-reload seam: the EXPLICIT token (override or env), with NO disk read.
349
+ // When this is null the live auth path re-reads the token file on each request
350
+ // (getOperatorToken) so `operator enable` takes effect with no restart; when a
351
+ // test/env pins a token, that value stays authoritative. (See index.mjs.)
352
+ operatorTokenExplicit:
353
+ overrides.operatorToken ?? env.WILD_WORKSPACE_OPERATOR_TOKEN ?? null,
354
+ workspaceId:
355
+ overrides.workspaceId ||
356
+ env.WILD_WORKSPACE_ID ||
357
+ path.basename(workspaceDir) ||
358
+ 'workspace',
359
+ role: overrides.role || env.WILD_WORKSPACE_ROLE || ROLES.PARTNER,
360
+ // bmo-sync daemon — a separate local process; the bridge retries quietly
361
+ // when it is absent. daemonUrl is the WebSocket event feed; daemonHttpUrl
362
+ // is the same origin's HTTP API (pair / detach / list).
363
+ daemonUrl,
364
+ daemonHttpUrl: daemonHttpBase(daemonUrl),
365
+ // Auto-start the bmo-sync daemon when the server boots. On by default;
366
+ // WILD_WORKSPACE_DAEMON_AUTOSTART=0 disables it. Forced off under the test
367
+ // runner (VITEST / NODE_ENV=test) so the suite never spawns a real daemon.
368
+ daemonAutostart:
369
+ overrides.daemonAutostart ??
370
+ (env.WILD_WORKSPACE_DAEMON_AUTOSTART !== '0' &&
371
+ !env.VITEST &&
372
+ env.NODE_ENV !== 'test'),
373
+ // Central bmo-sync server (Fly.io). Used to redeem invites (via the
374
+ // daemon) and — only when an admin key is set — to mint them.
375
+ bmoSyncServerUrl:
376
+ overrides.bmoSyncServerUrl ||
377
+ env.WILD_WORKSPACE_BMO_SYNC_URL ||
378
+ env.BMO_SYNC_URL ||
379
+ 'https://sync.venturewild.llc',
380
+ // Optional. Present only on an install that mints invites (the folder
381
+ // owner). Absent installs can still redeem. Never sent to the browser.
382
+ bmoSyncAdminKey:
383
+ overrides.bmoSyncAdminKey ||
384
+ env.BMO_SYNC_ADMIN_KEY ||
385
+ env.WILD_WORKSPACE_BMO_ADMIN_KEY ||
386
+ null,
387
+ home: os.homedir(),
388
+ nodeEnv: env.NODE_ENV || 'production',
389
+ };
390
+ }
391
+
392
+ // Read from package.json (the single source of truth) so the reported version
393
+ // can never drift from the published release — `npm version` bumps it for free.
394
+ // This previously hardcoded '0.1.0', so every release (0.1.1/0.1.2/0.1.3) shipped
395
+ // a stale version to `--version`, /api/health, doctor, and all telemetry.
396
+ function readAppVersion() {
397
+ try {
398
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '../../package.json'), 'utf8'));
399
+ return pkg.version || '0.0.0';
400
+ } catch {
401
+ return '0.0.0';
402
+ }
403
+ }
404
+ export const APP_VERSION = readAppVersion();