@venturewild/workspace 0.1.11 → 0.1.13

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