ftown 0.19.57 → 0.19.59
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/bridge-update.d.ts +53 -0
- package/dist/bridge-update.js +212 -0
- package/dist/bridge-update.js.map +1 -0
- package/dist/centrifugo-client.d.ts +6 -1
- package/dist/centrifugo-client.js +70 -8
- package/dist/centrifugo-client.js.map +1 -1
- package/dist/claude-runner.d.ts +2 -0
- package/dist/claude-runner.js +4 -0
- package/dist/claude-runner.js.map +1 -1
- package/dist/command-rpc.d.ts +4 -0
- package/dist/command-rpc.js +40 -16
- package/dist/command-rpc.js.map +1 -1
- package/dist/create-ftown-session.d.ts +5 -14
- package/dist/create-ftown-session.js +21 -2
- package/dist/create-ftown-session.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +102 -11
- package/dist/index.js.map +1 -1
- package/dist/installation-manifest.d.ts +1 -1
- package/dist/installation-manifest.js +1 -0
- package/dist/installation-manifest.js.map +1 -1
- package/dist/local-api-server.d.ts +3 -1
- package/dist/local-api-server.js +24 -9
- package/dist/local-api-server.js.map +1 -1
- package/dist/mail-delivery.d.ts +6 -0
- package/dist/mail-delivery.js +195 -72
- package/dist/mail-delivery.js.map +1 -1
- package/dist/native-mail.d.ts +1 -1
- package/dist/native-mail.js +2 -2
- package/dist/native-mail.js.map +1 -1
- package/dist/security/bridge/execute.d.ts +2 -1
- package/dist/security/bridge/execute.js +13 -2
- package/dist/security/bridge/execute.js.map +1 -1
- package/dist/security/bridge/execution-observer.d.ts +12 -0
- package/dist/security/bridge/execution-observer.js +2 -0
- package/dist/security/bridge/execution-observer.js.map +1 -0
- package/dist/security/bridge/permissions.js +2 -2
- package/dist/security/bridge/permissions.js.map +1 -1
- package/dist/setup.js +2 -0
- package/dist/setup.js.map +1 -1
- package/dist/start-commands.js +3 -1
- package/dist/start-commands.js.map +1 -1
- package/dist/terminal-pump.d.ts +3 -0
- package/dist/terminal-pump.js +16 -7
- package/dist/terminal-pump.js.map +1 -1
- package/dist/types.d.ts +3 -2
- package/dist/update-admission.d.ts +24 -0
- package/dist/update-admission.js +67 -0
- package/dist/update-admission.js.map +1 -0
- package/dist/update-effects.d.ts +20 -0
- package/dist/update-effects.js +117 -0
- package/dist/update-effects.js.map +1 -0
- package/dist/update-handoff.d.ts +38 -0
- package/dist/update-handoff.js +637 -0
- package/dist/update-handoff.js.map +1 -0
- package/dist/update-protocol.d.ts +40 -0
- package/dist/update-protocol.js +2 -0
- package/dist/update-protocol.js.map +1 -0
- package/dist/update-release.d.ts +30 -0
- package/dist/update-release.js +246 -0
- package/dist/update-release.js.map +1 -0
- package/dist/update-runtime-contract.d.ts +85 -0
- package/dist/update-runtime-contract.js +3 -0
- package/dist/update-runtime-contract.js.map +1 -0
- package/package.json +3 -2
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { fork, spawn } from 'node:child_process';
|
|
3
|
+
import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, readdirSync, renameSync, rmdirSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
7
|
+
const TOKEN = /^[a-f0-9]{64}$/;
|
|
8
|
+
const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
9
|
+
const PROOF_MS = 15_000;
|
|
10
|
+
export class UpdateHandoffUncertainError extends Error {
|
|
11
|
+
code = 'handoff_uncertain';
|
|
12
|
+
constructor() { super('Cannot prove update helper exited'); }
|
|
13
|
+
}
|
|
14
|
+
function privateDir(path) {
|
|
15
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
16
|
+
const s = lstatSync(path);
|
|
17
|
+
if (!s.isDirectory() || s.isSymbolicLink() || (s.mode & 0o077) || (process.getuid && s.uid !== process.getuid()) || realpathSync(path) !== resolve(path))
|
|
18
|
+
throw new Error('Unsafe update directory');
|
|
19
|
+
}
|
|
20
|
+
function privateRead(path) {
|
|
21
|
+
const s = lstatSync(path);
|
|
22
|
+
if (!s.isFile() || s.isSymbolicLink() || (s.mode & 0o077) || (process.getuid && s.uid !== process.getuid()) || s.size > 65_536)
|
|
23
|
+
throw new Error('Unsafe update file');
|
|
24
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
25
|
+
}
|
|
26
|
+
function atomic(path, value) {
|
|
27
|
+
const dir = dirname(path);
|
|
28
|
+
privateDir(dir);
|
|
29
|
+
if (existsSync(path))
|
|
30
|
+
privateRead(path);
|
|
31
|
+
const tmp = join(dir, `.update-${randomUUID()}.tmp`);
|
|
32
|
+
const fd = openSync(tmp, 'wx', 0o600);
|
|
33
|
+
try {
|
|
34
|
+
writeFileSync(fd, JSON.stringify(value));
|
|
35
|
+
closeSync(fd);
|
|
36
|
+
renameSync(tmp, path);
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
try {
|
|
40
|
+
closeSync(fd);
|
|
41
|
+
}
|
|
42
|
+
catch { }
|
|
43
|
+
try {
|
|
44
|
+
unlinkSync(tmp);
|
|
45
|
+
}
|
|
46
|
+
catch { }
|
|
47
|
+
throw e;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function parseLock(value) {
|
|
51
|
+
const v = value;
|
|
52
|
+
if (!v || v.schema !== 1 || typeof v.operationId !== 'string' || !UUID.test(v.operationId) || !Number.isSafeInteger(v.ownerPid) || v.ownerPid < 1 || typeof v.token !== 'string' || !TOKEN.test(v.token))
|
|
53
|
+
throw new Error('Invalid update reservation');
|
|
54
|
+
return v;
|
|
55
|
+
}
|
|
56
|
+
function lockPath(home) { return join(home, '.ftown', 'update-reservation.json'); }
|
|
57
|
+
function checkHome(home) {
|
|
58
|
+
if (!home || resolve(home) !== home || realpathSync(home) !== home)
|
|
59
|
+
throw new Error('Invalid update home');
|
|
60
|
+
privateDir(join(home, '.ftown'));
|
|
61
|
+
}
|
|
62
|
+
function alive(pid) {
|
|
63
|
+
try {
|
|
64
|
+
process.kill(pid, 0);
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
if (e.code === 'ESRCH')
|
|
69
|
+
return false;
|
|
70
|
+
throw new Error('Cannot verify update owner');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function withLockGuard(home, action) {
|
|
74
|
+
const parent = join(home, '.ftown');
|
|
75
|
+
const guard = join(parent, 'update-reservation.guard');
|
|
76
|
+
const token = randomBytes(16).toString('hex');
|
|
77
|
+
const marker = `owner-${process.pid}-${token}.json`;
|
|
78
|
+
const candidate = join(parent, `.update-guard-${process.pid}-${token}`);
|
|
79
|
+
mkdirSync(candidate, { mode: 0o700 });
|
|
80
|
+
writeFileSync(join(candidate, marker), JSON.stringify({ pid: process.pid, token }), { mode: 0o600, flag: 'wx' });
|
|
81
|
+
let acquired = false;
|
|
82
|
+
try {
|
|
83
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
84
|
+
try {
|
|
85
|
+
try {
|
|
86
|
+
lstatSync(guard);
|
|
87
|
+
const exists = new Error('guard exists');
|
|
88
|
+
exists.code = 'EEXIST';
|
|
89
|
+
throw exists;
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (error.code !== 'ENOENT')
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
renameSync(candidate, guard);
|
|
96
|
+
acquired = true;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (!['EEXIST', 'ENOTEMPTY'].includes(error.code ?? ''))
|
|
101
|
+
throw error;
|
|
102
|
+
const stat = lstatSync(guard);
|
|
103
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077) || (process.getuid && stat.uid !== process.getuid()))
|
|
104
|
+
throw new Error('Unknown update guard owner');
|
|
105
|
+
const entries = readdirSync(guard);
|
|
106
|
+
const match = entries.length === 1 ? /^owner-(\d+)-([a-f0-9]{32})\.json$/.exec(entries[0]) : null;
|
|
107
|
+
if (!match)
|
|
108
|
+
throw new Error('Unknown update guard owner');
|
|
109
|
+
const item = privateRead(join(guard, entries[0]));
|
|
110
|
+
const pid = Number(match[1]);
|
|
111
|
+
if (!Number.isSafeInteger(pid) || pid < 1 || item.pid !== pid || item.token !== match[2])
|
|
112
|
+
throw new Error('Unknown update guard owner');
|
|
113
|
+
if (alive(pid))
|
|
114
|
+
throw new Error('Update reservation changing');
|
|
115
|
+
try {
|
|
116
|
+
unlinkSync(join(guard, entries[0]));
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
if (e.code !== 'ENOENT')
|
|
120
|
+
throw e;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
rmdirSync(guard);
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(e.code ?? ''))
|
|
127
|
+
throw e;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (!acquired)
|
|
132
|
+
throw new Error('Update guard changed');
|
|
133
|
+
return action();
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
if (acquired) {
|
|
137
|
+
try {
|
|
138
|
+
unlinkSync(join(guard, marker));
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
if (e.code !== 'ENOENT')
|
|
142
|
+
throw e;
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
rmdirSync(guard);
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(e.code ?? ''))
|
|
149
|
+
throw e;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
else
|
|
153
|
+
rmSync(candidate, { recursive: true, force: true });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function readLock(home) {
|
|
157
|
+
const p = lockPath(home);
|
|
158
|
+
try {
|
|
159
|
+
return parseLock(privateRead(p));
|
|
160
|
+
}
|
|
161
|
+
catch (e) {
|
|
162
|
+
if (e.code === 'ENOENT')
|
|
163
|
+
return null;
|
|
164
|
+
throw e;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function parseContext(value) {
|
|
168
|
+
const c = value;
|
|
169
|
+
if (!c || c.protocol !== 1 || typeof c.operationId !== 'string' || !UUID.test(c.operationId)
|
|
170
|
+
|| typeof c.targetVersion !== 'string' || !VERSION.test(c.targetVersion) || typeof c.token !== 'string' || !TOKEN.test(c.token)
|
|
171
|
+
|| !Number.isSafeInteger(c.helperPid) || c.helperPid < 1 || typeof c.home !== 'string' || typeof c.jobDir !== 'string'
|
|
172
|
+
|| ![c.bridgeId, c.ownerId, c.endpointId].every(x => typeof x === 'string' && x.length > 0 && x.length <= 256))
|
|
173
|
+
throw new Error('Invalid update context');
|
|
174
|
+
if (resolve(c.home) !== c.home || c.jobDir !== join(c.home, '.ftown', 'updates', c.operationId))
|
|
175
|
+
throw new Error('Invalid update context');
|
|
176
|
+
return c;
|
|
177
|
+
}
|
|
178
|
+
let processContext = null;
|
|
179
|
+
function authorizedContext() {
|
|
180
|
+
let c = processContext;
|
|
181
|
+
if (!c) {
|
|
182
|
+
const path = process.env.FTOWN_UPDATE_CONTEXT;
|
|
183
|
+
const capability = process.env.FTOWN_UPDATE_CAPABILITY;
|
|
184
|
+
if (!path && !capability)
|
|
185
|
+
return null;
|
|
186
|
+
if (!path || !capability || !TOKEN.test(capability) || resolve(path) !== path)
|
|
187
|
+
throw new Error('Invalid update context');
|
|
188
|
+
c = parseContext(privateRead(path));
|
|
189
|
+
if (path !== join(c.jobDir, 'context.json') || c.token !== capability || c.helperPid !== process.ppid)
|
|
190
|
+
throw new Error('Invalid update context');
|
|
191
|
+
delete process.env.FTOWN_UPDATE_CONTEXT;
|
|
192
|
+
delete process.env.FTOWN_UPDATE_CAPABILITY;
|
|
193
|
+
processContext = c;
|
|
194
|
+
}
|
|
195
|
+
checkHome(c.home);
|
|
196
|
+
privateDir(c.jobDir);
|
|
197
|
+
const lock = readLock(c.home);
|
|
198
|
+
if (!lock || lock.ownerPid !== c.helperPid || lock.operationId !== c.operationId || lock.token !== c.token)
|
|
199
|
+
throw new Error('Invalid update reservation');
|
|
200
|
+
return c;
|
|
201
|
+
}
|
|
202
|
+
export async function reserveUpdate(home, operationId) {
|
|
203
|
+
if (!UUID.test(operationId))
|
|
204
|
+
throw new Error('Invalid update operation');
|
|
205
|
+
checkHome(home);
|
|
206
|
+
const path = lockPath(home);
|
|
207
|
+
const lock = { schema: 1, operationId, ownerPid: process.pid, token: randomBytes(32).toString('hex') };
|
|
208
|
+
withLockGuard(home, () => {
|
|
209
|
+
const previous = readLock(home);
|
|
210
|
+
if (previous) {
|
|
211
|
+
if (alive(previous.ownerPid))
|
|
212
|
+
throw new Error('Update already reserved');
|
|
213
|
+
unlinkSync(path);
|
|
214
|
+
}
|
|
215
|
+
const fd = openSync(path, 'wx', 0o600);
|
|
216
|
+
try {
|
|
217
|
+
writeFileSync(fd, JSON.stringify(lock));
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
closeSync(fd);
|
|
221
|
+
}
|
|
222
|
+
if (existsSync(join(home, '.ftown', 'setup.lock'))) {
|
|
223
|
+
unlinkSync(path);
|
|
224
|
+
throw new Error('Setup is in progress');
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
return { async release() {
|
|
228
|
+
withLockGuard(home, () => {
|
|
229
|
+
const current = readLock(home);
|
|
230
|
+
if (!current || current.operationId !== operationId || current.token !== lock.token)
|
|
231
|
+
throw new Error('Update reservation ownership lost');
|
|
232
|
+
if (current.ownerPid !== process.pid && alive(current.ownerPid))
|
|
233
|
+
throw new Error('Update helper still running');
|
|
234
|
+
unlinkSync(path);
|
|
235
|
+
});
|
|
236
|
+
} };
|
|
237
|
+
}
|
|
238
|
+
export function assertUpdateReservationAllowed(home) {
|
|
239
|
+
const actualHome = realpathSync(resolve(home));
|
|
240
|
+
const ftown = join(actualHome, '.ftown');
|
|
241
|
+
if (!existsSync(ftown)) {
|
|
242
|
+
if (process.env.FTOWN_UPDATE_CONTEXT || process.env.FTOWN_UPDATE_CAPABILITY)
|
|
243
|
+
throw new Error('Invalid update context');
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const directory = lstatSync(ftown);
|
|
247
|
+
if (!directory.isDirectory() || directory.isSymbolicLink() || (process.getuid && directory.uid !== process.getuid()))
|
|
248
|
+
throw new Error('Unsafe update directory');
|
|
249
|
+
try {
|
|
250
|
+
lstatSync(join(ftown, 'update-reservation.guard'));
|
|
251
|
+
withLockGuard(actualHome, () => undefined);
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
if (error.code !== 'ENOENT')
|
|
255
|
+
throw error;
|
|
256
|
+
}
|
|
257
|
+
const lock = readLock(actualHome);
|
|
258
|
+
const context = authorizedContext();
|
|
259
|
+
if (lock && (!context || context.home !== actualHome || context.operationId !== lock.operationId)) {
|
|
260
|
+
if (alive(lock.ownerPid))
|
|
261
|
+
throw new Error('Update in progress');
|
|
262
|
+
withLockGuard(actualHome, () => {
|
|
263
|
+
const current = readLock(actualHome);
|
|
264
|
+
if (!current || current.ownerPid !== lock.ownerPid || current.operationId !== lock.operationId || current.token !== lock.token || alive(current.ownerPid))
|
|
265
|
+
throw new Error('Update reservation changed');
|
|
266
|
+
unlinkSync(lockPath(actualHome));
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
export function readUpdateRestartContext() {
|
|
271
|
+
const c = authorizedContext();
|
|
272
|
+
return c ? { protocol: 1, operationId: c.operationId, targetVersion: c.targetVersion, bridgeId: c.bridgeId, ownerId: c.ownerId, endpointId: c.endpointId } : null;
|
|
273
|
+
}
|
|
274
|
+
export async function reportUpdateReady(identity) {
|
|
275
|
+
const c = authorizedContext();
|
|
276
|
+
if (!c || !process.send || identity.version !== c.targetVersion || identity.bridgeId !== c.bridgeId || identity.ownerId !== c.ownerId || identity.endpointId !== c.endpointId)
|
|
277
|
+
throw new Error('Update readiness identity mismatch');
|
|
278
|
+
await new Promise((resolvePromise, reject) => {
|
|
279
|
+
const timer = setTimeout(() => { process.off('message', onMessage); reject(new Error('Update readiness acknowledgement failed')); }, PROOF_MS);
|
|
280
|
+
const onMessage = (m) => { const v = m; if (v?.type === 'ready-ack' && v.operationId === c.operationId && v.token === c.token) {
|
|
281
|
+
clearTimeout(timer);
|
|
282
|
+
process.off('message', onMessage);
|
|
283
|
+
resolvePromise();
|
|
284
|
+
} };
|
|
285
|
+
process.on('message', onMessage);
|
|
286
|
+
process.send({ type: 'ready', operationId: c.operationId, token: c.token, ...identity }, error => { if (error) {
|
|
287
|
+
clearTimeout(timer);
|
|
288
|
+
process.off('message', onMessage);
|
|
289
|
+
reject(new Error('Update readiness acknowledgement failed'));
|
|
290
|
+
} });
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
export async function readUpdateReceipt(stagingRoot) {
|
|
294
|
+
const p = join(stagingRoot, 'operation.json');
|
|
295
|
+
try {
|
|
296
|
+
const value = privateRead(p);
|
|
297
|
+
if (value.schema !== 1 || typeof value.bridgeId !== 'string' || !value.operation || !UUID.test(value.operation.id) || !VERSION.test(value.operation.targetVersion) || !['staging', 'prepared', 'restarting', 'succeeded', 'failed'].includes(value.operation.phase))
|
|
298
|
+
throw new Error('Invalid update receipt');
|
|
299
|
+
return value;
|
|
300
|
+
}
|
|
301
|
+
catch (e) {
|
|
302
|
+
if (e.code === 'ENOENT')
|
|
303
|
+
return null;
|
|
304
|
+
throw e;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
export async function writeUpdateReceipt(stagingRoot, receipt) {
|
|
308
|
+
if (resolve(stagingRoot) !== stagingRoot || basenameSafe(stagingRoot) !== 'updates' || receipt.schema !== 1 || !UUID.test(receipt.operation.id) || !VERSION.test(receipt.operation.targetVersion))
|
|
309
|
+
throw new Error('Invalid update receipt');
|
|
310
|
+
privateDir(stagingRoot);
|
|
311
|
+
atomic(join(stagingRoot, 'operation.json'), receipt);
|
|
312
|
+
}
|
|
313
|
+
function basenameSafe(path) { return path.split('/').at(-1) ?? ''; }
|
|
314
|
+
function waitChildExit(child, timeout) {
|
|
315
|
+
return new Promise((res, rej) => {
|
|
316
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
317
|
+
res();
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const t = setTimeout(() => { child.off('exit', onExit); rej(new Error('Cannot prove update helper exited')); }, timeout);
|
|
321
|
+
const onExit = () => { clearTimeout(t); res(); };
|
|
322
|
+
child.once('exit', onExit);
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
function awaitMessage(child, type, op, token, signal) {
|
|
326
|
+
return new Promise((res, rej) => {
|
|
327
|
+
const timer = setTimeout(() => done(new Error('Update helper acknowledgement timed out')), PROOF_MS);
|
|
328
|
+
const done = (error) => { clearTimeout(timer); child.off('message', onMessage); child.off('exit', onExit); child.off('error', onError); signal.removeEventListener('abort', onAbort); error ? rej(error) : res(); };
|
|
329
|
+
const onMessage = (m) => { const v = m; if (v?.type === type && v.operationId === op && v.token === token)
|
|
330
|
+
done(); };
|
|
331
|
+
const onExit = () => done(new Error('Update helper exited'));
|
|
332
|
+
const onError = () => done(new Error('Update helper failed'));
|
|
333
|
+
const onAbort = () => done(new Error('Update cancelled'));
|
|
334
|
+
child.on('message', onMessage);
|
|
335
|
+
child.once('exit', onExit);
|
|
336
|
+
child.once('error', onError);
|
|
337
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
338
|
+
if (signal.aborted)
|
|
339
|
+
onAbort();
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
/** Forward only ordinary runtime settings and the explicit trusted-LAN opt-in. */
|
|
343
|
+
export function selectUpdateLaunchEnvironment(source) {
|
|
344
|
+
const result = {};
|
|
345
|
+
for (const [name, value] of Object.entries(source)) {
|
|
346
|
+
if (typeof value !== 'string')
|
|
347
|
+
continue;
|
|
348
|
+
if (/^(PATH|SHELL|TERM|LANG|LC_[A-Z_]+|TZ|TMPDIR)$/.test(name)
|
|
349
|
+
|| (name === 'FTOWN_ALLOW_INSECURE_API' && value === '1'))
|
|
350
|
+
result[name] = value;
|
|
351
|
+
}
|
|
352
|
+
return result;
|
|
353
|
+
}
|
|
354
|
+
/** The ACK is an irreversible attempt: the replacement might receive it even if sending fails. */
|
|
355
|
+
export async function finalizeUpdateReady(steps) {
|
|
356
|
+
await steps.persist();
|
|
357
|
+
steps.markAckAttempted();
|
|
358
|
+
await steps.acknowledge();
|
|
359
|
+
await steps.release();
|
|
360
|
+
}
|
|
361
|
+
/** After an ACK attempt, preserve the replacement and reservation on any uncertainty. */
|
|
362
|
+
export async function protectOrStopReplacement(ackAttempted, steps) {
|
|
363
|
+
if (ackAttempted)
|
|
364
|
+
await steps.retain();
|
|
365
|
+
else
|
|
366
|
+
await steps.stop();
|
|
367
|
+
}
|
|
368
|
+
function sendMessage(child, message) {
|
|
369
|
+
return new Promise((ok, fail) => {
|
|
370
|
+
try {
|
|
371
|
+
child.send(message, error => error ? fail(new Error('Update helper communication failed')) : ok());
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
fail(new Error('Update helper communication failed'));
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
export async function prepareHandoff(prepared, restart, signal) {
|
|
379
|
+
checkHome(restart.home);
|
|
380
|
+
if (prepared.protocol !== 1 || !UUID.test(prepared.operationId) || !VERSION.test(prepared.targetVersion)
|
|
381
|
+
|| prepared.jobDir !== join(restart.home, '.ftown', 'updates', prepared.operationId)
|
|
382
|
+
|| prepared.packageRoot !== join(prepared.jobDir, 'install', 'node_modules', 'ftown')
|
|
383
|
+
|| prepared.packageEntry !== join(prepared.packageRoot, 'dist', 'ftown-cli.js')
|
|
384
|
+
|| restart.dataDir !== join(restart.home, '.ftown', 'data') || !['local', 'cloud'].includes(restart.mode)
|
|
385
|
+
|| ![restart.bridgeId, restart.ownerId, restart.endpointId].every(x => typeof x === 'string' && x.length > 0 && x.length <= 256))
|
|
386
|
+
throw new Error('Invalid update handoff');
|
|
387
|
+
privateDir(prepared.jobDir);
|
|
388
|
+
const lock = readLock(restart.home);
|
|
389
|
+
if (!lock || lock.operationId !== prepared.operationId || lock.ownerPid !== process.pid)
|
|
390
|
+
throw new Error('Update reservation ownership lost');
|
|
391
|
+
const owner = privateRead(join(prepared.jobDir, 'owner.json'));
|
|
392
|
+
if (owner.operationId !== prepared.operationId || owner.version !== prepared.targetVersion)
|
|
393
|
+
throw new Error('Invalid update stage');
|
|
394
|
+
const helperPath = join(prepared.jobDir, 'helper.mjs');
|
|
395
|
+
const source = fileURLToPath(import.meta.url);
|
|
396
|
+
const bytes = readFileSync(source);
|
|
397
|
+
if (!source.endsWith('.js'))
|
|
398
|
+
throw new Error('Update helper requires installed runtime');
|
|
399
|
+
const fd = openSync(helperPath, 'wx', 0o500);
|
|
400
|
+
try {
|
|
401
|
+
writeFileSync(fd, bytes);
|
|
402
|
+
}
|
|
403
|
+
finally {
|
|
404
|
+
closeSync(fd);
|
|
405
|
+
}
|
|
406
|
+
chmodSync(helperPath, 0o500);
|
|
407
|
+
const helper = fork(helperPath, ['--ftown-update-helper'], { detached: true, cwd: prepared.jobDir, env: { HOME: restart.home, PATH: `${dirname(process.execPath)}:/usr/bin:/bin` }, stdio: ['ignore', 'ignore', 'ignore', 'ipc'] });
|
|
408
|
+
helper.on('error', () => undefined);
|
|
409
|
+
if (!helper.pid)
|
|
410
|
+
throw new Error('Update helper failed to start');
|
|
411
|
+
try {
|
|
412
|
+
withLockGuard(restart.home, () => {
|
|
413
|
+
const current = readLock(restart.home);
|
|
414
|
+
if (!current || current.ownerPid !== process.pid || current.operationId !== lock.operationId || current.token !== lock.token)
|
|
415
|
+
throw new Error('Update reservation ownership lost');
|
|
416
|
+
atomic(lockPath(restart.home), { ...lock, ownerPid: helper.pid });
|
|
417
|
+
});
|
|
418
|
+
const context = { protocol: 1, operationId: prepared.operationId, targetVersion: prepared.targetVersion, bridgeId: restart.bridgeId,
|
|
419
|
+
ownerId: restart.ownerId, endpointId: restart.endpointId, token: lock.token, helperPid: helper.pid, home: restart.home, jobDir: prepared.jobDir };
|
|
420
|
+
atomic(join(prepared.jobDir, 'context.json'), context);
|
|
421
|
+
const pending = awaitMessage(helper, 'prepared', prepared.operationId, lock.token, signal);
|
|
422
|
+
const launchEnv = selectUpdateLaunchEnvironment(process.env);
|
|
423
|
+
await Promise.all([pending, sendMessage(helper, { type: 'prepare', prepared, restart, oldPid: process.pid, token: lock.token, launchEnv })]);
|
|
424
|
+
let cancelled = false;
|
|
425
|
+
return {
|
|
426
|
+
async commit() {
|
|
427
|
+
if (cancelled)
|
|
428
|
+
throw new Error('Update helper cancelled');
|
|
429
|
+
const pendingCommit = awaitMessage(helper, 'committed', prepared.operationId, lock.token, signal);
|
|
430
|
+
await Promise.all([pendingCommit, sendMessage(helper, { type: 'commit', operationId: prepared.operationId, token: lock.token })]);
|
|
431
|
+
},
|
|
432
|
+
async cancel() {
|
|
433
|
+
cancelled = true;
|
|
434
|
+
if (helper.exitCode === null && helper.signalCode === null)
|
|
435
|
+
helper.kill('SIGKILL');
|
|
436
|
+
await waitChildExit(helper, PROOF_MS);
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
catch (error) {
|
|
441
|
+
try {
|
|
442
|
+
if (helper.exitCode === null && helper.signalCode === null)
|
|
443
|
+
helper.kill('SIGKILL');
|
|
444
|
+
await waitChildExit(helper, PROOF_MS);
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
throw new UpdateHandoffUncertainError();
|
|
448
|
+
}
|
|
449
|
+
throw error;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
453
|
+
export class UpdateProcessUncertainError extends Error {
|
|
454
|
+
code = 'update_process_uncertain';
|
|
455
|
+
constructor() { super('Cannot prove update child exited'); }
|
|
456
|
+
}
|
|
457
|
+
/** Bounded termination proof for helper-owned setup processes. */
|
|
458
|
+
export function waitForUpdateChild(child, stop, timeoutMs, proofMs) {
|
|
459
|
+
return new Promise((ok, fail) => {
|
|
460
|
+
let stopped = false;
|
|
461
|
+
let spawnError = false;
|
|
462
|
+
let finished = false;
|
|
463
|
+
let proof;
|
|
464
|
+
const finish = (error) => {
|
|
465
|
+
if (finished)
|
|
466
|
+
return;
|
|
467
|
+
finished = true;
|
|
468
|
+
clearTimeout(timer);
|
|
469
|
+
if (proof)
|
|
470
|
+
clearTimeout(proof);
|
|
471
|
+
child.off('close', onClose);
|
|
472
|
+
child.off('error', onError);
|
|
473
|
+
error ? fail(error) : ok();
|
|
474
|
+
};
|
|
475
|
+
const onStop = () => {
|
|
476
|
+
if (stopped || finished)
|
|
477
|
+
return;
|
|
478
|
+
stopped = true;
|
|
479
|
+
proof = setTimeout(() => finish(new UpdateProcessUncertainError()), proofMs);
|
|
480
|
+
try {
|
|
481
|
+
stop();
|
|
482
|
+
}
|
|
483
|
+
catch { /* Failure to prove exit must retain ownership. */ }
|
|
484
|
+
};
|
|
485
|
+
const onError = () => { spawnError = true; };
|
|
486
|
+
const onClose = (code) => finish(!stopped && !spawnError && code === 0 ? undefined : new Error('Setup failed'));
|
|
487
|
+
const timer = setTimeout(onStop, timeoutMs);
|
|
488
|
+
child.once('error', onError);
|
|
489
|
+
child.once('close', onClose);
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
async function runSetup(entry, cwd, env) {
|
|
493
|
+
const child = spawn(process.execPath, [entry, 'setup'], { cwd, env, stdio: 'ignore', shell: false, detached: true });
|
|
494
|
+
await waitForUpdateChild(child, () => {
|
|
495
|
+
try {
|
|
496
|
+
if (child.pid)
|
|
497
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
498
|
+
else
|
|
499
|
+
child.kill('SIGKILL');
|
|
500
|
+
}
|
|
501
|
+
catch {
|
|
502
|
+
try {
|
|
503
|
+
child.kill('SIGKILL');
|
|
504
|
+
}
|
|
505
|
+
catch { }
|
|
506
|
+
}
|
|
507
|
+
}, 300_000, 5_000);
|
|
508
|
+
}
|
|
509
|
+
/** A possibly live child means this helper must remain the live lock owner. */
|
|
510
|
+
async function holdUncertainReservation() {
|
|
511
|
+
return await new Promise(() => { setInterval(() => undefined, 60_000); });
|
|
512
|
+
}
|
|
513
|
+
async function helperMain() {
|
|
514
|
+
const initial = await new Promise((ok, fail) => {
|
|
515
|
+
const t = setTimeout(() => fail(new Error('Preparation timeout')), PROOF_MS);
|
|
516
|
+
process.once('message', m => { clearTimeout(t); ok(m); });
|
|
517
|
+
});
|
|
518
|
+
const p = initial.prepared;
|
|
519
|
+
const restart = initial.restart;
|
|
520
|
+
const token = initial.token;
|
|
521
|
+
const oldPid = initial.oldPid;
|
|
522
|
+
if (initial.type !== 'prepare' || !p || !restart || !TOKEN.test(token) || !Number.isSafeInteger(oldPid) || oldPid < 1)
|
|
523
|
+
throw new Error('Invalid helper preparation');
|
|
524
|
+
const c = parseContext(privateRead(join(p.jobDir, 'context.json')));
|
|
525
|
+
const lock = readLock(restart.home);
|
|
526
|
+
if (c.token !== token || c.helperPid !== process.pid || c.operationId !== p.operationId || c.targetVersion !== p.targetVersion || !lock || lock.ownerPid !== process.pid || lock.token !== token || lock.operationId !== p.operationId)
|
|
527
|
+
throw new Error('Invalid helper ownership');
|
|
528
|
+
process.send?.({ type: 'prepared', operationId: p.operationId, token });
|
|
529
|
+
await new Promise((ok, fail) => {
|
|
530
|
+
const t = setTimeout(() => fail(new Error('Commit timeout')), PROOF_MS);
|
|
531
|
+
process.once('message', m => { const v = m; clearTimeout(t); v?.type === 'commit' && v.operationId === p.operationId && v.token === token ? ok() : fail(new Error('Invalid commit')); });
|
|
532
|
+
});
|
|
533
|
+
process.send?.({ type: 'committed', operationId: p.operationId, token });
|
|
534
|
+
let child;
|
|
535
|
+
let readyAckAttempted = false;
|
|
536
|
+
try {
|
|
537
|
+
const deadline = Date.now() + PROOF_MS;
|
|
538
|
+
while (alive(oldPid) && Date.now() < deadline)
|
|
539
|
+
await sleep(100);
|
|
540
|
+
if (alive(oldPid))
|
|
541
|
+
throw new Error('Old bridge did not exit');
|
|
542
|
+
const supplied = initial.launchEnv;
|
|
543
|
+
const env = supplied && typeof supplied === 'object' ? selectUpdateLaunchEnvironment(supplied) : {};
|
|
544
|
+
env.HOME = restart.home;
|
|
545
|
+
env.PATH ??= `${dirname(process.execPath)}:/usr/bin:/bin`;
|
|
546
|
+
env.FTOWN_UPDATE_CONTEXT = join(p.jobDir, 'context.json');
|
|
547
|
+
env.FTOWN_UPDATE_CAPABILITY = token;
|
|
548
|
+
await runSetup(p.packageEntry, restart.cwd, env);
|
|
549
|
+
const installed = join(restart.home, '.ftown', 'cli', 'dist', 'ftown-cli.js');
|
|
550
|
+
const info = JSON.parse(readFileSync(join(restart.home, '.ftown', 'cli', 'package.json'), 'utf8'));
|
|
551
|
+
if (info.version !== p.targetVersion || info.ftownUpdateProtocol !== 1)
|
|
552
|
+
throw new Error('Installed update mismatch');
|
|
553
|
+
const args = [installed, 'start', '--allow-updates', '--data-dir', restart.dataDir, '--bridge-id', restart.bridgeId, '--api-url', restart.apiUrl];
|
|
554
|
+
if (restart.mode === 'local')
|
|
555
|
+
args.push('--local');
|
|
556
|
+
child = fork(installed, args.slice(1), { cwd: restart.cwd, env, detached: true, stdio: ['ignore', 'ignore', 'ignore', 'ipc'] });
|
|
557
|
+
const ready = await new Promise((ok) => {
|
|
558
|
+
const t = setTimeout(() => ok(false), 60_000);
|
|
559
|
+
child.on('message', m => {
|
|
560
|
+
const v = m;
|
|
561
|
+
if (v?.type === 'ready' && v.operationId === p.operationId && v.token === token && v.version === p.targetVersion && v.bridgeId === restart.bridgeId && v.ownerId === restart.ownerId && v.endpointId === restart.endpointId) {
|
|
562
|
+
clearTimeout(t);
|
|
563
|
+
ok(true);
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
child.once('exit', () => { clearTimeout(t); ok(false); });
|
|
567
|
+
child.once('error', () => { clearTimeout(t); ok(false); });
|
|
568
|
+
});
|
|
569
|
+
if (!ready)
|
|
570
|
+
throw new Error('Replacement did not become ready');
|
|
571
|
+
const receipt = await readUpdateReceipt(dirname(p.jobDir));
|
|
572
|
+
if (!receipt || receipt.bridgeId !== restart.bridgeId || receipt.operation.id !== p.operationId || receipt.operation.targetVersion !== p.targetVersion)
|
|
573
|
+
throw new Error('Update receipt mismatch');
|
|
574
|
+
await finalizeUpdateReady({
|
|
575
|
+
persist: () => writeUpdateReceipt(dirname(p.jobDir), { ...receipt, operation: { ...receipt.operation, phase: 'succeeded', updatedAt: new Date().toISOString(), error: null } }),
|
|
576
|
+
markAckAttempted: () => { readyAckAttempted = true; },
|
|
577
|
+
release: async () => withLockGuard(restart.home, () => {
|
|
578
|
+
const current = readLock(restart.home);
|
|
579
|
+
if (!current || current.ownerPid !== process.pid || current.operationId !== p.operationId || current.token !== token)
|
|
580
|
+
throw new Error('Update reservation ownership lost');
|
|
581
|
+
unlinkSync(lockPath(restart.home));
|
|
582
|
+
}),
|
|
583
|
+
acknowledge: () => sendMessage(child, { type: 'ready-ack', operationId: p.operationId, token }),
|
|
584
|
+
});
|
|
585
|
+
// Once acknowledged, do not turn an IPC disconnect race into a failed update.
|
|
586
|
+
try {
|
|
587
|
+
if (child.connected)
|
|
588
|
+
child.disconnect();
|
|
589
|
+
}
|
|
590
|
+
catch { /* The child may already have disconnected. */ }
|
|
591
|
+
try {
|
|
592
|
+
child.unref();
|
|
593
|
+
}
|
|
594
|
+
catch { /* Acknowledged replacement must not be stopped. */ }
|
|
595
|
+
}
|
|
596
|
+
catch (error) {
|
|
597
|
+
if (error?.code === 'update_process_uncertain')
|
|
598
|
+
await holdUncertainReservation();
|
|
599
|
+
await protectOrStopReplacement(readyAckAttempted, {
|
|
600
|
+
retain: async () => {
|
|
601
|
+
// Delivery or lock release may have succeeded: do not claim a final outcome.
|
|
602
|
+
try {
|
|
603
|
+
const current = await readUpdateReceipt(dirname(p.jobDir));
|
|
604
|
+
if (current && current.bridgeId === restart.bridgeId && current.operation.id === p.operationId && current.operation.targetVersion === p.targetVersion) {
|
|
605
|
+
await writeUpdateReceipt(dirname(p.jobDir), { ...current, operation: { ...current.operation, phase: 'restarting', updatedAt: new Date().toISOString(), error: 'Update readiness could not be confirmed.' } });
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
catch { /* Retain the live helper and reservation even if the receipt cannot be updated. */ }
|
|
609
|
+
await holdUncertainReservation();
|
|
610
|
+
},
|
|
611
|
+
stop: async () => {
|
|
612
|
+
if (child && child.exitCode === null && child.signalCode === null) {
|
|
613
|
+
try {
|
|
614
|
+
child.kill('SIGKILL');
|
|
615
|
+
await waitChildExit(child, 5_000);
|
|
616
|
+
}
|
|
617
|
+
catch {
|
|
618
|
+
await holdUncertainReservation();
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
},
|
|
622
|
+
});
|
|
623
|
+
const receipt = await readUpdateReceipt(dirname(p.jobDir));
|
|
624
|
+
if (receipt && receipt.operation.id === p.operationId && receipt.operation.targetVersion === p.targetVersion && receipt.bridgeId === restart.bridgeId) {
|
|
625
|
+
await writeUpdateReceipt(dirname(p.jobDir), { ...receipt, operation: { ...receipt.operation, phase: 'failed', updatedAt: new Date().toISOString(), error: 'Update did not complete. Run the exact-version setup --repair, then start the bridge.' } });
|
|
626
|
+
}
|
|
627
|
+
withLockGuard(restart.home, () => {
|
|
628
|
+
const current = readLock(restart.home);
|
|
629
|
+
if (current?.ownerPid === process.pid && current.token === token)
|
|
630
|
+
unlinkSync(lockPath(restart.home));
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url) && process.argv[2] === '--ftown-update-helper') {
|
|
635
|
+
void helperMain().then(() => process.exit(0), () => process.exit(1));
|
|
636
|
+
}
|
|
637
|
+
//# sourceMappingURL=update-handoff.js.map
|