@venturewild/workspace 0.3.3 → 0.3.5
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/package.json +1 -1
- package/server/src/auto-update.mjs +7 -3
- package/server/src/daemon-supervisor.mjs +55 -1
- package/server/src/google-vouch.mjs +87 -0
- package/server/src/index.mjs +81 -5
- package/server/src/supervisor.mjs +7 -4
- package/web/dist/assets/{index-BPjgICaB.js → index-DatlFPkm.js} +31 -31
- package/web/dist/index.html +1 -1
package/package.json
CHANGED
|
@@ -255,12 +255,16 @@ export class AutoUpdater {
|
|
|
255
255
|
/**
|
|
256
256
|
* One auto-update cycle, called on the supervisor's slow timer. Self-rate-limits
|
|
257
257
|
* via dueForCheck so the timer cadence and the check interval are independent.
|
|
258
|
-
*
|
|
258
|
+
* `force` bypasses dueForCheck — used by the supervisor's post-boot kick so a
|
|
259
|
+
* RESTART always checks for updates immediately (a reboot is a rare, deliberate
|
|
260
|
+
* signal; without this a reboot within the 6h window never re-checked — why a
|
|
261
|
+
* sleepy/rebooted Mac could sit on a stale version). Returns a short status
|
|
262
|
+
* string (exposed for tests/logging).
|
|
259
263
|
*/
|
|
260
|
-
async tick() {
|
|
264
|
+
async tick({ force = false } = {}) {
|
|
261
265
|
if (this.inProgress) return 'busy';
|
|
262
266
|
if (!this.enabled()) return 'disabled';
|
|
263
|
-
if (!this.dueForCheck()) return 'not-due';
|
|
267
|
+
if (!force && !this.dueForCheck()) return 'not-due';
|
|
264
268
|
this.inProgress = true;
|
|
265
269
|
try {
|
|
266
270
|
touchLastCheck(this.globalDir, this.nowImpl());
|
|
@@ -65,6 +65,9 @@ export class DaemonSupervisor {
|
|
|
65
65
|
this.globalDir = globalDir;
|
|
66
66
|
this.pidFile = path.join(globalDir, 'daemon.pid');
|
|
67
67
|
this.logFile = path.join(globalDir, 'daemon.log');
|
|
68
|
+
// Serializes concurrent ensureRunning() spawns (server + supervisor) so only
|
|
69
|
+
// one daemon is started — prevents the duplicate-daemon :8320 bind conflict.
|
|
70
|
+
this.spawnLockFile = path.join(globalDir, 'daemon-spawn.lock');
|
|
68
71
|
this.resolveBinary = resolveBinary;
|
|
69
72
|
this.spawnImpl = spawnImpl;
|
|
70
73
|
this.fetchImpl = fetchImpl;
|
|
@@ -89,6 +92,14 @@ export class DaemonSupervisor {
|
|
|
89
92
|
/**
|
|
90
93
|
* Start the daemon unless it is already up. Idempotent and best-effort —
|
|
91
94
|
* the result is reported, never thrown.
|
|
95
|
+
*
|
|
96
|
+
* Concurrency-safe: the daemon is ensured by BOTH the server (its DaemonBridge)
|
|
97
|
+
* and the always-on supervisor (daemonTick). At boot neither sees the daemon up
|
|
98
|
+
* yet, so without a guard they'd BOTH spawn → two daemons fighting over :8320
|
|
99
|
+
* (`Address already in use`) and both opening proxy links → the relay evicts one
|
|
100
|
+
* → reconnect churn. An atomic cross-process spawn lock serializes the decision:
|
|
101
|
+
* the winner re-checks health then spawns; the loser waits for the winner's
|
|
102
|
+
* daemon instead of spawning its own.
|
|
92
103
|
* @returns {Promise<{started:boolean, alreadyRunning?:boolean, pid?:number,
|
|
93
104
|
* error?:string}>}
|
|
94
105
|
*/
|
|
@@ -96,7 +107,50 @@ export class DaemonSupervisor {
|
|
|
96
107
|
if ((await this.health()).running) {
|
|
97
108
|
return { started: false, alreadyRunning: true };
|
|
98
109
|
}
|
|
99
|
-
|
|
110
|
+
if (!this.acquireSpawnLock()) {
|
|
111
|
+
// Another process (server bridge / always-on supervisor) is spawning —
|
|
112
|
+
// wait for ITS daemon rather than racing in a second one.
|
|
113
|
+
const up = await this.waitForHealthy(4000);
|
|
114
|
+
return up ? { started: false, alreadyRunning: true } : { started: false, error: 'spawn-in-progress' };
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
// Re-check under the lock: the other caller may have just brought it up.
|
|
118
|
+
if ((await this.health()).running) {
|
|
119
|
+
return { started: false, alreadyRunning: true };
|
|
120
|
+
}
|
|
121
|
+
return this.spawnDaemon();
|
|
122
|
+
} finally {
|
|
123
|
+
this.releaseSpawnLock();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Is a pid alive? EPERM ("exists, not ours") still counts as alive. */
|
|
128
|
+
pidAlive(pid) {
|
|
129
|
+
try { this.killImpl(pid, 0); return true; } catch (e) { return !!(e && e.code === 'EPERM'); }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Atomic, cross-process spawn lock (shared globalDir, so it serializes the
|
|
134
|
+
* server and the supervisor). Returns true iff we hold it. A stale lock whose
|
|
135
|
+
* recorded pid is dead is taken over — a crash mid-spawn can't wedge it.
|
|
136
|
+
*/
|
|
137
|
+
acquireSpawnLock() {
|
|
138
|
+
try { mkdirSync(this.globalDir, { recursive: true }); } catch { /* surfaced below */ }
|
|
139
|
+
try {
|
|
140
|
+
writeFileSync(this.spawnLockFile, String(process.pid), { flag: 'wx' }); // atomic exclusive create
|
|
141
|
+
return true;
|
|
142
|
+
} catch {
|
|
143
|
+
let holder = null;
|
|
144
|
+
try { holder = Number(readFileSync(this.spawnLockFile, 'utf8').trim()); } catch { /* unreadable */ }
|
|
145
|
+
if (holder && this.pidAlive(holder)) return false; // a live caller is spawning
|
|
146
|
+
try { writeFileSync(this.spawnLockFile, String(process.pid)); return true; } catch { return false; }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
releaseSpawnLock() {
|
|
151
|
+
try {
|
|
152
|
+
if (Number(readFileSync(this.spawnLockFile, 'utf8').trim()) === process.pid) unlinkSync(this.spawnLockFile);
|
|
153
|
+
} catch { /* already gone */ }
|
|
100
154
|
}
|
|
101
155
|
|
|
102
156
|
/** Spawn the daemon detached + window-hidden, logging to `daemon.log`. */
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Google sign-in vouch (req 2) — the LOCAL-server half of "Sign in with Google".
|
|
2
|
+
//
|
|
3
|
+
// Why a vouch at all: Google's OAuth redirect URI must be a single, pre-registered
|
|
4
|
+
// HTTPS URL, and the code exchange needs the client SECRET — neither can live on a
|
|
5
|
+
// per-user, behind-the-tunnel local server. So bmo-sync (the central relay, which
|
|
6
|
+
// already holds the account registry) owns the Google flow: it exchanges the code,
|
|
7
|
+
// reads the user's verified email, checks it against the workspace's OWNER email,
|
|
8
|
+
// and — only on a match — mints a short-lived VOUCH that it hands back to the local
|
|
9
|
+
// server via `<slug>.venturewild.llc/?gv=<vouch>`. The local server verifies the
|
|
10
|
+
// vouch and mints its own durable device token (reusing the same machinery as a
|
|
11
|
+
// device-approval), so the cookie/role/revocation story is unchanged.
|
|
12
|
+
//
|
|
13
|
+
// The trust anchor is a secret BOTH sides already share, with NO new key to
|
|
14
|
+
// distribute: the account token. bmo-sync stores `sha256(account_token)` in hex
|
|
15
|
+
// (accounts.account_token_hash); the local server has the raw account token and
|
|
16
|
+
// derives the same hex. That hex is the HS256 signing key. A third party can't
|
|
17
|
+
// forge a vouch without the account token (or bmo-sync's at-rest hash), and the
|
|
18
|
+
// local server already trusts bmo-sync (it routes all its traffic), so vouching
|
|
19
|
+
// for identity adds no party that wasn't already in the trust base.
|
|
20
|
+
|
|
21
|
+
import { SignJWT, jwtVerify } from 'jose';
|
|
22
|
+
import { createHash } from 'node:crypto';
|
|
23
|
+
|
|
24
|
+
export const VOUCH_AUDIENCE = 'wild-workspace-google-vouch';
|
|
25
|
+
export const VOUCH_ISSUER = 'bmo-sync';
|
|
26
|
+
// Vouches are single-hop and consumed immediately on the return redirect, so the
|
|
27
|
+
// lifetime is tiny — long enough to survive the 302 + the SPA's POST, no more.
|
|
28
|
+
export const VOUCH_TTL_SECONDS = 120;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The HS256 key shared with bmo-sync: the lowercase-hex sha256 of the account
|
|
32
|
+
* token, exactly as bmo-sync stores it in `accounts.account_token_hash`
|
|
33
|
+
* (routes/slug.rs::hash_token = `hex::encode(Sha256::digest(token))`). Returns
|
|
34
|
+
* null when the install has no account token (not slug-linked → no Google sign-in).
|
|
35
|
+
*/
|
|
36
|
+
export function vouchKey(accountToken) {
|
|
37
|
+
if (!accountToken) return null;
|
|
38
|
+
const hex = createHash('sha256').update(String(accountToken)).digest('hex');
|
|
39
|
+
return new TextEncoder().encode(hex);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Mint a Google vouch (HS256). This is the contract bmo-sync implements in Rust;
|
|
44
|
+
* we keep a JS minter so the local-server verifier can be tested round-trip and
|
|
45
|
+
* the claim shape has one source of truth. NOT used in production by the server.
|
|
46
|
+
*/
|
|
47
|
+
export async function mintGoogleVouch({ accountToken, email, slug, nowSec = Math.floor(Date.now() / 1000), ttlSeconds = VOUCH_TTL_SECONDS }) {
|
|
48
|
+
const key = vouchKey(accountToken);
|
|
49
|
+
if (!key) throw new Error('no account token');
|
|
50
|
+
return new SignJWT({ email: String(email), slug: slug ? String(slug) : undefined })
|
|
51
|
+
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
|
|
52
|
+
.setIssuer(VOUCH_ISSUER)
|
|
53
|
+
.setAudience(VOUCH_AUDIENCE)
|
|
54
|
+
.setIssuedAt(nowSec)
|
|
55
|
+
.setExpirationTime(nowSec + ttlSeconds)
|
|
56
|
+
.sign(key);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Verify a Google vouch against this install's account token. Returns
|
|
61
|
+
* `{ ok: true, email, slug }` or `{ ok: false, reason }`. Never throws. The caller
|
|
62
|
+
* still enforces that `email` matches the configured owner email + `slug` matches
|
|
63
|
+
* this install — the vouch only proves "bmo-sync verified this Google identity for
|
|
64
|
+
* this account", not "this is the owner".
|
|
65
|
+
*/
|
|
66
|
+
export async function verifyGoogleVouch(token, accountToken) {
|
|
67
|
+
const key = vouchKey(accountToken);
|
|
68
|
+
if (!token || !key) return { ok: false, reason: 'no-token-or-key' };
|
|
69
|
+
try {
|
|
70
|
+
const { payload } = await jwtVerify(token, key, {
|
|
71
|
+
issuer: VOUCH_ISSUER,
|
|
72
|
+
audience: VOUCH_AUDIENCE,
|
|
73
|
+
});
|
|
74
|
+
if (!payload.email || typeof payload.email !== 'string') {
|
|
75
|
+
return { ok: false, reason: 'no-email' };
|
|
76
|
+
}
|
|
77
|
+
return { ok: true, email: payload.email, slug: typeof payload.slug === 'string' ? payload.slug : null };
|
|
78
|
+
} catch (e) {
|
|
79
|
+
// jose throws on bad signature / expiry / aud-iss mismatch — all "not valid".
|
|
80
|
+
return { ok: false, reason: e?.code || 'invalid' };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Case-insensitive owner-email match (Google emails are case-insensitive). */
|
|
85
|
+
export function emailMatches(a, b) {
|
|
86
|
+
return Boolean(a) && Boolean(b) && String(a).trim().toLowerCase() === String(b).trim().toLowerCase();
|
|
87
|
+
}
|
package/server/src/index.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
TokenRegistry,
|
|
31
31
|
} from './share.mjs';
|
|
32
32
|
import { PairingStore } from './pairing.mjs';
|
|
33
|
+
import { verifyGoogleVouch, emailMatches } from './google-vouch.mjs';
|
|
33
34
|
import { listDir, readFile, fullTree, workspaceSummary, safeResolve } from './fs.mjs';
|
|
34
35
|
import { InboxWatcher } from './inbox.mjs';
|
|
35
36
|
import { ActivityBus } from './activity.mjs';
|
|
@@ -588,6 +589,14 @@ export async function createServer(overrides = {}) {
|
|
|
588
589
|
return attrs.join('; ');
|
|
589
590
|
}
|
|
590
591
|
|
|
592
|
+
// The host-owner loopback grant (req 1) is only sound when the server is BOUND
|
|
593
|
+
// to loopback — then the port is reachable solely by on-host processes, so the
|
|
594
|
+
// (client-controlled) Host header can't be spoofed from the network. On a
|
|
595
|
+
// 0.0.0.0 / public bind we must NOT trust loopback-looking headers; tokens only.
|
|
596
|
+
const isLocalBind = ['127.0.0.1', 'localhost', '::1', '[::1]'].includes(
|
|
597
|
+
String(config.host || '127.0.0.1').toLowerCase(),
|
|
598
|
+
);
|
|
599
|
+
|
|
591
600
|
// --- auth + role resolution ---
|
|
592
601
|
async function resolveRole(c) {
|
|
593
602
|
// 1. Authorization: Bearer — the only path that may carry the operator token.
|
|
@@ -619,7 +628,16 @@ export async function createServer(overrides = {}) {
|
|
|
619
628
|
if (!config.publicMode) {
|
|
620
629
|
return { role: ROLES.PARTNER, sub: 'local-partner', source: 'localhost' };
|
|
621
630
|
}
|
|
622
|
-
//
|
|
631
|
+
// Host-machine owner over GENUINE loopback (req 1: "same machine = no
|
|
632
|
+
// approval"). The owner opens localhost on the box that runs the workspace and
|
|
633
|
+
// is in — no device-approval. A tunneled visitor can never reach this: the
|
|
634
|
+
// relay stamps x-forwarded-host on every tunneled request (rejected by
|
|
635
|
+
// loopbackHeaders) and the server binds loopback. The only thing trusted here
|
|
636
|
+
// is an on-host process, which already holds the owner's files + secrets.
|
|
637
|
+
if (isLocalBind && isGenuineLoopback(c)) {
|
|
638
|
+
return { role: ROLES.PARTNER, sub: 'local-owner', source: 'loopback' };
|
|
639
|
+
}
|
|
640
|
+
// Public mode, not local, no valid token: deny. No anonymous viewer access —
|
|
623
641
|
// a share JWT or the partner token is required. (Concern C1.)
|
|
624
642
|
return { role: null, sub: 'anon', source: 'unauth', denied: true };
|
|
625
643
|
}
|
|
@@ -692,6 +710,10 @@ export async function createServer(overrides = {}) {
|
|
|
692
710
|
// request can reach it; the handler itself 404s anything that isn't real
|
|
693
711
|
// loopback, so a public visitor can never use it.
|
|
694
712
|
'/api/auth/bootstrap',
|
|
713
|
+
// Google sign-in return (req 2). A browser coming back from Google has no
|
|
714
|
+
// workspace token yet — it carries a bmo-sync vouch, which the handler
|
|
715
|
+
// verifies (an invalid vouch is rejected there), so being public is safe.
|
|
716
|
+
'/api/auth/google',
|
|
695
717
|
]);
|
|
696
718
|
app.use('*', async (c, next) => {
|
|
697
719
|
const session = await resolveRole(c);
|
|
@@ -760,14 +782,22 @@ export async function createServer(overrides = {}) {
|
|
|
760
782
|
// with no forwarding headers + a loopback Host. (B1 — basis for first-device
|
|
761
783
|
// bootstrap. A local process already has the user's filesystem + secrets, so
|
|
762
784
|
// trusting it grants nothing it didn't already have.)
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
785
|
+
// Shared loopback predicate over a header getter — reused by the Hono path
|
|
786
|
+
// (isGenuineLoopback) and the raw-Node WS upgrade path (req.headers). A GENUINE
|
|
787
|
+
// local request carries NONE of the relay's forwarding headers (bmo-sync strips
|
|
788
|
+
// any client-supplied X-Forwarded-*/X-Real-IP and stamps its own on every
|
|
789
|
+
// tunneled request, so they can't be faked from the internet) and has a loopback
|
|
790
|
+
// Host. The server binds loopback, so only an on-host process can produce this.
|
|
791
|
+
function loopbackHeaders(get) {
|
|
792
|
+
if (get('x-forwarded-for') || get('x-forwarded-host') || get('x-forwarded-proto') || get('x-real-ip')) {
|
|
766
793
|
return false;
|
|
767
794
|
}
|
|
768
|
-
const hostname = String(
|
|
795
|
+
const hostname = String(get('host') || '').toLowerCase().split(':')[0];
|
|
769
796
|
return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '::1' || hostname === '[::1]';
|
|
770
797
|
}
|
|
798
|
+
function isGenuineLoopback(c) {
|
|
799
|
+
return loopbackHeaders((n) => c.req.header(n));
|
|
800
|
+
}
|
|
771
801
|
|
|
772
802
|
// --- auth: first-device bootstrap (B1) -------------------------------------
|
|
773
803
|
// The everyday problem: a brand-new owner runs `wild-workspace` and wants to
|
|
@@ -862,6 +892,47 @@ export async function createServer(overrides = {}) {
|
|
|
862
892
|
return c.json({ ok: true, role: session.role, cookie: true });
|
|
863
893
|
});
|
|
864
894
|
|
|
895
|
+
// Google sign-in return (req 2). bmo-sync ran the Google OAuth centrally, checked
|
|
896
|
+
// the verified email against THIS account's owner, and handed the browser back a
|
|
897
|
+
// short-lived vouch via <slug>.venturewild.llc/?gv=<vouch>. The SPA POSTs it here;
|
|
898
|
+
// we verify it against the shared account secret, re-confirm the owner email +
|
|
899
|
+
// slug, then mint the SAME durable, individually-revocable device token a device
|
|
900
|
+
// approval would — so any device the owner signs into with Google is in, no
|
|
901
|
+
// second-device approval and no nag. Public-allowlisted: the vouch IS the
|
|
902
|
+
// credential, so an invalid one is rejected here (401/403), never trusted.
|
|
903
|
+
app.post('/api/auth/google', async (c) => {
|
|
904
|
+
// accountToken is kept top-level on config (out of the broadcast config.account).
|
|
905
|
+
if (!config.accountToken) {
|
|
906
|
+
return c.json({ error: 'not-linked' }, 400); // not slug-linked → no Google sign-in
|
|
907
|
+
}
|
|
908
|
+
let body = {};
|
|
909
|
+
try { body = await c.req.json(); } catch { /* empty body ok */ }
|
|
910
|
+
const vouch = typeof body.vouch === 'string' ? body.vouch : c.req.query('gv');
|
|
911
|
+
const v = await verifyGoogleVouch(vouch, config.accountToken);
|
|
912
|
+
if (!v.ok) {
|
|
913
|
+
log('[auth]', `google vouch rejected: ${v.reason}`);
|
|
914
|
+
return c.json({ error: 'invalid-vouch' }, 401);
|
|
915
|
+
}
|
|
916
|
+
// The vouch proves "bmo-sync verified this Google identity for this account".
|
|
917
|
+
// Re-enforce that it's the OWNER of THIS install (email + slug) so a vouch
|
|
918
|
+
// minted for another workspace can't be replayed here.
|
|
919
|
+
if (!emailMatches(v.email, config.account.email)) {
|
|
920
|
+
log('[auth]', `google vouch email mismatch (${v.email})`);
|
|
921
|
+
return c.json({ error: 'not-owner' }, 403);
|
|
922
|
+
}
|
|
923
|
+
if (v.slug && config.account.slug && v.slug !== config.account.slug) {
|
|
924
|
+
return c.json({ error: 'wrong-workspace' }, 403);
|
|
925
|
+
}
|
|
926
|
+
const device = await mintDeviceToken({ secret: config.shareSecret, workspaceId: config.workspaceId });
|
|
927
|
+
tokenRegistry.add({ ...device, kind: 'device', label: `Google (${v.email})`, createdAt: Date.now() });
|
|
928
|
+
const now = Math.floor(Date.now() / 1000);
|
|
929
|
+
const maxAge = Math.max(60, device.exp - now);
|
|
930
|
+
c.header('Set-Cookie', authCookieAttrs(device.token, maxAge));
|
|
931
|
+
auditAction(c, 'google-signin', `email=${v.email} sub=${device.sub}`);
|
|
932
|
+
log('[auth]', `google sign-in → durable device sub=${device.sub} email=${v.email} ttl=${maxAge}s`);
|
|
933
|
+
return c.json({ ok: true, role: 'partner', cookie: true });
|
|
934
|
+
});
|
|
935
|
+
|
|
865
936
|
app.post('/api/auth/logout', (c) => {
|
|
866
937
|
c.header('Set-Cookie', authCookieAttrs('', 0));
|
|
867
938
|
return c.json({ ok: true });
|
|
@@ -2078,6 +2149,11 @@ export async function createServer(overrides = {}) {
|
|
|
2078
2149
|
} else if (!cookieToken && !tokenFromQuery && !config.publicMode) {
|
|
2079
2150
|
role = ROLES.PARTNER;
|
|
2080
2151
|
sub = 'local-partner';
|
|
2152
|
+
} else if (!cookieToken && !tokenFromQuery && isLocalBind && loopbackHeaders((n) => req.headers[n])) {
|
|
2153
|
+
// Host-machine owner over genuine loopback (req 1) — mirror resolveRole so
|
|
2154
|
+
// the chat WS works for the local owner with no token, same as the SPA.
|
|
2155
|
+
role = ROLES.PARTNER;
|
|
2156
|
+
sub = 'local-owner';
|
|
2081
2157
|
}
|
|
2082
2158
|
// Deny: public mode with no token, or any invalid/revoked token. An
|
|
2083
2159
|
// invalid token must NOT silently fall back to partner. (Concern C1.)
|
|
@@ -503,9 +503,9 @@ export class WorkspaceSupervisor {
|
|
|
503
503
|
});
|
|
504
504
|
}
|
|
505
505
|
|
|
506
|
-
runUpdateTick() {
|
|
506
|
+
runUpdateTick(opts = {}) {
|
|
507
507
|
if (!this.autoUpdater) return;
|
|
508
|
-
this.autoUpdater.tick()
|
|
508
|
+
this.autoUpdater.tick(opts)
|
|
509
509
|
.then((r) => { if (r && !['not-due', 'disabled', 'up-to-date', 'busy'].includes(r)) this.log(`auto-update tick: ${r}`); })
|
|
510
510
|
.catch((e) => this.log(`auto-update error: ${e?.message || e}`));
|
|
511
511
|
}
|
|
@@ -606,7 +606,7 @@ export class WorkspaceSupervisor {
|
|
|
606
606
|
process.on('exit', () => this.releaseLock());
|
|
607
607
|
process.on('SIGTERM', () => process.exit(0));
|
|
608
608
|
process.on('SIGINT', () => process.exit(0));
|
|
609
|
-
this.log(`supervisor start pid=${process.pid} watching http://127.0.0.1:${this.port}/api/health (workspace=${this.workspaceDir})`);
|
|
609
|
+
this.log(`supervisor start pid=${process.pid} v${this.selfVersion || '?'} watching http://127.0.0.1:${this.port}/api/health (workspace=${this.workspaceDir})`);
|
|
610
610
|
this.timer = setInterval(() => { this.tick().catch((e) => this.log(`tick error: ${e?.message || e}`)); }, this.pollMs);
|
|
611
611
|
this.tick().catch((e) => this.log(`tick error: ${e?.message || e}`));
|
|
612
612
|
|
|
@@ -617,7 +617,10 @@ export class WorkspaceSupervisor {
|
|
|
617
617
|
this.autoUpdater = u;
|
|
618
618
|
this.updateTimer = setInterval(() => this.runUpdateTick(), this.updatePollMs);
|
|
619
619
|
if (this.updateTimer.unref) this.updateTimer.unref();
|
|
620
|
-
|
|
620
|
+
// FORCE the first post-boot check (bypass the 6h dueForCheck): a restart
|
|
621
|
+
// should always pull the latest, so a rebooted/woken machine can't sit on
|
|
622
|
+
// a stale version just because it last checked <6h ago.
|
|
623
|
+
const kick = setTimeout(() => this.runUpdateTick({ force: true }), 60_000);
|
|
621
624
|
if (kick.unref) kick.unref();
|
|
622
625
|
}).catch((e) => this.log(`auto-update init error: ${e?.message || e}`));
|
|
623
626
|
}
|