@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.
- package/LICENSE +21 -21
- package/README.md +112 -112
- package/package.json +83 -83
- package/server/bin/wild-workspace.mjs +1096 -995
- package/server/src/account.mjs +114 -114
- package/server/src/agent-login.mjs +146 -146
- package/server/src/agent-readiness.mjs +200 -200
- package/server/src/agent.mjs +468 -468
- package/server/src/bazaar/core.mjs +579 -579
- package/server/src/bazaar/index.mjs +75 -75
- package/server/src/bazaar/mcp-server.mjs +328 -328
- package/server/src/bazaar/mock-tickup.mjs +97 -97
- package/server/src/bazaar/preview-server.mjs +95 -95
- package/server/src/bazaar/seed-recipes/customer-feedback-form/know-how.md +23 -23
- package/server/src/bazaar/seed-recipes/customer-feedback-form/recipe.json +24 -24
- package/server/src/bazaar/seed-recipes/landing-page-launch/know-how.md +29 -29
- package/server/src/bazaar/seed-recipes/landing-page-launch/recipe.json +25 -25
- package/server/src/bazaar/seed-recipes/personal-portfolio/know-how.md +21 -21
- package/server/src/bazaar/seed-recipes/personal-portfolio/recipe.json +24 -24
- package/server/src/bazaar/seed-recipes/receipt-sorter/know-how.md +31 -31
- package/server/src/bazaar/seed-recipes/receipt-sorter/recipe.json +25 -25
- package/server/src/bazaar/seed-recipes/tickup-hr-matching/know-how.md +79 -79
- package/server/src/bazaar/seed-recipes/tickup-hr-matching/recipe.json +32 -32
- package/server/src/canvas/core.mjs +446 -421
- package/server/src/canvas/index.mjs +42 -42
- package/server/src/canvas/mcp-server.mjs +253 -253
- package/server/src/canvas-rails.mjs +108 -0
- package/server/src/config.mjs +404 -404
- package/server/src/daemon-bin.mjs +110 -110
- package/server/src/daemon-supervisor.mjs +285 -285
- package/server/src/doctor.mjs +375 -375
- package/server/src/inbox.mjs +86 -86
- package/server/src/index.mjs +2766 -2475
- package/server/src/logpaths.mjs +98 -98
- package/server/src/observability.mjs +45 -45
- package/server/src/operator.mjs +92 -92
- package/server/src/pairing.mjs +137 -137
- package/server/src/service.mjs +515 -515
- package/server/src/session-reporter.mjs +201 -201
- package/server/src/settings.mjs +145 -145
- package/server/src/share.mjs +182 -182
- package/server/src/skills.mjs +213 -213
- package/server/src/supervisor.mjs +647 -647
- package/server/src/support-consent.mjs +133 -133
- package/server/src/sync.mjs +248 -248
- package/server/src/transcript.mjs +121 -121
- package/server/src/turn-mcp.mjs +46 -46
- package/server/src/usage.mjs +405 -405
- package/server/src/workspace-registry.mjs +225 -0
- package/server/src/workspaces.mjs +111 -0
- package/web/dist/assets/index-NXZN2LU2.css +1 -0
- package/web/dist/assets/index-PAS8Inwp.js +91 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BxRx8EsD.js +0 -91
- package/web/dist/assets/index-DoOPBr3s.css +0 -1
package/server/src/logpaths.mjs
CHANGED
|
@@ -1,98 +1,98 @@
|
|
|
1
|
-
// logpaths — one registry for the machine-global logs so `doctor`, `logs`, and
|
|
2
|
-
// the operator channel all read the same set, and a tiny append/rotate/tail
|
|
3
|
-
// helper for the new logs we write ourselves (cli, operator).
|
|
4
|
-
//
|
|
5
|
-
// WHY a registry and not just scattered paths: a brand-new user's install is the
|
|
6
|
-
// riskiest moment (no Claude yet, wrong Node, port busy, daemon won't resolve),
|
|
7
|
-
// and "I can't see what happened on their machine" is the #1 un-debuggable
|
|
8
|
-
// failure. Centralising the log locations is what lets `wild-workspace doctor`
|
|
9
|
-
// gather them and the operator channel tail them by name — never by arbitrary
|
|
10
|
-
// path (the tail allowlist is TAILABLE below).
|
|
11
|
-
//
|
|
12
|
-
// NOTE: the supervisor + daemon-supervisor keep writing their logs at the
|
|
13
|
-
// global-dir root (supervisor.log / server.out.log / daemon.log) — those paths
|
|
14
|
-
// are reboot-proven and pinned by tests via an injected globalDir, so we mirror
|
|
15
|
-
// them here for READING rather than moving them. Everything lives under
|
|
16
|
-
// ~/.wild-workspace (NEVER the synced workspace — CLAUDE.md principle #1).
|
|
17
|
-
|
|
18
|
-
import os from 'node:os';
|
|
19
|
-
import path from 'node:path';
|
|
20
|
-
import fs from 'node:fs';
|
|
21
|
-
|
|
22
|
-
// THE machine-global dir. Mirrors service.mjs globalDir() + the supervisor
|
|
23
|
-
// defaults. Overridable via env for tests / unusual homes.
|
|
24
|
-
export function globalDir(env = process.env) {
|
|
25
|
-
return env.WILD_WORKSPACE_GLOBAL_DIR || path.join(os.homedir(), '.wild-workspace');
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// Logical name → filename. The first three are written by existing components;
|
|
29
|
-
// cli + operator are new. Doctor bundles go under diagnosticsDir() (below).
|
|
30
|
-
export const LOG_FILES = Object.freeze({
|
|
31
|
-
supervisor: 'supervisor.log', // WorkspaceSupervisor watchdog
|
|
32
|
-
server: 'server.out.log', // the :5173 server's stdout/stderr (launcher-redirected)
|
|
33
|
-
daemon: 'daemon.log', // the bmo-sync daemon
|
|
34
|
-
cli: 'cli.log', // every `wild-workspace …` invocation (first-run capture)
|
|
35
|
-
operator: 'operator.log', // the consented operator channel's audit trail
|
|
36
|
-
audit: 'audit.log', // privileged action audit trail (share/sync/agent — S8)
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
// Logs the operator channel may tail BY NAME — never an arbitrary path.
|
|
40
|
-
export const TAILABLE = Object.freeze(Object.keys(LOG_FILES));
|
|
41
|
-
|
|
42
|
-
export function logFile(name, env = process.env) {
|
|
43
|
-
return path.join(globalDir(env), LOG_FILES[name] || `${name}.log`);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function diagnosticsDir(env = process.env) {
|
|
47
|
-
return path.join(globalDir(env), 'diagnostics');
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const MAX_LOG_BYTES = 2 * 1024 * 1024; // 2 MB → rotate to .1 (keep one prior gen)
|
|
51
|
-
|
|
52
|
-
// Rotate `file` to `file.1` once it grows past maxBytes. Best-effort, no throw.
|
|
53
|
-
export function rotateIfBig(file, maxBytes = MAX_LOG_BYTES) {
|
|
54
|
-
try {
|
|
55
|
-
if (fs.statSync(file).size > maxBytes) fs.renameSync(file, `${file}.1`);
|
|
56
|
-
} catch {
|
|
57
|
-
/* missing / racing rename — nothing to rotate */
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Append a timestamped line to a named log; ensures the dir + rotates. Returns
|
|
62
|
-
// the file path. Never throws — logging must not break the caller's path.
|
|
63
|
-
export function appendLine(name, line, env = process.env) {
|
|
64
|
-
const file = logFile(name, env);
|
|
65
|
-
try {
|
|
66
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
67
|
-
rotateIfBig(file);
|
|
68
|
-
fs.appendFileSync(file, `[${new Date().toISOString()}] ${line}\n`);
|
|
69
|
-
} catch {
|
|
70
|
-
/* read-only fs etc. — degrade silently */
|
|
71
|
-
}
|
|
72
|
-
return file;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Last `n` lines of a file (logs are size-capped, so reading whole is cheap).
|
|
76
|
-
// Returns '' when the file is missing/unreadable.
|
|
77
|
-
export function tailFile(file, n = 200) {
|
|
78
|
-
try {
|
|
79
|
-
// Drop the trailing newline so the last line isn't an empty entry.
|
|
80
|
-
const lines = fs.readFileSync(file, 'utf8').replace(/\r?\n$/, '').split(/\r?\n/);
|
|
81
|
-
return lines.slice(Math.max(0, lines.length - n)).join('\n');
|
|
82
|
-
} catch {
|
|
83
|
-
return '';
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// All known logs with on-disk size + mtime (null when absent) — for doctor/logs.
|
|
88
|
-
export function listLogs(env = process.env) {
|
|
89
|
-
return TAILABLE.map((name) => {
|
|
90
|
-
const file = logFile(name, env);
|
|
91
|
-
try {
|
|
92
|
-
const st = fs.statSync(file);
|
|
93
|
-
return { name, file, exists: true, size: st.size, mtime: st.mtimeMs };
|
|
94
|
-
} catch {
|
|
95
|
-
return { name, file, exists: false, size: null, mtime: null };
|
|
96
|
-
}
|
|
97
|
-
});
|
|
98
|
-
}
|
|
1
|
+
// logpaths — one registry for the machine-global logs so `doctor`, `logs`, and
|
|
2
|
+
// the operator channel all read the same set, and a tiny append/rotate/tail
|
|
3
|
+
// helper for the new logs we write ourselves (cli, operator).
|
|
4
|
+
//
|
|
5
|
+
// WHY a registry and not just scattered paths: a brand-new user's install is the
|
|
6
|
+
// riskiest moment (no Claude yet, wrong Node, port busy, daemon won't resolve),
|
|
7
|
+
// and "I can't see what happened on their machine" is the #1 un-debuggable
|
|
8
|
+
// failure. Centralising the log locations is what lets `wild-workspace doctor`
|
|
9
|
+
// gather them and the operator channel tail them by name — never by arbitrary
|
|
10
|
+
// path (the tail allowlist is TAILABLE below).
|
|
11
|
+
//
|
|
12
|
+
// NOTE: the supervisor + daemon-supervisor keep writing their logs at the
|
|
13
|
+
// global-dir root (supervisor.log / server.out.log / daemon.log) — those paths
|
|
14
|
+
// are reboot-proven and pinned by tests via an injected globalDir, so we mirror
|
|
15
|
+
// them here for READING rather than moving them. Everything lives under
|
|
16
|
+
// ~/.wild-workspace (NEVER the synced workspace — CLAUDE.md principle #1).
|
|
17
|
+
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
|
|
22
|
+
// THE machine-global dir. Mirrors service.mjs globalDir() + the supervisor
|
|
23
|
+
// defaults. Overridable via env for tests / unusual homes.
|
|
24
|
+
export function globalDir(env = process.env) {
|
|
25
|
+
return env.WILD_WORKSPACE_GLOBAL_DIR || path.join(os.homedir(), '.wild-workspace');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Logical name → filename. The first three are written by existing components;
|
|
29
|
+
// cli + operator are new. Doctor bundles go under diagnosticsDir() (below).
|
|
30
|
+
export const LOG_FILES = Object.freeze({
|
|
31
|
+
supervisor: 'supervisor.log', // WorkspaceSupervisor watchdog
|
|
32
|
+
server: 'server.out.log', // the :5173 server's stdout/stderr (launcher-redirected)
|
|
33
|
+
daemon: 'daemon.log', // the bmo-sync daemon
|
|
34
|
+
cli: 'cli.log', // every `wild-workspace …` invocation (first-run capture)
|
|
35
|
+
operator: 'operator.log', // the consented operator channel's audit trail
|
|
36
|
+
audit: 'audit.log', // privileged action audit trail (share/sync/agent — S8)
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Logs the operator channel may tail BY NAME — never an arbitrary path.
|
|
40
|
+
export const TAILABLE = Object.freeze(Object.keys(LOG_FILES));
|
|
41
|
+
|
|
42
|
+
export function logFile(name, env = process.env) {
|
|
43
|
+
return path.join(globalDir(env), LOG_FILES[name] || `${name}.log`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function diagnosticsDir(env = process.env) {
|
|
47
|
+
return path.join(globalDir(env), 'diagnostics');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const MAX_LOG_BYTES = 2 * 1024 * 1024; // 2 MB → rotate to .1 (keep one prior gen)
|
|
51
|
+
|
|
52
|
+
// Rotate `file` to `file.1` once it grows past maxBytes. Best-effort, no throw.
|
|
53
|
+
export function rotateIfBig(file, maxBytes = MAX_LOG_BYTES) {
|
|
54
|
+
try {
|
|
55
|
+
if (fs.statSync(file).size > maxBytes) fs.renameSync(file, `${file}.1`);
|
|
56
|
+
} catch {
|
|
57
|
+
/* missing / racing rename — nothing to rotate */
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Append a timestamped line to a named log; ensures the dir + rotates. Returns
|
|
62
|
+
// the file path. Never throws — logging must not break the caller's path.
|
|
63
|
+
export function appendLine(name, line, env = process.env) {
|
|
64
|
+
const file = logFile(name, env);
|
|
65
|
+
try {
|
|
66
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
67
|
+
rotateIfBig(file);
|
|
68
|
+
fs.appendFileSync(file, `[${new Date().toISOString()}] ${line}\n`);
|
|
69
|
+
} catch {
|
|
70
|
+
/* read-only fs etc. — degrade silently */
|
|
71
|
+
}
|
|
72
|
+
return file;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Last `n` lines of a file (logs are size-capped, so reading whole is cheap).
|
|
76
|
+
// Returns '' when the file is missing/unreadable.
|
|
77
|
+
export function tailFile(file, n = 200) {
|
|
78
|
+
try {
|
|
79
|
+
// Drop the trailing newline so the last line isn't an empty entry.
|
|
80
|
+
const lines = fs.readFileSync(file, 'utf8').replace(/\r?\n$/, '').split(/\r?\n/);
|
|
81
|
+
return lines.slice(Math.max(0, lines.length - n)).join('\n');
|
|
82
|
+
} catch {
|
|
83
|
+
return '';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// All known logs with on-disk size + mtime (null when absent) — for doctor/logs.
|
|
88
|
+
export function listLogs(env = process.env) {
|
|
89
|
+
return TAILABLE.map((name) => {
|
|
90
|
+
const file = logFile(name, env);
|
|
91
|
+
try {
|
|
92
|
+
const st = fs.statSync(file);
|
|
93
|
+
return { name, file, exists: true, size: st.size, mtime: st.mtimeMs };
|
|
94
|
+
} catch {
|
|
95
|
+
return { name, file, exists: false, size: null, mtime: null };
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
@@ -1,45 +1,45 @@
|
|
|
1
|
-
// Consent for the proactive session + install observability feed (session-reporter.mjs
|
|
2
|
-
// + transcript.mjs).
|
|
3
|
-
//
|
|
4
|
-
// DEFAULT-ON: an absent file means consented — because the disclosure is shown at
|
|
5
|
-
// onboarding, and this is the model the apps users already trust use (they retain
|
|
6
|
-
// sessions by default with an easy off). An explicit opt-out persists here. Kept
|
|
7
|
-
// in its own file (not agent-identity.json) so it's readable at first load —
|
|
8
|
-
// before the agent is even named — and trivial to flip. Mirrors operator.mjs.
|
|
9
|
-
//
|
|
10
|
-
// What "on" actually streams: WHAT happened + install health, never the words
|
|
11
|
-
// (redaction lives in session-reporter.mjs). Off also hard-stops the kill switch
|
|
12
|
-
// path WILD_WORKSPACE_NO_TELEMETRY=1, which disables ALL telemetry regardless.
|
|
13
|
-
|
|
14
|
-
import fs from 'node:fs';
|
|
15
|
-
import path from 'node:path';
|
|
16
|
-
|
|
17
|
-
export const OBSERVABILITY_VERSION = 1;
|
|
18
|
-
|
|
19
|
-
function consentFile(dataDir) {
|
|
20
|
-
return path.join(dataDir, 'observability.json');
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function loadObservabilityConsent(dataDir) {
|
|
24
|
-
try {
|
|
25
|
-
const p = JSON.parse(fs.readFileSync(consentFile(dataDir), 'utf8'));
|
|
26
|
-
return {
|
|
27
|
-
enabled: p.enabled !== false,
|
|
28
|
-
decidedAt: p.decidedAt ? Number(p.decidedAt) : null,
|
|
29
|
-
version: Number(p.version) || OBSERVABILITY_VERSION,
|
|
30
|
-
};
|
|
31
|
-
} catch {
|
|
32
|
-
return { enabled: true, decidedAt: null, version: OBSERVABILITY_VERSION }; // default-on
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function setObservabilityConsent(dataDir, enabled) {
|
|
37
|
-
const rec = { enabled: Boolean(enabled), decidedAt: Date.now(), version: OBSERVABILITY_VERSION };
|
|
38
|
-
try {
|
|
39
|
-
fs.mkdirSync(dataDir, { recursive: true });
|
|
40
|
-
fs.writeFileSync(consentFile(dataDir), JSON.stringify(rec, null, 2), { mode: 0o600 });
|
|
41
|
-
} catch {
|
|
42
|
-
/* read-only fs — fall back to in-memory for this run */
|
|
43
|
-
}
|
|
44
|
-
return rec;
|
|
45
|
-
}
|
|
1
|
+
// Consent for the proactive session + install observability feed (session-reporter.mjs
|
|
2
|
+
// + transcript.mjs).
|
|
3
|
+
//
|
|
4
|
+
// DEFAULT-ON: an absent file means consented — because the disclosure is shown at
|
|
5
|
+
// onboarding, and this is the model the apps users already trust use (they retain
|
|
6
|
+
// sessions by default with an easy off). An explicit opt-out persists here. Kept
|
|
7
|
+
// in its own file (not agent-identity.json) so it's readable at first load —
|
|
8
|
+
// before the agent is even named — and trivial to flip. Mirrors operator.mjs.
|
|
9
|
+
//
|
|
10
|
+
// What "on" actually streams: WHAT happened + install health, never the words
|
|
11
|
+
// (redaction lives in session-reporter.mjs). Off also hard-stops the kill switch
|
|
12
|
+
// path WILD_WORKSPACE_NO_TELEMETRY=1, which disables ALL telemetry regardless.
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
export const OBSERVABILITY_VERSION = 1;
|
|
18
|
+
|
|
19
|
+
function consentFile(dataDir) {
|
|
20
|
+
return path.join(dataDir, 'observability.json');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function loadObservabilityConsent(dataDir) {
|
|
24
|
+
try {
|
|
25
|
+
const p = JSON.parse(fs.readFileSync(consentFile(dataDir), 'utf8'));
|
|
26
|
+
return {
|
|
27
|
+
enabled: p.enabled !== false,
|
|
28
|
+
decidedAt: p.decidedAt ? Number(p.decidedAt) : null,
|
|
29
|
+
version: Number(p.version) || OBSERVABILITY_VERSION,
|
|
30
|
+
};
|
|
31
|
+
} catch {
|
|
32
|
+
return { enabled: true, decidedAt: null, version: OBSERVABILITY_VERSION }; // default-on
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function setObservabilityConsent(dataDir, enabled) {
|
|
37
|
+
const rec = { enabled: Boolean(enabled), decidedAt: Date.now(), version: OBSERVABILITY_VERSION };
|
|
38
|
+
try {
|
|
39
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
40
|
+
fs.writeFileSync(consentFile(dataDir), JSON.stringify(rec, null, 2), { mode: 0o600 });
|
|
41
|
+
} catch {
|
|
42
|
+
/* read-only fs — fall back to in-memory for this run */
|
|
43
|
+
}
|
|
44
|
+
return rec;
|
|
45
|
+
}
|
package/server/src/operator.mjs
CHANGED
|
@@ -1,92 +1,92 @@
|
|
|
1
|
-
// Operator channel token — the consented "let the wild-workspace team help with
|
|
2
|
-
// my install" capability (docs/SECURITY.md, docs/user-experience.md §5).
|
|
3
|
-
//
|
|
4
|
-
// SECURITY POSTURE: this channel is OFF by default. It only exists once a token
|
|
5
|
-
// is minted (`wild-workspace operator enable`, an explicit user opt-in). With no
|
|
6
|
-
// token, the operator role is unreachable and every /api/operator/* route 404s.
|
|
7
|
-
// The token is a separate secret from the partner token (so it can be handed to
|
|
8
|
-
// support + revoked independently), persisted 0600, and accepted ONLY in an
|
|
9
|
-
// Authorization header — never a `?t=` query (it must not leak via logs /
|
|
10
|
-
// history / referrer; SECURITY.md S1). Disabling deletes the token.
|
|
11
|
-
|
|
12
|
-
import fs from 'node:fs';
|
|
13
|
-
import path from 'node:path';
|
|
14
|
-
import crypto from 'node:crypto';
|
|
15
|
-
|
|
16
|
-
export function operatorFile(dataDir) {
|
|
17
|
-
return path.join(dataDir, 'operator.json');
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
// The token, or null when the channel is disabled (the common case).
|
|
21
|
-
export function loadOperatorToken(dataDir) {
|
|
22
|
-
try {
|
|
23
|
-
const parsed = JSON.parse(fs.readFileSync(operatorFile(dataDir), 'utf8'));
|
|
24
|
-
return typeof parsed.operatorToken === 'string' && parsed.operatorToken
|
|
25
|
-
? parsed.operatorToken
|
|
26
|
-
: null;
|
|
27
|
-
} catch {
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// RC1 hot-reload: read the operator token LIVE (with a tiny TTL cache) instead of
|
|
33
|
-
// the value the server snapshotted at boot. Today `operator enable` writes the
|
|
34
|
-
// token to disk but a long-running server keeps serving its cached "disabled"
|
|
35
|
-
// state, so the channel 401s until a manual restart (the exact bug from the first
|
|
36
|
-
// external install). A short TTL keeps this off the hot auth path — every request
|
|
37
|
-
// reads from cache, and `enable`/`disable` take effect within `ttlMs`.
|
|
38
|
-
//
|
|
39
|
-
// The cache is keyed by dataDir so two servers (tests, multiple installs) in one
|
|
40
|
-
// process don't read each other's tokens. `now` is injectable for tests.
|
|
41
|
-
const _tokenCache = new Map(); // dataDir -> { token, at }
|
|
42
|
-
export function getOperatorToken(dataDir, { ttlMs = 2000, now = Date.now } = {}) {
|
|
43
|
-
const t = now();
|
|
44
|
-
const hit = _tokenCache.get(dataDir);
|
|
45
|
-
if (hit && t - hit.at < ttlMs) return hit.token;
|
|
46
|
-
const token = loadOperatorToken(dataDir);
|
|
47
|
-
_tokenCache.set(dataDir, { token, at: t });
|
|
48
|
-
return token;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// Drop the cached token for a dataDir (or all of them). `enable`/`disable` run in
|
|
52
|
-
// a separate CLI process from the server, so they don't need this — it exists so
|
|
53
|
-
// in-process callers (and tests) can force a re-read without waiting out the TTL.
|
|
54
|
-
export function invalidateOperatorTokenCache(dataDir) {
|
|
55
|
-
if (dataDir === undefined) _tokenCache.clear();
|
|
56
|
-
else _tokenCache.delete(dataDir);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// Turn the channel on. Idempotent by default — returns the existing token if one
|
|
60
|
-
// is already set (so a re-run doesn't invalidate the code the user already
|
|
61
|
-
// shared). Pass { rotate:true } to force a fresh token. Returns the token, or
|
|
62
|
-
// null if it couldn't be persisted.
|
|
63
|
-
export function enableOperator(dataDir, { rotate = false } = {}) {
|
|
64
|
-
const existing = loadOperatorToken(dataDir);
|
|
65
|
-
if (existing && !rotate) return existing;
|
|
66
|
-
const token = crypto.randomBytes(24).toString('base64url');
|
|
67
|
-
try {
|
|
68
|
-
fs.mkdirSync(dataDir, { recursive: true });
|
|
69
|
-
fs.writeFileSync(
|
|
70
|
-
operatorFile(dataDir),
|
|
71
|
-
JSON.stringify({ operatorToken: token, enabledAt: Date.now() }, null, 2),
|
|
72
|
-
{ mode: 0o600 },
|
|
73
|
-
);
|
|
74
|
-
} catch {
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
|
-
return token;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Turn the channel off (revoke). Returns true if a token file was removed.
|
|
81
|
-
export function disableOperator(dataDir) {
|
|
82
|
-
try {
|
|
83
|
-
fs.rmSync(operatorFile(dataDir));
|
|
84
|
-
return true;
|
|
85
|
-
} catch {
|
|
86
|
-
return false;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export function operatorStatus(dataDir) {
|
|
91
|
-
return { enabled: Boolean(loadOperatorToken(dataDir)), file: operatorFile(dataDir) };
|
|
92
|
-
}
|
|
1
|
+
// Operator channel token — the consented "let the wild-workspace team help with
|
|
2
|
+
// my install" capability (docs/SECURITY.md, docs/user-experience.md §5).
|
|
3
|
+
//
|
|
4
|
+
// SECURITY POSTURE: this channel is OFF by default. It only exists once a token
|
|
5
|
+
// is minted (`wild-workspace operator enable`, an explicit user opt-in). With no
|
|
6
|
+
// token, the operator role is unreachable and every /api/operator/* route 404s.
|
|
7
|
+
// The token is a separate secret from the partner token (so it can be handed to
|
|
8
|
+
// support + revoked independently), persisted 0600, and accepted ONLY in an
|
|
9
|
+
// Authorization header — never a `?t=` query (it must not leak via logs /
|
|
10
|
+
// history / referrer; SECURITY.md S1). Disabling deletes the token.
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import crypto from 'node:crypto';
|
|
15
|
+
|
|
16
|
+
export function operatorFile(dataDir) {
|
|
17
|
+
return path.join(dataDir, 'operator.json');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// The token, or null when the channel is disabled (the common case).
|
|
21
|
+
export function loadOperatorToken(dataDir) {
|
|
22
|
+
try {
|
|
23
|
+
const parsed = JSON.parse(fs.readFileSync(operatorFile(dataDir), 'utf8'));
|
|
24
|
+
return typeof parsed.operatorToken === 'string' && parsed.operatorToken
|
|
25
|
+
? parsed.operatorToken
|
|
26
|
+
: null;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// RC1 hot-reload: read the operator token LIVE (with a tiny TTL cache) instead of
|
|
33
|
+
// the value the server snapshotted at boot. Today `operator enable` writes the
|
|
34
|
+
// token to disk but a long-running server keeps serving its cached "disabled"
|
|
35
|
+
// state, so the channel 401s until a manual restart (the exact bug from the first
|
|
36
|
+
// external install). A short TTL keeps this off the hot auth path — every request
|
|
37
|
+
// reads from cache, and `enable`/`disable` take effect within `ttlMs`.
|
|
38
|
+
//
|
|
39
|
+
// The cache is keyed by dataDir so two servers (tests, multiple installs) in one
|
|
40
|
+
// process don't read each other's tokens. `now` is injectable for tests.
|
|
41
|
+
const _tokenCache = new Map(); // dataDir -> { token, at }
|
|
42
|
+
export function getOperatorToken(dataDir, { ttlMs = 2000, now = Date.now } = {}) {
|
|
43
|
+
const t = now();
|
|
44
|
+
const hit = _tokenCache.get(dataDir);
|
|
45
|
+
if (hit && t - hit.at < ttlMs) return hit.token;
|
|
46
|
+
const token = loadOperatorToken(dataDir);
|
|
47
|
+
_tokenCache.set(dataDir, { token, at: t });
|
|
48
|
+
return token;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Drop the cached token for a dataDir (or all of them). `enable`/`disable` run in
|
|
52
|
+
// a separate CLI process from the server, so they don't need this — it exists so
|
|
53
|
+
// in-process callers (and tests) can force a re-read without waiting out the TTL.
|
|
54
|
+
export function invalidateOperatorTokenCache(dataDir) {
|
|
55
|
+
if (dataDir === undefined) _tokenCache.clear();
|
|
56
|
+
else _tokenCache.delete(dataDir);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Turn the channel on. Idempotent by default — returns the existing token if one
|
|
60
|
+
// is already set (so a re-run doesn't invalidate the code the user already
|
|
61
|
+
// shared). Pass { rotate:true } to force a fresh token. Returns the token, or
|
|
62
|
+
// null if it couldn't be persisted.
|
|
63
|
+
export function enableOperator(dataDir, { rotate = false } = {}) {
|
|
64
|
+
const existing = loadOperatorToken(dataDir);
|
|
65
|
+
if (existing && !rotate) return existing;
|
|
66
|
+
const token = crypto.randomBytes(24).toString('base64url');
|
|
67
|
+
try {
|
|
68
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
69
|
+
fs.writeFileSync(
|
|
70
|
+
operatorFile(dataDir),
|
|
71
|
+
JSON.stringify({ operatorToken: token, enabledAt: Date.now() }, null, 2),
|
|
72
|
+
{ mode: 0o600 },
|
|
73
|
+
);
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
return token;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Turn the channel off (revoke). Returns true if a token file was removed.
|
|
81
|
+
export function disableOperator(dataDir) {
|
|
82
|
+
try {
|
|
83
|
+
fs.rmSync(operatorFile(dataDir));
|
|
84
|
+
return true;
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function operatorStatus(dataDir) {
|
|
91
|
+
return { enabled: Boolean(loadOperatorToken(dataDir)), file: operatorFile(dataDir) };
|
|
92
|
+
}
|