pinokiod 8.2.9 → 8.2.11

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,258 @@ 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 cannot control an unowned daemon while autostart is checking installation', async t => {
581
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-autostart-owner-'))
582
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
583
+ const kernel = fakeKernel(tmp)
584
+ const anywhere = new Anywhere(kernel)
585
+ anywhere.config = { enabled: true }
586
+ anywhere.readPid = () => null
587
+ let entered, release, checks = 0, writes = 0
588
+ const checking = new Promise(resolve => { entered = resolve })
589
+ const pending = new Promise(resolve => { release = resolve })
590
+ kernel.bin.mod.tailscaled.installed = () => ++checks === 1 ? (entered(), pending) : Promise.resolve(true)
591
+ anywhere.api = {
592
+ cancel() {}, ping: async () => true, status: async () => runningStatus(),
593
+ serveConfig: async () => null, setServeConfig: async config => { writes++; return config },
594
+ }
595
+ const startup = anywhere.autostart().catch(error => error)
596
+ await checking
597
+ try {
598
+ assert.equal((await anywhere.snapshot()).phase, 'starting')
599
+ assert.equal(writes, 0)
600
+ } finally { release(true) }
601
+ assert.match((await startup).message, /Cannot verify ownership/)
602
+ })
603
+
604
+ test('Stop drains autostart installation checks before a home change', async t => {
605
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-autostart-stop-'))
606
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
607
+ const kernel = fakeKernel(path.join(tmp, 'old'))
608
+ kernel.path = (...parts) => path.join(kernel.homedir, ...parts)
609
+ const anywhere = new Anywhere(kernel)
610
+ await anywhere.saveConfig({ enabled: true, hostname: 'old-home' })
611
+ let entered, release
612
+ const checking = new Promise(resolve => { entered = resolve })
613
+ kernel.bin.mod.tailscaled.installed = () => new Promise(resolve => { release = resolve; entered() })
614
+ anywhere.readPid = () => null
615
+ anywhere.api = { cancel() {}, ping: async () => false,
616
+ setPrefs: async () => assert.fail('cancelled startup must not change daemon preferences') }
617
+ anywhere.waitForDaemon = async () => assert.fail('cancelled startup must not spawn a daemon')
618
+ const startup = anywhere.autostart()
619
+ await checking
620
+ let stopped = false
621
+ const stop = anywhere.stop().then(() => { stopped = true })
622
+ await new Promise(setImmediate)
623
+ assert.equal(stopped, false, 'the installation check is part of the pending startup')
624
+ release(true)
625
+ assert.equal(await startup, false)
626
+ await stop
627
+ assert.equal(anywhere.child, null)
628
+ kernel.homedir = path.join(tmp, 'new')
629
+ anywhere.config = null
630
+ await anywhere.saveConfig({ enabled: false, hostname: 'new-home' })
631
+ assert.equal(anywhere.child, null)
632
+ assert.equal(fs.existsSync(anywhere.pidPath()), false)
633
+ })
634
+
635
+ test('a late status response after Off cannot start an HTTPS approval query', async t => {
636
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-status-stop-'))
637
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
638
+ const anywhere = new Anywhere(fakeKernel(tmp))
639
+ await anywhere.saveConfig({ enabled: true })
640
+ let owned = true, entered, release, queries = 0
641
+ const checking = new Promise(resolve => { entered = resolve })
642
+ const status = new Promise(resolve => { release = resolve })
643
+ anywhere.readPid = () => owned ? 123 : null
644
+ anywhere.api = { cancel() { owned = false }, ping: async () => false,
645
+ status() { entered(); return status },
646
+ queryFeature: async () => { queries++; return { Complete: false } } }
647
+ const pending = anywhere.snapshot()
648
+ await checking
649
+ await anywhere.disable()
650
+ release({ ...runningStatus(), CertDomains: null })
651
+ const snapshot = await pending
652
+ assert.equal(snapshot.phase, 'off')
653
+ assert.equal(snapshot.enabled, false)
654
+ assert.equal(queries, 0)
655
+ assert.equal(anywhere.api, null, 'the late request must not recreate a CLI client')
656
+ })
657
+
658
+ test('status replies from a stopped client cannot control or overwrite a later session', async t => {
659
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-status-session-'))
660
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
661
+ for (const failure of [false, true]) {
662
+ const anywhere = new Anywhere(fakeKernel(tmp))
663
+ anywhere.config = { enabled: true }
664
+ let release, reject, entered, owned = true
665
+ const checking = new Promise(resolve => { entered = resolve })
666
+ const status = new Promise((resolve, fail) => { release = resolve; reject = fail })
667
+ anywhere.readPid = () => owned ? 123 : null
668
+ anywhere.api = { cancel() { owned = false }, ping: async () => false,
669
+ status() { entered(); return status } }
670
+ const pending = anywhere.snapshot()
671
+ await checking
672
+ await anywhere.stop()
673
+ // A home change or re-enable may have already installed a different client.
674
+ anywhere.api = { queryFeature: async () => assert.fail('stale status must not act on the later session') }
675
+ const fresh = { BackendState: 'Stopped' }
676
+ anywhere.lastStatus = fresh
677
+ anywhere.lastStatusAt = Date.now()
678
+ if (failure) reject(new Error('old client failed'))
679
+ else release({ ...runningStatus(), CertDomains: null })
680
+ await pending
681
+ assert.equal(anywhere.lastStatus, fresh)
682
+ assert.equal(anywhere.error, null)
683
+ }
684
+ })
685
+
686
+ test('Stop drains pending ownership checks without starting login or approval afterward', async t => {
687
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-owner-stop-'))
688
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
689
+ for (const operation of ['login', 'approval']) {
690
+ const anywhere = new Anywhere(fakeKernel(path.join(tmp, operation)))
691
+ await anywhere.saveConfig({ enabled: true })
692
+ let release, reads = 0, controls = 0, enteredStop
693
+ const stopping = new Promise(resolve => { enteredStop = resolve })
694
+ anywhere.readPid = () => ++reads === 1 ? new Promise(resolve => { release = resolve }) : null
695
+ anywhere.api = { cancel() { enteredStop() }, ping: async () => false,
696
+ up: async () => { controls++ }, queryFeature: async () => { controls++; return { Complete: false } } }
697
+ let pending
698
+ if (operation === 'login') {
699
+ anywhere.requestLogin()
700
+ pending = anywhere.login.promise
701
+ } else pending = anywhere.httpsFeature({ ...runningStatus(), CertDomains: null })
702
+ let stopped = false
703
+ const stop = anywhere.disable().then(() => { stopped = true })
704
+ await stopping
705
+ assert.equal(stopped, false, operation)
706
+ release(123)
707
+ await Promise.all([pending, stop])
708
+ assert.equal(controls, 0, operation)
709
+ assert.equal(anywhere.api, null)
710
+ }
711
+ })
712
+
713
+ for (const adopted of [false, true]) {
714
+ test(`Stop waits for ${adopted ? 'an adopted' : 'its owned'} process after endpoint closure`, {
715
+ skip: process.platform === 'win32' && 'requires a POSIX SIGTERM handler', timeout: 10000,
716
+ }, async () => {
717
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-stop-exit-'))
718
+ const closed = path.join(tmp, 'closed')
719
+ const child = spawn(process.execPath, ['-e', `
720
+ const fs = require('node:fs')
721
+ process.on('SIGTERM', () => {
722
+ fs.writeFileSync(process.argv[1], '')
723
+ setTimeout(() => process.exit(0), 300)
724
+ })
725
+ process.stdout.write('ready')
726
+ setInterval(() => {}, 1000)
727
+ `, closed], { stdio: ['ignore', 'pipe', 'ignore'] })
728
+ const exited = once(child, 'exit')
729
+ try {
730
+ await once(child.stdout, 'data')
731
+ const anywhere = new Anywhere(fakeKernel(tmp))
732
+ if (!adopted) anywhere.child = child
733
+ anywhere.readPid = () => child.exitCode === null ? child.pid : null
734
+ anywhere.api = { cancel() {}, ping: async () => !fs.existsSync(closed) }
735
+ fs.mkdirSync(anywhere.stateDir(), { recursive: true })
736
+ fs.writeFileSync(anywhere.pidPath(), String(child.pid))
737
+ await anywhere.stop()
738
+ assert.equal(child.exitCode, 0, 'endpoint absence alone must not complete Stop')
739
+ assert.equal(fs.existsSync(anywhere.pidPath()), false)
740
+ } finally {
741
+ if (child.exitCode === null) child.kill('SIGKILL')
742
+ await exited
743
+ fs.rmSync(tmp, { recursive: true, force: true })
744
+ }
745
+ })
746
+ }
388
747
 
389
748
  test('a daemon that cannot be spawned reports the cause at once instead of waiting 20 s', async () => {
390
749
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-'))
@@ -403,41 +762,602 @@ test('a daemon that cannot be spawned reports the cause at once instead of waiti
403
762
  }
404
763
  })
405
764
 
406
- test('sign-out resets the login state, and a link the server rejected is replaced instead of shown', async () => {
765
+ for (const operation of ['writeFileSync', 'rmSync']) {
766
+ test(`PID ${operation} failure is handled without leaving an unmanaged child or throwing from exit`, async t => {
767
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-pid-io-'))
768
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
769
+ const child = new EventEmitter()
770
+ child.pid = 123
771
+ child.exitCode = null
772
+ let killed = false
773
+ child.kill = () => { killed = true }
774
+ const failure = Object.assign(new Error(`PID ${operation} failed`), { code: operation === 'writeFileSync' ? 'ENOSPC' : 'EACCES' })
775
+ const fixtureFs = Object.create(fs)
776
+ fixtureFs[operation] = (file, ...args) => {
777
+ if (file.endsWith('tailscaled.pid')) throw failure
778
+ return fs[operation](file, ...args)
779
+ }
780
+ const filename = require.resolve('../kernel/anywhere')
781
+ const baseRequire = require('node:module').createRequire(filename)
782
+ const context = { module: { exports: {} }, console, process, Buffer, URL, setTimeout,
783
+ require(name) {
784
+ if (name === 'fs') return fixtureFs
785
+ if (name === 'child_process') return { spawn: () => child }
786
+ return baseRequire(name)
787
+ } }
788
+ vm.runInNewContext(fs.readFileSync(filename, 'utf8'), context)
789
+ const anywhere = new context.module.exports(fakeKernel(tmp))
790
+ anywhere.config = { enabled: true }
791
+ anywhere.api = { cancel() {}, ping: async () => false, setPrefs: async () => {}, status: async () => runningStatus() }
792
+ anywhere.waitForDaemon = async () => true
793
+ if (operation === 'writeFileSync') {
794
+ await assert.rejects(anywhere.start(), error => error === failure)
795
+ assert.equal(killed, true)
796
+ } else await anywhere.start()
797
+ assert.equal(child.listenerCount('error'), 1)
798
+ assert.doesNotThrow(() => child.emit('exit', null, 'SIGTERM'))
799
+ assert.equal(anywhere.child, null)
800
+ assert.match(anywhere.error, new RegExp(failure.message))
801
+ if (operation === 'rmSync') assert.equal(fs.readFileSync(anywhere.pidPath(), 'utf8'), '123')
802
+ })
803
+ }
804
+
805
+ test('logout and rejected links are handled by one CLI flow, with a fresh retry', async () => {
407
806
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-'))
408
807
  try {
409
808
  const anywhere = new Anywhere(fakeKernel(tmp))
410
809
  await anywhere.saveConfig({ enabled: true })
411
- anywhere.pid = 123
810
+ anywhere.readPid = () => 123
412
811
  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
812
  anywhere.api = {
416
- status: async () => status,
813
+ cancel() { calls.push('cancel') },
814
+ ping: async () => true, setPrefs: async () => {},
815
+ status: async () => ({ BackendState: 'NeedsLogin', AuthURL: 'https://login.tailscale.com/a/stale' }),
417
816
  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') },
817
+ up: (update, force) => {
818
+ calls.push(force ? 'up:force' : 'up')
819
+ if (!force) return Promise.reject(new Error('login expired'))
820
+ update({ AuthURL: 'https://login.tailscale.com/a/fresh' })
821
+ return new Promise(() => {})
822
+ },
421
823
  }
422
824
  await anywhere.logout()
423
- assert.deepEqual(calls, ['logout', 'prefs:login', 'start'], 'sign-out: logout, then back to logged-in prefs, then reset')
825
+ await anywhere.login.promise
826
+ assert.deepEqual(calls, ['cancel', 'logout', 'up'])
827
+ for (let i = 0; i < 3; i++) assert.equal((await anywhere.snapshot()).phase, 'error')
828
+ assert.equal(calls.filter((call) => call === 'up').length, 1, 'no automatic login loop after rejection')
829
+ await anywhere.start()
830
+ const snap = await anywhere.snapshot()
831
+ assert.equal(snap.phase, 'login')
832
+ assert.equal(snap.loginUrl, 'https://login.tailscale.com/a/fresh')
833
+ assert.ok(calls.includes('up:force'))
834
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
835
+ })
424
836
 
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')
837
+ test('control failures preserve live-daemon records and logs on every platform', async () => {
838
+ for (const platform of ['darwin', 'win32', 'linux']) {
839
+ for (const cause of ['401', '403', 'busy', 'reset', 'timeout']) {
840
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-error-'))
841
+ try {
842
+ const kernel = fakeKernel(tmp)
843
+ kernel.platform = platform
844
+ const anywhere = new Anywhere(kernel)
845
+ await anywhere.saveConfig({ enabled: true })
846
+ fs.mkdirSync(anywhere.stateDir(), { recursive: true })
847
+ const files = [anywhere.pidPath(), anywhere.logPath(), anywhere.portMapPath(), anywhere.stateDir('tailscaled.state')]
848
+ for (const file of files) fs.writeFileSync(file, 'preserve me')
849
+ anywhere.api = { cancel() {}, ping: async () => { throw new Error(cause) } }
850
+ await assert.rejects(() => anywhere.autostart(), new RegExp(cause))
851
+ assert.equal((await anywhere.snapshot()).phase, 'error')
852
+ assert.equal(anywhere.child, null)
853
+ for (const file of files) assert.equal(fs.readFileSync(file, 'utf8'), 'preserve me')
854
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
855
+ }
856
+ }
857
+ })
858
+
859
+ test('saved Off neither probes the daemon nor removes its records', async () => {
860
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-off-'))
861
+ try {
862
+ const anywhere = new Anywhere(fakeKernel(tmp))
863
+ await anywhere.saveConfig({ enabled: false })
864
+ fs.mkdirSync(anywhere.stateDir(), { recursive: true })
865
+ fs.writeFileSync(anywhere.pidPath(), '123')
866
+ anywhere.api = new Proxy({}, { get() { throw new Error('off must not control the daemon') } })
867
+ assert.equal(await anywhere.autostart(), false)
868
+ assert.equal(fs.readFileSync(anywhere.pidPath(), 'utf8'), '123')
869
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
870
+ })
430
871
 
431
- // after the reset the daemon keeps a stale "last login error: ... context canceled" line; that is not a rejection
872
+ test('stopping during a pending start prevents a later spawn', async () => {
873
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-stop-'))
874
+ try {
875
+ const anywhere = new Anywhere(fakeKernel(tmp))
876
+ await anywhere.saveConfig({ enabled: true })
877
+ let release
878
+ let probing
879
+ const probeStarted = new Promise((resolve) => { probing = resolve })
880
+ anywhere.api = {
881
+ cancel() {},
882
+ ping: () => { probing(); return new Promise((resolve) => { release = resolve }) },
883
+ }
884
+ const starting = anywhere.start()
885
+ await probeStarted
886
+ const stopping = anywhere.stop()
887
+ anywhere.api.ping = async () => false
888
+ release(false)
889
+ await Promise.all([starting, stopping])
890
+ assert.equal(anywhere.child, null)
891
+ assert.equal(fs.existsSync(anywhere.logPath()), false)
892
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
893
+ })
894
+
895
+ test('PID ownership checks require the exact private daemon command on each platform', async () => {
896
+ const source = fs.readFileSync(require.resolve('../kernel/anywhere'), 'utf8')
897
+ const baseRequire = require('node:module').createRequire(require.resolve('../kernel/anywhere'))
898
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-owner-'))
899
+ try {
900
+ for (const platform of ['darwin', 'win32', 'linux']) {
901
+ let command
902
+ const signals = []
903
+ const context = { module: { exports: {} }, console, URL, Buffer, setTimeout,
904
+ process: { ...process, kill(pid, signal) { signals.push(signal) } },
905
+ require(name) {
906
+ if (name === 'child_process') return { spawn, execFileSync: () => command,
907
+ execFile: (file, args, options, done) => done(null, command) }
908
+ return baseRequire(name)
909
+ },
910
+ }
911
+ vm.runInNewContext(source, context)
912
+ const kernel = fakeKernel(path.join(tmp, 'home with spaces é'))
913
+ kernel.platform = platform
914
+ const anywhere = new context.module.exports(kernel)
915
+ fs.mkdirSync(anywhere.stateDir(), { recursive: true })
916
+ const expected = [anywhere.binPath(), ...anywhere.daemonArgs()].join(' ')
917
+ fs.writeFileSync(anywhere.pidPath(), '123')
918
+ command = platform === 'win32' ? '"' + anywhere.binPath().toUpperCase() + '" ' + anywhere.daemonArgs().join(' ') : expected
919
+ assert.equal(await anywhere.readPid(), 123, platform)
920
+ assert.equal(anywhere.readPidSync(), 123, platform)
921
+ for (const wrong of [expected.replace('tailscaled', 'unrelated'), expected + ' --other-state', expected.replace(anywhere.socketPath(), anywhere.socketPath() + '.wrong')]) {
922
+ command = wrong
923
+ assert.equal(await anywhere.readPid(), null, platform)
924
+ assert.equal(anywhere.readPidSync(), null, platform)
925
+ anywhere.killSync()
926
+ assert.ok(!signals.includes('SIGKILL'), 'no wrong-process signal')
927
+ }
928
+ }
929
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
930
+ })
931
+
932
+ test('adopted-daemon status inspection leaves the event loop responsive on every platform', async t => {
933
+ const source = fs.readFileSync(require.resolve('../kernel/anywhere'), 'utf8')
934
+ const baseRequire = require('node:module').createRequire(require.resolve('../kernel/anywhere'))
935
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-async-owner-'))
936
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
937
+ for (const platform of ['darwin', 'win32', 'linux']) {
938
+ let release, first = true, command, statusReads = 0
939
+ const context = { module: { exports: {} }, console, URL, Buffer, setTimeout,
940
+ process: { ...process, kill() {} }, require(name) {
941
+ if (name === 'child_process') return { spawn,
942
+ execFileSync() { assert.fail('request paths must not run synchronous process inspection') },
943
+ execFile(file, args, options, done) {
944
+ assert.equal(options.shell, false)
945
+ assert.equal(options.timeout, 2000)
946
+ assert.equal(file, platform === 'win32' ? 'powershell.exe' : '/bin/ps')
947
+ if (first) { first = false; release = () => done(null, command) }
948
+ else done(null, command)
949
+ } }
950
+ return baseRequire(name)
951
+ } }
952
+ vm.runInNewContext(source, context)
953
+ const kernel = fakeKernel(path.join(tmp, platform))
954
+ kernel.platform = platform
955
+ const anywhere = new context.module.exports(kernel)
956
+ await anywhere.saveConfig({ enabled: true })
957
+ fs.mkdirSync(anywhere.stateDir(), { recursive: true })
958
+ fs.writeFileSync(anywhere.pidPath(), '123')
959
+ command = [anywhere.binPath(), ...anywhere.daemonArgs()].join(' ')
960
+ anywhere.api = { status: async () => { statusReads++; return runningStatus() },
961
+ serveConfig: async () => buildServeConfig({ dnsName: 'pinokio.tail1.ts.net', serverPort: 42000 }) }
962
+ const pending = anywhere.snapshot()
963
+ await new Promise(resolve => setTimeout(resolve, 10))
964
+ assert.equal(typeof release, 'function')
965
+ assert.equal(statusReads, 0, 'the status call waits for ownership while other work continues')
966
+ release()
967
+ assert.equal((await pending).phase, 'ready')
968
+ }
969
+ })
970
+
971
+ test('a daemon exiting during process inspection is absent; live inspection failures still propagate', async t => {
972
+ const source = fs.readFileSync(require.resolve('../kernel/anywhere'), 'utf8')
973
+ const baseRequire = require('node:module').createRequire(require.resolve('../kernel/anywhere'))
974
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-exit-inspect-'))
975
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
976
+ for (const platform of ['darwin', 'win32', 'linux']) {
977
+ for (const disappeared of [true, false]) {
978
+ let inspected = false
979
+ const failure = Object.assign(new Error('inspection failed'), { code: 1 })
980
+ const context = { module: { exports: {} }, console, URL, Buffer, setTimeout,
981
+ process: { ...process, kill(pid, signal) {
982
+ assert.equal(signal, 0, 'the existence probe never signals the process')
983
+ if (inspected && disappeared) throw Object.assign(new Error('gone'), { code: 'ESRCH' })
984
+ } }, require(name) {
985
+ if (name === 'child_process') return { execFile(file, args, options, done) {
986
+ inspected = true
987
+ done(failure)
988
+ } }
989
+ return baseRequire(name)
990
+ } }
991
+ vm.runInNewContext(source, context)
992
+ const kernel = fakeKernel(path.join(tmp, platform))
993
+ kernel.platform = platform
994
+ const anywhere = new context.module.exports(kernel)
995
+ fs.mkdirSync(anywhere.stateDir(), { recursive: true })
996
+ fs.writeFileSync(anywhere.pidPath(), '123')
997
+ if (disappeared) assert.equal(await anywhere.readPid(), null)
998
+ else await assert.rejects(anywhere.readPid(), error => error === failure)
999
+ }
1000
+ }
1001
+ })
1002
+
1003
+ test('invalid port maps remain intact and failed saves cannot become ready', async () => {
1004
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-map-'))
1005
+ try {
1006
+ const anywhere = new Anywhere(fakeKernel(tmp))
1007
+ await anywhere.saveConfig({ enabled: true })
1008
+ for (const text of ['invalid JSON', '[]', 'null']) {
1009
+ fs.writeFileSync(anywhere.portMapPath(), text)
1010
+ await assert.rejects(() => loadPortMap(anywhere.portMapPath()))
1011
+ assert.equal(fs.readFileSync(anywhere.portMapPath(), 'utf8'), text)
1012
+ }
1013
+ fs.rmSync(anywhere.portMapPath())
1014
+ fs.mkdirSync(anywhere.portMapPath())
1015
+ anywhere.portMap = {}
1016
+ anywhere.kernel.memory.local = { [path.join(tmp, 'api/comfy/start.js')]: { url: 'http://127.0.0.1:7860' } }
1017
+ await assert.rejects(() => anywhere.refreshTargets())
1018
+ assert.deepEqual(anywhere.portMap, {}, 'failed persistence does not cache an unsaved assignment')
1019
+ fs.rmSync(anywhere.portMapPath(), { recursive: true })
1020
+ await anywhere.refreshTargets()
1021
+ assert.equal(JSON.parse(fs.readFileSync(anywhere.portMapPath(), 'utf8')).comfy, 43000)
1022
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
1023
+ })
1024
+
1025
+ test('first home selection skips Stop when there is no previous home', async () => {
1026
+ const Kernel = require('../kernel')
1027
+ const reached = new Error('reached existing initialization')
1028
+ const kernel = {
1029
+ homedir: undefined, path: Kernel.prototype.path,
1030
+ store: { get: () => '/new/home' },
1031
+ disposeVault: async () => { throw reached },
1032
+ }
1033
+ kernel.anywhere = new Anywhere(kernel)
1034
+ await assert.rejects(() => Kernel.prototype.init.call(kernel, {}), (error) => error === reached)
1035
+ })
1036
+
1037
+ test('peer refresh proceeds during an Anywhere sync without queuing more syncs', async () => {
1038
+ const Kernel = require('../kernel')
1039
+ for (const fail of [false, true]) {
1040
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-peer-refresh-'))
1041
+ const kernel = fakeKernel(tmp)
1042
+ let announced = 0, calls = 0, release
1043
+ const pending = new Promise((resolve) => { release = resolve })
1044
+ kernel.peer = {
1045
+ check: async () => {}, current_host: async () => ({}),
1046
+ notify_refresh: async () => {}, announce: () => { announced += 1 },
1047
+ }
1048
+ kernel.ensureRouterMode = async () => {}
1049
+ const anywhere = kernel.anywhere = new Anywhere(kernel)
1050
+ anywhere.config = { enabled: true }
1051
+ anywhere.readPid = () => 123
1052
+ anywhere.portMap = {}
1053
+ anywhere.lastStatus = runningStatus()
1054
+ anywhere.lastStatusAt = Date.now()
1055
+ anywhere.api = { serveConfig: async () => {
1056
+ calls += 1
1057
+ await pending
1058
+ if (fail) throw new Error('Serve unavailable')
1059
+ return buildServeConfig({ dnsName: 'pinokio.tail1.ts.net', serverPort: 42000 })
1060
+ } }
1061
+ const refresh = Kernel.prototype.refresh.call(kernel)
1062
+ try {
1063
+ await new Promise(setImmediate)
1064
+ assert.equal(announced, 1, 'peer notifications must not wait for Tailscale')
1065
+ await Kernel.prototype.refresh.call(kernel)
1066
+ assert.equal(announced, 2)
1067
+ assert.equal(calls, 1, 'the pending sync must not accumulate more work')
1068
+ } finally {
1069
+ release()
1070
+ await refresh
1071
+ if (anywhere.syncing) await anywhere.syncing
1072
+ fs.rmSync(tmp, { recursive: true, force: true })
1073
+ }
1074
+ assert.equal(anywhere.lastServeError, fail ? 'Serve unavailable' : null)
1075
+ }
1076
+ })
1077
+
1078
+ test('home changes stop Anywhere against the old home and preserve it on failure', async () => {
1079
+ const Kernel = require('../kernel')
1080
+ for (const failStop of [true, false]) {
1081
+ const failure = new Error('stop test before the rest of initialization')
1082
+ const calls = []
1083
+ const ready = Promise.resolve()
1084
+ const kernel = {
1085
+ homedir: '/old/home', sysReady: ready, store: { get: () => '/new/home' },
1086
+ anywhere: { stop: async () => {
1087
+ assert.equal(kernel.homedir, '/old/home')
1088
+ calls.push('stop')
1089
+ if (failStop) throw failure
1090
+ } },
1091
+ disposeVault: async () => { calls.push('dispose'); throw failure },
1092
+ }
1093
+ await assert.rejects(() => Kernel.prototype.init.call(kernel, {}), (error) => error === failure)
1094
+ assert.equal(kernel.homedir, '/old/home')
1095
+ if (failStop) assert.equal(kernel.sysReady, ready)
1096
+ assert.deepEqual(calls, failStop ? ['stop'] : ['stop', 'dispose'])
1097
+ }
1098
+ })
1099
+
1100
+ test('an unowned daemon does not block kernel re-init or lose its records', async t => {
1101
+ const Kernel = require('../kernel')
1102
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-init-unowned-'))
1103
+ t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
1104
+ t.mock.method(console, 'warn', () => {})
1105
+ const kernel = fakeKernel(tmp)
1106
+ const anywhere = kernel.anywhere = new Anywhere(kernel)
1107
+ const reached = new Error('reached existing initialization')
1108
+ kernel.store = { get: () => '/new/home' }
1109
+ kernel.disposeVault = async () => { throw reached }
1110
+ anywhere.readPid = () => null
1111
+ let cancelled = 0
1112
+ anywhere.api = { cancel() { cancelled++ }, ping: async () => true }
1113
+ fs.mkdirSync(anywhere.stateDir(), { recursive: true })
1114
+ const files = [anywhere.pidPath(), anywhere.logPath(), anywhere.stateDir('tailscaled.state')]
1115
+ for (const file of files) fs.writeFileSync(file, 'preserve')
1116
+ await assert.rejects(Kernel.prototype.init.call(kernel, {}), error => error === reached)
1117
+ assert.equal(cancelled, 1)
1118
+ assert.equal(kernel.homedir, tmp)
1119
+ for (const file of files) assert.equal(fs.readFileSync(file, 'utf8'), 'preserve')
1120
+ for (const code of ['EACCES', 'ETIMEDOUT']) {
1121
+ const failure = Object.assign(new Error('owned stop failed'), { code })
1122
+ anywhere.stop = async () => { throw failure }
1123
+ await assert.rejects(Kernel.prototype.init.call(kernel, {}), error => error === failure)
1124
+ }
1125
+ })
1126
+
1127
+ test('a completed login allows a later stopped backend to resume', async () => {
1128
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-resume-'))
1129
+ try {
1130
+ const anywhere = new Anywhere(fakeKernel(tmp))
1131
+ await anywhere.saveConfig({ enabled: true })
1132
+ anywhere.readPid = () => 123
1133
+ let status = runningStatus(), ups = 0
1134
+ anywhere.api = {
1135
+ status: async () => status,
1136
+ up: async () => { ups++; status = runningStatus() },
1137
+ serveConfig: async () => buildServeConfig({ dnsName: 'pinokio.tail1.ts.net', serverPort: 42000 }),
1138
+ }
1139
+ anywhere.requestLogin()
1140
+ await anywhere.login.promise
1141
+ status = { ...runningStatus(), BackendState: 'Stopped' }
1142
+ assert.equal((await anywhere.snapshot()).phase, 'starting')
432
1143
  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 })
1144
+ assert.equal((await anywhere.snapshot()).phase, 'ready')
1145
+ assert.equal(ups, 2)
1146
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
1147
+ })
1148
+
1149
+ test('logout while Off does not start another login', async () => {
1150
+ const anywhere = new Anywhere(fakeKernel(os.tmpdir()))
1151
+ anywhere.config = { enabled: false }
1152
+ anywhere.readPid = () => 123
1153
+ let logouts = 0, logins = 0
1154
+ anywhere.api = { cancel() {}, logout: async () => { logouts++ }, up: async () => { logins++ } }
1155
+ await anywhere.logout()
1156
+ assert.equal(logouts, 1)
1157
+ assert.equal(logins, 0)
1158
+ assert.equal(anywhere.login, null)
1159
+ })
1160
+
1161
+ test('readiness requires a running backend and confirmed routes after sync', async () => {
1162
+ for (const failure of ['control error', 'stopped backend', 'skipped sync']) {
1163
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-ready-'))
1164
+ try {
1165
+ const anywhere = new Anywhere(fakeKernel(tmp))
1166
+ await anywhere.saveConfig({ enabled: true })
1167
+ anywhere.readPid = () => 123
1168
+ let reads = 0
1169
+ anywhere.api = { status: async () => {
1170
+ if (++reads === 1) return runningStatus()
1171
+ if (failure === 'control error') throw new Error('control unavailable during sync')
1172
+ return { ...runningStatus(), BackendState: 'Stopped' }
1173
+ } }
1174
+ if (failure === 'stopped backend') anywhere.lastServe = buildServeConfig({ dnsName: 'pinokio.tail1.ts.net', serverPort: 42000 })
1175
+ anywhere.httpsFeature = async () => {
1176
+ anywhere.lastStatusAt = 0
1177
+ if (failure === 'skipped sync') anywhere.stopping = Promise.resolve()
1178
+ return { Complete: true }
1179
+ }
1180
+ const snapshot = await anywhere.snapshot()
1181
+ assert.equal(snapshot.phase, failure === 'skipped sync' ? 'starting' : 'error', failure)
1182
+ if (failure !== 'skipped sync') assert.match(snapshot.error, failure === 'control error' ? /control unavailable/ : /Routes are not confirmed/)
1183
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
1184
+ }
1185
+ })
1186
+
1187
+ test('a failed stop stays visible while disabled and a successful retry clears it', async () => {
1188
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-stop-error-'))
1189
+ try {
1190
+ const anywhere = new Anywhere(fakeKernel(tmp))
1191
+ await anywhere.saveConfig({ enabled: true })
1192
+ fs.mkdirSync(anywhere.stateDir(), { recursive: true })
1193
+ fs.writeFileSync(anywhere.pidPath(), '123')
1194
+ fs.writeFileSync(anywhere.logPath(), 'preserve log')
1195
+ anywhere.readPid = () => null
1196
+ anywhere.api = { cancel() {}, ping: async () => { throw new Error('pipe access denied') } }
1197
+ await assert.rejects(() => anywhere.disable(), /pipe access denied/)
1198
+ for (let i = 0; i < 2; i++) {
1199
+ const snapshot = await anywhere.snapshot()
1200
+ assert.equal(snapshot.enabled, false)
1201
+ assert.equal(snapshot.phase, 'error')
1202
+ assert.match(snapshot.error, /pipe access denied/)
1203
+ }
1204
+ assert.equal(fs.readFileSync(anywhere.pidPath(), 'utf8'), '123')
1205
+ assert.equal(fs.readFileSync(anywhere.logPath(), 'utf8'), 'preserve log')
1206
+ anywhere.api.ping = async () => false
1207
+ await anywhere.stop()
1208
+ assert.equal((await anywhere.snapshot()).phase, 'off')
1209
+ assert.equal(anywhere.error, null)
1210
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
1211
+ })
1212
+
1213
+ test('ownership-inspection failures cannot spawn a daemon or overwrite its records', async () => {
1214
+ const source = fs.readFileSync(require.resolve('../kernel/anywhere'), 'utf8')
1215
+ const baseRequire = require('node:module').createRequire(require.resolve('../kernel/anywhere'))
1216
+ for (const platform of ['darwin', 'win32', 'linux']) {
1217
+ for (const code of ['ETIMEDOUT', 'EACCES', 'ENOENT']) {
1218
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-inspect-'))
1219
+ try {
1220
+ const failure = Object.assign(new Error('process inspection failed'), { code })
1221
+ let spawns = 0
1222
+ const context = { module: { exports: {} }, console, URL, Buffer, setTimeout,
1223
+ process: { ...process, kill() {} },
1224
+ require(name) {
1225
+ if (name === 'child_process') return {
1226
+ execFileSync() { throw failure },
1227
+ execFile(file, args, options, done) { done(failure) },
1228
+ spawn() { spawns++; throw new Error('must not spawn') },
1229
+ }
1230
+ return baseRequire(name)
1231
+ },
1232
+ }
1233
+ vm.runInNewContext(source, context)
1234
+ const kernel = fakeKernel(tmp)
1235
+ kernel.platform = platform
1236
+ const anywhere = new context.module.exports(kernel)
1237
+ await anywhere.saveConfig({ enabled: true })
1238
+ fs.mkdirSync(anywhere.stateDir(), { recursive: true })
1239
+ fs.writeFileSync(anywhere.pidPath(), '123')
1240
+ fs.writeFileSync(anywhere.logPath(), 'preserve log')
1241
+ for (const alive of [false, true]) {
1242
+ anywhere.api = { cancel() {}, ping: async () => alive }
1243
+ await assert.rejects(() => anywhere.start(), (error) => error === failure)
1244
+ }
1245
+ assert.equal(spawns, 0)
1246
+ assert.equal(fs.readFileSync(anywhere.pidPath(), 'utf8'), '123')
1247
+ assert.equal(fs.readFileSync(anywhere.logPath(), 'utf8'), 'preserve log')
1248
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
1249
+ }
1250
+ }
1251
+ })
1252
+
1253
+ test('uninstall aborts when stopping fails and removes packages only after success', async () => {
1254
+ const mod = new Tailscaled()
1255
+ const order = []
1256
+ mod.kernel = {
1257
+ anywhere: { stop: async () => { throw new Error('cannot verify ownership') } },
1258
+ bin: { exec: async ({ message }) => { order.push(message) } },
442
1259
  }
1260
+ await assert.rejects(() => mod.uninstall({}, () => {}), /cannot verify ownership/)
1261
+ assert.deepEqual(order, [])
1262
+ mod.kernel.anywhere.stop = async () => { order.push('stop') }
1263
+ await mod.uninstall({}, () => {})
1264
+ assert.deepEqual(order, ['stop', 'conda remove -y tailscaled tailscale'])
1265
+ })
1266
+
1267
+ test('a changed Serve update reuses the initial read and confirms its result', async () => {
1268
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-reads-'))
1269
+ try {
1270
+ const anywhere = new Anywhere(fakeKernel(tmp))
1271
+ await anywhere.saveConfig({ enabled: true })
1272
+ anywhere.readPid = () => 123
1273
+ const desired = buildServeConfig({ dnsName: 'pinokio.tail1.ts.net', serverPort: 42000 })
1274
+ let reads = 0, writes = 0, applied = null
1275
+ anywhere.api = {
1276
+ status: async () => runningStatus(),
1277
+ serveConfig: async () => { reads++; return applied },
1278
+ run: async () => { writes++; applied = desired },
1279
+ setServeConfig(...args) { return TailscaleCLI.prototype.setServeConfig.call(this, ...args) },
1280
+ }
1281
+ assert.equal(await anywhere.sync(), true)
1282
+ assert.equal(reads, 2, 'one initial read and one confirmation read')
1283
+ assert.equal(writes, 1)
1284
+ assert.equal(await anywhere.sync(), false)
1285
+ assert.equal(reads, 3, 'unchanged routes need only one additional read')
1286
+ assert.equal(writes, 1)
1287
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
1288
+ })
1289
+
1290
+ test('the first failed status response includes the actual cause', async () => {
1291
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-status-error-'))
1292
+ try {
1293
+ const anywhere = new Anywhere(fakeKernel(tmp))
1294
+ await anywhere.saveConfig({ enabled: true })
1295
+ anywhere.readPid = () => 123
1296
+ anywhere.api = { status: async () => { throw new Error('status access denied') } }
1297
+ const snapshot = await anywhere.snapshot()
1298
+ assert.equal(snapshot.phase, 'error')
1299
+ assert.equal(snapshot.error, 'status access denied')
1300
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
1301
+ })
1302
+
1303
+ test('concurrent status requests share a pending HTTPS approval query', async () => {
1304
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-approval-query-'))
1305
+ try {
1306
+ const anywhere = new Anywhere(fakeKernel(tmp))
1307
+ await anywhere.saveConfig({ enabled: true })
1308
+ anywhere.readPid = () => 123
1309
+ let calls = 0, release
1310
+ const pendingQuery = new Promise((resolve) => { release = resolve })
1311
+ anywhere.api = {
1312
+ status: async () => ({ ...runningStatus(), CertDomains: null }),
1313
+ queryFeature: () => { calls++; return pendingQuery },
1314
+ }
1315
+ const pending = Promise.all(Array.from({ length: 4 }, () => anywhere.snapshot()))
1316
+ await new Promise(setImmediate)
1317
+ assert.equal(calls, 1)
1318
+ const url = 'https://login.tailscale.com/f/serve?node=1'
1319
+ release({ Complete: false, URL: url })
1320
+ for (const snapshot of await pending) {
1321
+ assert.equal(snapshot.phase, 'https')
1322
+ assert.equal(snapshot.approval.url, url)
1323
+ }
1324
+ assert.equal((await anywhere.snapshot()).approval.url, url)
1325
+ assert.equal(calls, 1, 'completed queries remain cached')
1326
+ anywhere.featureAt = 0
1327
+ anywhere.api.queryFeature = async () => ({ Complete: false, URL: url + '&fresh=1' })
1328
+ assert.equal((await anywhere.snapshot()).approval.url, url + '&fresh=1', 'an expired cache can query again')
1329
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
1330
+ })
1331
+
1332
+ test('cancelling a pending HTTPS approval during Stop keeps the result Off', async () => {
1333
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-approval-stop-'))
1334
+ try {
1335
+ const anywhere = new Anywhere(fakeKernel(tmp))
1336
+ await anywhere.saveConfig({ enabled: true })
1337
+ let rejectQuery
1338
+ const pendingQuery = new Promise((_, reject) => { rejectQuery = reject })
1339
+ let owned = true
1340
+ anywhere.readPid = () => owned ? 123 : null
1341
+ anywhere.api = {
1342
+ status: async () => ({ ...runningStatus(), CertDomains: null }),
1343
+ queryFeature: () => pendingQuery,
1344
+ ping: async () => false,
1345
+ cancel() { owned = false; setImmediate(() => rejectQuery(Object.assign(new Error('query cancelled'), { cancelled: true }))) },
1346
+ }
1347
+ const pending = anywhere.snapshot()
1348
+ await new Promise(setImmediate)
1349
+ await anywhere.disable()
1350
+ await pending
1351
+ const snapshot = await anywhere.snapshot()
1352
+ assert.equal(snapshot.phase, 'off')
1353
+ assert.equal(snapshot.error, null)
1354
+ assert.equal(JSON.parse(fs.readFileSync(anywhere.configPath(), 'utf8')).enabled, false)
1355
+ await anywhere.saveConfig({ enabled: true })
1356
+ owned = true
1357
+ anywhere.api = {
1358
+ status: async () => ({ ...runningStatus(), CertDomains: null }),
1359
+ queryFeature: async () => ({ Complete: false, URL: 'https://login.tailscale.com/f/fresh' }),
1360
+ }
1361
+ assert.equal((await anywhere.snapshot()).approval.url, 'https://login.tailscale.com/f/fresh')
1362
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
443
1363
  })