@venturewild/workspace 0.6.0 → 0.6.2

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 (56) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +112 -112
  3. package/package.json +85 -85
  4. package/server/bin/wild-workspace.mjs +1096 -1096
  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 +974 -974
  10. package/server/src/bazaar/index.mjs +88 -88
  11. package/server/src/bazaar/mcp-server.mjs +429 -429
  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 +40 -40
  24. package/server/src/canvas/core.mjs +446 -446
  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 -108
  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 +3332 -3322
  34. package/server/src/listings-rails.mjs +156 -156
  35. package/server/src/logpaths.mjs +98 -98
  36. package/server/src/observability.mjs +45 -45
  37. package/server/src/operator.mjs +92 -92
  38. package/server/src/pairing.mjs +137 -137
  39. package/server/src/service.mjs +515 -515
  40. package/server/src/session-reporter.mjs +201 -201
  41. package/server/src/settings.mjs +145 -145
  42. package/server/src/share.mjs +182 -182
  43. package/server/src/skills.mjs +213 -213
  44. package/server/src/supervisor.mjs +647 -647
  45. package/server/src/support-consent.mjs +133 -133
  46. package/server/src/sync.mjs +248 -248
  47. package/server/src/transcript.mjs +121 -121
  48. package/server/src/turn-mcp.mjs +46 -46
  49. package/server/src/usage.mjs +405 -405
  50. package/server/src/workspace-registry.mjs +295 -295
  51. package/server/src/workspaces.mjs +145 -145
  52. package/web/dist/assets/index-BgFan7ls.js +131 -0
  53. package/web/dist/assets/index-DHts78rO.css +32 -0
  54. package/web/dist/index.html +2 -2
  55. package/web/dist/assets/index-CSWkWdtM.js +0 -131
  56. package/web/dist/assets/index-CzUrGoMW.css +0 -32
@@ -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
+ }
@@ -1,137 +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
- }
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
+ }