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
49 changes: 49 additions & 0 deletions apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1371,6 +1371,55 @@ export type MachineConfigurationPreview = z.infer<typeof machineConfigurationPre
export type MachineConfigurationTransaction = z.infer<typeof machineConfigurationTransactionSchema>;
export type MachineConfigurationRollbackPlan = z.infer<typeof machineConfigurationRollbackPlanSchema>;

// The operator credential readback is redacted by construction: the key field
// carries a fingerprint and never a value, so this schema has no place to put
// one even if a future server tried to send it.
export const operatorCredentialFieldSchema = z.object({
configured: z.boolean(),
source: z.enum(["machine_store", "service_environment", "unset"]),
env_var: z.string().optional(),
fingerprint: z.string().nullable().optional(),
value: z.string().nullable().optional(),
blocked_by: z.string().optional(),
});

export const operatorCredentialSchema = z.object({
ok: z.literal(true),
// The chat route returns the same versioned projection the CLI prints, so the
// browser and the terminal cannot drift into two spellings of one readback.
schema_version: z.literal("operator_provider_credential_projection_v0"),
action: z.string().optional(),
store_ref: z.string(),
store_revision: z.string(),
record_present: z.boolean(),
status: z.enum(["configured", "absent", "invalid"]),
repair: z.string(),
provider_key: operatorCredentialFieldSchema,
base_url: operatorCredentialFieldSchema,
});

export type OperatorCredential = z.infer<typeof operatorCredentialSchema>;

export async function fetchOperatorCredential() {
return operatorCredentialSchema.parse(
await requestJson<unknown>("/api/chat/operator-credential"),
);
}

export async function writeOperatorCredential(update: {
provider_key?: string;
base_url?: string;
clear_provider_key?: boolean;
clear_base_url?: boolean;
}) {
return operatorCredentialSchema.parse(
await requestJson<unknown>("/api/chat/operator-credential", {
method: "POST",
body: JSON.stringify(update),
}),
);
}

export async function fetchMachineConfiguration() {
return machineConfigurationInspectionSchema.parse(
await requestJson<unknown>("/api/chat/machine-configuration"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,26 @@ const en = {
"machine.editorMode": "Editor mode",
"machine.liveDefault": "Live default; Goal override wins",
"machine.liveDefaultDescription": "Goals without an explicit override read the current machine policy at the capability’s next decision point. Changes and removal affect those existing Goals immediately; an explicit Goal override stays pinned.",
"machine.credentialTitle": "Operator model credential",
"machine.credentialDescription": "The key and endpoint the steward channel and the managed host authenticate with on this machine. The key is stored in its own owner-only file, never in the machine configuration that is projected here, and it is never read back — only its fingerprint is.",
"machine.credentialApiKey": "API key",
"machine.credentialApiKeyPlaceholder": "Paste a key to store it; leave blank to keep the stored one",
"machine.credentialBaseUrl": "Endpoint base URL",
"machine.credentialBaseUrlPlaceholder": "https://endpoint.example/v1 (blank keeps the endpoint default)",
"machine.credentialStore": "Store credential",
"machine.credentialClearKey": "Clear stored key",
"machine.credentialClearUrl": "Clear stored endpoint",
"machine.credentialConfigured": "configured",
"machine.credentialAbsent": "not configured",
"machine.credentialInvalid": "unreadable — repair required",
"machine.credentialSourceMachine": "this machine's stored credential",
"machine.credentialSourceEnvironment": "the service environment",
"machine.credentialSourceUnset": "no source",
"machine.credentialFingerprint": "fingerprint",
"machine.credentialStored": "Credential stored. The next turn uses it; no restart is needed.",
"machine.credentialCleared": "Stored credential cleared.",
"machine.credentialError": "The credential could not be stored.",
"machine.credentialBoundary": "Storing a credential grants no authority: it does not select an executor, model, or reasoning effort.",
"machine.genericNamespaceDescription": "Edit this registered namespace as JSON. LoopX validates it with the capability-owned schema before previewing any write.",
"machine.jsonConfiguration": "Namespace configuration (JSON)",
"machine.jsonConfigurationHelp": "Only this namespace is updated. Other machine configuration is preserved, and Apply remains locked to the reviewed preview revision.",
Expand Down Expand Up @@ -1809,6 +1829,26 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"machine.editorMode": "编辑模式",
"machine.liveDefault": "实时默认值;Goal 显式覆盖优先",
"machine.liveDefaultDescription": "没有显式覆盖的 Goal 会在该能力下一次决策时读取当前机器策略。修改或移除策略会立即影响这些已有 Goal;显式 Goal 覆盖保持固定。",
"machine.credentialTitle": "操作者模型凭据",
"machine.credentialDescription": "管家通道与托管宿主在本机认证用的 key 与 endpoint。key 单独存放于仅属主可读的文件,不会进入这里展示的机器配置,也不会被回读——回读的是它的指纹。",
"machine.credentialApiKey": "API key",
"machine.credentialApiKeyPlaceholder": "粘贴 key 以保存;留空则保留已存的 key",
"machine.credentialBaseUrl": "Endpoint base URL",
"machine.credentialBaseUrlPlaceholder": "https://endpoint.example/v1(留空则使用 endpoint 默认值)",
"machine.credentialStore": "保存凭据",
"machine.credentialClearKey": "清除已存 key",
"machine.credentialClearUrl": "清除已存 endpoint",
"machine.credentialConfigured": "已配置",
"machine.credentialAbsent": "未配置",
"machine.credentialInvalid": "无法读取——需要修复",
"machine.credentialSourceMachine": "本机已存凭据",
"machine.credentialSourceEnvironment": "服务环境变量",
"machine.credentialSourceUnset": "无来源",
"machine.credentialFingerprint": "指纹",
"machine.credentialStored": "凭据已保存。下一轮即生效,无需重启。",
"machine.credentialCleared": "已清除本机存储的凭据。",
"machine.credentialError": "凭据保存失败。",
"machine.credentialBoundary": "保存凭据不授予任何权限:它不会选择执行器、模型或推理强度。",
"machine.genericNamespaceDescription": "使用 JSON 编辑这个已注册 Namespace。LoopX 会先按 capability 自己拥有的 schema 校验,再允许预览写入。",
"machine.jsonConfiguration": "Namespace 配置(JSON)",
"machine.jsonConfigurationHelp": "只更新当前 Namespace;其他机器配置会保留,Apply 仍锁定到已审阅的 Preview Revision。",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { withReportScheduleTimezone } from "./periodic-report-schedule-field";
import { localizeCapability, localizedCapabilityFieldCopy } from "./capability-localization";
import { canEditCapability, CapabilityCatalogNavigation, CapabilityConfigurationSummary, CapabilityDetailHeader, CapabilityEditorStatus, orderCapabilitiesForPresentation } from "./capability-workbench";
import { useWorkspaceI18n } from "./i18n";
import { OperatorCredentialSettings } from "./operator-credential-settings";

type CapabilityDescriptor = CapabilityConfigurationCatalog["capabilities"][number];
type EditorMode = "guided" | "json";
Expand Down Expand Up @@ -300,6 +301,8 @@ export function MachineConfigurationSettings() {
</section>
) : null}

<OperatorCredentialSettings />

<div className="personal-capability-layout">
<CapabilityCatalogNavigation capabilities={capabilities} locale={locale} onSelect={setSelectedCapabilityId} scope="machine" selectedCapabilityId={selected.capability_id} t={t} />

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { useCallback, useEffect, useState } from "react";
import { Check, KeyRound, ShieldCheck, Trash2 } from "lucide-react";

import {
fetchOperatorCredential,
writeOperatorCredential,
type OperatorCredential,
} from "../../data/chat";
import { type WorkspaceTranslate, useWorkspaceI18n } from "./i18n";

function localizeStatus(status: OperatorCredential["status"], t: WorkspaceTranslate) {
if (status === "invalid") return t("machine.credentialInvalid");
return t(status === "configured" ? "machine.credentialConfigured" : "machine.credentialAbsent");
}

function localizeSource(source: string, t: WorkspaceTranslate) {
if (source === "machine_store") return t("machine.credentialSourceMachine");
if (source === "service_environment") return t("machine.credentialSourceEnvironment");
return t("machine.credentialSourceUnset");
}

/**
* The one place a person stores the operator model credential.
*
* The key is write-only end to end: this form submits one, and every readback
* it renders is the redacted projection, so the browser can configure a
* credential it is never able to display again. The endpoint is not a secret
* and reads back as itself.
*/
export function OperatorCredentialSettings() {
const { t } = useWorkspaceI18n();
const [credential, setCredential] = useState<OperatorCredential | null>(null);
const [apiKey, setApiKey] = useState("");
const [baseUrl, setBaseUrl] = useState("");
const [busy, setBusy] = useState<"" | "load" | "store">("");
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);

const reload = useCallback(async () => {
setBusy("load");
try {
const loaded = await fetchOperatorCredential();
setCredential(loaded);
// Only a non-secret field is prefilled; the key never round-trips.
setBaseUrl(String(loaded.base_url.value ?? ""));
} catch (cause) {
setError(cause instanceof Error ? cause.message : t("machine.credentialError"));
} finally {
setBusy("");
}
}, [t]);

useEffect(() => {
void reload();
}, [reload]);

async function submit(update: Parameters<typeof writeOperatorCredential>[0], done: string) {
setBusy("store");
setError(null);
setNotice(null);
try {
const stored = await writeOperatorCredential(update);
setCredential(stored);
setBaseUrl(String(stored.base_url.value ?? ""));
setApiKey("");
setNotice(done);
} catch (cause) {
setError(cause instanceof Error ? cause.message : t("machine.credentialError"));
} finally {
setBusy("");
}
}

if (!credential && busy === "load") {
return <div className="personal-machine-loading" role="status">{t("common.loading")}</div>;
}

const keyLabel = credential
? `${localizeStatus(credential.provider_key.configured ? "configured" : "absent", t)} · ${localizeSource(credential.provider_key.source, t)}`
: "";
const urlLabel = credential
? `${credential.base_url.value ?? t("machine.credentialAbsent")} · ${localizeSource(credential.base_url.source, t)}`
: "";

return (
<section className="personal-operator-credential" data-testid="operator-credential-settings">
<header>
<KeyRound aria-hidden size={17} />
<div>
<strong>{t("machine.credentialTitle")}</strong>
<p>{t("machine.credentialDescription")}</p>
</div>
<span className="personal-operator-credential-status">
{credential ? localizeStatus(credential.status, t) : t("common.loading")}
</span>
</header>

{credential ? (
<dl className="personal-operator-credential-readback">
<div>
<dt>{t("machine.credentialApiKey")}</dt>
<dd>{keyLabel}</dd>
</div>
<div>
<dt>{t("machine.credentialFingerprint")}</dt>
<dd><code>{credential.provider_key.fingerprint ?? t("common.none")}</code></dd>
</div>
<div>
<dt>{t("machine.credentialBaseUrl")}</dt>
<dd>{urlLabel}</dd>
</div>
</dl>
) : null}

{credential?.status === "invalid" && credential.repair ? (
<p className="personal-machine-error" role="alert">{credential.repair}</p>
) : null}

<label htmlFor="operator-credential-api-key">
<span>{t("machine.credentialApiKey")}</span>
<input
autoComplete="off"
disabled={Boolean(busy)}
id="operator-credential-api-key"
onChange={(event) => setApiKey(event.target.value)}
placeholder={t("machine.credentialApiKeyPlaceholder")}
type="password"
value={apiKey}
/>
</label>

<label htmlFor="operator-credential-base-url">
<span>{t("machine.credentialBaseUrl")}</span>
<input
autoComplete="off"
disabled={Boolean(busy)}
id="operator-credential-base-url"
onChange={(event) => setBaseUrl(event.target.value)}
placeholder={t("machine.credentialBaseUrlPlaceholder")}
type="text"
value={baseUrl}
/>
</label>

{error ? <p className="personal-machine-error" role="alert">{error}</p> : null}
{notice ? <p className="personal-machine-notice" role="status" aria-live="polite"><Check aria-hidden size={16} />{notice}</p> : null}

{/* Not `personal-capability-actions`: the browser smoke treats that class
as the capability editor's own action row, and this panel renders on
the same page. */}
<footer className="personal-operator-credential-actions">
<button
className="is-primary"
disabled={Boolean(busy) || (!apiKey.trim() && !baseUrl.trim())}
onClick={() => void submit(
{
...(apiKey.trim() ? { provider_key: apiKey } : {}),
...(baseUrl.trim() ? { base_url: baseUrl } : {}),
},
t("machine.credentialStored"),
)}
type="button"
>
{busy === "store" ? t("common.loading") : t("machine.credentialStore")}
</button>
<button
disabled={Boolean(busy) || credential?.provider_key.configured !== true}
onClick={() => void submit({ clear_provider_key: true }, t("machine.credentialCleared"))}
type="button"
>
<Trash2 aria-hidden size={15} />{t("machine.credentialClearKey")}
</button>
<button
disabled={Boolean(busy) || credential?.base_url.configured !== true}
onClick={() => void submit({ clear_base_url: true }, t("machine.credentialCleared"))}
type="button"
>
<Trash2 aria-hidden size={15} />{t("machine.credentialClearUrl")}
</button>
</footer>

<details className="personal-capability-scope-note">
<summary><ShieldCheck aria-hidden size={17} />{t("machine.credentialTitle")}</summary>
<p>{t("machine.credentialBoundary")}</p>
</details>
</section>
);
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading