test/remote-setup.test.js
Rama main · réplica de lectura del repositorio original.
import { describe, it, expect } from 'vitest';
import { existsSync, mkdtempSync, readFileSync, statSync, writeFileSync, mkdirSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { setupRemote, addOrigin } from '../server/remote-setup.js';
import { tailscaleBin, serveTargets, MAC_APP_CLI } from '../server/tailscale.js';
import { originHost } from '../server/state.js';
import { runCommand } from '../server/service.js';
import { removeTempDir } from './temp-dir.js';
const NAME = 'mini.tail1.ts.net';
const PORT = 7123;
const RUNNING = { BackendState: 'Running', Self: { DNSName: `${NAME}.` } };
const served = (proxy, host = NAME, port = 443) => ({ TCP: { [port]: { HTTPS: true } }, Web: { [`${host}:${port}`]: { Handlers: { '/': { Proxy: proxy } } } } });
// Never a real tailscale, launchd or ~/.agent-007: a temp HOME and a fake CLI.
function fakeCtx({ status = RUNNING, serve = {}, serveRun, answers = [], running = false, ...over } = {}) {
const home = mkdtempSync(join(tmpdir(), 'a007-remote-'));
const cfg = join(home, '.agent-007');
const out = [], calls = [], asked = [], restarts = [];
const ctx = {
home, cfg, out, calls, asked, restarts,
platform: 'darwin', tty: true, port: PORT, root: home,
env: { HOME: home, PATH: '/usr/bin', AGENT007_CONFIG_DIR: cfg },
tailscaleBin: () => '/usr/local/bin/tailscale',
run: async (cmd, args) => {
calls.push(args.join(' '));
if (args[0] === 'status') return status ? { code: 0, stdout: JSON.stringify(status), stderr: '' } : { code: 1, stdout: '', stderr: 'failed to connect to local tailscaled' };
if (args.join(' ') === 'serve status --json') return { code: 0, stdout: JSON.stringify(serve), stderr: '' };
return serveRun || { code: 0, stdout: '', stderr: '' };
},
write: (s) => out.push(`OUT ${s}`),
stream: async (cmd, args, o) => {
calls.push(args.join(' '));
const r = serveRun || { code: 0, stdout: '', stderr: '' };
if (r.stdout) o.onData(r.stdout);
return r;
},
ask: async (q) => { asked.push(q); return answers.shift() ?? ''; },
log: (s) => out.push(s), err: (s) => out.push(`ERR ${s}`),
cmd: (s) => `agent007 ${s}`,
...over,
};
const server = { running: async () => running, restart: async () => { restarts.push(1); } };
return { ctx, server };
}
const text = (c) => c.out.join('\n');
const envFile = (c) => join(c.cfg, '.env');
const changes = (c) => c.calls.filter(a => a.startsWith('serve --bg'));
describe('setupRemote', () => {
it('serves the port, adds the ts.net name to ALLOWED_ORIGINS (0600), restarts a running server', async () => {
const { ctx, server } = fakeCtx({ running: true });
expect(await setupRemote(ctx, server)).toBe(0);
expect(changes(ctx)).toEqual([`serve --bg ${PORT}`]);
const env = readFileSync(envFile(ctx), 'utf8');
expect(env).toContain(`\nALLOWED_ORIGINS=${NAME}\n`);
expect(env).not.toMatch(/^# ALLOWED_ORIGINS=/m);
if (process.platform !== 'win32') expect(statSync(envFile(ctx)).mode & 0o777).toBe(0o600);
expect(ctx.restarts).toEqual([1]);
expect(ctx.out.at(-1)).toBe(`Open https://${NAME} on any device in your tailnet.`);
removeTempDir(ctx.home);
});
it('keeps a port already served and existing origins, and changes nothing when all is in place', async () => {
const { ctx, server } = fakeCtx({ serve: served(`http://127.0.0.1:${PORT}`), running: true });
mkdirSync(ctx.cfg, { recursive: true });
writeFileSync(envFile(ctx), 'PORT=7123\nALLOWED_ORIGINS=other.example\n');
expect(await setupRemote(ctx, server)).toBe(0);
expect(changes(ctx)).toEqual([]);
expect(readFileSync(envFile(ctx), 'utf8')).toBe(`PORT=7123\nALLOWED_ORIGINS=other.example,${NAME}\n`);
expect(ctx.restarts).toEqual([1]);
ctx.out.length = 0;
expect(await setupRemote(ctx, server)).toBe(0);
expect(text(ctx)).toMatch(/already lets mini\.tail1\.ts\.net in/);
expect(ctx.restarts).toEqual([1]);
removeTempDir(ctx.home);
});
it('stops without changing anything when Tailscale is missing, not running or not logged in', async () => {
for (const [over, says] of [
[{ tailscaleBin: () => null }, /not installed.*\n.*tailscale\.com\/download/],
[{ status: null }, /`tailscale status` failed \(failed to connect.*\n.*tailscale up/],
[{ status: { BackendState: 'NeedsLogin' } }, /Tailscale is NeedsLogin.*\n.*Open the Tailscale app and log in, or run: \/usr\/local\/bin\/tailscale up/],
]) {
const { ctx, server } = fakeCtx(over);
expect(await setupRemote(ctx, server)).toBe(1);
expect(text(ctx)).toMatch(says);
expect(text(ctx)).toContain('Nothing was changed');
expect(changes(ctx)).toEqual([]);
expect(existsSync(envFile(ctx))).toBe(false);
removeTempDir(ctx.home);
}
});
const busy = (...ports) => ports.reduce((c, p) => {
const w = served('http://127.0.0.1:3000', NAME, p);
return { TCP: { ...c.TCP, ...w.TCP }, Web: { ...c.Web, ...w.Web } };
}, { TCP: {}, Web: {} });
it('leaves another site on 443 alone and uses the next free HTTPS port, without asking', async () => {
for (const [ports, p] of [[[443], 8443], [[443, 8443], 10000]]) {
const { ctx, server } = fakeCtx({ serve: busy(...ports) });
expect(await setupRemote(ctx, server)).toBe(0);
expect(ctx.asked).toEqual([]);
expect(changes(ctx)).toEqual([`serve --bg --https=${p} ${PORT}`]);
expect(readFileSync(envFile(ctx), 'utf8')).toContain(`ALLOWED_ORIGINS=${NAME}:${p}\n`);
expect(ctx.out.at(-1)).toBe(`Open https://${NAME}:${p} on any device in your tailnet.`);
removeTempDir(ctx.home);
}
});
it('stops without changes when every HTTPS port serves something else', async () => {
const { ctx, server } = fakeCtx({ serve: busy(443, 8443, 10000) });
expect(await setupRemote(ctx, server)).toBe(1);
expect(text(ctx)).toMatch(/Every HTTPS port.*http:\/\/127\.0\.0\.1:3000.*Nothing was changed/s);
expect(changes(ctx)).toEqual([]);
expect(existsSync(envFile(ctx))).toBe(false);
removeTempDir(ctx.home);
});
it('keeps this port when already served on a non-443 HTTPS port', async () => {
const mine = served(`http://127.0.0.1:${PORT}`, NAME, 8443);
const { ctx, server } = fakeCtx({ serve: { TCP: { ...busy(443).TCP, ...mine.TCP }, Web: { ...busy(443).Web, ...mine.Web } } });
expect(await setupRemote(ctx, server)).toBe(0);
expect(changes(ctx)).toEqual([]);
expect(ctx.out.at(-1)).toBe(`Open https://${NAME}:8443 on any device in your tailnet.`);
removeTempDir(ctx.home);
});
it('prints the link Tailscale gives when Serve is not enabled on the tailnet', async () => {
const url = 'https://login.tailscale.com/f/serve?node=abc123';
const { ctx, server } = fakeCtx({ serveRun: { code: -1, stdout: `Serve is not enabled on your tailnet.\nTo enable, visit:\n\n ${url}\n`, stderr: '' } });
expect(await setupRemote(ctx, server)).toBe(1);
expect(text(ctx)).toContain(`Serve is not enabled on your tailnet. Enable it here, then run agent007 install --remote again:\n ${url}`);
expect(existsSync(envFile(ctx))).toBe(false);
removeTempDir(ctx.home);
});
it('shows the enable link while tailscale is still waiting, and says which machine it is', async () => {
const url = 'https://login.tailscale.com/f/serve?node=abc123';
const { ctx, server } = fakeCtx();
let seenBeforeExit = false;
ctx.stream = async (cmd, args, o) => {
o.onData(`To enable, visit:\n ${url}\n`);
seenBeforeExit = ctx.out.some(l => l.includes(url));
return { code: 0, stdout: url, stderr: '' };
};
expect(await setupRemote(ctx, server)).toBe(0);
expect(seenBeforeExit).toBe(true);
expect(text(ctx)).toContain(`This machine is ${NAME}.`);
expect(text(ctx)).toMatch(/Tailscale prints a link to enable it/);
removeTempDir(ctx.home);
});
it('on timeout prints the link and the command to re-run; on Ctrl-C stops cleanly', async () => {
const url = 'https://login.tailscale.com/f/serve?node=abc123';
const t = fakeCtx({ serveRun: { code: -1, stdout: url, stderr: '', timedOut: true } });
expect(await setupRemote(t.ctx, t.server)).toBe(1);
expect(text(t.ctx)).toContain('Gave up waiting');
expect(text(t.ctx)).toContain(url);
expect(text(t.ctx)).toContain('agent007 install --remote');
const i = fakeCtx({ serveRun: { code: -1, stdout: '', stderr: '', interrupted: true } });
expect(await setupRemote(i.ctx, i.server)).toBe(130);
expect(existsSync(envFile(i.ctx))).toBe(false);
removeTempDir(t.ctx.home); removeTempDir(i.ctx.home);
});
it('stream: echoes a real child live, kills it on timeout', async () => {
const { stream } = await import('../server/service.js');
const seen = [];
const r = await stream(process.execPath, ['-e', "console.log('hello'); setInterval(()=>{},1000)"], { timeout: 700, onData: (d) => seen.push(d) });
expect(seen.join('')).toContain('hello');
expect(r.timedOut).toBe(true);
});
it('--dry-run reads status and prints what it would do, changing nothing', async () => {
const { ctx, server } = fakeCtx({ 'dry-run': true, running: true });
expect(await setupRemote(ctx, server)).toBe(0);
expect(changes(ctx)).toEqual([]);
expect(text(ctx)).toContain(`Would run: /usr/local/bin/tailscale serve --bg ${PORT}`);
expect(text(ctx)).toContain(`Would set ALLOWED_ORIGINS=${NAME}`);
expect(existsSync(envFile(ctx))).toBe(false);
expect(ctx.restarts).toEqual([]);
removeTempDir(ctx.home);
});
it('warns when the environment sets ALLOWED_ORIGINS without the name', async () => {
const { ctx, server } = fakeCtx();
ctx.env.ALLOWED_ORIGINS = 'other.example';
expect(await setupRemote(ctx, server)).toBe(0);
expect(text(ctx)).toMatch(/also set in your environment.*add mini\.tail1\.ts\.net there too/);
removeTempDir(ctx.home);
});
it('is what `install --remote` runs, alone', async () => {
const { ctx } = fakeCtx();
ctx.callServer = async () => null;
ctx.readServer = () => null;
expect(await runCommand('install', { remote: true }, ctx)).toBe(0);
expect(changes(ctx)).toEqual([`serve --bg ${PORT}`]);
expect(await runCommand('install', { remote: true, voice: true }, ctx)).toBe(2);
removeTempDir(ctx.home);
});
});
describe('tailscale helpers', () => {
it('finds the CLI on PATH, else the macOS app bundle', () => {
const none = { PATH: '/nonexistent' };
expect(tailscaleBin(none, { platform: 'darwin', exists: (p) => p === MAC_APP_CLI })).toBe(MAC_APP_CLI);
expect(tailscaleBin(none, { platform: 'linux', exists: () => true })).toBe(null);
expect(tailscaleBin(none, { platform: 'darwin', exists: () => false })).toBe(null);
});
it('serveTargets: names sent to this port, other targets, busy HTTPS ports', () => {
const cfg = { ...served(`http://localhost:${PORT}`), Foreground: { x: served('http://127.0.0.1:3000', NAME, 8443) } };
const t = serveTargets(cfg, PORT);
expect([...t.names]).toEqual([NAME]);
expect(t.targets).toEqual(['http://127.0.0.1:3000']);
expect([...t.busyPorts]).toEqual(['8443']);
expect([...serveTargets(served(`http://localhost:${PORT}`, NAME, 8443), PORT).names]).toEqual([`${NAME}:8443`]);
});
it('the server lets a host:port ALLOWED_ORIGINS entry in by hostname', () => {
expect(originHost(`${NAME}:8443`)).toBe(NAME);
});
it('addOrigin merges and never removes', () => {
expect(addOrigin(undefined, NAME)).toBe(NAME);
expect(addOrigin('a.example, b.example', NAME)).toBe(`a.example,b.example,${NAME}`);
expect(addOrigin(`https://${NAME}`, NAME)).toBe(null);
expect(addOrigin('*', NAME)).toBe(null);
expect(addOrigin('a.example', `${NAME}:8443`)).toBe(`a.example,${NAME}:8443`);
expect(addOrigin(NAME, `${NAME}:8443`)).toBe(null);
});
});