@venturewild/workspace 0.1.12 → 0.1.14
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 +76 -76
- package/server/bin/wild-workspace.mjs +763 -763
- package/server/src/agent.mjs +386 -386
- package/server/src/config.mjs +365 -325
- package/server/src/daemon-supervisor.mjs +216 -216
- package/server/src/inbox.mjs +86 -86
- package/server/src/index.mjs +1721 -1566
- package/server/src/logpaths.mjs +98 -98
- package/server/src/pairing.mjs +137 -0
- package/server/src/service.mjs +419 -419
- package/server/src/share.mjs +148 -115
- package/server/src/sync.mjs +248 -248
- package/web/dist/assets/{index-n0-hsCzL.js → index-Dc6jo84c.js} +19 -19
- package/web/dist/index.html +1 -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
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// In-memory store of pending "sign in this device" requests (Phase 2 device
|
|
2
|
+
// approval). A new device calls POST /api/auth/pair/start → we mint a short
|
|
3
|
+
// human CODE (the owner reads it off the new device) plus an unguessable
|
|
4
|
+
// requestId (the device polls by THIS, never the code). The owner approves from
|
|
5
|
+
// a partner session — which must echo the matching code (confused-deputy
|
|
6
|
+
// defense) — and we attach a minted device token; the device claims it ONCE.
|
|
7
|
+
//
|
|
8
|
+
// Deliberately in-memory + ephemeral (5-min TTL): a pending request that
|
|
9
|
+
// survived a restart would just be a longer attack window for no UX gain — the
|
|
10
|
+
// device simply re-requests. The durable artifact is the minted token
|
|
11
|
+
// (persisted via TokenRegistry), not the pairing record.
|
|
12
|
+
|
|
13
|
+
import crypto from 'node:crypto';
|
|
14
|
+
import { nanoid } from 'nanoid';
|
|
15
|
+
|
|
16
|
+
const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
17
|
+
const DEFAULT_MAX_PENDING = 5;
|
|
18
|
+
|
|
19
|
+
// 6-digit zero-padded code. Used ONLY owner-side (the approver matches it
|
|
20
|
+
// against the code shown on the new device). It is NEVER an auth credential on
|
|
21
|
+
// the public poll path — the device authenticates by requestId — so its low
|
|
22
|
+
// entropy is not an internet-facing brute-force surface.
|
|
23
|
+
function makeCode() {
|
|
24
|
+
return String(crypto.randomInt(0, 1_000_000)).padStart(6, '0');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class PairingStore {
|
|
28
|
+
constructor({ ttlMs = DEFAULT_TTL_MS, maxPending = DEFAULT_MAX_PENDING, clock = () => Date.now() } = {}) {
|
|
29
|
+
this.ttlMs = ttlMs;
|
|
30
|
+
this.maxPending = maxPending;
|
|
31
|
+
this.clock = clock;
|
|
32
|
+
// requestId -> { requestId, code, label, status, createdAt, expiresAt, token, sub, exp }
|
|
33
|
+
this.requests = new Map();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Expire stale pending requests; drop terminal records a while after expiry to
|
|
37
|
+
// bound memory. Called on every accessor so the map self-cleans.
|
|
38
|
+
prune() {
|
|
39
|
+
const now = this.clock();
|
|
40
|
+
for (const [id, r] of this.requests) {
|
|
41
|
+
if (r.status === 'pending' && r.expiresAt <= now) r.status = 'expired';
|
|
42
|
+
if (r.expiresAt + this.ttlMs <= now) this.requests.delete(id);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
pendingCount() {
|
|
47
|
+
this.prune();
|
|
48
|
+
let n = 0;
|
|
49
|
+
for (const r of this.requests.values()) if (r.status === 'pending') n += 1;
|
|
50
|
+
return n;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Returns { requestId, code, expiresAt } or null if the global pending cap is
|
|
54
|
+
// reached (anti-spam: bounds how cluttered the owner's approval list can get).
|
|
55
|
+
create({ label } = {}) {
|
|
56
|
+
if (this.pendingCount() >= this.maxPending) return null;
|
|
57
|
+
const now = this.clock();
|
|
58
|
+
const requestId = nanoid(16);
|
|
59
|
+
// Codes must be UNIQUE among pending so "owner types the code" resolves to
|
|
60
|
+
// exactly one request (collision odds are ~nil at maxPending=5, but be
|
|
61
|
+
// deterministic about it).
|
|
62
|
+
const pendingCodes = new Set(
|
|
63
|
+
[...this.requests.values()].filter((r) => r.status === 'pending').map((r) => r.code),
|
|
64
|
+
);
|
|
65
|
+
let code = makeCode();
|
|
66
|
+
for (let guard = 0; pendingCodes.has(code) && guard < 50; guard += 1) code = makeCode();
|
|
67
|
+
const rec = {
|
|
68
|
+
requestId,
|
|
69
|
+
code,
|
|
70
|
+
label: label || 'a device',
|
|
71
|
+
status: 'pending',
|
|
72
|
+
createdAt: now,
|
|
73
|
+
expiresAt: now + this.ttlMs,
|
|
74
|
+
token: null,
|
|
75
|
+
sub: null,
|
|
76
|
+
exp: null,
|
|
77
|
+
};
|
|
78
|
+
this.requests.set(requestId, rec);
|
|
79
|
+
return { requestId, code: rec.code, expiresAt: rec.expiresAt };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
get(requestId) {
|
|
84
|
+
this.prune();
|
|
85
|
+
return this.requests.get(requestId) || null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Pending, non-expired requests for the owner's approval UI (includes the code
|
|
89
|
+
// so the owner can visually match it against the new device's screen).
|
|
90
|
+
listPending() {
|
|
91
|
+
this.prune();
|
|
92
|
+
// Includes the code so the owner can match it against the new device's
|
|
93
|
+
// screen and approve the right one if more than one is pending. Approval
|
|
94
|
+
// itself is by requestId (one tap); the code is the human "is this mine?"
|
|
95
|
+
// confirmation, not a typed secret.
|
|
96
|
+
return [...this.requests.values()]
|
|
97
|
+
.filter((r) => r.status === 'pending')
|
|
98
|
+
.map((r) => ({ requestId: r.requestId, code: r.code, label: r.label, createdAt: r.createdAt }));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Approve a specific pending request (the one the owner tapped, having matched
|
|
102
|
+
// its code against the new device). Returns true on success. `tokenInfo` =
|
|
103
|
+
// { token, sub, exp } from mintDeviceToken.
|
|
104
|
+
approve(requestId, tokenInfo) {
|
|
105
|
+
this.prune();
|
|
106
|
+
const r = this.requests.get(requestId);
|
|
107
|
+
if (!r || r.status !== 'pending') return false;
|
|
108
|
+
r.status = 'approved';
|
|
109
|
+
r.token = tokenInfo.token;
|
|
110
|
+
r.sub = tokenInfo.sub;
|
|
111
|
+
r.exp = tokenInfo.exp;
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
deny(requestId) {
|
|
116
|
+
this.prune();
|
|
117
|
+
const r = this.requests.get(requestId);
|
|
118
|
+
if (!r || r.status !== 'pending') return false;
|
|
119
|
+
r.status = 'denied';
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// One-shot token read: once approved, the device gets the token exactly once;
|
|
124
|
+
// a replayed poll yields { status:'claimed' } with no token.
|
|
125
|
+
claim(requestId) {
|
|
126
|
+
this.prune();
|
|
127
|
+
const r = this.requests.get(requestId);
|
|
128
|
+
if (!r) return { status: 'expired' };
|
|
129
|
+
if (r.status === 'approved' && r.token) {
|
|
130
|
+
const token = r.token;
|
|
131
|
+
r.status = 'claimed';
|
|
132
|
+
r.token = null;
|
|
133
|
+
return { status: 'approved', token };
|
|
134
|
+
}
|
|
135
|
+
return { status: r.status };
|
|
136
|
+
}
|
|
137
|
+
}
|