Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
23 changes: 22 additions & 1 deletion docs-site/src/content/docs/guides/web-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,28 @@ they have been synchronized. See

## Remote Hub sessions, keys, and usage

In **Connect → API Keys**, every row of the key table has its own delete button, which asks for confirmation in place, and clicking a key shows its full value with a **Copy** button. Revealing a key needs a signed-in dashboard session; the admin token cannot read key values.
In **Connect → API Keys**, every row of the key table has its own delete button, which asks for confirmation in place. Clicking a key can show its full value with a **Copy** button only from an independently authorized session.

### Reading an existing API key

Showing a stored key's full value requires a dashboard session established by explicit pairing or
trusted Tailscale identity. An automatic local dashboard session can still show the masked key list,
but cannot reveal existing values; a raw admin token cannot call this session-only action either.
Use the existing [dashboard pairing flow](/guides/remote-hub/#pairing-this-browser-with-a-hub) to establish an
operator-authorized session before requesting a stored value; a refused reveal states the requirement
on the page. The local pairing form is offered only by a same-origin standalone dashboard opened
over HTTP at the literal `127.0.0.1` or `[::1]` address. On `http://localhost:<port>` (or another
localhost alias), reopen `http://127.0.0.1:<port>` and pair there. Pair again if that session expires
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
or is revoked. Ordinary dashboard sign-in, key creation, rotation, and
deletion are unchanged.

After pairing, click the key again to request its value. Pairing itself never reveals a key.
Displayed key values and copy feedback are cleared when the shared session is cleared or replaced,
pairing starts, you leave this panel, hide the browser tab or desktop window, or switch servers.
This also clears newly created and replacement values, so copy a one-time value before leaving
the panel. If the session or view changes while creation or rotation is in flight, the successful
mutation still refreshes that server's key list, but its one-time value stays hidden. Pending
rotations remain available for explicit commit or abort.

The dashboard's management plane is separate from direct client→hub model traffic. **Connect → API Keys** shows pending rotations, displays a replacement secret only once, and requires explicit commit or abort. Browser logout invalidates only the current remote session. Connected usage is the hub store filtered by the client's `apiKeyId`; disconnected usage is local, with no mirroring.

Expand Down
20 changes: 20 additions & 0 deletions docs-site/src/content/docs/ko/guides/web-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,26 @@ Dashboard의 **Sub-agent delegation** 선택기는 `injectionModel`과 선택적

대시보드 관리 API와 클라이언트에서 허브로 가는 모델 요청은 서로 다른 경로입니다. **연결 → API 키**에서는 진행 중인 키 교체를 확인하고, 새 키를 한 번만 표시하며, 확정 또는 취소를 직접 눌러야 합니다. 브라우저 로그아웃은 현재 원격 세션만 끝냅니다. 연결 중 사용량은 허브에서 해당 `apiKeyId`만 보고, 연결 해제 후에는 로컬 기록을 보며 서로 복제하지 않습니다.

### 기존 API 키의 전체 값 조회

저장된 키의 전체 값을 보려면 명시적인 페어링이나 신뢰된 Tailscale 인증으로 발급된
대시보드 세션이 필요합니다. 자동 발급된 로컬 세션은 마스킹된 목록을 계속 볼 수 있지만,
기존 키의 전체 값은 조회할 수 없습니다. 관리자 토큰도 이 세션 전용 동작을 호출하지 못합니다.
기존 대시보드 페어링 절차로 세션을 승인한 뒤 조회하세요. 거부된 조회는 페이지에서
요구 사항을 안내합니다. 로컬 페어링 폼은 같은 출처의 단독 대시보드를 HTTP의
`127.0.0.1` 또는 `[::1]` IP 주소에서 열었을 때만 제공합니다. `http://localhost:<port>`나
다른 localhost 별칭으로 접속했다면 `http://127.0.0.1:<port>`로 다시 열어 페어링하세요.
세션이 만료되거나 해제되면 다시 페어링해야 합니다. 일반 대시보드
접속과 키 생성·교체·삭제는 변경하지 않습니다.

페어링을 마친 뒤 키를 다시 클릭해야 전체 값을 조회합니다. 페어링만으로 키를
자동 조회하지 않습니다. 공유 세션이 해제되거나 교체될 때, 페어링을 시작할 때,
이 패널을 벗어나거나 브라우저 탭 또는 데스크톱 창을 숨길 때, 서버를 바꿀 때 표시된
키와 복사 상태를 지웁니다. 새로 생성하거나 교체한 값도 지우므로 일회성 값은 패널을
벗어나기 전에 복사하세요. 생성·교체 요청 중 세션이나 화면이 바뀌어도 성공한 작업은
해당 서버의 키 목록에 반영하지만, 일회성 값은 숨깁니다. 진행 중인 교체는 명시적으로
확정하거나 취소할 수 있습니다.

선택기에는 활성화된 네이티브 및 라우팅 모델과 Codex 전역 reasoning 단계가 표시됩니다. API는
선택한 강도가 전역 단계에 있는지 검사하고, Codex는 다시 대상 카탈로그 항목이 그 강도를 지원하는지
검사합니다.
Expand Down
27 changes: 18 additions & 9 deletions gui/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { adminTokenPromptAllowed, standaloneApiTargets, type ApiPlane, type ApiT
* its existing readiness state; cancelled callers and newer valid sessions emit no notice.
*/
export const SESSION_UNAVAILABLE_EVENT = "opencodex:session-unavailable";
/** Token identity changed; contains only the plane, never credential material. */
export const SESSION_CHANGED_EVENT = "opencodex:session-changed";

const LEGACY_TOKEN_KEY = "opencodex-api-token";
// Any guarded route answers 401 for a bad token; this one is a cheap config read. /api/settings
Expand Down Expand Up @@ -43,18 +45,24 @@ let rebootstrapTimeoutMs = SESSION_REBOOTSTRAP_TIMEOUT_MS;
let resolutionWatchdogMs = RESOLUTION_WATCHDOG_MS;
const runtimes = new Map<ApiPlane, TargetRuntime>();

function reportSessionUnavailable(plane: ApiPlane): void {
function reportSessionEvent(plane: ApiPlane, event: string): void {
if (typeof window === "undefined") return;
// Take the constructor off the same window we dispatch on: a test harness (and a
// sandboxed embed) can supply a document without installing CustomEvent globally.
const Ctor = (window as unknown as { CustomEvent?: typeof CustomEvent }).CustomEvent
?? (typeof CustomEvent === "function" ? CustomEvent : null);
if (!Ctor) return;
try {
window.dispatchEvent(new Ctor(SESSION_UNAVAILABLE_EVENT, { detail: { plane } }));
window.dispatchEvent(new Ctor(event, { detail: { plane } }));
} catch { /* a shell that cannot receive the notice must not break the fetch path */ }
}

function updateSession(state: TargetRuntime, session: ApiSessionState): void {
const changed = state.session.token !== session.token;
state.session = session;
if (changed) reportSessionEvent(state.target.id, SESSION_CHANGED_EVENT);
}

function blankSession(): ApiSessionState {
return { token: null, csrfToken: null, browserOrigin: null, serverOrigin: null };
}
Expand All @@ -72,6 +80,7 @@ export function configureApiTargets(targets: ApiTargets): void {
configuredTargets = targets;
for (const plane of ["machine", "shared"] as const) {
const current = runtimes.get(plane);
if (current && !sameTarget(current.target, targets[plane])) updateSession(current, blankSession());
runtimes.set(plane, current && sameTarget(current.target, targets[plane])
? { ...current, target: targets[plane] }
: { target: targets[plane], session: blankSession(), resolutionInFlight: null, promptCancelled: false });
Expand All @@ -85,7 +94,7 @@ function runtime(plane: ApiPlane): TargetRuntime {

function clearSessionIfCurrent(plane: ApiPlane, expected: string | null): void {
const state = runtime(plane);
if (expected !== null && state.session.token === expected) state.session = blankSession();
if (expected !== null && state.session.token === expected) updateSession(state, blankSession());
}

function storeSession(
Expand All @@ -98,10 +107,10 @@ function storeSession(
const state = runtime(plane);
if (!token?.startsWith("ocx_session_") || !csrfToken
|| browserOrigin !== window.location.origin || serverOrigin !== state.target.serverOrigin) {
state.session = blankSession();
updateSession(state, blankSession());
return false;
}
state.session = { token, csrfToken, browserOrigin, serverOrigin };
updateSession(state, { token, csrfToken, browserOrigin, serverOrigin });
state.promptCancelled = false;
return true;
}
Expand All @@ -120,7 +129,7 @@ export async function logoutApiSession(plane: ApiPlane): Promise<boolean> {
signal: bounded.signal,
});
if (!response.ok) return false;
state.session = blankSession();
updateSession(state, blankSession());
state.promptCancelled = false;
return true;
} catch {
Expand Down Expand Up @@ -287,7 +296,7 @@ async function resolveTokenAfter401(plane: ApiPlane, failedToken: string | null,
}
const prompted = await requestAdminToken(token => verifyAdminToken(plane, token));
if (prompted) {
state.session = { token: prompted, csrfToken: null, browserOrigin: null, serverOrigin: state.target.serverOrigin };
updateSession(state, { token: prompted, csrfToken: null, browserOrigin: null, serverOrigin: state.target.serverOrigin });
return prompted;
}
state.promptCancelled = true;
Expand Down Expand Up @@ -337,14 +346,14 @@ export function installApiAuthFetch(): void {
const callerSignal = init?.signal ?? (input instanceof Request ? input.signal : undefined);
const nextToken = await resolveTokenAfter401(classified.plane, token, callerSignal ?? undefined);
if (!nextToken) {
if (!callerSignal?.aborted && !hasApiSession(classified.plane)) reportSessionUnavailable(classified.plane);
if (!callerSignal?.aborted && !hasApiSession(classified.plane)) reportSessionEvent(classified.plane, SESSION_UNAVAILABLE_EVENT);
return response;
}
const [retryInput, retryInit] = withAuth(classified.plane, input, init, nextToken);
const retry = await originalFetch(retryInput, retryInit);
if (retry.status === 401) {
clearSessionIfCurrent(classified.plane, nextToken);
if (!callerSignal?.aborted && !hasApiSession(classified.plane)) reportSessionUnavailable(classified.plane);
if (!callerSignal?.aborted && !hasApiSession(classified.plane)) reportSessionEvent(classified.plane, SESSION_UNAVAILABLE_EVENT);
}
return retry;
};
Expand Down
22 changes: 20 additions & 2 deletions gui/src/client-resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type ResourceSnapshot<T> = {
};

type Store<T> = {
key: string;
snapshot: ResourceSnapshot<T>;
listeners: Set<() => void>;
/** listener → requested poll interval (undefined = no poll from that subscriber) */
Expand Down Expand Up @@ -58,6 +59,8 @@ type Store<T> = {
* different resource types (no runtime check — keys are an API contract).
*/
const stores = new Map<string, Store<unknown>>();
// Explicit invalidations survive store eviction and fresh session-cache seeds.
const invalidatedKeys = new Set<string>();

/**
* Per-attempt deadline for every store fetch. Endpoints documented as slow finish in
Expand All @@ -81,6 +84,7 @@ function getStore<T>(key: string): Store<T> {
let store = stores.get(key) as Store<T> | undefined;
if (!store) {
store = {
key,
snapshot: {
data: undefined,
error: undefined,
Expand Down Expand Up @@ -360,6 +364,7 @@ async function runFetch<T>(
if (gen !== store.generation || controller.signal.aborted) return;
// Cleared on settle (not at subscribe) so StrictMode's aborted first mount still revalidates.
store.seedNeedsRevalidate = false;
invalidatedKeys.delete(store.key);
store.lastSettledAt = Date.now();
store.snapshot = {
data,
Expand Down Expand Up @@ -458,7 +463,7 @@ function subscribeResource<T>(
const stale = typeof staleAfterMs === "number"
&& store.lastSettledAt !== undefined
&& Date.now() - store.lastSettledAt > staleAfterMs;
if (store.snapshot.data === undefined || store.seedNeedsRevalidate || stale) {
if (store.snapshot.data === undefined || store.seedNeedsRevalidate || invalidatedKeys.has(key) || stale) {
void runFetch(store, fetcher, { replaceInflight: true, owner: onStoreChange, deadlineMs });
}
}
Expand Down Expand Up @@ -530,7 +535,7 @@ function seedClientResourceIfEmpty<T>(key: string, data: T, cachedAt?: number |
setClientResourceData(key, data);
// A seed fresher than the staleness window skips the mount revalidation entirely:
// that is the whole request saved on a tab revisit. Unknown age counts as stale.
if (typeof staleAfterMs === "number" && typeof cachedAt === "number" && Date.now() - cachedAt < staleAfterMs) {
if (!invalidatedKeys.has(key) && typeof staleAfterMs === "number" && typeof cachedAt === "number" && Date.now() - cachedAt < staleAfterMs) {
store.seedNeedsRevalidate = false;
store.lastSettledAt = cachedAt;
}
Expand Down Expand Up @@ -638,6 +643,18 @@ export function useKeyedClientResource<T>(
return resource;
}

/** Mark a resource stale without publishing a snapshot. Active subscribers refresh
* through runFetch; an inactive/evicted key revalidates on its next subscription. */
export function invalidateClientResource(key: string): void {
invalidatedKeys.add(key);
const store = stores.get(key);
if (!store) return;
const entry = pickFetcherEntry(store);
if (entry) {
void runFetch(store, entry.fetcher, { replaceInflight: true, owner: entry.owner, deadlineMs: entry.deadlineMs });
}
}

/** Publish data for a key and invalidate any in-flight fetch so it cannot stomp this write. */
export function setClientResourceData<T>(key: string, data: T) {
const store = getStore<T>(key);
Expand Down Expand Up @@ -670,6 +687,7 @@ export function clearClientResourceStoresForTests(): void {
store.inflightOwner = null;
}
stores.clear();
invalidatedKeys.clear();
for (const [intervalMs, bucket] of [...pollBuckets]) {
if (bucket.timer !== null) clearInterval(bucket.timer);
pollBuckets.delete(intervalMs);
Expand Down
Loading
Loading