pinokiod 8.2.10 → 8.2.12

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.
@@ -1,16 +1,17 @@
1
1
  const assert = require('node:assert/strict')
2
2
  const test = require('node:test')
3
- const http = require('node:http')
4
3
  const os = require('node:os')
5
4
  const fs = require('node:fs')
6
5
  const path = require('node:path')
7
6
 
8
7
  const { buildServeConfig, describeServeConfig, sameServeConfig } = require('../kernel/anywhere/serve')
9
- const { assignPorts } = require('../kernel/anywhere/ports')
10
- const { LocalAPI } = require('../kernel/anywhere/localapi')
8
+ const { assignPorts, loadPortMap, savePortMap } = require('../kernel/anywhere/ports')
9
+ const vm = require('node:vm')
11
10
  const Anywhere = require('../kernel/anywhere')
12
- const { spawn } = require('node:child_process')
11
+ const TailscaleCLI = require('../kernel/anywhere/cli')
13
12
  const Tailscaled = require('../kernel/bin/tailscaled')
13
+ const { spawn } = require('node:child_process')
14
+ const { once, EventEmitter } = require('node:events')
14
15
  const Setup = require('../kernel/bin/setup')
15
16
 
16
17
  test('serve config maps pinokiod to 443 and each app to its stable remote port', () => {
@@ -53,87 +54,49 @@ test('stable ports are allocated once, lowest free first, and never move', () =>
53
54
  test('anywhere preset requires tailscaled but not caddy', () => {
54
55
  const preset = Setup.anywhere({})
55
56
  const names = preset.requirements.map((r) => r.name)
56
- assert.ok(names.includes('tailscaled'))
57
+ assert.deepEqual(names, ['conda', 'tailscaled'])
58
+ assert.deepEqual(preset.conda_requirements, ['tailscaled', 'tailscale'])
57
59
  assert.ok(!names.includes('caddy'))
58
60
  assert.ok(preset.conda_requirements.includes('tailscaled'))
59
61
  assert.ok(!preset.conda_requirements.includes('caddy'))
60
62
  })
61
63
 
62
- test('tailscaled bin module pins 1.104.1 and checks the installed version', async () => {
63
- const mod = new Tailscaled()
64
- assert.equal(mod.cmd(), 'tailscaled=1.104.1')
65
- mod.kernel = {
66
- platform: 'darwin',
67
- bin: {
68
- installed: { conda: new Set(['tailscaled']), conda_versions: { tailscaled: '1.104.1' } },
69
- path: (...segments) => segments.join('/'),
70
- },
64
+ test('tailscaled checks both package versions, binary versions and missing executables', async () => {
65
+ const files = new Map()
66
+ const calls = []
67
+ const context = { module: { exports: {} }, console, require(name) {
68
+ if (name === 'fs') return { promises: { stat: async (file) => {
69
+ if (!files.has(file)) throw new Error('missing binary')
70
+ return { mtimeMs: 1, size: 1 }
71
+ } } }
72
+ if (name === 'child_process') return { execFile(file, args, options, done) {
73
+ calls.push([file, args, options])
74
+ done(null, files.get(file), '')
75
+ } }
76
+ if (name === 'util') return { promisify: (fn) => (...args) => new Promise((resolve, reject) => {
77
+ fn(...args, (error, stdout) => error ? reject(error) : resolve({ stdout }))
78
+ }) }
79
+ return require(name)
80
+ } }
81
+ vm.runInNewContext(fs.readFileSync(require.resolve('../kernel/bin/tailscaled'), 'utf8'), context)
82
+ for (const platform of ['darwin', 'win32', 'linux']) {
83
+ const mod = new context.module.exports()
84
+ mod.kernel = { platform, bin: { path: (...parts) => path.join('/managed', ...parts),
85
+ installed: { conda: new Set(['tailscaled', 'tailscale']), conda_versions: { tailscaled: '1.104.1', tailscale: '1.104.1' } } } }
86
+ assert.equal(mod.cmd(), 'tailscaled=1.104.1 tailscale=1.104.1')
87
+ files.clear()
88
+ files.set(mod.binPath(), '1.104.1\n')
89
+ assert.equal(await mod.installed(), false, 'missing CLI fails setup')
90
+ files.set(mod.cliPath(), '1.104.1\n')
91
+ assert.equal(await mod.installed(), true)
92
+ mod.kernel.bin.installed.conda_versions.tailscale = '1.102.2'
93
+ assert.equal(await mod.installed(), false, 'mismatched CLI package fails setup')
94
+ mod.kernel.bin.installed.conda_versions.tailscale = '1.104.1'
95
+ mod.verified.clear()
96
+ files.set(mod.cliPath(), '1.102.2\n')
97
+ assert.equal(await mod.installed(), false, 'binary version is verified independently')
71
98
  }
72
- assert.equal(await mod.installed(), true)
73
- assert.equal(mod.binPath(), 'miniforge/bin/tailscaled')
74
- mod.kernel.bin.installed.conda_versions.tailscaled = '1.98.5'
75
- assert.equal(await mod.installed(), false, 'older than 1.104 (no --windows-mode=dev) is not accepted')
76
- mod.kernel.bin.installed.conda = new Set()
77
- assert.equal(await mod.installed(), false)
78
- mod.kernel.platform = 'win32'
79
- assert.equal(mod.binPath(), 'miniforge/Library/bin/tailscaled.exe')
80
- })
81
-
82
- function withMockDaemon(handler, fn) {
83
- const socketPath = path.join(os.tmpdir(), `pk-ts-test-${process.pid}-${Date.now()}.sock`)
84
- const server = http.createServer(handler)
85
- return new Promise((resolve, reject) => {
86
- server.listen(socketPath, async () => {
87
- try {
88
- resolve(await fn(socketPath))
89
- } catch (e) {
90
- reject(e)
91
- } finally {
92
- server.close()
93
- fs.rmSync(socketPath, { force: true })
94
- }
95
- })
96
- })
97
- }
98
-
99
- test('LocalAPI client talks plain HTTP over the socket with the Sec-Tailscale header', { skip: process.platform === 'win32' }, async () => {
100
- const seen = []
101
- await withMockDaemon((req, res) => {
102
- let body = ''
103
- req.on('data', (c) => body += c)
104
- req.on('end', () => {
105
- seen.push({ method: req.method, url: req.url, sec: req.headers['sec-tailscale'], ifMatch: req.headers['if-match'], body })
106
- if (req.url === '/localapi/v0/status') {
107
- res.setHeader('Content-Type', 'application/json')
108
- res.end(JSON.stringify({ BackendState: 'Running', Self: { DNSName: 'pinokio.tail1.ts.net.' } }))
109
- } else if (req.url === '/localapi/v0/serve-config' && req.method === 'GET') {
110
- res.setHeader('Etag', 'abc')
111
- res.end('null')
112
- } else if (req.url === '/localapi/v0/serve-config' && req.method === 'POST') {
113
- res.statusCode = 200
114
- res.end()
115
- } else if (req.url === '/localapi/v0/login-interactive') {
116
- res.statusCode = 204
117
- res.end()
118
- } else {
119
- res.statusCode = 404
120
- res.end('nope')
121
- }
122
- })
123
- }, async (socketPath) => {
124
- const api = new LocalAPI({ socketPath })
125
- const status = await api.status()
126
- assert.equal(status.BackendState, 'Running')
127
- const { etag } = await api.serveConfig()
128
- assert.equal(etag, 'abc')
129
- await api.setServeConfig({ TCP: {} }, etag)
130
- await api.loginInteractive()
131
- await assert.rejects(() => api.expect('GET', '/missing'), /404/)
132
- })
133
- const post = seen.find((s) => s.method === 'POST' && s.url === '/localapi/v0/serve-config')
134
- assert.equal(post.sec, 'localapi')
135
- assert.equal(post.ifMatch, 'abc')
136
- assert.equal(post.body, JSON.stringify({ TCP: {} }))
99
+ assert.ok(calls.every(([, args, options]) => args[0] === '--version' && options.shell === false))
137
100
  })
138
101
 
139
102
  function fakeKernel(tmp, { installed = true } = {}) {
@@ -146,11 +109,73 @@ function fakeKernel(tmp, { installed = true } = {}) {
146
109
  memory: { local: {} },
147
110
  bin: {
148
111
  path: (...segments) => path.join(tmp, 'bin', ...segments),
149
- mod: { tailscaled: { installed: async () => installed, binPath: () => path.join(tmp, 'bin', 'tailscaled') } },
112
+ mod: { tailscaled: { installed: async () => installed, binPath: () => path.join(tmp, 'bin', 'tailscaled'), cliPath: () => path.join(tmp, 'bin', 'tailscale') } },
150
113
  },
151
114
  }
152
115
  }
153
116
 
117
+ function runningStatus() {
118
+ return { BackendState: 'Running', Self: { DNSName: 'pinokio.tail1.ts.net.' }, CertDomains: ['pinokio.tail1.ts.net'] }
119
+ }
120
+
121
+ for (const method of ['writeFile', 'rename']) {
122
+ test(`failed ${method} preserves Anywhere settings, cache and port assignments`, async (t) => {
123
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-save-'))
124
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
125
+ const anywhere = new Anywhere(fakeKernel(tmp))
126
+ await anywhere.saveConfig({ enabled: true, hostname: 'keep-name' })
127
+ await savePortMap(anywhere.portMapPath(), { comfy: 43000 })
128
+ const files = [anywhere.configPath(), anywhere.portMapPath()]
129
+ const before = files.map((file) => fs.readFileSync(file, 'utf8'))
130
+ const original = fs.promises[method]
131
+ const code = method === 'writeFile' ? 'ENOSPC' : 'EACCES'
132
+ t.mock.method(fs.promises, method, async function (...args) {
133
+ const target = method === 'rename' ? args[1] : args[0]
134
+ if (files.some((file) => target === file || String(target).startsWith(`${file}.`))) {
135
+ if (method === 'writeFile') await original.call(this, target, '{"partial":')
136
+ throw Object.assign(new Error('injected save failure'), { code })
137
+ }
138
+ return original.apply(this, args)
139
+ })
140
+ await assert.rejects(anywhere.saveConfig({ enabled: false, hostname: 'lost-name' }), { code })
141
+ await assert.rejects(savePortMap(files[1], { comfy: 43000, other: 43001 }), { code })
142
+ assert.deepEqual(files.map((file) => fs.readFileSync(file, 'utf8')), before)
143
+ assert.equal(await anywhere.enabled(), true)
144
+ assert.equal(await anywhere.hostname(), 'keep-name')
145
+ assert.deepEqual(await loadPortMap(files[1]), { comfy: 43000 })
146
+ assert.deepEqual(fs.readdirSync(anywhere.dir()).sort(), ['config.json', 'ports.json'])
147
+ })
148
+ }
149
+
150
+ test('failed first Anywhere save leaves no partial config or enabled cache', async (t) => {
151
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-save-'))
152
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
153
+ const anywhere = new Anywhere(fakeKernel(tmp))
154
+ t.mock.method(fs.promises, 'writeFile', async () => {
155
+ throw Object.assign(new Error('disk full'), { code: 'ENOSPC' })
156
+ })
157
+ await assert.rejects(anywhere.saveConfig({ enabled: true }), { code: 'ENOSPC' })
158
+ assert.equal(await anywhere.enabled(), false)
159
+ assert.equal(await anywhere.configured(), false)
160
+ assert.deepEqual(fs.readdirSync(anywhere.dir()), [])
161
+ })
162
+
163
+ test('Anywhere replacements persist settings and stable ports for a fresh reader', async (t) => {
164
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-save-'))
165
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
166
+ const kernel = fakeKernel(tmp)
167
+ const anywhere = new Anywhere(kernel)
168
+ await anywhere.saveConfig({ enabled: true, hostname: 'keep-name' })
169
+ await anywhere.saveConfig({ enabled: false })
170
+ await savePortMap(anywhere.portMapPath(), { comfy: 43000 })
171
+ await savePortMap(anywhere.portMapPath(), { comfy: 43000, other: 43001 })
172
+ const fresh = new Anywhere(kernel)
173
+ assert.equal(await fresh.enabled(), false)
174
+ assert.equal(await fresh.hostname(), 'keep-name')
175
+ assert.deepEqual(await loadPortMap(fresh.portMapPath()), { comfy: 43000, other: 43001 })
176
+ assert.deepEqual(fs.readdirSync(anywhere.dir()).sort(), ['config.json', 'ports.json'])
177
+ })
178
+
154
179
  test('anywhere snapshot walks setup -> off -> login -> https -> ready', async () => {
155
180
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-'))
156
181
  try {
@@ -165,27 +190,31 @@ test('anywhere snapshot walks setup -> off -> login -> https -> ready', async ()
165
190
  assert.equal(await anywhere.configured(), true)
166
191
  assert.equal(JSON.parse(fs.readFileSync(path.join(tmp, 'anywhere', 'config.json'), 'utf8')).enabled, true)
167
192
 
168
- // stub the daemon
193
+ // Stub a verified owned daemon.
194
+ anywhere.readPid = () => 123
169
195
  let status = { BackendState: 'NeedsLogin', AuthURL: 'https://login.tailscale.com/a/abc', Self: {}, Peer: null, User: null }
170
196
  let loginRequests = 0
171
197
  let pushed = null
172
- anywhere.pid = 123
198
+ let applied = null
173
199
  anywhere.api = {
174
200
  status: async () => status,
175
- loginInteractive: async () => { loginRequests += 1 },
201
+ up: (update) => { loginRequests += 1; update({ AuthURL: status.AuthURL }); return new Promise(() => {}) },
176
202
  queryFeature: async () => ({ Complete: false, Text: 'approve', URL: 'https://login.tailscale.com/f/serve?node=1' }),
177
- serveConfig: async () => ({ config: null, etag: 'e1' }),
178
- setServeConfig: async (config) => { pushed = config },
203
+ serveConfig: async () => applied,
204
+ setServeConfig: async (config) => { pushed = applied = config; return applied },
179
205
  }
180
206
  let snap = await anywhere.snapshot()
207
+ assert.equal(snap.phase, 'starting', 'the first poll does not wait for asynchronous login ownership inspection')
208
+ await new Promise(setImmediate)
209
+ snap = await anywhere.snapshot()
181
210
  assert.equal(snap.phase, 'login')
182
211
  assert.equal(snap.loginUrl, 'https://login.tailscale.com/a/abc')
183
- assert.equal(loginRequests, 0, 'uses the AuthURL the daemon already has')
212
+ assert.equal(loginRequests, 1, 'owns one CLI login flow')
184
213
 
185
214
  anywhere.lastStatusAt = 0
186
215
  status = { BackendState: 'NeedsLogin', AuthURL: '', Self: {}, Peer: null, User: null }
187
216
  snap = await anywhere.snapshot()
188
- assert.equal(loginRequests, 1, 'asks the daemon for a login URL when it has none')
217
+ assert.equal(loginRequests, 1, 'polling does not duplicate the pending CLI login')
189
218
 
190
219
  anywhere.lastStatusAt = 0
191
220
  status = {
@@ -237,8 +266,8 @@ test('anywhere snapshot walks setup -> off -> login -> https -> ready', async ()
237
266
  assert.equal(rewritten[2].href, '/files/api/comfy')
238
267
  assert.equal(rewritten[3].href, 'https://example.com:7860/')
239
268
  }
240
- // sync pushes once, then only on changes
241
- assert.equal(await anywhere.sync(), true)
269
+ // The ready snapshot already verified the routes; unchanged syncs do not write.
270
+ assert.equal(await anywhere.sync(), false)
242
271
  assert.deepEqual(Object.keys(pushed.TCP), ['443', '43000'])
243
272
  pushed = null
244
273
  assert.equal(await anywhere.sync(), false)
@@ -262,6 +291,77 @@ test('anywhere snapshot walks setup -> off -> login -> https -> ready', async ()
262
291
  }
263
292
  })
264
293
 
294
+ test('unchanged menus reuse confirmed routes and changed apps sync before publishing links', async t => {
295
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-menu-sync-'))
296
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
297
+ const kernel = fakeKernel(tmp)
298
+ const local = kernel.memory.local
299
+ const comfy = path.join(tmp, 'api/comfy/start.js')
300
+ const forge = path.join(tmp, 'api/forge/start.js')
301
+ local[comfy] = { url: 'http://localhost:7860' }
302
+ const anywhere = new Anywhere(kernel)
303
+ anywhere.config = { enabled: true }
304
+ let applied = null, reads = 0, release
305
+ anywhere.readPid = () => 123
306
+ anywhere.api = {
307
+ status: async () => runningStatus(),
308
+ serveConfig: async () => { reads++; return applied },
309
+ setServeConfig: async desired => {
310
+ if (release) await release.promise
311
+ return (applied = desired)
312
+ },
313
+ }
314
+ await anywhere.sync()
315
+ reads = 0
316
+ for (let i = 0; i < 10; i++) {
317
+ const menu = await anywhere.rewriteMenu('pinokio.tail1.ts.net', [{ href: local[comfy].url }])
318
+ assert.equal(menu[0].href, 'https://pinokio.tail1.ts.net:43000/')
319
+ }
320
+ assert.equal(reads, 0, 'unchanged menu requests do not spawn more CLI reads')
321
+ await anywhere.sync()
322
+ assert.equal(reads, 1, 'the periodic refresh still checks the daemon')
323
+
324
+ local[forge] = { url: 'http://localhost:8188' }
325
+ const fresh = await anywhere.rewriteMenu('pinokio.tail1.ts.net', [{ href: local[forge].url }])
326
+ assert.equal(fresh[0].href, 'https://pinokio.tail1.ts.net:43001/')
327
+ delete local[forge]
328
+ await anywhere.rewriteMenu('pinokio.tail1.ts.net', [])
329
+ assert.equal(applied.TCP[43001], undefined)
330
+
331
+ local[comfy].url = 'http://localhost:7861'
332
+ release = {}
333
+ release.promise = new Promise(resolve => { release.resolve = resolve })
334
+ const sync = anywhere.sync()
335
+ await new Promise(setImmediate)
336
+ let published = false
337
+ const menu = anywhere.rewriteMenu('pinokio.tail1.ts.net', [{ href: local[comfy].url }])
338
+ .then(value => { published = true; return value })
339
+ await new Promise(setImmediate)
340
+ assert.equal(published, false, 'the new link waits for its pending route update')
341
+ release.resolve()
342
+ await sync
343
+ assert.equal((await menu)[0].href, 'https://pinokio.tail1.ts.net:43000/')
344
+ })
345
+
346
+ test('concurrent syncs share one follow-up pass and include the latest targets', async () => {
347
+ const anywhere = new Anywhere(fakeKernel(os.tmpdir()))
348
+ let release, target = 'old'
349
+ const pending = new Promise(resolve => { release = resolve })
350
+ const seen = []
351
+ anywhere.pushServeConfig = async () => {
352
+ seen.push(target)
353
+ if (seen.length === 1) await pending
354
+ return seen.length === 1
355
+ }
356
+ const calls = [anywhere.sync()]
357
+ target = 'new'
358
+ for (let i = 0; i < 7; i++) calls.push(anywhere.sync())
359
+ release()
360
+ assert.deepEqual(await Promise.all(calls), Array(8).fill(true))
361
+ assert.deepEqual(seen, ['old', 'new'])
362
+ assert.equal(anywhere.syncing, null)
363
+ })
364
+
265
365
  test('anywhere uses dev mode flags on windows and a short socket path on long homes', () => {
266
366
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-'))
267
367
  try {
@@ -323,26 +423,58 @@ test('serve targets follow the Home Server router rules for an app\'s URL variab
323
423
  }
324
424
  const anywhere = new Anywhere(kernel)
325
425
  assert.deepEqual(anywhere.appTargets(), [ // `url` first, then the other keys alphabetically
326
- { key: 'comfy', name: 'comfy', local: 7860 },
327
- { key: 'api.comfy', name: 'comfy', local: 7861 },
328
- { key: 'nocfg', name: 'nocfg', local: 8000 },
426
+ { key: 'comfy', name: 'comfy', local: 7860, origin: 'http://localhost:7860' },
427
+ { key: 'api.comfy', name: 'comfy', local: 7861, origin: 'http://127.0.0.1:7861' },
428
+ { key: 'nocfg', name: 'nocfg', local: 8000, origin: 'http://localhost:8000' },
329
429
  ])
330
430
  })
331
431
 
332
- test('a failed https feature query keeps the page in starting instead of showing an approval', async () => {
432
+ test('Serve preserves upstream protocol and host, including changes on the same port', async t => {
433
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-origin-'))
434
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
435
+ const kernel = fakeKernel(tmp)
436
+ const app = path.join(tmp, 'api/app/start.js')
437
+ const anywhere = new Anywhere(kernel)
438
+ anywhere.config = { enabled: true }
439
+ anywhere.lastStatus = runningStatus()
440
+ let applied = null
441
+ anywhere.readPid = () => 123
442
+ anywhere.api = {
443
+ status: async () => runningStatus(),
444
+ serveConfig: async () => applied,
445
+ setServeConfig: async desired => (applied = desired),
446
+ }
447
+ for (const [url, origin] of [
448
+ ['http://127.0.0.1:7860/ui/', 'http://127.0.0.1:7860'],
449
+ ['https://127.0.0.1:7860/ui/', 'https://127.0.0.1:7860'],
450
+ ['https://localhost:7860/ui/', 'https://localhost:7860'],
451
+ ['http://[::1]:7860/ui/', 'http://[::1]:7860'],
452
+ ['http://0.0.0.0:7860/ui/', 'http://127.0.0.1:7860'],
453
+ ]) {
454
+ kernel.memory.local[app] = { url }
455
+ await anywhere.rewriteMenu('pinokio.tail1.ts.net', [{ href: url }])
456
+ assert.equal(applied.Web['pinokio.tail1.ts.net:43000'].Handlers['/'].Proxy, origin)
457
+ assert.equal(describeServeConfig(applied, 'pinokio.tail1.ts.net')[1].local, 7860)
458
+ assert.equal(anywhere.targets[0].listen, 43000, 'the remote address stays stable')
459
+ }
460
+ })
461
+
462
+ test('a failed HTTPS query surfaces an error instead of looping in starting', async () => {
333
463
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-'))
334
464
  try {
335
465
  const anywhere = new Anywhere(fakeKernel(tmp))
336
466
  await anywhere.saveConfig({ enabled: true })
337
- anywhere.pid = 123
338
467
  let feature = () => { throw new Error('503 no netmap') }
468
+ anywhere.readPid = () => 123
339
469
  anywhere.api = {
340
470
  status: async () => ({ BackendState: 'Running', CertDomains: null, Self: { DNSName: 'pinokio.tail1.ts.net.', UserID: 7 }, User: {}, Peer: null }),
341
471
  queryFeature: async () => feature(),
342
472
  }
343
473
  let snap = await anywhere.snapshot()
344
- assert.equal(snap.phase, 'starting')
474
+ assert.equal(snap.phase, 'error')
475
+ assert.match(snap.error, /503 no netmap/)
345
476
  assert.equal(snap.approval, null)
477
+ anywhere.error = null
346
478
  feature = () => ({ Complete: false, Text: 'approve', URL: 'https://login.tailscale.com/f/serve?node=1' })
347
479
  anywhere.lastStatusAt = 0
348
480
  snap = await anywhere.snapshot()
@@ -360,31 +492,270 @@ function spawnIdle() {
360
492
  return { child, exited: (ms = 3000) => Promise.race([exited, new Promise((resolve) => setTimeout(() => resolve(false), ms))]) }
361
493
  }
362
494
 
363
- test('adopting a daemon left by a previous run recovers its pid from the pid file', async () => {
495
+ test('a stored PID never authorizes signaling an unrelated process', async () => {
364
496
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-'))
365
497
  const idle = spawnIdle()
366
498
  try {
367
- const kernel = fakeKernel(tmp)
368
- const anywhere = new Anywhere(kernel)
499
+ const anywhere = new Anywhere(fakeKernel(tmp))
500
+ await anywhere.saveConfig({ enabled: true })
369
501
  fs.mkdirSync(anywhere.stateDir(), { recursive: true })
370
- const dead = spawnIdle(); dead.child.kill('SIGKILL'); await dead.exited()
371
- fs.writeFileSync(anywhere.pidPath(), String(dead.child.pid))
372
- assert.equal(anywhere.readPid(), null, 'a pid that no longer exists is ignored')
373
502
  fs.writeFileSync(anywhere.pidPath(), String(idle.child.pid))
374
- await anywhere.saveConfig({ enabled: true })
503
+ let mutations = 0
375
504
  anywhere.api = {
376
- ping: async () => true,
377
- patchPrefs: async () => ({}),
378
- status: async () => ({ BackendState: 'NeedsLogin', AuthURL: 'https://login.tailscale.com/a/abc', Self: {}, User: null, Peer: null }),
505
+ cancel() {}, ping: async () => true, setPrefs: async () => { mutations++ },
506
+ status: async () => ({ BackendState: 'Running' }),
379
507
  }
380
- await anywhere.start()
381
- assert.equal(anywhere.pid, idle.child.pid)
508
+ await assert.rejects(() => anywhere.start(), /Cannot verify ownership/)
509
+ assert.equal(mutations, 0, 'an unverified daemon must not receive settings changes')
510
+ assert.equal(anywhere.child, null, 'does not adopt a child handle from disk')
511
+ assert.equal(await anywhere.readPid(), null)
512
+ await assert.rejects(() => anywhere.stop(), /Cannot verify ownership/)
513
+ anywhere.killSync()
514
+ process.kill(idle.child.pid, 0)
515
+ assert.equal(fs.readFileSync(anywhere.pidPath(), 'utf8'), String(idle.child.pid))
382
516
  } finally {
383
517
  idle.child.kill('SIGKILL')
384
518
  fs.rmSync(tmp, { recursive: true, force: true })
385
519
  }
386
520
  })
387
521
 
522
+ test('a responding daemon requires a verified PID or a live owned child', async t => {
523
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-adopt-'))
524
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
525
+ for (const platform of ['win32', 'darwin', 'linux']) {
526
+ for (const owner of ['missing', 'verified', 'child']) {
527
+ const kernel = fakeKernel(path.join(tmp, platform, owner))
528
+ kernel.platform = platform
529
+ const anywhere = new Anywhere(kernel)
530
+ anywhere.config = { enabled: true }
531
+ if (owner === 'child') anywhere.child = { pid: 123, exitCode: null }
532
+ anywhere.readPid = () => owner === 'verified' ? 123 : null
533
+ let mutations = 0
534
+ anywhere.api = { cancel() {}, ping: async () => true,
535
+ setPrefs: async () => { mutations++ }, status: async () => runningStatus() }
536
+ if (owner === 'missing') await assert.rejects(anywhere.start(), /Cannot verify ownership/)
537
+ else assert.equal(await anywhere.start(), true)
538
+ assert.equal(mutations, owner === 'missing' ? 0 : 1)
539
+ }
540
+ }
541
+ })
542
+
543
+ test('unverified daemons cannot receive logout, login, approval or Serve changes', async t => {
544
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-unowned-control-'))
545
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
546
+ for (const platform of ['win32', 'darwin', 'linux']) {
547
+ const kernel = fakeKernel(path.join(tmp, platform))
548
+ kernel.platform = platform
549
+ const anywhere = new Anywhere(kernel)
550
+ anywhere.config = { enabled: true }
551
+ anywhere.readPid = () => null
552
+ const mutations = []
553
+ anywhere.api = {
554
+ cancel() {}, ping: async () => true, status: async () => runningStatus(),
555
+ setPrefs: async () => { mutations.push('prefs') },
556
+ logout: async () => { mutations.push('logout') },
557
+ up: async () => { mutations.push('login') },
558
+ queryFeature: async () => { mutations.push('approval'); return { Complete: true } },
559
+ serveConfig: async () => null,
560
+ setServeConfig: async config => { mutations.push('serve'); return config },
561
+ }
562
+ await assert.rejects(anywhere.start(), /Cannot verify ownership/)
563
+ await assert.rejects(anywhere.logout(), /Cannot verify ownership/)
564
+ anywhere.error = null
565
+ anywhere.requestLogin()
566
+ await anywhere.login.promise
567
+ assert.match(anywhere.error, /Cannot verify ownership/)
568
+ anywhere.error = null
569
+ for (const backend of ['NeedsLogin', 'Stopped', 'Running']) {
570
+ anywhere.lastStatus = null
571
+ anywhere.api.status = async () => ({ ...runningStatus(), BackendState: backend, CertDomains: null })
572
+ await assert.rejects(anywhere.snapshot(), /Cannot verify ownership/)
573
+ }
574
+ anywhere.api.status = async () => runningStatus()
575
+ await assert.rejects(anywhere.sync(), /Cannot verify ownership/)
576
+ assert.deepEqual(mutations, [])
577
+ }
578
+ })
579
+
580
+ test('status reports starting before autostart begins', async () => {
581
+ const anywhere = new Anywhere(fakeKernel(os.tmpdir()))
582
+ anywhere.config = { enabled: true }
583
+ anywhere.readPid = () => assert.fail('ownership must wait for autostart')
584
+ assert.equal(anywhere.starting, null)
585
+ const snapshot = await anywhere.snapshot()
586
+ assert.equal(snapshot.phase, 'starting')
587
+ assert.equal(snapshot.running, false)
588
+ assert.equal(snapshot.error, null)
589
+ assert.equal(anywhere.api, null, 'polling must not create a client before autostart')
590
+ })
591
+
592
+ test('status cannot control an unowned daemon while autostart is checking installation', async t => {
593
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-autostart-owner-'))
594
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
595
+ const kernel = fakeKernel(tmp)
596
+ const anywhere = new Anywhere(kernel)
597
+ anywhere.config = { enabled: true }
598
+ anywhere.readPid = () => null
599
+ let entered, release, checks = 0, writes = 0
600
+ const checking = new Promise(resolve => { entered = resolve })
601
+ const pending = new Promise(resolve => { release = resolve })
602
+ kernel.bin.mod.tailscaled.installed = () => ++checks === 1 ? (entered(), pending) : Promise.resolve(true)
603
+ anywhere.api = {
604
+ cancel() {}, ping: async () => true, status: async () => runningStatus(),
605
+ serveConfig: async () => null, setServeConfig: async config => { writes++; return config },
606
+ }
607
+ const startup = anywhere.autostart().catch(error => error)
608
+ await checking
609
+ try {
610
+ assert.equal((await anywhere.snapshot()).phase, 'starting')
611
+ assert.equal(writes, 0)
612
+ } finally { release(true) }
613
+ assert.match((await startup).message, /Cannot verify ownership/)
614
+ })
615
+
616
+ test('Stop drains autostart installation checks before a home change', async t => {
617
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-autostart-stop-'))
618
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
619
+ const kernel = fakeKernel(path.join(tmp, 'old'))
620
+ kernel.path = (...parts) => path.join(kernel.homedir, ...parts)
621
+ const anywhere = new Anywhere(kernel)
622
+ await anywhere.saveConfig({ enabled: true, hostname: 'old-home' })
623
+ let entered, release
624
+ const checking = new Promise(resolve => { entered = resolve })
625
+ kernel.bin.mod.tailscaled.installed = () => new Promise(resolve => { release = resolve; entered() })
626
+ anywhere.readPid = () => null
627
+ anywhere.api = { cancel() {}, ping: async () => false,
628
+ setPrefs: async () => assert.fail('cancelled startup must not change daemon preferences') }
629
+ anywhere.waitForDaemon = async () => assert.fail('cancelled startup must not spawn a daemon')
630
+ const startup = anywhere.autostart()
631
+ await checking
632
+ let stopped = false
633
+ const stop = anywhere.stop().then(() => { stopped = true })
634
+ await new Promise(setImmediate)
635
+ assert.equal(stopped, false, 'the installation check is part of the pending startup')
636
+ release(true)
637
+ assert.equal(await startup, false)
638
+ await stop
639
+ assert.equal(anywhere.child, null)
640
+ kernel.homedir = path.join(tmp, 'new')
641
+ anywhere.config = null
642
+ await anywhere.saveConfig({ enabled: false, hostname: 'new-home' })
643
+ assert.equal(anywhere.child, null)
644
+ assert.equal(fs.existsSync(anywhere.pidPath()), false)
645
+ })
646
+
647
+ test('a late status response after Off cannot start an HTTPS approval query', async t => {
648
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-status-stop-'))
649
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
650
+ const anywhere = new Anywhere(fakeKernel(tmp))
651
+ await anywhere.saveConfig({ enabled: true })
652
+ let owned = true, entered, release, queries = 0
653
+ const checking = new Promise(resolve => { entered = resolve })
654
+ const status = new Promise(resolve => { release = resolve })
655
+ anywhere.readPid = () => owned ? 123 : null
656
+ anywhere.api = { cancel() { owned = false }, ping: async () => false,
657
+ status() { entered(); return status },
658
+ queryFeature: async () => { queries++; return { Complete: false } } }
659
+ const pending = anywhere.snapshot()
660
+ await checking
661
+ await anywhere.disable()
662
+ release({ ...runningStatus(), CertDomains: null })
663
+ const snapshot = await pending
664
+ assert.equal(snapshot.phase, 'off')
665
+ assert.equal(snapshot.enabled, false)
666
+ assert.equal(queries, 0)
667
+ assert.equal(anywhere.api, null, 'the late request must not recreate a CLI client')
668
+ })
669
+
670
+ test('status replies from a stopped client cannot control or overwrite a later session', async t => {
671
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-status-session-'))
672
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
673
+ for (const failure of [false, true]) {
674
+ const anywhere = new Anywhere(fakeKernel(tmp))
675
+ anywhere.config = { enabled: true }
676
+ let release, reject, entered, owned = true
677
+ const checking = new Promise(resolve => { entered = resolve })
678
+ const status = new Promise((resolve, fail) => { release = resolve; reject = fail })
679
+ anywhere.readPid = () => owned ? 123 : null
680
+ anywhere.api = { cancel() { owned = false }, ping: async () => false,
681
+ status() { entered(); return status } }
682
+ const pending = anywhere.snapshot()
683
+ await checking
684
+ await anywhere.stop()
685
+ // A home change or re-enable may have already installed a different client.
686
+ anywhere.api = { queryFeature: async () => assert.fail('stale status must not act on the later session') }
687
+ const fresh = { BackendState: 'Stopped' }
688
+ anywhere.lastStatus = fresh
689
+ anywhere.lastStatusAt = Date.now()
690
+ if (failure) reject(new Error('old client failed'))
691
+ else release({ ...runningStatus(), CertDomains: null })
692
+ await pending
693
+ assert.equal(anywhere.lastStatus, fresh)
694
+ assert.equal(anywhere.error, null)
695
+ }
696
+ })
697
+
698
+ test('Stop drains pending ownership checks without starting login or approval afterward', async t => {
699
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-owner-stop-'))
700
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
701
+ for (const operation of ['login', 'approval']) {
702
+ const anywhere = new Anywhere(fakeKernel(path.join(tmp, operation)))
703
+ await anywhere.saveConfig({ enabled: true })
704
+ let release, reads = 0, controls = 0, enteredStop
705
+ const stopping = new Promise(resolve => { enteredStop = resolve })
706
+ anywhere.readPid = () => ++reads === 1 ? new Promise(resolve => { release = resolve }) : null
707
+ anywhere.api = { cancel() { enteredStop() }, ping: async () => false,
708
+ up: async () => { controls++ }, queryFeature: async () => { controls++; return { Complete: false } } }
709
+ let pending
710
+ if (operation === 'login') {
711
+ anywhere.requestLogin()
712
+ pending = anywhere.login.promise
713
+ } else pending = anywhere.httpsFeature({ ...runningStatus(), CertDomains: null })
714
+ let stopped = false
715
+ const stop = anywhere.disable().then(() => { stopped = true })
716
+ await stopping
717
+ assert.equal(stopped, false, operation)
718
+ release(123)
719
+ await Promise.all([pending, stop])
720
+ assert.equal(controls, 0, operation)
721
+ assert.equal(anywhere.api, null)
722
+ }
723
+ })
724
+
725
+ for (const adopted of [false, true]) {
726
+ test(`Stop waits for ${adopted ? 'an adopted' : 'its owned'} process after endpoint closure`, {
727
+ skip: process.platform === 'win32' && 'requires a POSIX SIGTERM handler', timeout: 10000,
728
+ }, async () => {
729
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-stop-exit-'))
730
+ const closed = path.join(tmp, 'closed')
731
+ const child = spawn(process.execPath, ['-e', `
732
+ const fs = require('node:fs')
733
+ process.on('SIGTERM', () => {
734
+ fs.writeFileSync(process.argv[1], '')
735
+ setTimeout(() => process.exit(0), 300)
736
+ })
737
+ process.stdout.write('ready')
738
+ setInterval(() => {}, 1000)
739
+ `, closed], { stdio: ['ignore', 'pipe', 'ignore'] })
740
+ const exited = once(child, 'exit')
741
+ try {
742
+ await once(child.stdout, 'data')
743
+ const anywhere = new Anywhere(fakeKernel(tmp))
744
+ if (!adopted) anywhere.child = child
745
+ anywhere.readPid = () => child.exitCode === null ? child.pid : null
746
+ anywhere.api = { cancel() {}, ping: async () => !fs.existsSync(closed) }
747
+ fs.mkdirSync(anywhere.stateDir(), { recursive: true })
748
+ fs.writeFileSync(anywhere.pidPath(), String(child.pid))
749
+ await anywhere.stop()
750
+ assert.equal(child.exitCode, 0, 'endpoint absence alone must not complete Stop')
751
+ assert.equal(fs.existsSync(anywhere.pidPath()), false)
752
+ } finally {
753
+ if (child.exitCode === null) child.kill('SIGKILL')
754
+ await exited
755
+ fs.rmSync(tmp, { recursive: true, force: true })
756
+ }
757
+ })
758
+ }
388
759
 
389
760
  test('a daemon that cannot be spawned reports the cause at once instead of waiting 20 s', async () => {
390
761
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-'))
@@ -403,41 +774,602 @@ test('a daemon that cannot be spawned reports the cause at once instead of waiti
403
774
  }
404
775
  })
405
776
 
406
- test('sign-out resets the login state, and a link the server rejected is replaced instead of shown', async () => {
777
+ for (const operation of ['writeFileSync', 'rmSync']) {
778
+ test(`PID ${operation} failure is handled without leaving an unmanaged child or throwing from exit`, async t => {
779
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-pid-io-'))
780
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
781
+ const child = new EventEmitter()
782
+ child.pid = 123
783
+ child.exitCode = null
784
+ let killed = false
785
+ child.kill = () => { killed = true }
786
+ const failure = Object.assign(new Error(`PID ${operation} failed`), { code: operation === 'writeFileSync' ? 'ENOSPC' : 'EACCES' })
787
+ const fixtureFs = Object.create(fs)
788
+ fixtureFs[operation] = (file, ...args) => {
789
+ if (file.endsWith('tailscaled.pid')) throw failure
790
+ return fs[operation](file, ...args)
791
+ }
792
+ const filename = require.resolve('../kernel/anywhere')
793
+ const baseRequire = require('node:module').createRequire(filename)
794
+ const context = { module: { exports: {} }, console, process, Buffer, URL, setTimeout,
795
+ require(name) {
796
+ if (name === 'fs') return fixtureFs
797
+ if (name === 'child_process') return { spawn: () => child }
798
+ return baseRequire(name)
799
+ } }
800
+ vm.runInNewContext(fs.readFileSync(filename, 'utf8'), context)
801
+ const anywhere = new context.module.exports(fakeKernel(tmp))
802
+ anywhere.config = { enabled: true }
803
+ anywhere.api = { cancel() {}, ping: async () => false, setPrefs: async () => {}, status: async () => runningStatus() }
804
+ anywhere.waitForDaemon = async () => true
805
+ if (operation === 'writeFileSync') {
806
+ await assert.rejects(anywhere.start(), error => error === failure)
807
+ assert.equal(killed, true)
808
+ } else await anywhere.start()
809
+ assert.equal(child.listenerCount('error'), 1)
810
+ assert.doesNotThrow(() => child.emit('exit', null, 'SIGTERM'))
811
+ assert.equal(anywhere.child, null)
812
+ assert.match(anywhere.error, new RegExp(failure.message))
813
+ if (operation === 'rmSync') assert.equal(fs.readFileSync(anywhere.pidPath(), 'utf8'), '123')
814
+ })
815
+ }
816
+
817
+ test('logout and rejected links are handled by one CLI flow, with a fresh retry', async () => {
407
818
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-'))
408
819
  try {
409
820
  const anywhere = new Anywhere(fakeKernel(tmp))
410
821
  await anywhere.saveConfig({ enabled: true })
411
- anywhere.pid = 123
822
+ anywhere.readPid = () => 123
412
823
  const calls = []
413
- let status = { BackendState: 'NeedsLogin', AuthURL: 'https://login.tailscale.com/a/dead', Self: {}, User: null, Peer: null,
414
- Health: ['You are logged out. The last login error was: register request: http 410: auth path not found'] }
415
824
  anywhere.api = {
416
- status: async () => status,
825
+ cancel() { calls.push('cancel') },
826
+ ping: async () => true, setPrefs: async () => {},
827
+ status: async () => ({ BackendState: 'NeedsLogin', AuthURL: 'https://login.tailscale.com/a/stale' }),
417
828
  logout: async () => { calls.push('logout') },
418
- patchPrefs: async (p) => { calls.push('prefs:' + (p.LoggedOut === false ? 'login' : 'other')); return {} },
419
- start: async () => { calls.push('start') },
420
- loginInteractive: async () => { calls.push('login-interactive') },
829
+ up: (update, force) => {
830
+ calls.push(force ? 'up:force' : 'up')
831
+ if (!force) return Promise.reject(new Error('login expired'))
832
+ update({ AuthURL: 'https://login.tailscale.com/a/fresh' })
833
+ return new Promise(() => {})
834
+ },
421
835
  }
422
836
  await anywhere.logout()
423
- assert.deepEqual(calls, ['logout', 'prefs:login', 'start'], 'sign-out: logout, then back to logged-in prefs, then reset')
837
+ await anywhere.login.promise
838
+ assert.deepEqual(calls, ['cancel', 'logout', 'up'])
839
+ for (let i = 0; i < 3; i++) assert.equal((await anywhere.snapshot()).phase, 'error')
840
+ assert.equal(calls.filter((call) => call === 'up').length, 1, 'no automatic login loop after rejection')
841
+ await anywhere.start()
842
+ const snap = await anywhere.snapshot()
843
+ assert.equal(snap.phase, 'login')
844
+ assert.equal(snap.loginUrl, 'https://login.tailscale.com/a/fresh')
845
+ assert.ok(calls.includes('up:force'))
846
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
847
+ })
424
848
 
425
- calls.length = 0
426
- let snap = await anywhere.snapshot()
427
- assert.equal(snap.loginUrl, null, 'the rejected link is not offered')
428
- assert.equal(snap.phase, 'starting')
429
- assert.deepEqual(calls, ['start', 'login-interactive'], 'a rejected link triggers a reset and a fresh request')
849
+ test('control failures preserve live-daemon records and logs on every platform', async () => {
850
+ for (const platform of ['darwin', 'win32', 'linux']) {
851
+ for (const cause of ['401', '403', 'busy', 'reset', 'timeout']) {
852
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-error-'))
853
+ try {
854
+ const kernel = fakeKernel(tmp)
855
+ kernel.platform = platform
856
+ const anywhere = new Anywhere(kernel)
857
+ await anywhere.saveConfig({ enabled: true })
858
+ fs.mkdirSync(anywhere.stateDir(), { recursive: true })
859
+ const files = [anywhere.pidPath(), anywhere.logPath(), anywhere.portMapPath(), anywhere.stateDir('tailscaled.state')]
860
+ for (const file of files) fs.writeFileSync(file, 'preserve me')
861
+ anywhere.api = { cancel() {}, ping: async () => { throw new Error(cause) } }
862
+ await assert.rejects(() => anywhere.autostart(), new RegExp(cause))
863
+ assert.equal((await anywhere.snapshot()).phase, 'error')
864
+ assert.equal(anywhere.child, null)
865
+ for (const file of files) assert.equal(fs.readFileSync(file, 'utf8'), 'preserve me')
866
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
867
+ }
868
+ }
869
+ })
870
+
871
+ test('saved Off neither probes the daemon nor removes its records', async () => {
872
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-off-'))
873
+ try {
874
+ const anywhere = new Anywhere(fakeKernel(tmp))
875
+ await anywhere.saveConfig({ enabled: false })
876
+ fs.mkdirSync(anywhere.stateDir(), { recursive: true })
877
+ fs.writeFileSync(anywhere.pidPath(), '123')
878
+ anywhere.api = new Proxy({}, { get() { throw new Error('off must not control the daemon') } })
879
+ assert.equal(await anywhere.autostart(), false)
880
+ assert.equal(fs.readFileSync(anywhere.pidPath(), 'utf8'), '123')
881
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
882
+ })
883
+
884
+ test('stopping during a pending start prevents a later spawn', async () => {
885
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-stop-'))
886
+ try {
887
+ const anywhere = new Anywhere(fakeKernel(tmp))
888
+ await anywhere.saveConfig({ enabled: true })
889
+ let release
890
+ let probing
891
+ const probeStarted = new Promise((resolve) => { probing = resolve })
892
+ anywhere.api = {
893
+ cancel() {},
894
+ ping: () => { probing(); return new Promise((resolve) => { release = resolve }) },
895
+ }
896
+ const starting = anywhere.start()
897
+ await probeStarted
898
+ const stopping = anywhere.stop()
899
+ anywhere.api.ping = async () => false
900
+ release(false)
901
+ await Promise.all([starting, stopping])
902
+ assert.equal(anywhere.child, null)
903
+ assert.equal(fs.existsSync(anywhere.logPath()), false)
904
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
905
+ })
906
+
907
+ test('PID ownership checks require the exact private daemon command on each platform', async () => {
908
+ const source = fs.readFileSync(require.resolve('../kernel/anywhere'), 'utf8')
909
+ const baseRequire = require('node:module').createRequire(require.resolve('../kernel/anywhere'))
910
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-owner-'))
911
+ try {
912
+ for (const platform of ['darwin', 'win32', 'linux']) {
913
+ let command
914
+ const signals = []
915
+ const context = { module: { exports: {} }, console, URL, Buffer, setTimeout,
916
+ process: { ...process, kill(pid, signal) { signals.push(signal) } },
917
+ require(name) {
918
+ if (name === 'child_process') return { spawn, execFileSync: () => command,
919
+ execFile: (file, args, options, done) => done(null, command) }
920
+ return baseRequire(name)
921
+ },
922
+ }
923
+ vm.runInNewContext(source, context)
924
+ const kernel = fakeKernel(path.join(tmp, 'home with spaces é'))
925
+ kernel.platform = platform
926
+ const anywhere = new context.module.exports(kernel)
927
+ fs.mkdirSync(anywhere.stateDir(), { recursive: true })
928
+ const expected = [anywhere.binPath(), ...anywhere.daemonArgs()].join(' ')
929
+ fs.writeFileSync(anywhere.pidPath(), '123')
930
+ command = platform === 'win32' ? '"' + anywhere.binPath().toUpperCase() + '" ' + anywhere.daemonArgs().join(' ') : expected
931
+ assert.equal(await anywhere.readPid(), 123, platform)
932
+ assert.equal(anywhere.readPidSync(), 123, platform)
933
+ for (const wrong of [expected.replace('tailscaled', 'unrelated'), expected + ' --other-state', expected.replace(anywhere.socketPath(), anywhere.socketPath() + '.wrong')]) {
934
+ command = wrong
935
+ assert.equal(await anywhere.readPid(), null, platform)
936
+ assert.equal(anywhere.readPidSync(), null, platform)
937
+ anywhere.killSync()
938
+ assert.ok(!signals.includes('SIGKILL'), 'no wrong-process signal')
939
+ }
940
+ }
941
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
942
+ })
943
+
944
+ test('adopted-daemon status inspection leaves the event loop responsive on every platform', async t => {
945
+ const source = fs.readFileSync(require.resolve('../kernel/anywhere'), 'utf8')
946
+ const baseRequire = require('node:module').createRequire(require.resolve('../kernel/anywhere'))
947
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-async-owner-'))
948
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
949
+ for (const platform of ['darwin', 'win32', 'linux']) {
950
+ let release, first = true, command, statusReads = 0
951
+ const context = { module: { exports: {} }, console, URL, Buffer, setTimeout,
952
+ process: { ...process, kill() {} }, require(name) {
953
+ if (name === 'child_process') return { spawn,
954
+ execFileSync() { assert.fail('request paths must not run synchronous process inspection') },
955
+ execFile(file, args, options, done) {
956
+ assert.equal(options.shell, false)
957
+ assert.equal(options.timeout, 2000)
958
+ assert.equal(file, platform === 'win32' ? 'powershell.exe' : '/bin/ps')
959
+ if (first) { first = false; release = () => done(null, command) }
960
+ else done(null, command)
961
+ } }
962
+ return baseRequire(name)
963
+ } }
964
+ vm.runInNewContext(source, context)
965
+ const kernel = fakeKernel(path.join(tmp, platform))
966
+ kernel.platform = platform
967
+ const anywhere = new context.module.exports(kernel)
968
+ await anywhere.saveConfig({ enabled: true })
969
+ fs.mkdirSync(anywhere.stateDir(), { recursive: true })
970
+ fs.writeFileSync(anywhere.pidPath(), '123')
971
+ command = [anywhere.binPath(), ...anywhere.daemonArgs()].join(' ')
972
+ anywhere.api = { status: async () => { statusReads++; return runningStatus() },
973
+ serveConfig: async () => buildServeConfig({ dnsName: 'pinokio.tail1.ts.net', serverPort: 42000 }) }
974
+ const pending = anywhere.snapshot()
975
+ await new Promise(resolve => setTimeout(resolve, 10))
976
+ assert.equal(typeof release, 'function')
977
+ assert.equal(statusReads, 0, 'the status call waits for ownership while other work continues')
978
+ release()
979
+ assert.equal((await pending).phase, 'ready')
980
+ }
981
+ })
982
+
983
+ test('a daemon exiting during process inspection is absent; live inspection failures still propagate', async t => {
984
+ const source = fs.readFileSync(require.resolve('../kernel/anywhere'), 'utf8')
985
+ const baseRequire = require('node:module').createRequire(require.resolve('../kernel/anywhere'))
986
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-exit-inspect-'))
987
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
988
+ for (const platform of ['darwin', 'win32', 'linux']) {
989
+ for (const disappeared of [true, false]) {
990
+ let inspected = false
991
+ const failure = Object.assign(new Error('inspection failed'), { code: 1 })
992
+ const context = { module: { exports: {} }, console, URL, Buffer, setTimeout,
993
+ process: { ...process, kill(pid, signal) {
994
+ assert.equal(signal, 0, 'the existence probe never signals the process')
995
+ if (inspected && disappeared) throw Object.assign(new Error('gone'), { code: 'ESRCH' })
996
+ } }, require(name) {
997
+ if (name === 'child_process') return { execFile(file, args, options, done) {
998
+ inspected = true
999
+ done(failure)
1000
+ } }
1001
+ return baseRequire(name)
1002
+ } }
1003
+ vm.runInNewContext(source, context)
1004
+ const kernel = fakeKernel(path.join(tmp, platform))
1005
+ kernel.platform = platform
1006
+ const anywhere = new context.module.exports(kernel)
1007
+ fs.mkdirSync(anywhere.stateDir(), { recursive: true })
1008
+ fs.writeFileSync(anywhere.pidPath(), '123')
1009
+ if (disappeared) assert.equal(await anywhere.readPid(), null)
1010
+ else await assert.rejects(anywhere.readPid(), error => error === failure)
1011
+ }
1012
+ }
1013
+ })
1014
+
1015
+ test('invalid port maps remain intact and failed saves cannot become ready', async () => {
1016
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-map-'))
1017
+ try {
1018
+ const anywhere = new Anywhere(fakeKernel(tmp))
1019
+ await anywhere.saveConfig({ enabled: true })
1020
+ for (const text of ['invalid JSON', '[]', 'null']) {
1021
+ fs.writeFileSync(anywhere.portMapPath(), text)
1022
+ await assert.rejects(() => loadPortMap(anywhere.portMapPath()))
1023
+ assert.equal(fs.readFileSync(anywhere.portMapPath(), 'utf8'), text)
1024
+ }
1025
+ fs.rmSync(anywhere.portMapPath())
1026
+ fs.mkdirSync(anywhere.portMapPath())
1027
+ anywhere.portMap = {}
1028
+ anywhere.kernel.memory.local = { [path.join(tmp, 'api/comfy/start.js')]: { url: 'http://127.0.0.1:7860' } }
1029
+ await assert.rejects(() => anywhere.refreshTargets())
1030
+ assert.deepEqual(anywhere.portMap, {}, 'failed persistence does not cache an unsaved assignment')
1031
+ fs.rmSync(anywhere.portMapPath(), { recursive: true })
1032
+ await anywhere.refreshTargets()
1033
+ assert.equal(JSON.parse(fs.readFileSync(anywhere.portMapPath(), 'utf8')).comfy, 43000)
1034
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
1035
+ })
430
1036
 
431
- // after the reset the daemon keeps a stale "last login error: ... context canceled" line; that is not a rejection
1037
+ test('first home selection skips Stop when there is no previous home', async () => {
1038
+ const Kernel = require('../kernel')
1039
+ const reached = new Error('reached existing initialization')
1040
+ const kernel = {
1041
+ homedir: undefined, path: Kernel.prototype.path,
1042
+ store: { get: () => '/new/home' },
1043
+ disposeVault: async () => { throw reached },
1044
+ }
1045
+ kernel.anywhere = new Anywhere(kernel)
1046
+ await assert.rejects(() => Kernel.prototype.init.call(kernel, {}), (error) => error === reached)
1047
+ })
1048
+
1049
+ test('peer refresh proceeds during an Anywhere sync without queuing more syncs', async () => {
1050
+ const Kernel = require('../kernel')
1051
+ for (const fail of [false, true]) {
1052
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-peer-refresh-'))
1053
+ const kernel = fakeKernel(tmp)
1054
+ let announced = 0, calls = 0, release
1055
+ const pending = new Promise((resolve) => { release = resolve })
1056
+ kernel.peer = {
1057
+ check: async () => {}, current_host: async () => ({}),
1058
+ notify_refresh: async () => {}, announce: () => { announced += 1 },
1059
+ }
1060
+ kernel.ensureRouterMode = async () => {}
1061
+ const anywhere = kernel.anywhere = new Anywhere(kernel)
1062
+ anywhere.config = { enabled: true }
1063
+ anywhere.readPid = () => 123
1064
+ anywhere.portMap = {}
1065
+ anywhere.lastStatus = runningStatus()
1066
+ anywhere.lastStatusAt = Date.now()
1067
+ anywhere.api = { serveConfig: async () => {
1068
+ calls += 1
1069
+ await pending
1070
+ if (fail) throw new Error('Serve unavailable')
1071
+ return buildServeConfig({ dnsName: 'pinokio.tail1.ts.net', serverPort: 42000 })
1072
+ } }
1073
+ const refresh = Kernel.prototype.refresh.call(kernel)
1074
+ try {
1075
+ await new Promise(setImmediate)
1076
+ assert.equal(announced, 1, 'peer notifications must not wait for Tailscale')
1077
+ await Kernel.prototype.refresh.call(kernel)
1078
+ assert.equal(announced, 2)
1079
+ assert.equal(calls, 1, 'the pending sync must not accumulate more work')
1080
+ } finally {
1081
+ release()
1082
+ await refresh
1083
+ if (anywhere.syncing) await anywhere.syncing
1084
+ fs.rmSync(tmp, { recursive: true, force: true })
1085
+ }
1086
+ assert.equal(anywhere.lastServeError, fail ? 'Serve unavailable' : null)
1087
+ }
1088
+ })
1089
+
1090
+ test('home changes stop Anywhere against the old home and preserve it on failure', async () => {
1091
+ const Kernel = require('../kernel')
1092
+ for (const failStop of [true, false]) {
1093
+ const failure = new Error('stop test before the rest of initialization')
1094
+ const calls = []
1095
+ const ready = Promise.resolve()
1096
+ const kernel = {
1097
+ homedir: '/old/home', sysReady: ready, store: { get: () => '/new/home' },
1098
+ anywhere: { stop: async () => {
1099
+ assert.equal(kernel.homedir, '/old/home')
1100
+ calls.push('stop')
1101
+ if (failStop) throw failure
1102
+ } },
1103
+ disposeVault: async () => { calls.push('dispose'); throw failure },
1104
+ }
1105
+ await assert.rejects(() => Kernel.prototype.init.call(kernel, {}), (error) => error === failure)
1106
+ assert.equal(kernel.homedir, '/old/home')
1107
+ if (failStop) assert.equal(kernel.sysReady, ready)
1108
+ assert.deepEqual(calls, failStop ? ['stop'] : ['stop', 'dispose'])
1109
+ }
1110
+ })
1111
+
1112
+ test('an unowned daemon does not block kernel re-init or lose its records', async t => {
1113
+ const Kernel = require('../kernel')
1114
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-init-unowned-'))
1115
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
1116
+ t.mock.method(console, 'warn', () => {})
1117
+ const kernel = fakeKernel(tmp)
1118
+ const anywhere = kernel.anywhere = new Anywhere(kernel)
1119
+ const reached = new Error('reached existing initialization')
1120
+ kernel.store = { get: () => '/new/home' }
1121
+ kernel.disposeVault = async () => { throw reached }
1122
+ anywhere.readPid = () => null
1123
+ let cancelled = 0
1124
+ anywhere.api = { cancel() { cancelled++ }, ping: async () => true }
1125
+ fs.mkdirSync(anywhere.stateDir(), { recursive: true })
1126
+ const files = [anywhere.pidPath(), anywhere.logPath(), anywhere.stateDir('tailscaled.state')]
1127
+ for (const file of files) fs.writeFileSync(file, 'preserve')
1128
+ await assert.rejects(Kernel.prototype.init.call(kernel, {}), error => error === reached)
1129
+ assert.equal(cancelled, 1)
1130
+ assert.equal(kernel.homedir, tmp)
1131
+ for (const file of files) assert.equal(fs.readFileSync(file, 'utf8'), 'preserve')
1132
+ for (const code of ['EACCES', 'ETIMEDOUT']) {
1133
+ const failure = Object.assign(new Error('owned stop failed'), { code })
1134
+ anywhere.stop = async () => { throw failure }
1135
+ await assert.rejects(Kernel.prototype.init.call(kernel, {}), error => error === failure)
1136
+ }
1137
+ })
1138
+
1139
+ test('a completed login allows a later stopped backend to resume', async () => {
1140
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-resume-'))
1141
+ try {
1142
+ const anywhere = new Anywhere(fakeKernel(tmp))
1143
+ await anywhere.saveConfig({ enabled: true })
1144
+ anywhere.readPid = () => 123
1145
+ let status = runningStatus(), ups = 0
1146
+ anywhere.api = {
1147
+ status: async () => status,
1148
+ up: async () => { ups++; status = runningStatus() },
1149
+ serveConfig: async () => buildServeConfig({ dnsName: 'pinokio.tail1.ts.net', serverPort: 42000 }),
1150
+ }
1151
+ anywhere.requestLogin()
1152
+ await anywhere.login.promise
1153
+ status = { ...runningStatus(), BackendState: 'Stopped' }
1154
+ assert.equal((await anywhere.snapshot()).phase, 'starting')
432
1155
  anywhere.lastStatusAt = 0
433
- calls.length = 0
434
- status = { BackendState: 'NeedsLogin', AuthURL: 'https://login.tailscale.com/a/fresh', Self: {}, User: null, Peer: null,
435
- Health: ['You are logged out. The last login error was: fetch control key: Get "https://controlplane.tailscale.com/key?v=148": context canceled'] }
436
- snap = await anywhere.snapshot()
437
- assert.equal(snap.loginUrl, 'https://login.tailscale.com/a/fresh', 'a fresh link is offered as is')
438
- assert.equal(snap.phase, 'login')
439
- assert.deepEqual(calls, [], 'no reset loop on a stale error line')
440
- } finally {
441
- fs.rmSync(tmp, { recursive: true, force: true })
1156
+ assert.equal((await anywhere.snapshot()).phase, 'ready')
1157
+ assert.equal(ups, 2)
1158
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
1159
+ })
1160
+
1161
+ test('logout while Off does not start another login', async () => {
1162
+ const anywhere = new Anywhere(fakeKernel(os.tmpdir()))
1163
+ anywhere.config = { enabled: false }
1164
+ anywhere.readPid = () => 123
1165
+ let logouts = 0, logins = 0
1166
+ anywhere.api = { cancel() {}, logout: async () => { logouts++ }, up: async () => { logins++ } }
1167
+ await anywhere.logout()
1168
+ assert.equal(logouts, 1)
1169
+ assert.equal(logins, 0)
1170
+ assert.equal(anywhere.login, null)
1171
+ })
1172
+
1173
+ test('readiness requires a running backend and confirmed routes after sync', async () => {
1174
+ for (const failure of ['control error', 'stopped backend', 'skipped sync']) {
1175
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-ready-'))
1176
+ try {
1177
+ const anywhere = new Anywhere(fakeKernel(tmp))
1178
+ await anywhere.saveConfig({ enabled: true })
1179
+ anywhere.readPid = () => 123
1180
+ let reads = 0
1181
+ anywhere.api = { status: async () => {
1182
+ if (++reads === 1) return runningStatus()
1183
+ if (failure === 'control error') throw new Error('control unavailable during sync')
1184
+ return { ...runningStatus(), BackendState: 'Stopped' }
1185
+ } }
1186
+ if (failure === 'stopped backend') anywhere.lastServe = buildServeConfig({ dnsName: 'pinokio.tail1.ts.net', serverPort: 42000 })
1187
+ anywhere.httpsFeature = async () => {
1188
+ anywhere.lastStatusAt = 0
1189
+ if (failure === 'skipped sync') anywhere.stopping = Promise.resolve()
1190
+ return { Complete: true }
1191
+ }
1192
+ const snapshot = await anywhere.snapshot()
1193
+ assert.equal(snapshot.phase, failure === 'skipped sync' ? 'starting' : 'error', failure)
1194
+ if (failure !== 'skipped sync') assert.match(snapshot.error, failure === 'control error' ? /control unavailable/ : /Routes are not confirmed/)
1195
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
1196
+ }
1197
+ })
1198
+
1199
+ test('a failed stop stays visible while disabled and a successful retry clears it', async () => {
1200
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-stop-error-'))
1201
+ try {
1202
+ const anywhere = new Anywhere(fakeKernel(tmp))
1203
+ await anywhere.saveConfig({ enabled: true })
1204
+ fs.mkdirSync(anywhere.stateDir(), { recursive: true })
1205
+ fs.writeFileSync(anywhere.pidPath(), '123')
1206
+ fs.writeFileSync(anywhere.logPath(), 'preserve log')
1207
+ anywhere.readPid = () => null
1208
+ anywhere.api = { cancel() {}, ping: async () => { throw new Error('pipe access denied') } }
1209
+ await assert.rejects(() => anywhere.disable(), /pipe access denied/)
1210
+ for (let i = 0; i < 2; i++) {
1211
+ const snapshot = await anywhere.snapshot()
1212
+ assert.equal(snapshot.enabled, false)
1213
+ assert.equal(snapshot.phase, 'error')
1214
+ assert.match(snapshot.error, /pipe access denied/)
1215
+ }
1216
+ assert.equal(fs.readFileSync(anywhere.pidPath(), 'utf8'), '123')
1217
+ assert.equal(fs.readFileSync(anywhere.logPath(), 'utf8'), 'preserve log')
1218
+ anywhere.api.ping = async () => false
1219
+ await anywhere.stop()
1220
+ assert.equal((await anywhere.snapshot()).phase, 'off')
1221
+ assert.equal(anywhere.error, null)
1222
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
1223
+ })
1224
+
1225
+ test('ownership-inspection failures cannot spawn a daemon or overwrite its records', async () => {
1226
+ const source = fs.readFileSync(require.resolve('../kernel/anywhere'), 'utf8')
1227
+ const baseRequire = require('node:module').createRequire(require.resolve('../kernel/anywhere'))
1228
+ for (const platform of ['darwin', 'win32', 'linux']) {
1229
+ for (const code of ['ETIMEDOUT', 'EACCES', 'ENOENT']) {
1230
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-inspect-'))
1231
+ try {
1232
+ const failure = Object.assign(new Error('process inspection failed'), { code })
1233
+ let spawns = 0
1234
+ const context = { module: { exports: {} }, console, URL, Buffer, setTimeout,
1235
+ process: { ...process, kill() {} },
1236
+ require(name) {
1237
+ if (name === 'child_process') return {
1238
+ execFileSync() { throw failure },
1239
+ execFile(file, args, options, done) { done(failure) },
1240
+ spawn() { spawns++; throw new Error('must not spawn') },
1241
+ }
1242
+ return baseRequire(name)
1243
+ },
1244
+ }
1245
+ vm.runInNewContext(source, context)
1246
+ const kernel = fakeKernel(tmp)
1247
+ kernel.platform = platform
1248
+ const anywhere = new context.module.exports(kernel)
1249
+ await anywhere.saveConfig({ enabled: true })
1250
+ fs.mkdirSync(anywhere.stateDir(), { recursive: true })
1251
+ fs.writeFileSync(anywhere.pidPath(), '123')
1252
+ fs.writeFileSync(anywhere.logPath(), 'preserve log')
1253
+ for (const alive of [false, true]) {
1254
+ anywhere.api = { cancel() {}, ping: async () => alive }
1255
+ await assert.rejects(() => anywhere.start(), (error) => error === failure)
1256
+ }
1257
+ assert.equal(spawns, 0)
1258
+ assert.equal(fs.readFileSync(anywhere.pidPath(), 'utf8'), '123')
1259
+ assert.equal(fs.readFileSync(anywhere.logPath(), 'utf8'), 'preserve log')
1260
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
1261
+ }
1262
+ }
1263
+ })
1264
+
1265
+ test('uninstall aborts when stopping fails and removes packages only after success', async () => {
1266
+ const mod = new Tailscaled()
1267
+ const order = []
1268
+ mod.kernel = {
1269
+ anywhere: { stop: async () => { throw new Error('cannot verify ownership') } },
1270
+ bin: { exec: async ({ message }) => { order.push(message) } },
442
1271
  }
1272
+ await assert.rejects(() => mod.uninstall({}, () => {}), /cannot verify ownership/)
1273
+ assert.deepEqual(order, [])
1274
+ mod.kernel.anywhere.stop = async () => { order.push('stop') }
1275
+ await mod.uninstall({}, () => {})
1276
+ assert.deepEqual(order, ['stop', 'conda remove -y tailscaled tailscale'])
1277
+ })
1278
+
1279
+ test('a changed Serve update reuses the initial read and confirms its result', async () => {
1280
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-reads-'))
1281
+ try {
1282
+ const anywhere = new Anywhere(fakeKernel(tmp))
1283
+ await anywhere.saveConfig({ enabled: true })
1284
+ anywhere.readPid = () => 123
1285
+ const desired = buildServeConfig({ dnsName: 'pinokio.tail1.ts.net', serverPort: 42000 })
1286
+ let reads = 0, writes = 0, applied = null
1287
+ anywhere.api = {
1288
+ status: async () => runningStatus(),
1289
+ serveConfig: async () => { reads++; return applied },
1290
+ run: async () => { writes++; applied = desired },
1291
+ setServeConfig(...args) { return TailscaleCLI.prototype.setServeConfig.call(this, ...args) },
1292
+ }
1293
+ assert.equal(await anywhere.sync(), true)
1294
+ assert.equal(reads, 2, 'one initial read and one confirmation read')
1295
+ assert.equal(writes, 1)
1296
+ assert.equal(await anywhere.sync(), false)
1297
+ assert.equal(reads, 3, 'unchanged routes need only one additional read')
1298
+ assert.equal(writes, 1)
1299
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
1300
+ })
1301
+
1302
+ test('the first failed status response includes the actual cause', async () => {
1303
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-status-error-'))
1304
+ try {
1305
+ const anywhere = new Anywhere(fakeKernel(tmp))
1306
+ await anywhere.saveConfig({ enabled: true })
1307
+ anywhere.readPid = () => 123
1308
+ anywhere.api = { status: async () => { throw new Error('status access denied') } }
1309
+ const snapshot = await anywhere.snapshot()
1310
+ assert.equal(snapshot.phase, 'error')
1311
+ assert.equal(snapshot.error, 'status access denied')
1312
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
1313
+ })
1314
+
1315
+ test('concurrent status requests share a pending HTTPS approval query', async () => {
1316
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-approval-query-'))
1317
+ try {
1318
+ const anywhere = new Anywhere(fakeKernel(tmp))
1319
+ await anywhere.saveConfig({ enabled: true })
1320
+ anywhere.readPid = () => 123
1321
+ let calls = 0, release
1322
+ const pendingQuery = new Promise((resolve) => { release = resolve })
1323
+ anywhere.api = {
1324
+ status: async () => ({ ...runningStatus(), CertDomains: null }),
1325
+ queryFeature: () => { calls++; return pendingQuery },
1326
+ }
1327
+ const pending = Promise.all(Array.from({ length: 4 }, () => anywhere.snapshot()))
1328
+ await new Promise(setImmediate)
1329
+ assert.equal(calls, 1)
1330
+ const url = 'https://login.tailscale.com/f/serve?node=1'
1331
+ release({ Complete: false, URL: url })
1332
+ for (const snapshot of await pending) {
1333
+ assert.equal(snapshot.phase, 'https')
1334
+ assert.equal(snapshot.approval.url, url)
1335
+ }
1336
+ assert.equal((await anywhere.snapshot()).approval.url, url)
1337
+ assert.equal(calls, 1, 'completed queries remain cached')
1338
+ anywhere.featureAt = 0
1339
+ anywhere.api.queryFeature = async () => ({ Complete: false, URL: url + '&fresh=1' })
1340
+ assert.equal((await anywhere.snapshot()).approval.url, url + '&fresh=1', 'an expired cache can query again')
1341
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
1342
+ })
1343
+
1344
+ test('cancelling a pending HTTPS approval during Stop keeps the result Off', async () => {
1345
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-approval-stop-'))
1346
+ try {
1347
+ const anywhere = new Anywhere(fakeKernel(tmp))
1348
+ await anywhere.saveConfig({ enabled: true })
1349
+ let rejectQuery
1350
+ const pendingQuery = new Promise((_, reject) => { rejectQuery = reject })
1351
+ let owned = true
1352
+ anywhere.readPid = () => owned ? 123 : null
1353
+ anywhere.api = {
1354
+ status: async () => ({ ...runningStatus(), CertDomains: null }),
1355
+ queryFeature: () => pendingQuery,
1356
+ ping: async () => false,
1357
+ cancel() { owned = false; setImmediate(() => rejectQuery(Object.assign(new Error('query cancelled'), { cancelled: true }))) },
1358
+ }
1359
+ const pending = anywhere.snapshot()
1360
+ await new Promise(setImmediate)
1361
+ await anywhere.disable()
1362
+ await pending
1363
+ const snapshot = await anywhere.snapshot()
1364
+ assert.equal(snapshot.phase, 'off')
1365
+ assert.equal(snapshot.error, null)
1366
+ assert.equal(JSON.parse(fs.readFileSync(anywhere.configPath(), 'utf8')).enabled, false)
1367
+ await anywhere.saveConfig({ enabled: true })
1368
+ owned = true
1369
+ anywhere.api = {
1370
+ status: async () => ({ ...runningStatus(), CertDomains: null }),
1371
+ queryFeature: async () => ({ Complete: false, URL: 'https://login.tailscale.com/f/fresh' }),
1372
+ }
1373
+ assert.equal((await anywhere.snapshot()).approval.url, 'https://login.tailscale.com/f/fresh')
1374
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
443
1375
  })