@pellux/goodvibes-sdk 0.21.27 → 0.21.29
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/dist/_internal/platform/daemon/http-listener.d.ts +12 -0
- package/dist/_internal/platform/daemon/http-listener.d.ts.map +1 -1
- package/dist/_internal/platform/daemon/http-listener.js +44 -11
- package/dist/_internal/platform/pairing/companion-token.d.ts +8 -8
- package/dist/_internal/platform/pairing/companion-token.d.ts.map +1 -1
- package/dist/_internal/platform/pairing/companion-token.js +23 -32
- package/dist/_internal/platform/security/user-auth.d.ts.map +1 -1
- package/dist/_internal/platform/security/user-auth.js +25 -12
- package/dist/_internal/platform/version.js +1 -1
- package/dist/_internal/platform/workspace/daemon-home.d.ts +24 -3
- package/dist/_internal/platform/workspace/daemon-home.d.ts.map +1 -1
- package/dist/_internal/platform/workspace/daemon-home.js +96 -84
- package/package.json +1 -1
|
@@ -10,6 +10,14 @@ interface HttpListenerConfig {
|
|
|
10
10
|
serveFactory?: typeof Bun.serve;
|
|
11
11
|
/** Max requests per 60-second window per IP. Default: 60. */
|
|
12
12
|
rateLimit?: number;
|
|
13
|
+
/** Max POST /login attempts per 60-second window per IP. Default: 5. */
|
|
14
|
+
loginRateLimit?: number;
|
|
15
|
+
/**
|
|
16
|
+
* When true, x-forwarded-for / x-real-ip headers are trusted for client IP
|
|
17
|
+
* extraction (rate limiting, audit logging). Only enable behind a trusted
|
|
18
|
+
* reverse proxy. Overrides the httpListener.trustProxy config value when set.
|
|
19
|
+
*/
|
|
20
|
+
trustProxy?: boolean;
|
|
13
21
|
/** Pre-configured UserAuthManager owned by the runtime service graph. */
|
|
14
22
|
userAuth: UserAuthManager;
|
|
15
23
|
}
|
|
@@ -36,6 +44,10 @@ export declare class HttpListener {
|
|
|
36
44
|
private authToken;
|
|
37
45
|
private userAuth;
|
|
38
46
|
private rateLimiter;
|
|
47
|
+
/** Dedicated tight rate-limiter for POST /login (SEC-03). */
|
|
48
|
+
private loginRateLimiter;
|
|
49
|
+
/** Whether to trust x-forwarded-for / x-real-ip for client IP resolution. */
|
|
50
|
+
private trustProxy;
|
|
39
51
|
private readonly configManager;
|
|
40
52
|
private readonly serveFactory;
|
|
41
53
|
private tlsState;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"http-listener.d.ts","sourceRoot":"","sources":["../../../../src/_internal/platform/daemon/http-listener.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAMxD,OAAO,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAC3D,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAWrD,UAAU,kBAAkB;IAC1B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,aAAa,EAAE,aAAa,CAAC;IAC7B,YAAY,CAAC,EAAE,OAAO,GAAG,CAAC,KAAK,CAAC;IAChC,6DAA6D;IAC7D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,yEAAyE;IACzE,QAAQ,EAAE,eAAe,CAAC;CAC3B;AAED,UAAU,gBAAgB;IACxB,YAAY,EAAE,OAAO,CAAC;CACvB;AA0ED;;;;;;;;GAQG;AACH,qBAAa,YAAY;
|
|
1
|
+
{"version":3,"file":"http-listener.d.ts","sourceRoot":"","sources":["../../../../src/_internal/platform/daemon/http-listener.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAMxD,OAAO,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAC3D,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAWrD,UAAU,kBAAkB;IAC1B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,aAAa,EAAE,aAAa,CAAC;IAC7B,YAAY,CAAC,EAAE,OAAO,GAAG,CAAC,KAAK,CAAC;IAChC,6DAA6D;IAC7D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,wEAAwE;IACxE,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,yEAAyE;IACzE,QAAQ,EAAE,eAAe,CAAC;CAC3B;AAED,UAAU,gBAAgB;IACxB,YAAY,EAAE,OAAO,CAAC;CACvB;AA0ED;;;;;;;;GAQG;AACH,qBAAa,YAAY;IA0BX,OAAO,CAAC,MAAM;IAzB1B,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,MAAM,CAA6C;IAC3D,OAAO,CAAC,IAAI,CAAS;IACrB,OAAO,CAAC,IAAI,CAAS;IACrB,OAAO,CAAC,cAAc,CAAW;IACjC,OAAO,CAAC,cAAc,CAAwB;IAC9C,OAAO,CAAC,SAAS,CAAuB;IACxC,OAAO,CAAC,QAAQ,CAAkB;IAClC,OAAO,CAAC,WAAW,CAAc;IACjC,6DAA6D;IAC7D,OAAO,CAAC,gBAAgB,CAAc;IACtC,6EAA6E;IAC7E,OAAO,CAAC,UAAU,CAAU;IAC5B,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAgB;IAC9C,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAmB;IAChD,OAAO,CAAC,QAAQ,CAA0C;IAC1D,4EAA4E;IAC5E,OAAO,CAAC,iBAAiB,CAA6B;IACtD,gFAAgF;IAChF,OAAO,CAAC,WAAW,CAAS;IAC5B,sEAAsE;IACtE,OAAO,CAAC,kBAAkB,CAA8B;IACxD,0FAA0F;IAC1F,OAAO,CAAC,aAAa,CAAS;gBAEV,MAAM,EAAE,kBAAkB;IAiC9C;;;;OAIG;IACH,MAAM,CAAC,YAAY,EAAE,gBAAgB,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO;IAU/D;;OAEG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAsC5B;;OAEG;IACG,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC;IAKrC;;OAEG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAqB3B;;;OAGG;IACH,OAAO,CAAC,gCAAgC;IAwDxC;;OAEG;IACH,IAAI,SAAS,IAAI,OAAO,CAEvB;IAMD,OAAO,CAAC,SAAS;YAOH,aAAa;YAYb,aAAa;YA0Db,WAAW;YA6BX,aAAa;CAgD5B"}
|
|
@@ -93,6 +93,10 @@ export class HttpListener {
|
|
|
93
93
|
authToken = null;
|
|
94
94
|
userAuth;
|
|
95
95
|
rateLimiter;
|
|
96
|
+
/** Dedicated tight rate-limiter for POST /login (SEC-03). */
|
|
97
|
+
loginRateLimiter;
|
|
98
|
+
/** Whether to trust x-forwarded-for / x-real-ip for client IP resolution. */
|
|
99
|
+
trustProxy;
|
|
96
100
|
configManager;
|
|
97
101
|
serveFactory;
|
|
98
102
|
tlsState = null;
|
|
@@ -111,9 +115,22 @@ export class HttpListener {
|
|
|
111
115
|
this.port = config.port ?? resolvedHttpBinding.port;
|
|
112
116
|
this.host = config.host ?? resolvedHttpBinding.host;
|
|
113
117
|
this.allowedOrigins = config.allowedOrigins ?? [];
|
|
118
|
+
// SEC-07: Refuse to construct when hostMode=network and allowedOrigins is not configured.
|
|
119
|
+
// An empty allowedOrigins combined with a network-reachable bind is an open CSRF vector —
|
|
120
|
+
// any browser-initiated cross-origin request will carry an Origin header and be accepted.
|
|
121
|
+
const effectiveHostMode = this.configManager.get('httpListener.hostMode') ?? 'local';
|
|
122
|
+
if (effectiveHostMode === 'network' && this.allowedOrigins.length === 0) {
|
|
123
|
+
throw new Error('SECURITY_UNSAFE_ORIGIN_CONFIG: hostMode=network requires non-empty allowedOrigins to prevent CSRF. '
|
|
124
|
+
+ 'Set config.httpListener.allowedOrigins to a list of trusted origins '
|
|
125
|
+
+ "(e.g. ['https://companion.example.com']).");
|
|
126
|
+
}
|
|
114
127
|
this.hookDispatcher = config.hookDispatcher ?? null;
|
|
115
128
|
this.userAuth = config.userAuth;
|
|
116
129
|
this.rateLimiter = new RateLimiter(config.rateLimit ?? 60);
|
|
130
|
+
// SEC-03: /login gets its own tight budget (5 attempts/min per IP) to prevent
|
|
131
|
+
// scrypt-cost-throttled online brute-force attacks.
|
|
132
|
+
this.loginRateLimiter = new RateLimiter(config.loginRateLimit ?? 5);
|
|
133
|
+
this.trustProxy = config.trustProxy ?? Boolean(this.configManager.get('httpListener.trustProxy'));
|
|
117
134
|
this.serveFactory = config.serveFactory ?? Bun.serve;
|
|
118
135
|
}
|
|
119
136
|
/**
|
|
@@ -190,8 +207,9 @@ export class HttpListener {
|
|
|
190
207
|
this._configWatchUnsub?.();
|
|
191
208
|
this._configWatchUnsub = null;
|
|
192
209
|
}
|
|
193
|
-
// Stop rate limiter sweep
|
|
210
|
+
// Stop rate limiter sweep intervals before tearing down.
|
|
194
211
|
this.rateLimiter.stop();
|
|
212
|
+
this.loginRateLimiter.stop();
|
|
195
213
|
this.server.stop(true);
|
|
196
214
|
this.server = null;
|
|
197
215
|
this.tlsState = null;
|
|
@@ -281,20 +299,35 @@ export class HttpListener {
|
|
|
281
299
|
// Request handling
|
|
282
300
|
// -------------------------------------------------------------------------
|
|
283
301
|
async handleRequest(req) {
|
|
284
|
-
// Handle login route before auth check
|
|
285
302
|
const url = new URL(req.url);
|
|
303
|
+
const clientIp = extractForwardedClientIp(req, this.trustProxy || (this.tlsState?.trustProxy ?? false)) ?? 'unknown';
|
|
304
|
+
// SEC-07: CORS origin check applies to ALL paths (including /login).
|
|
305
|
+
// Logic:
|
|
306
|
+
// - No Origin header → same-origin or non-browser request → allow.
|
|
307
|
+
// - Origin present + allowedOrigins empty → no allowlist configured; block to
|
|
308
|
+
// prevent CSRF even when hostMode is not 'network' (e.g. 'auto' with a
|
|
309
|
+
// network-reachable bind). Constructor already refuses hostMode=network with
|
|
310
|
+
// empty allowedOrigins at startup, but defence-in-depth covers the request path.
|
|
311
|
+
// - Origin present + allowedOrigins non-empty → check allowlist.
|
|
312
|
+
const origin = req.headers.get('origin');
|
|
313
|
+
if (origin !== null) {
|
|
314
|
+
if (this.allowedOrigins.length === 0) {
|
|
315
|
+
return Response.json({ error: 'CORS_NOT_CONFIGURED: no allowedOrigins set' }, { status: 403 });
|
|
316
|
+
}
|
|
317
|
+
if (!this.allowedOrigins.includes(origin)) {
|
|
318
|
+
return Response.json({ error: 'ORIGIN_NOT_ALLOWED' }, { status: 403 });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
// SEC-03: /login route handled AFTER origin check and under its own tight
|
|
322
|
+
// rate-limit budget (5/min per IP) to prevent online brute-force attacks.
|
|
323
|
+
// x-forwarded-for is only trustworthy when running behind a trusted reverse proxy.
|
|
286
324
|
if (url.pathname === '/login' && req.method === 'POST') {
|
|
325
|
+
if (!this.loginRateLimiter.check(clientIp)) {
|
|
326
|
+
return Response.json({ error: 'Too many requests' }, { status: 429 });
|
|
327
|
+
}
|
|
287
328
|
return this.handleLogin(req);
|
|
288
329
|
}
|
|
289
|
-
//
|
|
290
|
-
const origin = req.headers.get('origin') ?? '';
|
|
291
|
-
if (this.allowedOrigins.length > 0 && origin && !this.allowedOrigins.includes(origin)) {
|
|
292
|
-
return Response.json({ error: 'Origin not allowed' }, { status: 403 });
|
|
293
|
-
}
|
|
294
|
-
// Rate limiting (keyed by a synthetic IP-like string from headers)
|
|
295
|
-
// Note: x-forwarded-for is only trustworthy when running behind a trusted reverse proxy.
|
|
296
|
-
// If exposed directly to the internet, clients can spoof this header.
|
|
297
|
-
const clientIp = extractForwardedClientIp(req, this.tlsState?.trustProxy ?? Boolean(this.configManager.get('httpListener.trustProxy'))) ?? 'unknown';
|
|
330
|
+
// General rate limiting for all other routes.
|
|
298
331
|
if (!this.rateLimiter.check(clientIp)) {
|
|
299
332
|
return Response.json({ error: 'Too many requests' }, { status: 429 });
|
|
300
333
|
}
|
|
@@ -18,19 +18,19 @@ export interface CompanionTokenRecord {
|
|
|
18
18
|
readonly createdAt: number;
|
|
19
19
|
}
|
|
20
20
|
/**
|
|
21
|
-
* Load the stored companion token
|
|
21
|
+
* Load the stored companion token, or generate and persist a new one.
|
|
22
|
+
* Token is always written to <daemonHomeDir>/operator-tokens.json at mode 0600.
|
|
22
23
|
*/
|
|
23
|
-
export declare function getOrCreateCompanionToken(surface: string, options
|
|
24
|
-
|
|
25
|
-
daemonHomeDir?: string;
|
|
24
|
+
export declare function getOrCreateCompanionToken(surface: string, options: {
|
|
25
|
+
daemonHomeDir: string;
|
|
26
26
|
regenerate?: boolean;
|
|
27
27
|
}): CompanionPairingResult;
|
|
28
28
|
/**
|
|
29
|
-
* Regenerate the companion token
|
|
29
|
+
* Regenerate the companion token, replacing any existing token.
|
|
30
|
+
* Written to <daemonHomeDir>/operator-tokens.json at mode 0600.
|
|
30
31
|
*/
|
|
31
|
-
export declare function regenerateCompanionToken(surface: string, options
|
|
32
|
-
|
|
33
|
-
daemonHomeDir?: string;
|
|
32
|
+
export declare function regenerateCompanionToken(surface: string, options: {
|
|
33
|
+
daemonHomeDir: string;
|
|
34
34
|
}): CompanionPairingResult;
|
|
35
35
|
/**
|
|
36
36
|
* Build a CompanionConnectionInfo object from raw parameters.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"companion-token.d.ts","sourceRoot":"","sources":["../../../../src/_internal/platform/pairing/companion-token.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,mFAAmF;IACnF,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;
|
|
1
|
+
{"version":3,"file":"companion-token.d.ts","sourceRoot":"","sources":["../../../../src/_internal/platform/pairing/companion-token.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,mFAAmF;IACnF,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAyBD;;;GAGG;AACH,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,MAAM,EACf,OAAO,EAAE;IAAE,aAAa,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,OAAO,CAAA;CAAE,GACvD,sBAAsB,CA6BxB;AAED;;;GAGG;AACH,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,MAAM,EACf,OAAO,EAAE;IAAE,aAAa,EAAE,MAAM,CAAA;CAAE,GACjC,sBAAsB,CAExB;AAED;;GAEG;AACH,wBAAgB,4BAA4B,CAAC,OAAO,EAAE;IACpD,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,GAAG,uBAAuB,CAS1B;AAED;;GAEG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,uBAAuB,GAAG,MAAM,CAS7E"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
2
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from 'node:fs';
|
|
3
3
|
import { join, dirname } from 'node:path';
|
|
4
4
|
const TOKEN_PREFIX = 'gv_';
|
|
5
5
|
function generateTokenValue() {
|
|
@@ -9,41 +9,25 @@ function generatePeerId() {
|
|
|
9
9
|
return randomBytes(12).toString('hex');
|
|
10
10
|
}
|
|
11
11
|
/**
|
|
12
|
-
* Resolve the token store path.
|
|
12
|
+
* Resolve the operator token store path.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* 3. process.cwd() — absolute fallback
|
|
14
|
+
* The only valid location is <daemonHomeDir>/operator-tokens.json.
|
|
15
|
+
* Operator tokens are global (daemon-home scoped) since 0.21.28.
|
|
16
|
+
* No workspace-scoped fallback exists.
|
|
18
17
|
*
|
|
19
|
-
*
|
|
20
|
-
* paired companions — a token paired once works regardless of which working
|
|
21
|
-
* directory the daemon is currently serving.
|
|
18
|
+
* @throws {Error} when daemonHomeDir is not provided — all callers must supply it.
|
|
22
19
|
*/
|
|
23
|
-
function resolveSharedTokenPath(
|
|
24
|
-
|
|
25
|
-
return join(daemonHomeDir, 'operator-tokens.json');
|
|
26
|
-
}
|
|
27
|
-
const base = basePath ?? process.cwd();
|
|
28
|
-
return join(base, '.goodvibes', 'operator-tokens.json');
|
|
20
|
+
function resolveSharedTokenPath(daemonHomeDir) {
|
|
21
|
+
return join(daemonHomeDir, 'operator-tokens.json');
|
|
29
22
|
}
|
|
30
23
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* Always resolves to the shared workspace-level path regardless of surface,
|
|
34
|
-
* so that tokens are portable across TUI-embedded and standalone daemon postures.
|
|
35
|
-
*
|
|
36
|
-
* @deprecated `surface` parameter is ignored; use {@link resolveSharedTokenPath} directly.
|
|
37
|
-
*/
|
|
38
|
-
function resolveTokenPath(_surface, basePath, daemonHomeDir) {
|
|
39
|
-
return resolveSharedTokenPath(basePath, daemonHomeDir);
|
|
40
|
-
}
|
|
41
|
-
/**
|
|
42
|
-
* Load the stored companion token for a surface, or generate and persist a new one.
|
|
24
|
+
* Load the stored companion token, or generate and persist a new one.
|
|
25
|
+
* Token is always written to <daemonHomeDir>/operator-tokens.json at mode 0600.
|
|
43
26
|
*/
|
|
44
27
|
export function getOrCreateCompanionToken(surface, options) {
|
|
45
|
-
|
|
46
|
-
|
|
28
|
+
void surface; // surface parameter retained for API compatibility; token path is global
|
|
29
|
+
const tokenPath = resolveSharedTokenPath(options.daemonHomeDir);
|
|
30
|
+
if (!options.regenerate && existsSync(tokenPath)) {
|
|
47
31
|
try {
|
|
48
32
|
const raw = readFileSync(tokenPath, 'utf-8');
|
|
49
33
|
const record = JSON.parse(raw);
|
|
@@ -60,12 +44,19 @@ export function getOrCreateCompanionToken(surface, options) {
|
|
|
60
44
|
peerId: generatePeerId(),
|
|
61
45
|
createdAt: Date.now(),
|
|
62
46
|
};
|
|
63
|
-
|
|
64
|
-
|
|
47
|
+
const dir = dirname(tokenPath);
|
|
48
|
+
mkdirSync(dir, { recursive: true });
|
|
49
|
+
// Write with mode 0600 (owner read/write only) and enforce after write
|
|
50
|
+
writeFileSync(tokenPath, JSON.stringify(record, null, 2), { encoding: 'utf-8', mode: 0o600 });
|
|
51
|
+
try {
|
|
52
|
+
chmodSync(tokenPath, 0o600);
|
|
53
|
+
}
|
|
54
|
+
catch { /* best-effort */ }
|
|
65
55
|
return { token: record.token, peerId: record.peerId, createdAt: record.createdAt };
|
|
66
56
|
}
|
|
67
57
|
/**
|
|
68
|
-
* Regenerate the companion token
|
|
58
|
+
* Regenerate the companion token, replacing any existing token.
|
|
59
|
+
* Written to <daemonHomeDir>/operator-tokens.json at mode 0600.
|
|
69
60
|
*/
|
|
70
61
|
export function regenerateCompanionToken(surface, options) {
|
|
71
62
|
return getOrCreateCompanionToken(surface, { ...options, regenerate: true });
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"user-auth.d.ts","sourceRoot":"","sources":["../../../../src/_internal/platform/security/user-auth.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,QAAQ;IACvB,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,UAAU,cAAc;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,KAAK,CAAC,EAAE,QAAQ,EAAE,CAAC;IACnB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,uBAAuB,EAAE,MAAM,CAAC;CACjC;AAUD,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;CACnC;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,uBAAuB,EAAE,MAAM,CAAC;IACzC,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,0BAA0B,EAAE,OAAO,CAAC;IAC7C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,KAAK,EAAE,SAAS,cAAc,EAAE,CAAC;IAC1C,QAAQ,CAAC,QAAQ,EAAE,SAAS,iBAAiB,EAAE,CAAC;CACjD;
|
|
1
|
+
{"version":3,"file":"user-auth.d.ts","sourceRoot":"","sources":["../../../../src/_internal/platform/security/user-auth.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,QAAQ;IACvB,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,UAAU,cAAc;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,KAAK,CAAC,EAAE,QAAQ,EAAE,CAAC;IACnB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,uBAAuB,EAAE,MAAM,CAAC;CACjC;AAUD,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;CACnC;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,uBAAuB,EAAE,MAAM,CAAC;IACzC,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,0BAA0B,EAAE,OAAO,CAAC;IAC7C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,KAAK,EAAE,SAAS,cAAc,EAAE,CAAC;IAC1C,QAAQ,CAAC,QAAQ,EAAE,SAAS,iBAAiB,EAAE,CAAC;CACjD;AAgKD,qBAAa,eAAe;IAC1B,OAAO,CAAC,KAAK,CAA+B;IAC5C,OAAO,CAAC,QAAQ,CAAkC;IAClD,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,uBAAuB,CAAS;IACjD,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAU;gBAE3B,MAAM,EAAE,cAAc;IAqBlC,MAAM,CAAC,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM;IAI7C,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI;IAMjE,OAAO,CAAC,QAAQ,EAAE,MAAM,GAAG,cAAc,GAAG,IAAI;IAShD,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,WAAW;IAY5C,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI;IAUlD,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO;IAIrC,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM;IAW/C,SAAS,IAAI,cAAc,EAAE;IAS7B,YAAY,IAAI,iBAAiB,EAAE;IAOnC,OAAO,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,GAAE,SAAS,MAAM,EAAc,GAAG,cAAc;IAejG,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO;IAarC,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,IAAI;IAgB5D,OAAO,IAAI,iBAAiB;IAc5B,4BAA4B,IAAI,OAAO;IAMvC,0BAA0B,IAAI,MAAM;IAIpC,OAAO,CAAC,oBAAoB;IAS5B,OAAO,CAAC,OAAO;CAIhB"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomBytes, scryptSync, timingSafeEqual } from 'crypto';
|
|
2
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { logger } from '../utils/logger.js';
|
|
5
5
|
const DEFAULT_SESSION_TTL_MS = 3_600_000;
|
|
@@ -48,26 +48,39 @@ function readBootstrapUsers(filePath) {
|
|
|
48
48
|
return null;
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Write secret file content atomically at 0600 (owner read/write only).
|
|
53
|
+
* Uses write-to-tmp-then-rename for atomicity; chmod applied at both
|
|
54
|
+
* sides to defeat filesystem-reset behaviour on rename (observed on
|
|
55
|
+
* some Linux fs drivers).
|
|
56
|
+
*/
|
|
57
|
+
function atomicWriteSecretFile(filePath, content) {
|
|
52
58
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
59
|
+
const tmpPath = filePath + '.tmp';
|
|
60
|
+
writeFileSync(tmpPath, content, { encoding: 'utf-8', mode: 0o600 });
|
|
61
|
+
try {
|
|
62
|
+
chmodSync(tmpPath, 0o600);
|
|
63
|
+
}
|
|
64
|
+
catch { /* best-effort */ }
|
|
65
|
+
renameSync(tmpPath, filePath);
|
|
66
|
+
try {
|
|
67
|
+
chmodSync(filePath, 0o600);
|
|
68
|
+
}
|
|
69
|
+
catch { /* best-effort */ }
|
|
70
|
+
}
|
|
71
|
+
function writeBootstrapUsers(filePath, users) {
|
|
72
|
+
// SEC-01: auth-user store contains scrypt password hashes — must be 0600.
|
|
53
73
|
const payload = { version: 1, users };
|
|
54
|
-
|
|
74
|
+
atomicWriteSecretFile(filePath, `${JSON.stringify(payload, null, 2)}\n`);
|
|
55
75
|
}
|
|
56
76
|
function writeBootstrapCredentialFile(filePath, username, password) {
|
|
57
|
-
|
|
58
|
-
writeFileSync(filePath, [
|
|
77
|
+
atomicWriteSecretFile(filePath, [
|
|
59
78
|
'GoodVibes bootstrap auth',
|
|
60
79
|
`username=${username}`,
|
|
61
80
|
`password=${password}`,
|
|
62
81
|
'purpose=Use these credentials only for local daemon/http listener /login routes when those surfaces are enabled.',
|
|
63
82
|
'note=Normal SDK host usage does not require these credentials.',
|
|
64
|
-
].join('\n') + '\n'
|
|
65
|
-
try {
|
|
66
|
-
chmodSync(filePath, 0o600);
|
|
67
|
-
}
|
|
68
|
-
catch {
|
|
69
|
-
// best-effort only
|
|
70
|
-
}
|
|
83
|
+
].join('\n') + '\n');
|
|
71
84
|
}
|
|
72
85
|
function loadOrBootstrapUsers(filePath, credentialPath) {
|
|
73
86
|
const existing = readBootstrapUsers(filePath);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
let version = '0.21.
|
|
3
|
+
let version = '0.21.29';
|
|
4
4
|
try {
|
|
5
5
|
const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', '..', 'package.json'), 'utf-8'));
|
|
6
6
|
version = pkg.version ?? version;
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
* - If ~/.goodvibes/daemon/ does not exist:
|
|
19
19
|
* - ~/.goodvibes/tui/auth-users.json → ~/.goodvibes/daemon/auth-users.json
|
|
20
20
|
* - ~/.goodvibes/tui/auth-bootstrap.txt → ~/.goodvibes/daemon/auth-bootstrap.txt
|
|
21
|
-
*
|
|
21
|
+
* - Operator tokens are global-only: read/written exclusively at
|
|
22
|
+
* <daemonHomeDir>/operator-tokens.json. No workspace-scoped paths.
|
|
22
23
|
* - Old paths are left intact (never deleted) to avoid breaking older binaries.
|
|
23
24
|
*/
|
|
24
25
|
import type { RuntimeEventBus } from '../runtime/events/index.js';
|
|
@@ -31,8 +32,6 @@ export interface DaemonHomeDirs {
|
|
|
31
32
|
export interface DaemonHomeOptions {
|
|
32
33
|
/** Value of --daemon-home CLI flag, if provided. */
|
|
33
34
|
readonly daemonHomeArg?: string | undefined;
|
|
34
|
-
/** Current working directory, used as base for operator-tokens migration. */
|
|
35
|
-
readonly cwd?: string;
|
|
36
35
|
/** Override process.env for testing. */
|
|
37
36
|
readonly env?: NodeJS.ProcessEnv;
|
|
38
37
|
}
|
|
@@ -48,14 +47,36 @@ export interface DaemonHomeMigrationDeps {
|
|
|
48
47
|
* Resolve the daemon home directory from CLI flag, environment variable, or default.
|
|
49
48
|
*/
|
|
50
49
|
export declare function resolveDaemonHomeDir(options?: DaemonHomeOptions): string;
|
|
50
|
+
/**
|
|
51
|
+
* Returns the single canonical path for operator tokens.
|
|
52
|
+
* All reads and writes MUST use this path. No workspace-scoped fallback exists.
|
|
53
|
+
*/
|
|
54
|
+
export declare function resolveOperatorTokenPath(daemonHomeDir: string): string;
|
|
51
55
|
/**
|
|
52
56
|
* Run migration if the daemon home directory does not yet exist.
|
|
53
57
|
* Creates the directory and copies identity files from legacy paths if found.
|
|
54
58
|
* Old files are NOT deleted.
|
|
55
59
|
*
|
|
60
|
+
* Operator tokens are NOT migrated from workspace-scoped paths — the global
|
|
61
|
+
* daemon-home path is canonical since 0.21.28. If tokens are missing,
|
|
62
|
+
* the first pairing operation will create them at the global path.
|
|
63
|
+
*
|
|
56
64
|
* Returns `freshInstall: true` when migration ran, `false` when the dir already existed.
|
|
57
65
|
*/
|
|
58
66
|
export declare function runDaemonHomeMigration(daemonHomeDir: string, options?: DaemonHomeOptions, deps?: DaemonHomeMigrationDeps): DaemonHomeDirs;
|
|
67
|
+
/**
|
|
68
|
+
* Write operator tokens to the global daemon-home path with mode 0600.
|
|
69
|
+
* All token provisioning MUST go through this function.
|
|
70
|
+
*
|
|
71
|
+
* Uses a write-to-tmp-then-rename pattern for atomicity.
|
|
72
|
+
* Applies chmod 0600 after rename so the file is never world-readable.
|
|
73
|
+
*/
|
|
74
|
+
export declare function writeOperatorTokenFile(daemonHomeDir: string, content: string): void;
|
|
75
|
+
/**
|
|
76
|
+
* Read operator tokens from the global daemon-home path.
|
|
77
|
+
* Returns undefined when the file does not exist or cannot be parsed.
|
|
78
|
+
*/
|
|
79
|
+
export declare function readOperatorTokenFile(daemonHomeDir: string): string | undefined;
|
|
59
80
|
/**
|
|
60
81
|
* Read a single key from daemon-settings.json, or return undefined if missing.
|
|
61
82
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"daemon-home.d.ts","sourceRoot":"","sources":["../../../../src/_internal/platform/workspace/daemon-home.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"daemon-home.d.ts","sourceRoot":"","sources":["../../../../src/_internal/platform/workspace/daemon-home.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAOH,OAAO,KAAK,EAAE,eAAe,EAAwB,MAAM,4BAA4B,CAAC;AAOxF,MAAM,WAAW,cAAc;IAC7B,6EAA6E;IAC7E,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,iFAAiF;IACjF,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;CAChC;AAED,MAAM,WAAW,iBAAiB;IAChC,oDAAoD;IACpD,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5C,wCAAwC;IACxC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;CAClC;AAED;;;;GAIG;AACH,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,UAAU,CAAC,EAAE,eAAe,CAAC;CACvC;AAMD;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,GAAE,iBAAsB,GAAG,MAAM,CAiB5E;AAMD;;;GAGG;AACH,wBAAgB,wBAAwB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAEtE;AAMD;;;;;;;;;;GAUG;AACH,wBAAgB,sBAAsB,CACpC,aAAa,EAAE,MAAM,EACrB,OAAO,GAAE,iBAAsB,EAC/B,IAAI,GAAE,uBAA4B,GACjC,cAAc,CAgChB;AAMD;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,aAAa,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CASnF;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAQ/E;AAMD;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,aAAa,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAWxF;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,aAAa,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAiB1F"}
|
|
@@ -18,10 +18,11 @@
|
|
|
18
18
|
* - If ~/.goodvibes/daemon/ does not exist:
|
|
19
19
|
* - ~/.goodvibes/tui/auth-users.json → ~/.goodvibes/daemon/auth-users.json
|
|
20
20
|
* - ~/.goodvibes/tui/auth-bootstrap.txt → ~/.goodvibes/daemon/auth-bootstrap.txt
|
|
21
|
-
*
|
|
21
|
+
* - Operator tokens are global-only: read/written exclusively at
|
|
22
|
+
* <daemonHomeDir>/operator-tokens.json. No workspace-scoped paths.
|
|
22
23
|
* - Old paths are left intact (never deleted) to avoid breaking older binaries.
|
|
23
24
|
*/
|
|
24
|
-
import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync, renameSync,
|
|
25
|
+
import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync, renameSync, chmodSync } from 'node:fs';
|
|
25
26
|
import { join, isAbsolute, resolve, dirname } from 'node:path';
|
|
26
27
|
import { homedir } from 'node:os';
|
|
27
28
|
import { logger } from '../utils/logger.js';
|
|
@@ -49,6 +50,16 @@ export function resolveDaemonHomeDir(options = {}) {
|
|
|
49
50
|
return join(homedir(), '.goodvibes', 'daemon');
|
|
50
51
|
}
|
|
51
52
|
// ---------------------------------------------------------------------------
|
|
53
|
+
// Global operator token path
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
/**
|
|
56
|
+
* Returns the single canonical path for operator tokens.
|
|
57
|
+
* All reads and writes MUST use this path. No workspace-scoped fallback exists.
|
|
58
|
+
*/
|
|
59
|
+
export function resolveOperatorTokenPath(daemonHomeDir) {
|
|
60
|
+
return join(daemonHomeDir, 'operator-tokens.json');
|
|
61
|
+
}
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
52
63
|
// One-time migration
|
|
53
64
|
// ---------------------------------------------------------------------------
|
|
54
65
|
/**
|
|
@@ -56,6 +67,10 @@ export function resolveDaemonHomeDir(options = {}) {
|
|
|
56
67
|
* Creates the directory and copies identity files from legacy paths if found.
|
|
57
68
|
* Old files are NOT deleted.
|
|
58
69
|
*
|
|
70
|
+
* Operator tokens are NOT migrated from workspace-scoped paths — the global
|
|
71
|
+
* daemon-home path is canonical since 0.21.28. If tokens are missing,
|
|
72
|
+
* the first pairing operation will create them at the global path.
|
|
73
|
+
*
|
|
59
74
|
* Returns `freshInstall: true` when migration ran, `false` when the dir already existed.
|
|
60
75
|
*/
|
|
61
76
|
export function runDaemonHomeMigration(daemonHomeDir, options = {}, deps = {}) {
|
|
@@ -66,78 +81,63 @@ export function runDaemonHomeMigration(daemonHomeDir, options = {}, deps = {}) {
|
|
|
66
81
|
// Create the daemon home directory tree
|
|
67
82
|
mkdirSync(daemonHomeDir, { recursive: true });
|
|
68
83
|
const userGoodVibesRoot = join(homedir(), '.goodvibes');
|
|
69
|
-
const cwd = options.cwd ?? process.cwd();
|
|
70
84
|
// Migrate auth-users.json from tui surface path
|
|
85
|
+
// SEC-02: credential-bearing files must land at 0600 regardless of source perms.
|
|
71
86
|
const legacyAuthUsers = join(userGoodVibesRoot, 'tui', 'auth-users.json');
|
|
72
87
|
if (existsSync(legacyAuthUsers)) {
|
|
73
|
-
|
|
88
|
+
safeCopyIdentity(legacyAuthUsers, join(daemonHomeDir, 'auth-users.json'));
|
|
74
89
|
}
|
|
75
90
|
// Migrate auth-bootstrap.txt from tui surface path
|
|
76
91
|
const legacyBootstrap = join(userGoodVibesRoot, 'tui', 'auth-bootstrap.txt');
|
|
77
92
|
if (existsSync(legacyBootstrap)) {
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
// Migrate operator-tokens.json — search multiple legacy paths (F3 revision).
|
|
81
|
-
// 0.21.17 used <cwd>/.goodvibes/operator-tokens.json (workspace-scoped).
|
|
82
|
-
// 0.21.16 and earlier used surface-scoped ~/.goodvibes/<surface>/companion-token.json.
|
|
83
|
-
// 0.21.19+ canonical path is <daemonHomeDir>/operator-tokens.json.
|
|
84
|
-
const destTokenPath = join(daemonHomeDir, 'operator-tokens.json');
|
|
85
|
-
if (!existsSync(destTokenPath)) {
|
|
86
|
-
// Priority 1: workspace-scoped path from 0.21.17
|
|
87
|
-
const legacyWorkspaceTokens = join(cwd, '.goodvibes', 'operator-tokens.json');
|
|
88
|
-
// Priority 2: surface-scoped legacy tokens from 0.21.16 and earlier
|
|
89
|
-
const legacySurfaceToken = join(userGoodVibesRoot, 'tui', 'companion-token.json');
|
|
90
|
-
// Priority 3: XDG data home if set
|
|
91
|
-
const xdgDataHome = options.env?.['XDG_DATA_HOME'];
|
|
92
|
-
const xdgToken = xdgDataHome ? join(xdgDataHome, 'goodvibes', 'operator-tokens.json') : null;
|
|
93
|
-
// Scan for any surface-scoped companion-token.json files under ~/.goodvibes/
|
|
94
|
-
const surfaceScopedTokens = [];
|
|
95
|
-
try {
|
|
96
|
-
const entries = readdirSync(userGoodVibesRoot, { withFileTypes: true });
|
|
97
|
-
for (const entry of entries) {
|
|
98
|
-
if (entry.isDirectory()) {
|
|
99
|
-
const candidate = join(userGoodVibesRoot, entry.name, 'companion-token.json');
|
|
100
|
-
if (existsSync(candidate))
|
|
101
|
-
surfaceScopedTokens.push(candidate);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
catch {
|
|
106
|
-
// Best-effort scan
|
|
107
|
-
}
|
|
108
|
-
const tokenSources = [
|
|
109
|
-
legacyWorkspaceTokens,
|
|
110
|
-
...(xdgToken ? [xdgToken] : []),
|
|
111
|
-
legacySurfaceToken,
|
|
112
|
-
...surfaceScopedTokens,
|
|
113
|
-
];
|
|
114
|
-
for (const src of tokenSources) {
|
|
115
|
-
if (!existsSync(src))
|
|
116
|
-
continue;
|
|
117
|
-
// Validate JSON before copying — corrupt JSON must not be migrated.
|
|
118
|
-
try {
|
|
119
|
-
JSON.parse(readFileSync(src, 'utf-8'));
|
|
120
|
-
}
|
|
121
|
-
catch (parseErr) {
|
|
122
|
-
const reason = parseErr instanceof Error ? parseErr.message : String(parseErr);
|
|
123
|
-
logger.warn('daemon-home: skipping corrupt token file during migration', {
|
|
124
|
-
sourcePath: src,
|
|
125
|
-
reason,
|
|
126
|
-
});
|
|
127
|
-
_emitMigrationEvent(deps.runtimeBus, { type: 'WORKSPACE_IDENTITY_MIGRATION_FAILED', sourcePath: src, reason });
|
|
128
|
-
safeCopy(src, destTokenPath, { skipIfInvalid: true });
|
|
129
|
-
continue;
|
|
130
|
-
}
|
|
131
|
-
if (safeCopy(src, destTokenPath)) {
|
|
132
|
-
logger.info('daemon-home: migrated operator token', { from: src, to: destTokenPath });
|
|
133
|
-
_emitMigrationEvent(deps.runtimeBus, { type: 'WORKSPACE_IDENTITY_MIGRATED', from: src, to: destTokenPath });
|
|
134
|
-
}
|
|
135
|
-
break; // First valid source wins
|
|
136
|
-
}
|
|
93
|
+
safeCopyIdentity(legacyBootstrap, join(daemonHomeDir, 'auth-bootstrap.txt'));
|
|
137
94
|
}
|
|
95
|
+
// NOTE: Operator tokens are NOT migrated from legacy workspace-scoped paths.
|
|
96
|
+
// The canonical path is <daemonHomeDir>/operator-tokens.json (global, set at 0600).
|
|
97
|
+
// If no token file exists at the canonical path, the first pairing call will
|
|
98
|
+
// create it via getOrCreateCompanionToken (companion-token.ts).
|
|
99
|
+
void deps; // deps.runtimeBus reserved for future migration event emission
|
|
138
100
|
return { daemonHomeDir, freshInstall: true };
|
|
139
101
|
}
|
|
140
102
|
// ---------------------------------------------------------------------------
|
|
103
|
+
// Operator token file write (global-only, mode 0600)
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
/**
|
|
106
|
+
* Write operator tokens to the global daemon-home path with mode 0600.
|
|
107
|
+
* All token provisioning MUST go through this function.
|
|
108
|
+
*
|
|
109
|
+
* Uses a write-to-tmp-then-rename pattern for atomicity.
|
|
110
|
+
* Applies chmod 0600 after rename so the file is never world-readable.
|
|
111
|
+
*/
|
|
112
|
+
export function writeOperatorTokenFile(daemonHomeDir, content) {
|
|
113
|
+
const tokenPath = resolveOperatorTokenPath(daemonHomeDir);
|
|
114
|
+
mkdirSync(dirname(tokenPath), { recursive: true });
|
|
115
|
+
const tmpPath = tokenPath + '.tmp';
|
|
116
|
+
writeFileSync(tmpPath, content, { encoding: 'utf-8', mode: 0o600 });
|
|
117
|
+
chmodSync(tmpPath, 0o600);
|
|
118
|
+
renameSync(tmpPath, tokenPath);
|
|
119
|
+
// Apply chmod again after rename — some filesystems reset permissions on rename
|
|
120
|
+
try {
|
|
121
|
+
chmodSync(tokenPath, 0o600);
|
|
122
|
+
}
|
|
123
|
+
catch { /* best-effort */ }
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Read operator tokens from the global daemon-home path.
|
|
127
|
+
* Returns undefined when the file does not exist or cannot be parsed.
|
|
128
|
+
*/
|
|
129
|
+
export function readOperatorTokenFile(daemonHomeDir) {
|
|
130
|
+
const tokenPath = resolveOperatorTokenPath(daemonHomeDir);
|
|
131
|
+
if (!existsSync(tokenPath))
|
|
132
|
+
return undefined;
|
|
133
|
+
try {
|
|
134
|
+
return readFileSync(tokenPath, 'utf-8');
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
141
|
// Daemon settings persistence
|
|
142
142
|
// ---------------------------------------------------------------------------
|
|
143
143
|
/**
|
|
@@ -176,36 +176,26 @@ export function writeDaemonSetting(daemonHomeDir, key, value) {
|
|
|
176
176
|
// Overwrite corrupt file
|
|
177
177
|
}
|
|
178
178
|
}
|
|
179
|
-
|
|
179
|
+
// SEC-12: daemon-settings.json may contain sensitive pairing state; write at 0600.
|
|
180
|
+
writeFileSync(tmpPath, JSON.stringify({ ...existing, [key]: value }, null, 2), { encoding: 'utf-8', mode: 0o600 });
|
|
181
|
+
try {
|
|
182
|
+
chmodSync(tmpPath, 0o600);
|
|
183
|
+
}
|
|
184
|
+
catch { /* best-effort */ }
|
|
180
185
|
renameSync(tmpPath, settingsPath);
|
|
186
|
+
try {
|
|
187
|
+
chmodSync(settingsPath, 0o600);
|
|
188
|
+
}
|
|
189
|
+
catch { /* best-effort */ }
|
|
181
190
|
}
|
|
182
191
|
// ---------------------------------------------------------------------------
|
|
183
192
|
// Helpers
|
|
184
193
|
// ---------------------------------------------------------------------------
|
|
185
|
-
/**
|
|
186
|
-
* Emit a workspace migration event on the runtime bus.
|
|
187
|
-
* Never throws — bus emission must not interrupt migration.
|
|
188
|
-
*/
|
|
189
|
-
function _emitMigrationEvent(bus, payload) {
|
|
190
|
-
if (!bus)
|
|
191
|
-
return;
|
|
192
|
-
try {
|
|
193
|
-
const envelope = createEventEnvelope(payload.type, payload, { sessionId: '', source: 'daemon-home-migration' });
|
|
194
|
-
bus.emit('workspace',
|
|
195
|
-
// WorkspaceEvent discriminated-union member; single widening cast is safe.
|
|
196
|
-
envelope);
|
|
197
|
-
}
|
|
198
|
-
catch {
|
|
199
|
-
// Swallow — never let event emission break migration
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
194
|
/**
|
|
203
195
|
* Copy src to dest. Returns true on success, false on failure.
|
|
204
196
|
* Failures are logged at warn level. Never throws.
|
|
205
197
|
*/
|
|
206
|
-
function safeCopy(src, dest
|
|
207
|
-
if (opts?.skipIfInvalid)
|
|
208
|
-
return false;
|
|
198
|
+
function safeCopy(src, dest) {
|
|
209
199
|
try {
|
|
210
200
|
copyFileSync(src, dest);
|
|
211
201
|
return true;
|
|
@@ -219,3 +209,25 @@ function safeCopy(src, dest, opts) {
|
|
|
219
209
|
return false;
|
|
220
210
|
}
|
|
221
211
|
}
|
|
212
|
+
/**
|
|
213
|
+
* Copy a credential-bearing identity file from src to dest and force mode 0600.
|
|
214
|
+
*
|
|
215
|
+
* SEC-02: `copyFileSync` preserves source permissions. Legacy TUI files may be
|
|
216
|
+
* 0644 (world-readable). Calling `chmodSync` after copy ensures the new
|
|
217
|
+
* canonical path is always owner-only regardless of the source's permissions.
|
|
218
|
+
* Never throws — failures are logged at warn level.
|
|
219
|
+
*/
|
|
220
|
+
function safeCopyIdentity(src, dest) {
|
|
221
|
+
if (!safeCopy(src, dest))
|
|
222
|
+
return false;
|
|
223
|
+
try {
|
|
224
|
+
chmodSync(dest, 0o600);
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
logger.warn('daemon-home: safeCopyIdentity chmod failed (best-effort)', {
|
|
228
|
+
dest,
|
|
229
|
+
error: err instanceof Error ? err.message : String(err),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
return true;
|
|
233
|
+
}
|