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
11 changes: 6 additions & 5 deletions packages/sdk/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ function startObserverMint(
return mint({
workspaceKey: link.workspaceKey,
...(link.baseUrl !== undefined ? { baseUrl: link.baseUrl } : {}),
...(link.dashboardUrl !== undefined ? { dashboardUrl: link.dashboardUrl } : {}),
}).catch((error) => ({
warning: error instanceof Error ? error.message : 'unknown mint error',
}));
Expand All @@ -246,12 +247,12 @@ async function observerUrlFrom(
/**
* Grace budget the plain-text emit path waits for a still-pending mint after
* the RUN summary is out. `mintObserverUrl` already caps its own network
* round-trip at `MINT_TIMEOUT_MS` (5s), so a mint that has not completed by
* the time the run ends is almost certainly stuck; 2s is enough for the
* common "run finished before the mint round-tripped" case without holding
* the shell noticeably.
* round-trip at `MINT_TIMEOUT_MS` (5s). The grace matches that ceiling so a
* slow-but-legitimate mint (empirically ~1.6s cold against
* `cast.agentrelay.com`) is not clipped by a shorter grace. A mint that has
* not resolved by 5s is genuinely stuck.
*/
const OBSERVER_FINALIZE_GRACE_MS = 2_000;
const OBSERVER_FINALIZE_GRACE_MS = 5_000;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Observer command drops dashboard override

Medium Severity

runObserverCommand reads dashboardUrl from resolveObserverLinkEnv but never forwards it to mint. flows observer therefore always emits the default agentrelay.com host when RELAYCAST_DASHBOARD_URL is set, while startObserverMint honors the override. The two verbs no longer produce the same URL shape.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c2e9bb3. Configure here.


/**
* Finalize the plain-text observer line after the RUN summary is already on
Expand Down
52 changes: 38 additions & 14 deletions packages/sdk/src/observer-link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,22 +41,31 @@ const OBSERVER_SCOPES = [
const OBSERVER_TOKEN_TTL_MS = 60 * 60 * 24 * 1000;

/**
* Default Relaycast API base; overridable via `RELAYCAST_API_URL`.
* Default hosts for the two axes of the observer link. They are DIFFERENT
* subdomains and must not be conflated (empirically verified 2026-09-10):
*
* `agentrelay.com` returns 404 for `/v1/observer-tokens` — the mint endpoint
* only lives on the `cast.` subdomain. Setting a wrong default here made every
* `flows run` on a real workspace key silently 404 with `[observer] mint API
* returned HTTP 404`. Shakedown 2026-09-10 verified: `cast.agentrelay.com`
* accepts the mint (401 with a dummy token, 429 with a real one). Closes #278.
* - Mint API: `POST cast.agentrelay.com/v1/observer-tokens`. `agentrelay.com`
* returns 404 for this path. Overridable via `RELAYCAST_API_URL`.
* - Observer dashboard: `agentrelay.com/observer?key=<ot_live_...>`.
* `cast.agentrelay.com/observer` returns 404. Overridable via
* `RELAYCAST_DASHBOARD_URL`.
*
* An earlier fix collapsed both to `cast.agentrelay.com` and closed #278 for
* the mint half only; a demo verification then landed on the 404 dashboard
* URL. Keep the two constants distinct.
*/
const DEFAULT_RELAYCAST_URL = 'https://cast.agentrelay.com';
const DEFAULT_RELAYCAST_MINT_URL = 'https://cast.agentrelay.com';
const DEFAULT_RELAYCAST_DASHBOARD_URL = 'https://agentrelay.com';

/** Bounded so a stalled Relaycast API cannot delay the RUN summary. */
const MINT_TIMEOUT_MS = 5_000;

export interface ObserverLinkEnv {
workspaceKey?: string;
/** Base URL for the mint API (`POST /v1/observer-tokens`). */
baseUrl?: string;
/** Base URL for the observer dashboard the emitted link points at. */
dashboardUrl?: string;
/** `FLOWS_NO_OBSERVER=1` suppresses the mint even when a key is present. */
suppressed: boolean;
}
Expand Down Expand Up @@ -187,13 +196,18 @@ export function readObserverLinkEnv(
): ObserverLinkEnv {
const rawKey = env['RELAYCAST_WORKSPACE_KEY'];
const workspaceKey = typeof rawKey === 'string' ? rawKey.trim() : '';
const rawUrl = env['RELAYCAST_API_URL'];
const baseUrl = typeof rawUrl === 'string' && rawUrl.trim() !== ''
? rawUrl.trim()
const rawApi = env['RELAYCAST_API_URL'];
const baseUrl = typeof rawApi === 'string' && rawApi.trim() !== ''
? rawApi.trim()
: undefined;
const rawDashboard = env['RELAYCAST_DASHBOARD_URL'];
const dashboardUrl = typeof rawDashboard === 'string' && rawDashboard.trim() !== ''
? rawDashboard.trim()
: undefined;
return {
...(workspaceKey !== '' ? { workspaceKey } : {}),
...(baseUrl !== undefined ? { baseUrl } : {}),
...(dashboardUrl !== undefined ? { dashboardUrl } : {}),
suppressed: env['FLOWS_NO_OBSERVER'] === '1',
};
}
Expand All @@ -218,7 +232,12 @@ export type ObserverFetch = (

export interface MintObserverOptions {
workspaceKey: string;
/** Base URL of the mint API (default `https://cast.agentrelay.com`). */
baseUrl?: string;
/** Base URL of the observer dashboard the returned URL points at
* (default `https://agentrelay.com`). Separate axis from `baseUrl` — the
* mint API lives on a different subdomain from the dashboard. */
dashboardUrl?: string;
fetch?: ObserverFetch;
now?: () => number;
/** Called for the token's uniquely-suffixed name; injectable for tests. */
Expand Down Expand Up @@ -253,14 +272,19 @@ export async function mintObserverUrl(
return { warning: 'no fetch implementation available' };
}

const rawBase = options.baseUrl ?? DEFAULT_RELAYCAST_URL;
const rawApi = options.baseUrl ?? DEFAULT_RELAYCAST_MINT_URL;
const rawDashboard = options.dashboardUrl ?? DEFAULT_RELAYCAST_DASHBOARD_URL;
let mintUrl: URL;
let observerBase: URL;
try {
mintUrl = new URL('/v1/observer-tokens', rawBase);
observerBase = new URL('/observer', rawBase);
mintUrl = new URL('/v1/observer-tokens', rawApi);
} catch {
return { warning: `invalid RELAYCAST_API_URL "${rawApi}"` };
}
try {
observerBase = new URL('/observer', rawDashboard);
} catch {
return { warning: `invalid RELAYCAST_API_URL "${rawBase}"` };
return { warning: `invalid RELAYCAST_DASHBOARD_URL "${rawDashboard}"` };
}

const uuid = (options.uuid ?? defaultUuid)();
Expand Down
43 changes: 32 additions & 11 deletions packages/sdk/tests/observer-link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
resolveObserverLinkEnv,
type ObserverFetch,
} from '../src/observer-link.js';
import { socketPathFor } from '../src/daemon-connection.js';
import { sendOk, sendResult, startLoopback, type LoopbackHandlers } from './journal-client-loopback.js';

const TESTDATA = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'testdata');
Expand Down Expand Up @@ -71,7 +72,7 @@ function temporaryProject(prefix = 'flows-observer-'): string {
}

async function startCliLoopback(dataDir: string, handlers: LoopbackHandlers): Promise<void> {
const server = startLoopback(join(dataDir, 'relayflowd.sock'), handlers);
const server = startLoopback(socketPathFor(dataDir), handlers);
loopbackServers.push(server);
if (!server.listening) await once(server, 'listening');
}
Expand Down Expand Up @@ -102,7 +103,7 @@ describe('mintObserverUrl', () => {
now: () => 1_700_000_000_000,
});

expect(result).toEqual({ observerUrl: 'https://cast.agentrelay.com/observer?key=ot_live_abc123' });
expect(result).toEqual({ observerUrl: 'https://agentrelay.com/observer?key=ot_live_abc123' });
expect(fetch).toHaveBeenCalledOnce();
const [url, init] = fetch.mock.calls[0]!;
expect(url).toBe('https://cast.agentrelay.com/v1/observer-tokens');
Expand All @@ -114,7 +115,11 @@ describe('mintObserverUrl', () => {
expect(payload.expires_at).toBe(new Date(1_700_000_000_000 + 86_400_000).toISOString());
});

it('respects a custom RELAYCAST_API_URL as both the mint host and the observer host', async () => {
it('routes RELAYCAST_API_URL to the mint host only; the dashboard stays on its own default', async () => {
// The two hostnames are separate axes (mint API vs dashboard). Overriding
// one must not silently move the other, or a caller pointing the mint at
// a preview API would end up emitting production dashboard URLs (or
// vice versa).
const fetch = vi.fn<ObserverFetch>().mockResolvedValue(
jsonResponse(200, { data: { token: 'ot_live_zzz' } }),
);
Expand All @@ -125,8 +130,24 @@ describe('mintObserverUrl', () => {
fetch,
});

expect(result.observerUrl).toBe('https://relay.example.com/observer?key=ot_live_zzz');
expect(fetch.mock.calls[0]![0]).toBe('https://relay.example.com/v1/observer-tokens');
expect(result.observerUrl).toBe('https://agentrelay.com/observer?key=ot_live_zzz');
});

it('respects dashboardUrl independently of baseUrl', async () => {
const fetch = vi.fn<ObserverFetch>().mockResolvedValue(
jsonResponse(200, { data: { token: 'ot_live_dash' } }),
);

const result = await mintObserverUrl({
workspaceKey: 'rk_live_key',
baseUrl: 'https://relay.example.com',
dashboardUrl: 'https://observer.example.com',
fetch,
});

expect(fetch.mock.calls[0]![0]).toBe('https://relay.example.com/v1/observer-tokens');
expect(result.observerUrl).toBe('https://observer.example.com/observer?key=ot_live_dash');
});

it('returns a warning and no URL when the mint API returns 500', async () => {
Expand Down Expand Up @@ -329,7 +350,7 @@ describe('flows observer verb', () => {
const exit = await runCli(['observer'], output.io);

expect(exit).toBe(0);
expect(output.stdout).toEqual(['https://cast.agentrelay.com/observer?key=ot_live_verb']);
expect(output.stdout).toEqual(['https://agentrelay.com/observer?key=ot_live_verb']);
expect(output.stderr).toEqual([]);
expect(fetch).toHaveBeenCalledOnce();
});
Expand Down Expand Up @@ -416,7 +437,7 @@ describe('flows observer verb', () => {
const exit = await runCli(['observer'], output.io);

expect(exit).toBe(0);
expect(output.stdout).toEqual(['https://cast.agentrelay.com/observer?key=ot_live_via_login']);
expect(output.stdout).toEqual(['https://agentrelay.com/observer?key=ot_live_via_login']);
// Verify the mint was called with the fallback key, not with anything
// else -- specifically, not with an empty string that would sneak past
// the "workspaceKey === undefined" gate.
Expand All @@ -439,9 +460,9 @@ describe('flows observer verb', () => {
describe('finalizeObserverLine', () => {
it('prints Observer: <url> on stdout when the mint resolves within the grace budget', async () => {
const output = capture();
const mint = Promise.resolve({ observerUrl: 'https://cast.agentrelay.com/observer?key=ot_live_x' });
const mint = Promise.resolve({ observerUrl: 'https://agentrelay.com/observer?key=ot_live_x' });
await finalizeObserverLine(mint, output.io, 100);
expect(output.stdout).toEqual(['Observer: https://cast.agentrelay.com/observer?key=ot_live_x']);
expect(output.stdout).toEqual(['Observer: https://agentrelay.com/observer?key=ot_live_x']);
expect(output.stderr).toEqual([]);
});

Expand Down Expand Up @@ -514,7 +535,7 @@ describe('flows run: observer link integration', () => {
const runIndex = output.stdout.findIndex((line) => line.startsWith('RUN run-observer-happy'));
expect(runIndex).toBeGreaterThanOrEqual(0);
expect(output.stdout[runIndex + 1]).toBe(
'Observer: https://cast.agentrelay.com/observer?key=ot_live_integration_ok',
'Observer: https://agentrelay.com/observer?key=ot_live_integration_ok',
);
expect(fetch).toHaveBeenCalledOnce();
});
Expand Down Expand Up @@ -676,7 +697,7 @@ describe('flows run: observer link integration', () => {
const observerIndex = output.stdout.findIndex((line) => line.startsWith('Observer:'));
expect(observerIndex).toBeGreaterThan(runIndex);
expect(output.stdout[observerIndex]).toBe(
'Observer: https://cast.agentrelay.com/observer?key=ot_live_late',
'Observer: https://agentrelay.com/observer?key=ot_live_late',
);
});

Expand Down Expand Up @@ -715,6 +736,6 @@ describe('flows run: observer link integration', () => {
expect(jsonLine).toBeDefined();
const parsed = JSON.parse(jsonLine!) as { runId: string; observerUrl?: string };
expect(parsed.runId).toBe('run-observer-json');
expect(parsed.observerUrl).toBe('https://cast.agentrelay.com/observer?key=ot_live_json');
expect(parsed.observerUrl).toBe('https://agentrelay.com/observer?key=ot_live_json');
});
});
Loading