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
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
defaultConfiguredSshHostsUrl,
parseConfiguredSshHostCatalog,
} from "../src/data/ssh-host-catalog";
import {sourceReadApiUrl} from "../src/data/local-status-query";

function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
Expand Down Expand Up @@ -139,6 +140,20 @@ equal(runtimeStatusSourceCatalog(undefined, runtimeUrl, baseHref), null, "existi
equal(runtimeStatusSourceCatalog(runtimeEntries, "http://127.0.0.1:19803/status.json", baseHref), null, "an unrelated service cannot replace source choices");
equal(runtimeStatusSourceCatalog([...runtimeEntries, runtimeEntries[0]], runtimeUrl, baseHref), null, "duplicate runtime ids or URLs fail closed");
equal(runtimeStatusSourceCatalog([{...runtimeEntries[0], status_url: "https://external.example/status.json"}], runtimeUrl, baseHref), null, "runtime readers remain loopback-only");
const namespace = "a".repeat(64), changedNamespace = "b".repeat(64);
const pinnedUrl = `${runtimeUrl}?source_namespace=${namespace}`;
const pinnedEntries = [{...runtimeEntries[0], status_url: pinnedUrl}];
const pinned = runtimeStatusSourceCatalog(pinnedEntries, "/status.json", "http://127.0.0.1:19801/chat/");
assert(pinned, "an initial relative entry adopts only its own origin's source identity");
equal(statusSourceForUrl(pinned, "/status.json", "http://127.0.0.1:19801/chat/")?.statusUrl, pinnedUrl, "the prepared canonical URL retains its namespace");
equal(runtimeStatusSourceCatalog(pinnedEntries, `${runtimeUrl}?source_namespace=${changedNamespace}`, baseHref), null, "the same port cannot replace a pinned source");
const historyUrl = new URL(sourceReadApiUrl(pinnedUrl, "/api/chat/explore-results?goal_id=same&cursor=page", baseHref));
equal(historyUrl.searchParams.get("source_namespace"), namespace, "history requests retain physical source identity");
equal(historyUrl.searchParams.get("cursor"), "page", "source binding preserves history pagination");
equal(sourceReadApiUrl(undefined, "/api/chat/research-analysis?goal_id=same", baseHref), "/api/chat/research-analysis?goal_id=same", "ordinary local analysis is unchanged");
let escaped = false;
try {sourceReadApiUrl(pinnedUrl, "http://127.0.0.1:19802/api/chat/explore-results", baseHref);} catch {escaped = true;}
assert(escaped, "a Goal read cannot escape to another source's origin");
const runtimeStorage = new MemoryStorage();
saveStatusSourceCatalog(runtimeStorage, runtimes);
equal(loadStatusSourceCatalog(runtimeStorage, baseHref).sources.length, 1, "ephemeral runtime choices are not persisted as execution or SSH configuration");
Expand Down
16 changes: 16 additions & 0 deletions apps/presentation/dashboard/src/data/local-status-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,22 @@ export function scopedStatusUrl(
return url.toString();
}

/** Keep prepared source identity on every Goal read, including paged history. */
export function sourceReadApiUrl(statusUrl: string | undefined, path: string, baseHref?: string): string {
if (!statusUrl) return path;
const source = new URL(statusUrl, baseHref ?? window.location.href);
const target = new URL(path, source);
if (!/^https?:$/.test(source.protocol) || !isLoopbackHostname(source.hostname) || target.origin !== source.origin) {
throw new Error("Goal reads must stay on the selected source");
}
const namespaces = source.searchParams.getAll("source_namespace");
if (namespaces.length > 1 || (namespaces.length === 1 && !/^[0-9a-f]{64}$/.test(namespaces[0]))) {
throw new Error("Invalid Goal source identity");
}
if (namespaces.length) target.searchParams.set("source_namespace", namespaces[0]);
return target.toString();
}

export function statusContractFreshnessIssue(
payload: StatusPayload,
source: ResolvedFrontstageStatusUrl,
Expand Down
20 changes: 15 additions & 5 deletions apps/presentation/dashboard/src/data/status-source-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ export type StatusSourceCatalog = {

export type StatusSourceStorage = Pick<Storage, "getItem" | "setItem">;

function matchesRuntimeUrl(candidate: string, requested: string, baseHref: string): boolean {
const expected = new URL(candidate, baseHref), actual = new URL(requested, baseHref);
if (expected.toString() === actual.toString()) return true;
// An unbound entry may adopt this origin's first identity. A previously
// pinned identity must never be replaced, even on the same port.
if (actual.searchParams.has("source_namespace")) return false;
expected.searchParams.delete("source_namespace");
return expected.toString() === actual.toString();
}

/** Runtime choices describe physical readers, not new execution Goals. */
export function runtimeStatusSourceCatalog(
entries: {id: string; label: string; status_url: string}[] | undefined,
Expand All @@ -35,7 +45,7 @@ export function runtimeStatusSourceCatalog(
}
const current = new URL(currentUrl, baseHref).toString();
// A source cannot replace the selected origin with an unrelated catalog.
return sources.some((s) => s.statusUrl === current) ? {schemaVersion: 1, sources} : null;
return sources.some((s) => matchesRuntimeUrl(s.statusUrl, current, baseHref)) ? {schemaVersion: 1, sources} : null;
}

export const localStatusSource: StatusSource = {
Expand Down Expand Up @@ -191,10 +201,10 @@ export function statusSourceForUrl(catalog: StatusSourceCatalog, statusUrl: stri
} catch {
return null;
}
if (resolved.source?.isRelative) {
return catalog.sources.find((source) => source.kind === "runtime" && source.statusUrl === normalized)
?? localStatusSource;
}
const runtime = catalog.sources.find((source) => source.kind === "runtime"
&& matchesRuntimeUrl(source.statusUrl, normalized, baseHref));
if (runtime) return runtime;
if (resolved.source?.isRelative) return localStatusSource;
return catalog.sources.find((source) => source.statusUrl === normalized) ?? null;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {ArrowUpRight, LoaderCircle, RefreshCw, Sparkles, X} from "lucide-react";
import type {AssessmentCitation, TrajectoryEpisode} from "../../../../../../loopx/control_plane/capabilities/trajectory_assessment_types.js";
import {ResearchProgress, metricCopy, verdictLabel, type ResearchProgressPack} from "./goal-research-progress";
import {requestJson} from "../../data/chat";
import {sourceReadApiUrl} from "../../data/local-status-query";
import {ContextHelp} from "./context-help";
import "./goal-research-analysis.css";

Expand All @@ -24,7 +25,7 @@ const locationFor = (goal: string) => {
};

export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean, readSourceUrl?: string) {
const endpoint = readSourceUrl ? new URL("/api/chat/research-analysis", readSourceUrl).toString() : "/api/chat/research-analysis";
const endpoint = "/api/chat/research-analysis";
const [citation, setCitation] = useState<Citation | null>(null);
const [citationError, setCitationError] = useState("");
const [citationBusy, setCitationBusy] = useState(false);
Expand All @@ -49,7 +50,7 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean
const current = generation.current, sequence = ++readSequence.current;
const query = new URLSearchParams({goal_id: goalId, ...(packId ? {pack_id: packId} : {})});
let value: Snapshot;
try {value = await requestJson<Snapshot>(`${endpoint}?${query}`, {signal});}
try {value = await requestJson<Snapshot>(sourceReadApiUrl(readSourceUrl, `${endpoint}?${query}`), {signal});}
catch (e) {if (current === generation.current && sequence === readSequence.current && !signal?.aborted) setReadError(errorText(e)); throw e;}
if (!value?.ok || !Array.isArray(value.catalog?.packs) || (value.pack && value.pack.pack_id !== (packId ?? value.catalog.default_pack_id))) {
const failure = new Error(zh ? "分析来源读回不一致。" : "Analysis source readback does not match.");
Expand All @@ -58,7 +59,7 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean
}
if (current === generation.current && sequence === readSequence.current && scope.current.goalId === goalId && scope.current.packId === packId && !signal?.aborted) {setSnapshot(value); setReadError(""); if (value.pack?.job?.status === "succeeded" || value.pack?.job?.status === "cancelled") setError("");}
return value;
}, [goalId, location.packId, zh, endpoint]);
}, [goalId, location.packId, zh, readSourceUrl]);
const applyLocation = useCallback((next: typeof location) => {
// Selection and browser history share one source transition. Invalidate
// pending reads/actions before a new scope can display the old snapshot.
Expand All @@ -77,15 +78,15 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean
const pop = () => applyLocation(locationFor(goalId));
window.addEventListener("popstate", pop);
return () => {generation.current++; citeGeneration.current++; window.removeEventListener("popstate", pop);};
}, [goalId, applyLocation]);
}, [goalId, applyLocation, readSourceUrl]);
useEffect(() => {
if (!active) return;
const controller = new AbortController(); setLoading(true);
void read(location.packId, controller.signal).catch(() => undefined).finally(() => {if (!controller.signal.aborted) setLoading(false);});
return () => controller.abort();
}, [active, read, location.packId]);
const pack = snapshot?.pack ?? null;
useEffect(() => {citeGeneration.current++; setCitation(null); setCitationError(""); setCitationBusy(false);}, [goalId, snapshot?.pack?.pack_id, snapshot?.pack?.analysis_key]);
useEffect(() => {citeGeneration.current++; setCitation(null); setCitationError(""); setCitationBusy(false);}, [goalId, readSourceUrl, snapshot?.pack?.pack_id, snapshot?.pack?.analysis_key]);

// A pending request is observed here. A persisted running receipt alone is
// not proof that the model is currently executing; old receipts stop polling.
Expand Down Expand Up @@ -144,7 +145,7 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean
show();
const current = ++citeGeneration.current; setCitationBusy(true); setCitationError("");
const query = new URLSearchParams({goal_id: goalId, pack_id: p.pack_id, analysis_key: p.analysis_key, source_id: c.source_id, first_line: String(c.first_line), last_line: String(c.last_line)});
try {const value = await requestJson<Citation>(`${endpoint}/citation?${query}`); if (current === citeGeneration.current) {setCitation(value); requestAnimationFrame(() => {sourcePanel.current?.focus({preventScroll: true}); sourcePanel.current?.scrollIntoView({block: "nearest"});});}}
try {const value = await requestJson<Citation>(sourceReadApiUrl(readSourceUrl, `${endpoint}/citation?${query}`)); if (current === citeGeneration.current) {setCitation(value); requestAnimationFrame(() => {sourcePanel.current?.focus({preventScroll: true}); sourcePanel.current?.scrollIntoView({block: "nearest"});});}}
catch (e) {if (current === citeGeneration.current) setCitationError(errorText(e));}
finally {if (current === citeGeneration.current) setCitationBusy(false);}
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {ResearchAnalysisButton, ResearchAnalysisPanel, ResearchAnalysisStatus, R
import {buildExploreTimeline, parseExploreFindingSections} from "../../../../../../loopx/control_plane/presentation/explore_timeline.js";
import {GoalResearchTimeline} from "./goal-research-timeline";
import {requestJson} from "../../data/chat";
import {sourceReadApiUrl} from "../../data/local-status-query";
import {ContextHelp} from "./context-help";
import {groupUnlinkedHistoryQuestions, layoutExploreHistory, parseExploreHistoryPage, projectExploreHistory, type ExploreHistoryEvent} from "../../../../../../loopx/control_plane/presentation/explore_history.js";
import "./goal-research-history.css";
Expand Down Expand Up @@ -107,7 +108,7 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re
let cursor = "";
do {
const query = new URLSearchParams({goal_id: goalId, view: "history", ...(cursor ? {cursor} : {})});
const page = parseExploreHistoryPage(await requestJson<unknown>(readSourceUrl ? new URL(`/api/chat/explore-results?${query}`, readSourceUrl).toString() : `/api/chat/explore-results?${query}`, {signal: controller.signal}), goalId);
const page = parseExploreHistoryPage(await requestJson<unknown>(sourceReadApiUrl(readSourceUrl, `/api/chat/explore-results?${query}`), {signal: controller.signal}), goalId);
if (total !== -1 && total !== page.total) throw new Error("Explore history snapshot changed");
total = page.total; events.push(...page.items);
if (events.length > total || (page.next_cursor && (!page.items.length || cursors.has(page.next_cursor)))) throw new Error("Invalid Explore history pagination");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1122,6 +1122,8 @@ const en = {
"source.notAvailable": "Unavailable",
"source.readOnly": "SSH tunnel · read only",
"source.runtimeReadOnly": "Runtime · read only",
"source.runtimeUnavailable": "Cannot read {source}. Your selected source is unchanged.",
"source.failureDetails": "Read details",
"source.readOnlyNoticeDescription": "You can view Goals, Tasks, evidence, and run status. Writes, guidance, and Agent sessions remain on the source host.",
"source.readOnlyNoticeTitle": "Remote read-only projection",
"source.readOnlyWriteError": "Remote SSH tunnel sources are read-only projections and cannot perform control-plane writes.",
Expand Down Expand Up @@ -2708,6 +2710,8 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"source.notAvailable": "不可用",
"source.readOnly": "SSH 隧道 · 只读",
"source.runtimeReadOnly": "运行来源 · 只读",
"source.runtimeUnavailable": "暂时读不到 {source},已保留所选来源。",
"source.failureDetails": "读取详情",
"source.readOnlyNoticeDescription": "你可以查看 Goal、Task、证据与运行状态;写入、纠偏和 Agent 会话仍留在来源主机。",
"source.readOnlyNoticeTitle": "远端只读投影",
"source.readOnlyWriteError": "远端 SSH 隧道来源是只读投影,不能执行控制面写入。",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -965,6 +965,8 @@ export function PersonalWorkspacePage({
const [localGoalId, setLocalGoalId] = useState<string | null>(controlledGoalId ?? null);
const [localAgentId, setLocalAgentId] = useState(controlledAgentId ?? agents.find((agent) => agent.available)?.agentId ?? "codex");
const selectionScope = `${statusSourceControl?.activeSource.statusUrl ?? "/status.json"}:${controlledGoalId === undefined ? localGoalId : controlledGoalId}`;
const runtimeSourceUnavailable = statusSourceControl?.activeSource.kind === "runtime"
&& statusSourceControl.connectionState === "error";
const [selectionState, setSelectionState] = useState<{scope: string; value: WorkspaceDrawerSelection | null} | null>(null);
const selection = selectionState?.scope === selectionScope ? selectionState.value : null;
const currentSelectionScope = useRef({scope: selectionScope, generation: 0});
Expand Down Expand Up @@ -2700,7 +2702,7 @@ export function PersonalWorkspacePage({
onOpenGoalCapabilities={selectedGoal && !readOnly ? () => openSettings({ goalId: selectedGoal.goalId, kind: "settings", tab: "capabilities" }) : undefined}
onOpenManagerSettings={!readOnly ? () => openSettings({ kind: "settings", tab: "steward" }) : undefined}
onOpenConversationSettings={!readOnly ? () => openSettings({kind: "settings", tab: "conversation"}, true) : undefined}
onRefresh={callbacks.onRefresh ? () => void refreshWorkspace() : undefined}
onRefresh={callbacks.onRefresh && !runtimeSourceUnavailable ? () => void refreshWorkspace() : undefined}
onOpenNavigation={() => setMobileSidebarOpen(true)}
onOpenManagerChat={() => {
setManagerConversationReceiptVisible(false);
Expand All @@ -2725,7 +2727,7 @@ export function PersonalWorkspacePage({
selectedAgentReasoningEffort={selectedAgentReasoningEffort}
refreshState={refreshState}
readOnlySourceLabel={readOnly ? statusSourceControl?.activeSource.label : undefined}
selectedGoal={selectedGoal}
selectedGoal={runtimeSourceUnavailable ? null : selectedGoal}
selectedGoalTab={selectedGoalTab}
goalLifecycleBusy={selectedGoal ? lifecycleBusyGoalIds.has(selectedGoal.goalId) : false}
onOpenWorkspace={() => selectGoal(null)}
Expand All @@ -2734,8 +2736,13 @@ export function PersonalWorkspacePage({
: undefined}
resumeReviewed={!callbacks.onExecuteGoalLifecycle}
/>
{selectedGoal && !selectedGoal.loadState && !agentWorkActive ? <GoalVitals digest={workspaceDigest} goal={selectedGoal} items={items} userTodos={model.userTodos} onFocusRegion={focusGoalRegion} />
: selectedGoal?.loadState === "loading" ? <GoalVitalsSkeleton /> : null}
{runtimeSourceUnavailable ? <section className="personal-runtime-source-error" role="alert">
<div><strong>{t("source.runtimeUnavailable", {source: statusSourceControl.activeSource.label})}</strong>
{statusSourceControl.errorMessage ? <details><summary>{t("source.failureDetails")}</summary><p>{statusSourceControl.errorMessage}</p></details> : null}</div>
{callbacks.onRefresh ? <button disabled={refreshState === "loading"} type="button" onClick={() => void refreshWorkspace()}>{t("startup.retry")}</button> : null}
</section> : null}
{!runtimeSourceUnavailable && selectedGoal && !selectedGoal.loadState && !agentWorkActive ? <GoalVitals digest={workspaceDigest} goal={selectedGoal} items={items} userTodos={model.userTodos} onFocusRegion={focusGoalRegion} />
: !runtimeSourceUnavailable && selectedGoal?.loadState === "loading" ? <GoalVitalsSkeleton /> : null}
{!readOnly ? <UsageStatisticsNotice onDetails={() => openSettings({ kind: "settings", tab: "machine" })} /> : null}
</div>
{selectedGoalId && selectedGoalTab === "chat" && !agentWorkActive && !readOnly && selectedAgentId === "codex" && callbacks.onStartLoopX ? <GoalLoopXMode
Expand All @@ -2751,7 +2758,7 @@ export function PersonalWorkspacePage({
followConversationRef.current = nearBottom;
setShowLatestMessage(!nearBottom);
}}>
{!rootUnavailable ? <>
{!rootUnavailable && !runtimeSourceUnavailable ? <>
{selectedGoalId && selectedGoalTab === "chat" && !agentWorkActive && callbacks.onSelectWorkAgent ? <section className="attached-agent-conversations"><button type="button" className="attached-conversation-entry" onClick={() => callbacks.onSelectWorkAgent?.("")}>{locale === "zh-CN" ? "Agent 工作记录" : "Agent work conversations"}</button></section> : null}
{selectedGoalId && selectedGoalTab === "chat" && !agentWorkActive && !readOnly && selectedAgentId === "codex" && conversationSessionId && loopxMode?.settings.execution_config && loopxMode.settings.agent_id ? <GoalTeamResults
key={`${conversationSessionId}:${loopxMode.settings.agent_id}:${loopxMode.settings.execution_config}`}
Expand Down
Loading
Loading