Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ program.hook('postAction', (_thisCommand, actionCommand) => {
if (name === 'status' || name === 'update') return;
if (!actionCommand.opts()['json']) {
const hint = updateHint();
if (hint) console.log(chalk.dim(hint));
if (hint) console.error(chalk.dim(hint)); // stderr: never corrupt piped stdout
}
void refreshUpdateCache();
});
Expand Down
2 changes: 1 addition & 1 deletion src/commands/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ const warnDaemonMismatch = async (): Promise<void> => {
const h = await health(resolvePort());
if (!h) return;
const notice = mismatchNotice(h.version);
if (notice) console.log(chalk.yellow(notice));
if (notice) console.error(chalk.yellow(notice)); // diagnostics go to stderr
};

export const runStatus = async (opts: { json?: boolean } = {}): Promise<void> => {
Expand Down
14 changes: 14 additions & 0 deletions src/lib/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ describe('authedGet', () => {
expect(result.ok).toBe(true);
expect(fetchMock).toHaveBeenCalledWith('https://x.example/me', {
headers: { authorization: 'Bearer old-token' },
redirect: 'manual',
});
});

Expand Down Expand Up @@ -79,6 +80,18 @@ describe('authedGet', () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(500, {})));
await expect(authedGet(makeAuth(), target, 'https://x.example/x')).rejects.toThrow('500');
});

it('refuses to follow a redirect and reports its status', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(302, {}));
vi.stubGlobal('fetch', fetchMock);
await expect(authedGet(makeAuth(), target, 'https://x.example/me')).rejects.toThrow(
'refused redirect (302)'
);
expect(fetchMock).toHaveBeenCalledWith('https://x.example/me', {
headers: { authorization: 'Bearer old-token' },
redirect: 'manual',
});
});
});

describe('authedPost', () => {
Expand Down Expand Up @@ -107,6 +120,7 @@ describe('authedPost', () => {
method: 'POST',
headers: { authorization: 'Bearer old-token', 'content-type': 'application/json' },
body: JSON.stringify({ name: 'acct', channel: 'couch' }),
redirect: 'manual',
});
});

Expand Down
11 changes: 10 additions & 1 deletion src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,20 @@ export const currentBearer = async (
return auth.tokens.accessToken;
};

// redirect: 'manual' so the bearer is never replayed to a redirect target; any 3xx is an error.
const isRedirect = (res: Response): boolean =>
res.type === 'opaqueredirect' || (res.status >= 300 && res.status < 400);

export const authedGet = async <T>(auth: AuthState, links: Links, url: string): Promise<T> => {
const call = (): Promise<Response> =>
fetch(url, { headers: { authorization: `Bearer ${auth.tokens.accessToken}` } });
fetch(url, {
headers: { authorization: `Bearer ${auth.tokens.accessToken}` },
redirect: 'manual',
});
let res = await call();
if (res.status === 401 && (await tryRefresh(auth, links))) res = await call();
if (res.status === 401) throw new AuthRequiredError('session expired');
if (isRedirect(res)) throw new Error(`GET ${url} refused redirect (${res.status})`);
if (!res.ok) throw new Error(`GET ${url} failed (${res.status})`);
return (await res.json()) as T;
};
Expand All @@ -72,6 +80,7 @@ export const authedPost = async (
'content-type': 'application/json',
},
body: JSON.stringify(body),
redirect: 'manual',
});
let res = await call();
if (res.status === 401 && (await tryRefresh(auth, links))) res = await call();
Expand Down
12 changes: 12 additions & 0 deletions src/lib/callback-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,18 @@ describe('startCallbackServer', () => {
}
});

it('binds loopback so the callback is reachable on 127.0.0.1', async () => {
const server = await startCallbackServer('state-1');
try {
const port = new URL(server.redirectUri).port;
const res = await fetch(`http://127.0.0.1:${port}/callback?code=code-4&state=state-1`);
expect(res.status).toBe(200);
await expect(server.waitForCode(1000)).resolves.toBe('code-4');
} finally {
server.close();
}
});

it('times out when no callback arrives', async () => {
const server = await startCallbackServer('s');
try {
Expand Down
3 changes: 2 additions & 1 deletion src/lib/callback-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ export const startCallbackServer = (expectedState: string): Promise<CallbackServ
finish(code);
});

server.listen(0, () => {
// Bind loopback only so the one-shot sign-in callback is never LAN-reachable.
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
resolveServer({
Expand Down
13 changes: 13 additions & 0 deletions src/lib/http.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { fetchJsonUnref } from './http.js';

describe('fetchJsonUnref', () => {
it('resolves null within the timeout bound on an unreachable host', async () => {
const started = Date.now();
// 192.0.2.1 (TEST-NET-1) either blackholes (the destroy timer fires) or refuses fast.
const result = await fetchJsonUnref('https://192.0.2.1/x', 500);
expect(result).toBeNull();
// Generous CI bound (timers stretch under load); still well under undici's ~10s floor.
expect(Date.now() - started).toBeLessThan(8000);
});
});
39 changes: 39 additions & 0 deletions src/lib/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { get } from 'node:https';

export interface UnrefResponse {
ok: boolean;
status: number;
json: unknown;
}

// node:https instead of global fetch: undici keeps a ref'd ~10s connect timer alive even after
// its AbortSignal fires, stalling process exit on an unreachable network. req.destroy() from an
// unref'd timer caps the whole attempt and frees the event loop the moment it settles. Never
// throws: an unreachable host or malformed body resolves null (unreachable) or json:null.
export const fetchJsonUnref = (url: string, timeoutMs: number): Promise<UnrefResponse | null> =>
new Promise((resolve) => {
const req = get(url, { headers: { Accept: 'application/json' } }, (res) => {
let body = '';
res.setEncoding('utf8');
res.on('data', (chunk: string) => {
body += chunk;
});
res.on('end', () => {
const status = res.statusCode ?? 0;
let json: unknown = null;
try {
json = body ? JSON.parse(body) : null;
} catch {
json = null;
}
resolve({ ok: status >= 200 && status < 300, status, json });
});
});
const timer = setTimeout(() => req.destroy(), timeoutMs);
timer.unref();
req.once('error', () => resolve(null));
req.once('close', () => {
clearTimeout(timer);
resolve(null); // no-op when already resolved
});
});
14 changes: 14 additions & 0 deletions src/lib/oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,4 +144,18 @@ describe('revokeToken', () => {
await expect(revokeToken(baseUrl, 'at')).resolves.toBeUndefined();
await expect(revokeToken('http://127.0.0.1:1', 'at')).resolves.toBeUndefined();
});

it('aborts a stalled revoke within the timeout instead of hanging', async () => {
const hang = createServer(() => {
// never responds: the AbortSignal.timeout must cap the request
});
await new Promise<void>((resolve) => hang.listen(0, '127.0.0.1', resolve));
const addr = hang.address();
const url = `http://127.0.0.1:${typeof addr === 'object' && addr ? addr.port : 0}`;
const started = Date.now();
await expect(revokeToken(url, 'at', 50)).resolves.toBeUndefined();
expect(Date.now() - started).toBeLessThan(2000);
hang.closeAllConnections();
hang.close();
});
});
9 changes: 8 additions & 1 deletion src/lib/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,12 +100,19 @@ export const refreshTokens = (
client_id: clientId,
});

export const revokeToken = async (authUrl: string, token: string): Promise<void> => {
// AbortSignal.timeout caps a stalled revoke on a packet-drop network; residual undici keepalive is
// acceptable here since --disconnect exits right after and errors are swallowed anyway.
export const revokeToken = async (
authUrl: string,
token: string,
timeoutMs = 3000
): Promise<void> => {
try {
await fetch(`${authUrl}${REVOKE_PATH}`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ token }).toString(),
signal: AbortSignal.timeout(timeoutMs),
});
} catch {
// best-effort: local credentials are removed regardless
Expand Down
73 changes: 40 additions & 33 deletions src/lib/status-info.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { type AuthState } from './config.js';
import { fetchJsonUnref } from './http.js';
import { gatherStatus } from './status-info.js';

vi.mock('./http.js', () => ({ fetchJsonUnref: vi.fn() }));
const httpMock = vi.mocked(fetchJsonUnref);

const auth: AuthState = {
siteFqdn: 'dev.agentage.io',
clientId: 'c1',
Expand All @@ -11,25 +15,27 @@ const auth: AuthState = {
const jsonResponse = (status: number, body: unknown): Response =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });

const stubFetch = (routes: Record<string, () => Response>): void => {
// The update check now reads the npm registry; default it to "current" so it never adds noise.
const all = { 'cli/latest': () => jsonResponse(200, { version: '0.0.0' }), ...routes };
vi.stubGlobal(
'fetch',
vi.fn((url: string) => {
for (const [suffix, response] of Object.entries(all)) {
if (url.endsWith(suffix)) return Promise.resolve(response());
}
return Promise.reject(new Error(`unmatched url: ${url}`));
})
);
// health + the npm-registry update check go through fetchJsonUnref (node:https); introspection
// (get-session) still uses global fetch via authedGet.
const stubHttp = (health: 'reachable' | 'unreachable'): void => {
httpMock.mockImplementation((url: string) => {
if (url.endsWith('/latest'))
return Promise.resolve({ ok: true, status: 200, json: { version: '0.0.0' } });
if (url.endsWith('/health'))
return Promise.resolve(health === 'reachable' ? { ok: true, status: 200, json: {} } : null);
return Promise.resolve(null);
});
};

afterEach(() => vi.unstubAllGlobals());
beforeEach(() => stubHttp('reachable'));

afterEach(() => {
httpMock.mockReset();
vi.unstubAllGlobals();
});

describe('gatherStatus', () => {
it('reports a degraded status when not signed in', async () => {
stubFetch({ '/health': () => jsonResponse(200, { ok: true }) });
const report = await gatherStatus(null, 'dev.agentage.io');
expect(report.env).toBe('development');
expect(report.auth.signedIn).toBe(false);
Expand All @@ -39,47 +45,48 @@ describe('gatherStatus', () => {
});

it('marks the endpoint unreachable on network failure', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('refused')));
stubHttp('unreachable');
const report = await gatherStatus(null, 'agentage.io');
expect(report.endpoint.reachable).toBe(false);
});

it('reports signed-in with token expiry when introspection succeeds', async () => {
stubFetch({
'/health': () => jsonResponse(200, {}),
'/get-session': () =>
jsonResponse(200, { userId: 'u1', accessTokenExpiresAt: '2026-06-12T20:00:00Z' }),
});
vi.stubGlobal(
'fetch',
vi.fn(async () =>
jsonResponse(200, { userId: 'u1', accessTokenExpiresAt: '2026-06-12T20:00:00Z' })
)
);
const report = await gatherStatus(auth, 'dev.agentage.io');
expect(report.auth).toEqual({ signedIn: true, tokenExpiresAt: '2026-06-12T20:00:00Z' });
});

it('treats a 200 + null session as signed-out instead of crashing', async () => {
stubFetch({
'/health': () => jsonResponse(200, {}),
'/get-session': () => jsonResponse(200, null),
});
vi.stubGlobal(
'fetch',
vi.fn(async () => jsonResponse(200, null))
);
const report = await gatherStatus(auth, 'dev.agentage.io');
expect(report.auth.signedIn).toBe(false);
expect(report.auth.note).toContain('agentage setup');
expect(report.auth.note).not.toContain('Cannot read properties');
});

it('downgrades to signed-out with a hint when the token is rejected', async () => {
stubFetch({
'/health': () => jsonResponse(200, {}),
'/get-session': () => jsonResponse(401, {}),
});
vi.stubGlobal(
'fetch',
vi.fn(async () => jsonResponse(401, {}))
);
const report = await gatherStatus(auth, 'dev.agentage.io');
expect(report.auth.signedIn).toBe(false);
expect(report.auth.note).toContain('session expired');
});

it('reports verification failures without claiming signed-in', async () => {
stubFetch({
'/health': () => jsonResponse(200, {}),
'/get-session': () => jsonResponse(500, {}),
});
vi.stubGlobal(
'fetch',
vi.fn(async () => jsonResponse(500, {}))
);
const report = await gatherStatus(auth, 'dev.agentage.io');
expect(report.auth.signedIn).toBe(false);
expect(report.auth.note).toContain('could not verify session');
Expand Down
11 changes: 5 additions & 6 deletions src/lib/status-info.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AuthRequiredError, introspectToken } from './api.js';
import { type AuthState } from './config.js';
import { fetchJsonUnref } from './http.js';
import { environment, links, type Env } from './origins.js';
import { checkForUpdate, type UpdateInfo } from './update-check.js';
import { VERSION } from '../utils/version.js';
Expand All @@ -13,13 +14,11 @@ export interface StatusReport {
update: UpdateInfo;
}

// node:https via fetchJsonUnref, not global fetch: undici's ref'd connect timer keeps the process
// alive ~10s after an aborted request on a packet-drop network, stalling `status` exit.
const checkEndpoint = async (apiUrl: string): Promise<boolean> => {
try {
const res = await fetch(`${apiUrl}/health`, { signal: AbortSignal.timeout(3000) });
return res.ok;
} catch {
return false;
}
const res = await fetchJsonUnref(`${apiUrl}/health`, 3000);
return res?.ok ?? false;
};

export const gatherStatus = async (auth: AuthState | null, fqdn: string): Promise<StatusReport> => {
Expand Down
Loading