From 14c87d359ec840882650c5b686ba1f45397e5b78 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 2 Nov 2026 17:42:37 -0500 Subject: [PATCH 1/7] feat(server): read usage limit source keys from a secret provider A usage limit source's management key can now name a secret provider reference instead of holding the key. Refreshing secrets re-reads it, and an unreadable secret behaves as unset. Signed-off-by: Yordis Prieto --- .../sourceControlHostSecrets.test.ts | 23 +++++- .../sourceControl/sourceControlHostSecrets.ts | 14 +++- .../src/usage/UsageLimitSources.test.ts | 72 ++++++++++++++++++- apps/server/src/usage/UsageLimitSources.ts | 44 ++++++++++-- apps/server/src/usage/cliproxyApi.ts | 46 ++++++------ apps/server/src/ws.ts | 6 +- .../settings/AddUsageLimitSourceDialog.tsx | 25 ++++--- .../SecretSourceInput.browser.test.tsx | 6 ++ .../components/settings/SecretSourceInput.tsx | 8 +++ packages/contracts/src/settings.ts | 6 +- 10 files changed, 204 insertions(+), 46 deletions(-) diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts index be1da6f3d610c..697e166c324a4 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts @@ -1,5 +1,5 @@ import { assert, it } from "@effect/vitest"; -import { SecretProviderId } from "@t3tools/contracts"; +import { SecretProviderId, UsageLimitSourceId } from "@t3tools/contracts"; import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as Effect from "effect/Effect"; @@ -125,12 +125,31 @@ it.effect("reads a collected reference again once it is invalidated", () => }), }); const read = () => resolveSourceControlHostSecrets(resolver, hosts, "bitbucket"); + const usageLimitSources = { + [UsageLimitSourceId.make("referenced")]: { + kind: "cliproxy", + url: "https://hub.test", + managementKey: source("cliproxy#key"), + enabled: true, + }, + [UsageLimitSourceId.make("literal")]: { + kind: "cliproxy", + url: "https://hub.test", + managementKey: "plain-key", + enabled: true, + }, + } as const; + cache.set("cliproxy#key", "stale"); assert.deepEqual((yield* read()).bitbucket, { email: "me@example.test", accessToken: "stale", }); current = "rotated"; - yield* invalidateSettingsSecrets(resolver, Effect.succeed({ sourceControlHosts: hosts })); + yield* invalidateSettingsSecrets( + resolver, + Effect.succeed({ sourceControlHosts: hosts, usageLimitSources }), + ); + assert.isFalse(cache.has("cliproxy#key")); assert.deepEqual((yield* read()).bitbucket, { email: "me@example.test", accessToken: "rotated", diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.ts index 82de25be4de69..32d2f41f9c504 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.ts @@ -97,13 +97,23 @@ export const resolveSourceControlHostSecrets = Effect.fn("SourceControlHost.reso }, ); +const collectUsageLimitSecretReferences = ( + sources: ServerSettings["usageLimitSources"], +): ReadonlyArray => + Object.values(sources).flatMap(({ managementKey }) => + isSecretReference(managementKey) ? [managementKey] : [], + ); + /** Settings that cannot be read clear every cached secret, so a refresh never keeps a stale one. */ export const invalidateSettingsSecrets = Effect.fn("SettingsSecrets.invalidate")(function* ( resolver: SecretResolver.SecretResolver["Service"], - settings: Effect.Effect, E>, + settings: Effect.Effect, E>, ) { const references = yield* settings.pipe( - Effect.map((current) => collectSourceControlHostSecretReferences(current.sourceControlHosts)), + Effect.map((current) => [ + ...collectSourceControlHostSecretReferences(current.sourceControlHosts), + ...collectUsageLimitSecretReferences(current.usageLimitSources), + ]), Effect.orElseSucceed(() => undefined), ); yield* resolver.invalidate(references); diff --git a/apps/server/src/usage/UsageLimitSources.test.ts b/apps/server/src/usage/UsageLimitSources.test.ts index b8976fca81e7d..1d955d497ef6b 100644 --- a/apps/server/src/usage/UsageLimitSources.test.ts +++ b/apps/server/src/usage/UsageLimitSources.test.ts @@ -1,10 +1,11 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { expect, it } from "@effect/vitest"; -import { ServerConfigStreamEvent, UsageLimitSourceId } from "@t3tools/contracts"; +import { SecretProviderId, ServerConfigStreamEvent, UsageLimitSourceId } from "@t3tools/contracts"; +import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import { HttpClient } from "effect/http"; +import { HttpClient, HttpClientResponse } from "effect/http"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as ServerSettings from "../serverSettings.ts"; @@ -36,6 +37,10 @@ it.effect("keeps malformed source URLs encodable for config subscribers", () => ); }).pipe( Effect.provide([ + Layer.succeed(SecretResolver.SecretResolver, { + read: (references) => Effect.succeed(references.map(() => undefined)), + invalidate: () => Effect.void, + }), ServerSettings.layerTest({ usageLimitSources: { [UsageLimitSourceId.make("local-hub")]: { @@ -83,3 +88,66 @@ it.effect("keeps malformed source URLs encodable for config subscribers", () => Effect.scoped, ), ); + +it.effect("reads a management key from its secret provider", () => { + const authorizations: Array = []; + const keySource = (reference: string) => ({ + provider: SecretProviderId.make("homelab"), + reference, + }); + return Effect.gen(function* () { + const sources = yield* UsageLimitSources.make; + yield* sources.refresh; + const snapshots = yield* sources.current; + expect(snapshots.map(({ id, error }) => ({ id, error }))).toEqual([ + { id: "resolved-hub", error: undefined }, + { + id: "unresolved-hub", + error: "Could not read the management key from its secret provider.", + }, + ]); + expect(authorizations).toEqual(["Bearer resolved-key"]); + }).pipe( + Effect.provide([ + ServerSettings.layerTest({ + usageLimitSources: { + [UsageLimitSourceId.make("resolved-hub")]: { + kind: "cliproxy", + url: "https://resolved.test", + managementKey: keySource("cliproxy#key"), + enabled: true, + }, + [UsageLimitSourceId.make("unresolved-hub")]: { + kind: "cliproxy", + url: "https://unresolved.test", + managementKey: keySource("missing#key"), + enabled: true, + }, + }, + }), + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(false), + }), + Layer.succeed(SecretResolver.SecretResolver, { + read: (references) => + Effect.succeed( + references.map((reference) => + reference.reference === "cliproxy#key" ? "resolved-key" : undefined, + ), + ), + invalidate: () => Effect.void, + }), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + authorizations.push(request.headers.authorization); + return HttpClientResponse.fromWeb(request, Response.json({ files: [] })); + }), + ), + ), + NodeCrypto.layer, + ]), + Effect.scoped, + ); +}); diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index 7a068cde85f6c..7d40b0fb9ecea 100644 --- a/apps/server/src/usage/UsageLimitSources.ts +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -4,7 +4,8 @@ import { type UsageLimitSourceConsumeResetCreditInput, type ProviderConsumeResetCreditResult, type ServerSettings, - type UsageLimitSourceConfig, + UsageLimitSourceConfig, + settingsSecretLiteral, type UsageLimitSourceId, type UsageLimitSourceSnapshot, } from "@t3tools/contracts"; @@ -21,6 +22,9 @@ import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; +import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; +import * as SecretResolverLayer from "../secrets/SecretResolver.ts"; +import { resolveSettingsSecrets } from "../secrets/settingsSecretValue.ts"; import * as Settings from "../serverSettings.ts"; import { makeCliproxyApi } from "./cliproxyApi.ts"; @@ -46,11 +50,24 @@ function sourceLabel(id: string, config: UsageLimitSourceConfig): string { } } +function missingManagementKey(config: UsageLimitSourceConfig): string { + return typeof config.managementKey === "string" + ? "No management key configured." + : "Could not read the management key from its secret provider."; +} + /** @public */ export const make = Effect.gen(function* () { const api = yield* makeCliproxyApi; const settingsService = yield* Settings.ServerSettingsService; + const secrets = yield* SecretResolver.SecretResolver; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; + const readManagementKey = Effect.fn("UsageLimitSources.readManagementKey")(function* ( + config: UsageLimitSourceConfig, + ) { + const resolved = yield* resolveSettingsSecrets(secrets, UsageLimitSourceConfig, config); + return settingsSecretLiteral(resolved.managementKey); + }); const stateRef = yield* Ref.make>([]); const changes = yield* Effect.acquireRelease( PubSub.unbounded>(), @@ -63,10 +80,13 @@ export const make = Effect.gen(function* () { ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); const base = { id, kind: config.kind, label: sourceLabel(id, config), checkedAt } as const; - if (config.managementKey.length === 0) { - return { ...base, accounts: [], error: "No management key configured." }; + const managementKey = yield* readManagementKey(config); + if (managementKey.length === 0) { + return { ...base, accounts: [], error: missingManagementKey(config) }; } - const accounts = yield* api.readAccounts(config).pipe(Effect.result); + const accounts = yield* api + .readAccounts({ url: config.url, managementKey }) + .pipe(Effect.result); if (accounts._tag === "Failure") { yield* Effect.logDebug("usage limit source read failed", { id, cause: accounts.failure }); return { ...base, accounts: [], error: accounts.failure.detail }; @@ -108,12 +128,20 @@ export const make = Effect.gen(function* () { Effect.mapError(() => new UsageLimitSourceError({ detail: "Could not read hub settings." })), ); const config = settings.usageLimitSources[input.sourceId]; - if (!config?.enabled || !config.managementKey) { + if (!config?.enabled) { return yield* new UsageLimitSourceError({ detail: "The usage limit source is missing or disabled.", }); } - const result = yield* api.consume(config, input.accountId, input.creditId); + const managementKey = yield* readManagementKey(config); + if (managementKey.length === 0) { + return yield* new UsageLimitSourceError({ detail: missingManagementKey(config) }); + } + const result = yield* api.consume( + { url: config.url, managementKey }, + input.accountId, + input.creditId, + ); const snapshot = yield* readSource(input.sourceId, config); const previous = yield* Ref.get(stateRef); yield* publish(previous.map((source) => (source.id === input.sourceId ? snapshot : source))); @@ -167,4 +195,6 @@ export const make = Effect.gen(function* () { } satisfies UsageLimitSources["Service"]; }); -export const layer = Layer.effect(UsageLimitSources, make); +export const layer = Layer.effect(UsageLimitSources, make).pipe( + Layer.provide(SecretResolverLayer.layer), +); diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index eb956a67a31d0..8ed874ac6853c 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -18,6 +18,12 @@ import { codexRateLimitsToLimits } from "../provider/codexUsageLimits.ts"; import { claudeUsageResponseToLimits } from "../provider/claudeUsageLimits.ts"; import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits"; +/** A hub with its management key already read from wherever the setting keeps it. */ +export interface CliproxyHub { + readonly url: UsageLimitSourceConfig["url"]; + readonly managementKey: string; +} + const AuthFile = Schema.Struct({ id: Schema.String, auth_index: Schema.String, @@ -173,7 +179,7 @@ export const makeCliproxyApi = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const management = Effect.fn("CliproxyApi.management")(function* ( - config: UsageLimitSourceConfig, + hub: CliproxyHub, path: string, body?: unknown, ) { @@ -181,13 +187,13 @@ export const makeCliproxyApi = Effect.gen(function* () { try: () => new URL( `v0/management/${path}`, - config.url.endsWith("/") ? config.url : `${config.url}/`, + hub.url.endsWith("/") ? hub.url : `${hub.url}/`, ).toString(), catch: () => new UsageLimitSourceError({ detail: "The hub URL is not valid." }), }); const request = ( body === undefined ? HttpClientRequest.get(url) : HttpClientRequest.post(url) - ).pipe(HttpClientRequest.setHeader("Authorization", `Bearer ${config.managementKey}`)); + ).pipe(HttpClientRequest.setHeader("Authorization", `Bearer ${hub.managementKey}`)); const response = yield* client .execute(body === undefined ? request : request.pipe(HttpClientRequest.bodyJsonUnsafe(body))) .pipe( @@ -201,13 +207,13 @@ export const makeCliproxyApi = Effect.gen(function* () { return response; }); - const authFiles = Effect.fn("CliproxyApi.authFiles")(function* (config: UsageLimitSourceConfig) { - const response = yield* management(config, "auth-files"); + const authFiles = Effect.fn("CliproxyApi.authFiles")(function* (hub: CliproxyHub) { + const response = yield* management(hub, "auth-files"); return (yield* decodeAuthFiles(response)).files; }); const apiCall = Effect.fn("CliproxyApi.apiCall")(function* ( - config: UsageLimitSourceConfig, + hub: CliproxyHub, account: typeof AuthFile.Type, url: string, data?: unknown, @@ -229,7 +235,7 @@ export const makeCliproxyApi = Effect.gen(function* () { // The OAuth usage endpoint can return 429 without a Claude Code user agent. "User-Agent": "claude-code/2.1.273", }; - const raw = yield* management(config, "api-call", { + const raw = yield* management(hub, "api-call", { auth_index: account.auth_index, method: data === undefined ? "GET" : "POST", url, @@ -246,10 +252,10 @@ export const makeCliproxyApi = Effect.gen(function* () { }); const credits = Effect.fn("CliproxyApi.credits")(function* ( - config: UsageLimitSourceConfig, + hub: CliproxyHub, account: typeof AuthFile.Type, ) { - const body = yield* apiCall(config, account, CREDIT_URL); + const body = yield* apiCall(hub, account, CREDIT_URL); const response = yield* decodeCreditList(body); const now = DateTime.toEpochMillis(yield* DateTime.now); return response.credits @@ -263,7 +269,7 @@ export const makeCliproxyApi = Effect.gen(function* () { }); const readAccount = Effect.fn("CliproxyApi.readAccount")(function* ( - config: UsageLimitSourceConfig, + hub: CliproxyHub, account: typeof AuthFile.Type, ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); @@ -277,7 +283,7 @@ export const makeCliproxyApi = Effect.gen(function* () { }; const read = Effect.gen(function* () { if (account.provider === "claude") { - const body = yield* apiCall(config, account, "https://api.anthropic.com/api/oauth/usage"); + const body = yield* apiCall(hub, account, "https://api.anthropic.com/api/oauth/usage"); const usage = yield* decodeClaudeUsage(body); const model_scoped = (usage.limits ?? []).flatMap((limit) => limit.kind === "weekly_scoped" && limit.scope?.model && typeof limit.percent === "number" @@ -309,7 +315,7 @@ export const makeCliproxyApi = Effect.gen(function* () { }).limits, }; } - const body = yield* apiCall(config, account, `${CODEX_BASE}/usage`); + const body = yield* apiCall(hub, account, `${CODEX_BASE}/usage`); const usage = yield* decodeCodexUsage(body); const toWindow = (window: typeof CodexWindow.Type | null | undefined) => window @@ -321,7 +327,7 @@ export const makeCliproxyApi = Effect.gen(function* () { : { windowDurationMins: window.limit_window_seconds / 60 }), } : null; - const available = yield* credits(config, account).pipe(Effect.orElseSucceed(() => undefined)); + const available = yield* credits(hub, account).pipe(Effect.orElseSucceed(() => undefined)); const next = available?.[0]; const planType = usage.plan_type ?? account.id_token?.plan_type ?? account.id_token?.chatgpt_plan_type; @@ -366,9 +372,9 @@ export const makeCliproxyApi = Effect.gen(function* () { }); const readAccounts = Effect.fn("CliproxyApi.readAccounts")(function* ( - config: UsageLimitSourceConfig, + hub: CliproxyHub, ): Effect.fn.Return, UsageLimitSourceError> { - const accounts = yield* authFiles(config).pipe( + const accounts = yield* authFiles(hub).pipe( Effect.mapError((error) => isUsageLimitSourceError(error) ? error @@ -380,24 +386,24 @@ export const makeCliproxyApi = Effect.gen(function* () { (account) => !account.disabled && (account.provider === "codex" || account.provider === "claude"), ), - (account) => readAccount(config, account), + (account) => readAccount(hub, account), { concurrency: 4 }, ); }); const consume = Effect.fn("CliproxyApi.consume")(function* ( - config: UsageLimitSourceConfig, + hub: CliproxyHub, accountId: string, creditId: string, ): Effect.fn.Return { const operation = Effect.gen(function* () { - const account = (yield* authFiles(config)).find((account) => account.id === accountId); + const account = (yield* authFiles(hub)).find((account) => account.id === accountId); if (!account || account.disabled || account.provider !== "codex") { return yield* new UsageLimitSourceError({ detail: "The Codex hub account is missing or disabled.", }); } - const body = yield* apiCall(config, account, `${CREDIT_URL}/consume`, { + const body = yield* apiCall(hub, account, `${CREDIT_URL}/consume`, { redeem_request_id: yield* creditRedeemRequestId( account.id_token?.chatgpt_account_id ?? account.id, creditId, @@ -414,7 +420,7 @@ export const makeCliproxyApi = Effect.gen(function* () { } as const )[response.code]; if (outcome !== "reset" && outcome !== "alreadyRedeemed") return { outcome }; - const cleared = yield* management(config, "reset-quota", { + const cleared = yield* management(hub, "reset-quota", { auth_index: account.auth_index, }).pipe(Effect.result); return { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 00842afde41b7..76a4a47a49110 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2377,13 +2377,13 @@ export const layerWsRpc = ( { concurrency: "unbounded", discard: true }, ); } + if (input.reloadSecrets === true && input.instanceId === undefined) { + yield* invalidateSettingsSecrets(secretResolver, serverSettings.getSettings); + } // Await refresh; RPC scope closure would interrupt a fork before the hub answered. if (input.instanceId === undefined) { yield* usageLimitSources.refresh; } - if (input.reloadSecrets === true && input.instanceId === undefined) { - yield* invalidateSettingsSecrets(secretResolver, serverSettings.getSettings); - } let providers = yield* input.cwd !== undefined && input.instanceId !== undefined ? providerRegistry.refreshWorkspaceSnapshot({ instanceId: input.instanceId, diff --git a/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx b/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx index e3abd8b951067..12b9bbe820869 100644 --- a/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx +++ b/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx @@ -1,4 +1,4 @@ -import type { EnvironmentId, UnifiedSettings } from "@t3tools/contracts"; +import type { EnvironmentId, SecretReference, UnifiedSettings } from "@t3tools/contracts"; import { useState } from "react"; import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; @@ -14,7 +14,7 @@ import { } from "../ui/dialog"; import { Input } from "../ui/input"; import { sourceIdFromUrl } from "./AddUsageLimitSourceDialog.logic"; -import { SecretInput } from "./SecretInput"; +import { SecretSourceInput, useSecretSources } from "./SecretSourceInput"; import { Label } from "../ui/label"; /** Send the management key once to server secret storage; settings retain only a redaction marker. */ @@ -34,14 +34,16 @@ export function AddUsageLimitSourceDialog({ const updateSettings = useUpdateEnvironmentSettings(environmentId); const [label, setLabel] = useState(""); const [url, setUrl] = useState(""); - const [managementKey, setManagementKey] = useState(""); + const secretSources = useSecretSources(environmentId); + const [keyDraft, setKeyDraft] = useState(""); + const managementKey = typeof keyDraft === "string" ? keyDraft.trim() : keyDraft; const trimmedUrl = url.trim(); - const canSave = trimmedUrl.length > 0 && managementKey.trim().length > 0; + const canSave = trimmedUrl.length > 0 && managementKey !== ""; const reset = () => { setLabel(""); setUrl(""); - setManagementKey(""); + setKeyDraft(""); }; const save = () => { @@ -53,7 +55,7 @@ export function AddUsageLimitSourceDialog({ kind: "cliproxy", ...(label.trim() ? { label: label.trim() } : {}), url: trimmedUrl, - managementKey: managementKey.trim(), + managementKey, enabled: true, }, }, @@ -98,10 +100,15 @@ export function AddUsageLimitSourceDialog({
- setManagementKey(event.target.value)} + aria-label="Management key" + value={keyDraft} + sources={secretSources} + onChange={(event) => setKeyDraft(event.target.value)} + onSecretChange={(secret) => setKeyDraft(secret ?? "")} + onProviderPick={() => setKeyDraft("")} + onReferenceReject={() => setKeyDraft("")} />
diff --git a/apps/web/src/components/settings/SecretSourceInput.browser.test.tsx b/apps/web/src/components/settings/SecretSourceInput.browser.test.tsx index e1b67465c2168..bbfe2d95e4133 100644 --- a/apps/web/src/components/settings/SecretSourceInput.browser.test.tsx +++ b/apps/web/src/components/settings/SecretSourceInput.browser.test.tsx @@ -23,6 +23,7 @@ async function commitReference(screen: Screen, value: string) { describe("SecretSourceInput", () => { it("moves a reference to another provider only once one is committed for it", async () => { const onSecretChange = vi.fn(); + const onProviderPick = vi.fn(); await using screen = await render( { sources={sources} onCommit={() => {}} onSecretChange={onSecretChange} + onProviderPick={onProviderPick} />, ); await userEvent.click(screen.getByRole("combobox", { name: "API key source" })); await userEvent.click(screen.getByRole("option", { name: "Work" })); expect(onSecretChange).not.toHaveBeenCalled(); + expect(onProviderPick).toHaveBeenCalledOnce(); await expect.element(reference(screen)).toHaveValue(""); await commitReference(screen, "op://Shared/OpenAI/credential"); @@ -64,6 +67,7 @@ describe("SecretSourceInput", () => { it("rejects a reference 1Password cannot read", async () => { const onSecretChange = vi.fn(); + const onReferenceReject = vi.fn(); await using screen = await render( { sources={sources} onCommit={() => {}} onSecretChange={onSecretChange} + onReferenceReject={onReferenceReject} />, ); await commitReference(screen, "Private/OpenAI/credential"); expect(onSecretChange).not.toHaveBeenCalled(); + expect(onReferenceReject).toHaveBeenCalledOnce(); await expect.element(reference(screen)).toHaveAttribute("aria-invalid", "true"); await expect.element(reference(screen)).toHaveValue("Private/OpenAI/credential"); }); diff --git a/apps/web/src/components/settings/SecretSourceInput.tsx b/apps/web/src/components/settings/SecretSourceInput.tsx index 2fed001b09e91..2970539a28fff 100644 --- a/apps/web/src/components/settings/SecretSourceInput.tsx +++ b/apps/web/src/components/settings/SecretSourceInput.tsx @@ -64,6 +64,10 @@ type SecretSourceInputProps = Omit, "value" | readonly value: string | SecretReference; readonly sources: ReadonlyArray; readonly onSecretChange: (secret: SecretReference | undefined) => void; + /** Called when a provider is picked, before a reference for it exists. */ + readonly onProviderPick?: () => void; + /** Called when an edited reference is rejected, so the committed one no longer matches the field. */ + readonly onReferenceReject?: () => void; /** Places the parts in the caller's layout; `source` is null when there is nothing to pick. */ readonly children?: (parts: { readonly source: ReactNode; @@ -76,6 +80,8 @@ export function SecretSourceInput({ value, sources, onSecretChange, + onProviderPick, + onReferenceReject, children, ...rest }: SecretSourceInputProps) { @@ -106,6 +112,7 @@ export function SecretSourceInput({ return; } const next = value as SecretProviderId; + if (next !== secret?.provider) onProviderPick?.(); setPendingProvider(next === secret?.provider ? null : next); }} > @@ -159,6 +166,7 @@ export function SecretSourceInput({ definition?.isReference(decoded.value.reference) === false ) { setRejectedReference(next); + onReferenceReject?.(); return; } setRejectedReference(null); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index f291cec115f73..eefbcce97a6e1 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -22,6 +22,7 @@ import { import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; import { UsageModelFilter } from "./usage.ts"; import { SecretProviderId, SecretProviderSlug } from "./secretReference.ts"; +import { SettingsSecretValue } from "./settingsSecretValue.ts"; import { EnvironmentMachineKind, ThreadEnvMode, WorktreeSubmodules } from "./environment.ts"; import { KeybindingShortcut } from "./keybindings.ts"; import { @@ -858,7 +859,10 @@ export const UsageLimitSourceConfig = Schema.Struct({ kind: Schema.Literal("cliproxy"), label: Schema.optional(TrimmedNonEmptyString), url: TrimmedNonEmptyString, - managementKey: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed("")), secretSetting), + managementKey: SettingsSecretValue.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + secretSetting, + ), enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), }); export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type; From afae75511e03fbff1f3953c2be79983f9780ac29 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 2 Nov 2026 23:56:38 +0000 Subject: [PATCH 2/7] fix(web): preserve rejected usage key references --- .../settings/SecretSourceInput.browser.test.tsx | 15 +++++++++++++++ .../src/components/settings/SecretSourceInput.tsx | 1 + 2 files changed, 16 insertions(+) diff --git a/apps/web/src/components/settings/SecretSourceInput.browser.test.tsx b/apps/web/src/components/settings/SecretSourceInput.browser.test.tsx index bbfe2d95e4133..0d0f8703a034c 100644 --- a/apps/web/src/components/settings/SecretSourceInput.browser.test.tsx +++ b/apps/web/src/components/settings/SecretSourceInput.browser.test.tsx @@ -82,7 +82,22 @@ describe("SecretSourceInput", () => { await commitReference(screen, "Private/OpenAI/credential"); expect(onSecretChange).not.toHaveBeenCalled(); expect(onReferenceReject).toHaveBeenCalledOnce(); + await screen.rerender( + {}} + onSecretChange={onSecretChange} + onReferenceReject={onReferenceReject} + />, + ); await expect.element(reference(screen)).toHaveAttribute("aria-invalid", "true"); await expect.element(reference(screen)).toHaveValue("Private/OpenAI/credential"); + await commitReference(screen, "op://Private/OpenAI/corrected"); + expect(onSecretChange).toHaveBeenCalledExactlyOnceWith({ + provider: personal, + reference: "op://Private/OpenAI/corrected", + }); }); }); diff --git a/apps/web/src/components/settings/SecretSourceInput.tsx b/apps/web/src/components/settings/SecretSourceInput.tsx index 2970539a28fff..960c1b4cd621a 100644 --- a/apps/web/src/components/settings/SecretSourceInput.tsx +++ b/apps/web/src/components/settings/SecretSourceInput.tsx @@ -166,6 +166,7 @@ export function SecretSourceInput({ definition?.isReference(decoded.value.reference) === false ) { setRejectedReference(next); + setPendingProvider(provider); onReferenceReject?.(); return; } From 8d017b6b429ab866747cc222c94570d134055c54 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 3 Nov 2026 00:03:55 +0000 Subject: [PATCH 3/7] fix(web): keep provider variable references removable --- .../src/components/settings/SecretSourceInput.browser.test.tsx | 2 ++ apps/web/src/components/settings/SecretSourceInput.tsx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/SecretSourceInput.browser.test.tsx b/apps/web/src/components/settings/SecretSourceInput.browser.test.tsx index 0d0f8703a034c..18ff271e97974 100644 --- a/apps/web/src/components/settings/SecretSourceInput.browser.test.tsx +++ b/apps/web/src/components/settings/SecretSourceInput.browser.test.tsx @@ -60,6 +60,8 @@ describe("SecretSourceInput", () => { />, ); + await commitReference(screen, "invalid-reference"); + await expect.element(reference(screen)).toHaveAttribute("aria-invalid", "true"); await userEvent.clear(reference(screen)); await userEvent.keyboard("{Enter}"); expect(onSecretChange).toHaveBeenCalledExactlyOnceWith(undefined); diff --git a/apps/web/src/components/settings/SecretSourceInput.tsx b/apps/web/src/components/settings/SecretSourceInput.tsx index 960c1b4cd621a..83ca9ad2ced71 100644 --- a/apps/web/src/components/settings/SecretSourceInput.tsx +++ b/apps/web/src/components/settings/SecretSourceInput.tsx @@ -166,7 +166,7 @@ export function SecretSourceInput({ definition?.isReference(decoded.value.reference) === false ) { setRejectedReference(next); - setPendingProvider(provider); + if (onReferenceReject !== undefined) setPendingProvider(provider); onReferenceReject?.(); return; } From b44a8bed38fb429b30b5fcea9f31cc5d702e8113 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 2 Nov 2026 17:00:53 -0500 Subject: [PATCH 4/7] feat(device): read the EAS access token from a secret provider An EAS host's access token can now name a secret provider reference. Refreshing secrets re-reads it, and a named secret that cannot be read fails instead of falling back to another Expo login. Signed-off-by: Yordis Prieto --- apps/server/src/device/DeviceService.test.ts | 11 ++- apps/server/src/device/DeviceService.ts | 15 +++- apps/server/src/device/EasDeviceHost.test.ts | 78 ++++++++++++++++++- apps/server/src/device/EasDeviceHost.ts | 34 ++++++-- .../server/src/secrets/settingsSecretValue.ts | 35 ++++++++- .../sourceControlHostSecrets.test.ts | 17 +++- .../sourceControl/sourceControlHostSecrets.ts | 11 ++- .../src/usage/UsageLimitSources.test.ts | 8 +- .../settings/EasDeviceHostEditor.tsx | 25 ++++-- packages/contracts/src/device.ts | 11 +-- 10 files changed, 217 insertions(+), 28 deletions(-) diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index cdcb100918beb..0d257b14c2bf2 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -18,6 +18,7 @@ import * as Exit from "effect/Exit"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; +import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; @@ -534,6 +535,11 @@ describe("device setup consent", () => { ); }); +const layerSecretResolver = Layer.succeed(SecretResolver.SecretResolver, { + read: (references) => Effect.succeed(references.map(() => undefined)), + invalidate: () => Effect.void, +}); + it.effect("publishes boot progress and does not restore sessions after support is disabled", () => Effect.gen(function* () { const started = yield* Deferred.make(); @@ -1436,7 +1442,9 @@ it.effect("reconciles SSH host settings without publishing no-op changes", () => expect(removedFiles).toHaveLength(1); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(ServerSettings.layerTest(), NodeServices.layer)), + Effect.provide( + Layer.mergeAll(ServerSettings.layerTest(), layerSecretResolver, NodeServices.layer), + ), ), ); @@ -1494,6 +1502,7 @@ it.effect("a failed tool update reports npm's exit code but not its output", () Effect.provide( Layer.mergeAll( ServerSettings.layerTest({ enableDeviceSupport: true }), + layerSecretResolver, ServerConfig.layerTest(process.cwd(), { prefix: "t3-device-update-" }), NodeCrypto.layer, NetService.layer, diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index d91c9d8d0b53b..7323682ffc1c5 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -68,12 +68,14 @@ import { isLocalSshDeviceHost, remoteSshDeviceHosts } from "./localSshDeviceHost import { readDeviceDetail, runDeviceAction } from "./DeviceActions.ts"; import * as ProcessRunner from "../processRunner.ts"; +import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; +import * as SecretResolverLayer from "../secrets/SecretResolver.ts"; import * as DeviceHost from "./DeviceHost.ts"; import * as SshDeviceHost from "./SshDeviceHost.ts"; import * as EasDeviceHost from "./EasDeviceHost.ts"; import * as SimlockDeviceHost from "./SimlockDeviceHost.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import { makeExpoApi, resolveExpoCredential } from "./ExpoApi.ts"; +import { makeExpoApi } from "./ExpoApi.ts"; import * as Exit from "effect/Exit"; import * as LocalDeviceHost from "./LocalDeviceHost.ts"; @@ -1220,6 +1222,7 @@ export const make = Effect.gen(function* () { const path = yield* Path.Path; const runner = yield* ProcessRunner.ProcessRunner; const settings = yield* ServerSettings.ServerSettingsService; + const secrets = yield* SecretResolver.SecretResolver; const scope = yield* Scope.Scope; const hosts = new Map([ [localHost.id, localHost], @@ -1537,7 +1540,10 @@ export const make = Effect.gen(function* () { ? "" : ((yield* settings.getSettings).easDeviceHosts.find((host) => host.id === input.hostId) ?.accessToken ?? ""); - const credential = yield* resolveExpoCredential(input.accessToken ?? saved); + const credential = yield* EasDeviceHost.resolveSavedExpoCredential( + secrets, + input.accessToken ?? saved, + ); const viewer = yield* makeExpoApi(yield* HttpClient.HttpClient, credential).viewer; return { signedInAs: viewer.name, credentialSource: credential.source, apps: viewer.apps }; }, @@ -1618,7 +1624,10 @@ export const make = Effect.gen(function* () { }; }); -export const layer = Layer.effect(DeviceService, make).pipe(Layer.provide(LocalDeviceHost.layer)); +export const layer = Layer.effect(DeviceService, make).pipe( + Layer.provide(LocalDeviceHost.layer), + Layer.provide(SecretResolverLayer.layer), +); export const stateStream = (service: DeviceService["Service"]): Stream.Stream => Stream.unwrap( diff --git a/apps/server/src/device/EasDeviceHost.test.ts b/apps/server/src/device/EasDeviceHost.test.ts index b5825d3f973e7..47d74e65b4743 100644 --- a/apps/server/src/device/EasDeviceHost.test.ts +++ b/apps/server/src/device/EasDeviceHost.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "@effect/vitest"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { type EasDeviceHostConfig, ThreadId } from "@t3tools/contracts"; -import * as HostProcess from "@t3tools/shared/HostProcess"; +import { type EasDeviceHostConfig, SecretProviderId, ThreadId } from "@t3tools/contracts"; +import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -13,6 +13,7 @@ import * as Path from "effect/Path"; import * as Scope from "effect/Scope"; import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/http"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; @@ -228,9 +229,24 @@ const fakeExpo = ( return { http, named, stopped, allowPreview, allowCreate, releases, previews }; }; +const makeSecretResolver = (values: Record = {}) => + Layer.succeed(SecretResolver.SecretResolver, { + read: (references) => + Effect.succeed(references.map((reference) => values[reference.reference])), + invalidate: () => Effect.void, + }); + +const layerWith = (secrets: Layer.Layer) => + Layer.mergeAll( + ProcessRunner.layer, + ServerConfig.layerTest(process.cwd(), { prefix: "t3-eas-host-" }), + secrets, + ).pipe(Layer.provideMerge(NodeServices.layer)); + const layer = Layer.mergeAll( ProcessRunner.layer, ServerConfig.layerTest(process.cwd(), { prefix: "t3-eas-host-" }), + makeSecretResolver(), ).pipe(Layer.provideMerge(NodeServices.layer)); const makeHost = ( @@ -299,6 +315,64 @@ describe("EAS Simulator host", () => { }).pipe(Effect.scoped, Effect.provide(layer)), ); + it.effect("reads its token from the secret provider the setting names", () => + Effect.gen(function* () { + const expo = fakeExpo(); + const source = { provider: SecretProviderId.make("homelab"), reference: "expo#token" }; + const host = yield* makeHost(expo, config({ accessToken: source }), yield* Scope.make()); + const ready = yield* host.ensureReady(() => Effect.void); + const [catalogDevice] = (yield* ready.devices.list).devices; + expect(yield* ready.devices.boot(catalogDevice!)).toBe("session-1"); + }).pipe( + Effect.scoped, + Effect.provide(layerWith(makeSecretResolver({ "expo#token": "expo-token" }))), + ), + ); + + it.effect("fails instead of using another Expo login when its secret cannot be read", () => + Effect.gen(function* () { + const expo = fakeExpo(); + const source = { provider: SecretProviderId.make("homelab"), reference: "expo#token" }; + const host = yield* makeHost(expo, config({ accessToken: source }), yield* Scope.make()).pipe( + Effect.provideService(HostProcess.Environment, { EXPO_TOKEN: "expo-token" }), + ); + const ready = yield* host.ensureReady(() => Effect.void); + const [catalogDevice] = (yield* ready.devices.list).devices; + const failure = yield* ready.devices.boot(catalogDevice!).pipe(Effect.flip); + expect(failure.message).toContain('secret provider "homelab"'); + expect(expo.named("T3CreateEasSimulatorSession")).toHaveLength(0); + }).pipe(Effect.scoped, Effect.provide(layer)), + ); + + it.effect("still stops its sessions when the secret becomes unreadable after they start", () => { + const readable = { value: true }; + return Effect.gen(function* () { + const expo = fakeExpo(); + const source = { provider: SecretProviderId.make("homelab"), reference: "expo#token" }; + const scope = yield* Scope.make(); + const host = yield* makeHost(expo, config({ accessToken: source }), scope); + const ready = yield* host.ensureReady(() => Effect.void); + const [catalogDevice] = (yield* ready.devices.list).devices; + expect(yield* ready.devices.boot(catalogDevice!)).toBe("session-1"); + + readable.value = false; + yield* ready.devices.shutdown({ ...catalogDevice!, id: "session-1" }); + expect(expo.stopped()).toEqual(["session-1"]); + yield* Scope.close(scope, Exit.void); + }).pipe( + Effect.scoped, + Effect.provide( + layerWith( + Layer.succeed(SecretResolver.SecretResolver, { + read: (references) => + Effect.succeed(references.map(() => (readable.value ? "expo-token" : undefined))), + invalidate: () => Effect.void, + }), + ), + ), + ); + }); + it.effect("runs an Android session through serve-emu beside an iOS one", () => Effect.gen(function* () { const expo = fakeExpo(); diff --git a/apps/server/src/device/EasDeviceHost.ts b/apps/server/src/device/EasDeviceHost.ts index e82638dbd76fa..719cfbf435a8a 100644 --- a/apps/server/src/device/EasDeviceHost.ts +++ b/apps/server/src/device/EasDeviceHost.ts @@ -11,6 +11,7 @@ import { DeviceOperationError, type DevicePlatform, type EasDeviceHostConfig, + type SettingsSecretValue, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; @@ -32,6 +33,8 @@ import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as ServerConfig from "../config.ts"; +import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; +import { requireSettingsSecret } from "../secrets/settingsSecretValue.ts"; import { releaseAgentDeviceSession } from "./AgentDeviceTarget.ts"; import * as DeviceHost from "./DeviceHost.ts"; import { findEasProjectId } from "./easProjectId.ts"; @@ -140,11 +143,21 @@ const decodePathSegment = (segment: string): string | null => { } }; +/** + * The Expo credential a host's setting holds or names. Every session bills the account behind + * it, so a named secret that cannot be read fails rather than falling back to another login. + */ +export const resolveSavedExpoCredential = ( + secrets: SecretResolver.SecretResolver["Service"], + saved: SettingsSecretValue, +) => requireSettingsSecret(secrets, saved).pipe(Effect.flatMap(resolveExpoCredential)); + export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDeviceHostConfig) { const http = yield* HttpClient.HttpClient; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const server = yield* ServerConfig.ServerConfig; + const secrets = yield* SecretResolver.SecretResolver; const services = yield* Effect.context< FileSystem.FileSystem | Path.Path | Effect.Services> >(); @@ -198,13 +211,24 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice ...(cause === undefined ? {} : { cause }), }); - const api: Effect.Effect = resolveExpoCredential( + let lastApi: ExpoApi | undefined; + const api: Effect.Effect = resolveSavedExpoCredential( + secrets, config.accessToken, ).pipe( Effect.map((credential) => makeExpoApi(http, credential)), + Effect.tap((expo) => + Effect.sync(() => { + lastApi = expo; + }), + ), Effect.provide(services), Effect.mapError((error) => unavailable(error.message, error)), ); + /** Stopping a billed session must not depend on the secret still being readable; starting one must. */ + const apiForStop = api.pipe( + Effect.catch((error) => (lastApi === undefined ? Effect.fail(error) : Effect.succeed(lastApi))), + ); /** A missing ledger is empty; an unreadable one is an error, never an empty list. */ const readLedger = fs.readFileString(ledgerPath).pipe( @@ -626,7 +650,7 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice if (stale.length === 0 && unresolved.size === 0 && uncertain.size === 0 && !reapDue) return; const reap = reapDue || uncertain.size > 0; if (reap) reapedAt = now; - const expo = yield* api.pipe( + const expo = yield* apiForStop.pipe( // Without a credential no sweep can look; a record Expo would have // listed by now still stops counting, so the limit is not held forever. Effect.tapError(() => @@ -723,7 +747,7 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice Option.match(sessionFor(device.id), { onNone: () => Effect.void, onSome: (session) => - api.pipe( + apiForStop.pipe( Effect.flatMap((expo) => stopTracked(expo, session)), Effect.asVoid, Effect.mapError( @@ -853,7 +877,7 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice // Interrupted starts stop the sessions they created before this returns. yield* FiberSet.clear(starts); yield* FiberSet.clear(background); - const expo = yield* api; + const expo = yield* apiForStop; yield* Effect.forEach( [...sessions.values()], (session) => stopTracked(expo, session).pipe(Effect.ignore), @@ -884,7 +908,7 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice // Each server signs in on its own, so one with no Expo credential answers for itself. Reading // the credential is local; whether Expo accepts it is only known once a session starts. const platformAvailability = (platform: DevicePlatform) => - resolveExpoCredential(config.accessToken).pipe( + resolveSavedExpoCredential(secrets, config.accessToken).pipe( Effect.provide(services), Effect.as({ platform, available: true }), Effect.catch((error) => diff --git a/apps/server/src/secrets/settingsSecretValue.ts b/apps/server/src/secrets/settingsSecretValue.ts index 83a0adc5f11dd..50a99b953d327 100644 --- a/apps/server/src/secrets/settingsSecretValue.ts +++ b/apps/server/src/secrets/settingsSecretValue.ts @@ -1,7 +1,12 @@ -import { secretReferencesInSettings, withSettingsSecrets } from "@t3tools/contracts"; +import { + isSecretReference, + secretReferencesInSettings, + type SettingsSecretValue, + withSettingsSecrets, +} from "@t3tools/contracts"; import type * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as Effect from "effect/Effect"; -import type * as Schema from "effect/Schema"; +import * as Schema from "effect/Schema"; /** * Replaces each reference in the slots `schema` declares as `SettingsSecretValue` with the secret @@ -18,3 +23,29 @@ export const resolveSettingsSecrets = Effect.fn("SettingsSecrets.resolve")(funct pending.map((slot, index) => ({ ...slot, value: values[index] ?? "" })), ); }); + +export class SettingsSecretUnresolvedError extends Schema.TaggedError()( + "SettingsSecretUnresolvedError", + { provider: Schema.String }, +) { + override get message(): string { + return `Could not read the credential from the secret provider "${this.provider}". Unlock or fix it in Settings, then try again.`; + } +} + +/** + * The secret a credential setting holds or names. A named secret that cannot be read, or reads + * empty, fails instead of passing for a missing token, so callers that would fall back to another + * credential cannot silently switch accounts. + */ +export const requireSettingsSecret = Effect.fn("SettingsSecrets.require")(function* ( + resolver: SecretResolver.SecretResolver["Service"], + value: SettingsSecretValue, +) { + if (!isSecretReference(value)) return value; + const [secret] = yield* resolver.read([value]); + if (secret === undefined || secret.trim() === "") { + return yield* new SettingsSecretUnresolvedError({ provider: value.provider }); + } + return secret; +}); diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts index 697e166c324a4..974fc7cc91c49 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts @@ -1,5 +1,5 @@ import { assert, it } from "@effect/vitest"; -import { SecretProviderId, UsageLimitSourceId } from "@t3tools/contracts"; +import { DeviceHostId, SecretProviderId, UsageLimitSourceId } from "@t3tools/contracts"; import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as Effect from "effect/Effect"; @@ -139,7 +139,19 @@ it.effect("reads a collected reference again once it is invalidated", () => enabled: true, }, } as const; + const easDeviceHosts = [ + { + kind: "eas" as const, + id: DeviceHostId.make("ref"), + label: "Ref", + maxSessionMinutes: 30, + idleMinutes: 10, + maxConcurrentSessions: 2, + accessToken: source("expo#token"), + }, + ]; cache.set("cliproxy#key", "stale"); + cache.set("expo#token", "stale"); assert.deepEqual((yield* read()).bitbucket, { email: "me@example.test", accessToken: "stale", @@ -147,9 +159,10 @@ it.effect("reads a collected reference again once it is invalidated", () => current = "rotated"; yield* invalidateSettingsSecrets( resolver, - Effect.succeed({ sourceControlHosts: hosts, usageLimitSources }), + Effect.succeed({ sourceControlHosts: hosts, usageLimitSources, easDeviceHosts }), ); assert.isFalse(cache.has("cliproxy#key")); + assert.isFalse(cache.has("expo#token")); assert.deepEqual((yield* read()).bitbucket, { email: "me@example.test", accessToken: "rotated", diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.ts index 32d2f41f9c504..21213d27bdb8b 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.ts @@ -104,15 +104,24 @@ const collectUsageLimitSecretReferences = ( isSecretReference(managementKey) ? [managementKey] : [], ); +const collectEasSecretReferences = ( + hosts: ServerSettings["easDeviceHosts"], +): ReadonlyArray => + hosts.flatMap(({ accessToken }) => (isSecretReference(accessToken) ? [accessToken] : [])); + /** Settings that cannot be read clear every cached secret, so a refresh never keeps a stale one. */ export const invalidateSettingsSecrets = Effect.fn("SettingsSecrets.invalidate")(function* ( resolver: SecretResolver.SecretResolver["Service"], - settings: Effect.Effect, E>, + settings: Effect.Effect< + Pick, + E + >, ) { const references = yield* settings.pipe( Effect.map((current) => [ ...collectSourceControlHostSecretReferences(current.sourceControlHosts), ...collectUsageLimitSecretReferences(current.usageLimitSources), + ...collectEasSecretReferences(current.easDeviceHosts), ]), Effect.orElseSucceed(() => undefined), ); diff --git a/apps/server/src/usage/UsageLimitSources.test.ts b/apps/server/src/usage/UsageLimitSources.test.ts index 1d955d497ef6b..7b078b7b61202 100644 --- a/apps/server/src/usage/UsageLimitSources.test.ts +++ b/apps/server/src/usage/UsageLimitSources.test.ts @@ -5,6 +5,7 @@ import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretReso import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/http"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; @@ -97,8 +98,11 @@ it.effect("reads a management key from its secret provider", () => { }); return Effect.gen(function* () { const sources = yield* UsageLimitSources.make; - yield* sources.refresh; - const snapshots = yield* sources.current; + const snapshots = yield* sources.streamChanges.pipe( + Stream.filter((next) => next.length > 0), + Stream.runHead, + Effect.flatMap(Effect.fromOption), + ); expect(snapshots.map(({ id, error }) => ({ id, error }))).toEqual([ { id: "resolved-hub", error: undefined }, { diff --git a/apps/web/src/components/settings/EasDeviceHostEditor.tsx b/apps/web/src/components/settings/EasDeviceHostEditor.tsx index 220f5ffa3269b..a1214c3869d04 100644 --- a/apps/web/src/components/settings/EasDeviceHostEditor.tsx +++ b/apps/web/src/components/settings/EasDeviceHostEditor.tsx @@ -3,9 +3,11 @@ import { useId, useRef, useState } from "react"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; import { + isSecretReference, type DeviceEasAccount, type EasDeviceHostConfig, type EnvironmentId, + type SettingsSecretValue, } from "@t3tools/contracts"; import { XIcon } from "lucide-react"; @@ -25,7 +27,7 @@ import { Input } from "../ui/input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Spinner } from "../ui/spinner"; import { parseEasDeviceHostDraft } from "./easDeviceHostsSettings.logic"; -import { SecretInput } from "./SecretInput"; +import { SecretSourceInput, useSecretSources } from "./SecretSourceInput"; const FROM_THREAD_PROJECT = "thread-project"; @@ -57,9 +59,10 @@ export function EasDeviceHostEditor(props: { const check = useAtomCommand(deviceEnvironment.easAccount, { reportFailure: false }); const [draft, setDraft] = useState(props.host); const tokenId = useId(); + const secretSources = useSecretSources(props.environmentId); // A saved token arrives redacted; only a replacement or a clear changes it. const [token, setToken] = useState< - { kind: "saved" } | { kind: "new"; value: string } | { kind: "cleared" } + { kind: "saved" } | { kind: "new"; value: SettingsSecretValue } | { kind: "cleared" } >(props.host.accessToken ? { kind: "saved" } : { kind: "cleared" }); // Checking on open names the saved project and shows a rejected credential // before anything is changed. A manual check or a token edit replaces it. @@ -192,11 +195,23 @@ export function EasDeviceHostEditor(props: { Expo access token
- { + setToken(secret ? { kind: "new", value: secret } : { kind: "cleared" }); + resetAccount(); + }} disabled={props.busy} placeholder="Optional" onChange={(event) => { diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index 24b72dbe26d7a..417d1c81b418e 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -3,6 +3,7 @@ import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { ThreadId, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; +import { SettingsSecretValue } from "./settingsSecretValue.ts"; export const DevicePlatform = Schema.Literals(["ios", "android"]); export type DevicePlatform = typeof DevicePlatform.Type; @@ -40,11 +41,11 @@ export const EasDeviceHostConfig = Schema.Struct({ /** EAS project that sessions run under, when the project's app config does not name one. */ appId: Schema.optional(TrimmedNonEmptyString), /** - * Expo access token, kept in the secret store. When empty the server uses - * EXPO_TOKEN, then the `eas login` session. This is `secretSetting` from - * settings.ts, which imports this module. + * Expo access token, kept in the secret store, or the secret provider to read + * it from. When empty the server uses EXPO_TOKEN, then the `eas login` session. + * This is `secretSetting` from settings.ts, which imports this module. */ - accessToken: TrimmedString.pipe( + accessToken: SettingsSecretValue.pipe( Schema.withDecodingDefaultKey(Effect.succeed("")), Schema.annotateKey({ settingsSecret: true }), ), @@ -116,7 +117,7 @@ export const SimlockDeviceHostConfigs = Schema.Array(SimlockDeviceHostConfig).ch export const DeviceEasAccountInput = Schema.Struct({ /** A saved EAS host whose stored token is checked when no new token is given. */ hostId: Schema.optional(DeviceHostId), - accessToken: Schema.optional(TrimmedString), + accessToken: Schema.optional(SettingsSecretValue), }); export type DeviceEasAccountInput = typeof DeviceEasAccountInput.Type; From 3e0fed8a418a7488aec37e050d9d579bbd9af9d9 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 3 Nov 2026 00:40:50 +0000 Subject: [PATCH 5/7] fix(web): block stale secret references in credential forms --- .../settings/AddUsageLimitSourceDialog.tsx | 7 +++-- .../settings/EasDeviceHostEditor.tsx | 21 ++++++++++---- .../SecretSourceInput.browser.test.tsx | 28 +++++++------------ .../components/settings/SecretSourceInput.tsx | 17 ++++++----- 4 files changed, 38 insertions(+), 35 deletions(-) diff --git a/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx b/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx index 12b9bbe820869..780fba4a7007d 100644 --- a/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx +++ b/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx @@ -36,14 +36,16 @@ export function AddUsageLimitSourceDialog({ const [url, setUrl] = useState(""); const secretSources = useSecretSources(environmentId); const [keyDraft, setKeyDraft] = useState(""); + const [keyValid, setKeyValid] = useState(true); const managementKey = typeof keyDraft === "string" ? keyDraft.trim() : keyDraft; const trimmedUrl = url.trim(); - const canSave = trimmedUrl.length > 0 && managementKey !== ""; + const canSave = keyValid && trimmedUrl.length > 0 && managementKey !== ""; const reset = () => { setLabel(""); setUrl(""); setKeyDraft(""); + setKeyValid(true); }; const save = () => { @@ -107,8 +109,7 @@ export function AddUsageLimitSourceDialog({ sources={secretSources} onChange={(event) => setKeyDraft(event.target.value)} onSecretChange={(secret) => setKeyDraft(secret ?? "")} - onProviderPick={() => setKeyDraft("")} - onReferenceReject={() => setKeyDraft("")} + onValidityChange={setKeyValid} />
diff --git a/apps/web/src/components/settings/EasDeviceHostEditor.tsx b/apps/web/src/components/settings/EasDeviceHostEditor.tsx index a1214c3869d04..1223fe09b7d7f 100644 --- a/apps/web/src/components/settings/EasDeviceHostEditor.tsx +++ b/apps/web/src/components/settings/EasDeviceHostEditor.tsx @@ -60,6 +60,8 @@ export function EasDeviceHostEditor(props: { const [draft, setDraft] = useState(props.host); const tokenId = useId(); const secretSources = useSecretSources(props.environmentId); + const [tokenValid, setTokenValid] = useState(true); + const [tokenInputKey, setTokenInputKey] = useState(0); // A saved token arrives redacted; only a replacement or a clear changes it. const [token, setToken] = useState< { kind: "saved" } | { kind: "new"; value: SettingsSecretValue } | { kind: "cleared" } @@ -90,13 +92,15 @@ export function EasDeviceHostEditor(props: { accessToken: token.kind === "saved" ? props.host.accessToken : token.kind === "new" ? token.value : "", }; - const valid = Option.isSome(parseEasDeviceHostDraft(next)); + const valid = tokenValid && Option.isSome(parseEasDeviceHostDraft(next)); // Why Save is off, for the limits the fields' ranges do not show. const problem = valid ? null - : draft.maxSessionMinutes > 0 && draft.idleMinutes >= draft.maxSessionMinutes - ? "Idle minutes must be smaller than session minutes." - : "Give the host a label and limits within the ranges shown."; + : !tokenValid + ? "Enter a valid secret reference." + : draft.maxSessionMinutes > 0 && draft.idleMinutes >= draft.maxSessionMinutes + ? "Idle minutes must be smaller than session minutes." + : "Give the host a label and limits within the ranges shown."; const apps = account.status === "signed-in" ? account.account.apps : []; // A check answers for the credential it was asked about: a later check or a @@ -196,6 +200,7 @@ export function EasDeviceHostEditor(props: {
{ + setTokenValid(valid); + resetAccount(); + }} onSecretChange={(secret) => { setToken(secret ? { kind: "new", value: secret } : { kind: "cleared" }); resetAccount(); @@ -236,6 +245,8 @@ export function EasDeviceHostEditor(props: { aria-label="Clear access token" onClick={() => { setToken({ kind: "cleared" }); + setTokenValid(true); + setTokenInputKey((key) => key + 1); resetAccount(); }} > @@ -258,7 +269,7 @@ export function EasDeviceHostEditor(props: { type="button" size="sm" variant="outline" - disabled={props.busy || account.status === "checking"} + disabled={props.busy || !tokenValid || account.status === "checking"} onClick={() => void checkAccount()} > {account.status === "checking" ? : null} Check account diff --git a/apps/web/src/components/settings/SecretSourceInput.browser.test.tsx b/apps/web/src/components/settings/SecretSourceInput.browser.test.tsx index 18ff271e97974..f7c598ab10568 100644 --- a/apps/web/src/components/settings/SecretSourceInput.browser.test.tsx +++ b/apps/web/src/components/settings/SecretSourceInput.browser.test.tsx @@ -23,7 +23,7 @@ async function commitReference(screen: Screen, value: string) { describe("SecretSourceInput", () => { it("moves a reference to another provider only once one is committed for it", async () => { const onSecretChange = vi.fn(); - const onProviderPick = vi.fn(); + const onValidityChange = vi.fn(); await using screen = await render( { sources={sources} onCommit={() => {}} onSecretChange={onSecretChange} - onProviderPick={onProviderPick} + onValidityChange={onValidityChange} />, ); await userEvent.click(screen.getByRole("combobox", { name: "API key source" })); await userEvent.click(screen.getByRole("option", { name: "Work" })); expect(onSecretChange).not.toHaveBeenCalled(); - expect(onProviderPick).toHaveBeenCalledOnce(); + expect(onValidityChange).toHaveBeenLastCalledWith(false); await expect.element(reference(screen)).toHaveValue(""); await commitReference(screen, "op://Shared/OpenAI/credential"); @@ -46,6 +46,7 @@ describe("SecretSourceInput", () => { provider: work, reference: "op://Shared/OpenAI/credential", }); + expect(onValidityChange).toHaveBeenLastCalledWith(true); }); it("removes the reference when its field is cleared", async () => { @@ -69,7 +70,7 @@ describe("SecretSourceInput", () => { it("rejects a reference 1Password cannot read", async () => { const onSecretChange = vi.fn(); - const onReferenceReject = vi.fn(); + const onValidityChange = vi.fn(); await using screen = await render( { sources={sources} onCommit={() => {}} onSecretChange={onSecretChange} - onReferenceReject={onReferenceReject} + onValidityChange={onValidityChange} />, ); await commitReference(screen, "Private/OpenAI/credential"); expect(onSecretChange).not.toHaveBeenCalled(); - expect(onReferenceReject).toHaveBeenCalledOnce(); - await screen.rerender( - {}} - onSecretChange={onSecretChange} - onReferenceReject={onReferenceReject} - />, - ); + expect(onValidityChange).toHaveBeenLastCalledWith(false); await expect.element(reference(screen)).toHaveAttribute("aria-invalid", "true"); await expect.element(reference(screen)).toHaveValue("Private/OpenAI/credential"); - await commitReference(screen, "op://Private/OpenAI/corrected"); + await commitReference(screen, "op://Private/OpenAI/replacement"); + expect(onValidityChange).toHaveBeenLastCalledWith(true); expect(onSecretChange).toHaveBeenCalledExactlyOnceWith({ provider: personal, - reference: "op://Private/OpenAI/corrected", + reference: "op://Private/OpenAI/replacement", }); }); }); diff --git a/apps/web/src/components/settings/SecretSourceInput.tsx b/apps/web/src/components/settings/SecretSourceInput.tsx index 83ca9ad2ced71..67053a054db38 100644 --- a/apps/web/src/components/settings/SecretSourceInput.tsx +++ b/apps/web/src/components/settings/SecretSourceInput.tsx @@ -64,10 +64,8 @@ type SecretSourceInputProps = Omit, "value" | readonly value: string | SecretReference; readonly sources: ReadonlyArray; readonly onSecretChange: (secret: SecretReference | undefined) => void; - /** Called when a provider is picked, before a reference for it exists. */ - readonly onProviderPick?: () => void; - /** Called when an edited reference is rejected, so the committed one no longer matches the field. */ - readonly onReferenceReject?: () => void; + /** Whether the selected source and committed field can be saved or used. */ + readonly onValidityChange?: (valid: boolean) => void; /** Places the parts in the caller's layout; `source` is null when there is nothing to pick. */ readonly children?: (parts: { readonly source: ReactNode; @@ -80,8 +78,7 @@ export function SecretSourceInput({ value, sources, onSecretChange, - onProviderPick, - onReferenceReject, + onValidityChange, children, ...rest }: SecretSourceInputProps) { @@ -108,11 +105,12 @@ export function SecretSourceInput({ setRejectedReference(null); if (value === STORED_VALUE || value === null) { setPendingProvider(null); + onValidityChange?.(true); if (secret !== undefined) onSecretChange(undefined); return; } const next = value as SecretProviderId; - if (next !== secret?.provider) onProviderPick?.(); + onValidityChange?.(next === secret?.provider); setPendingProvider(next === secret?.provider ? null : next); }} > @@ -157,6 +155,7 @@ export function SecretSourceInput({ onCommit={(next) => { if (next.trim().length === 0) { setRejectedReference(null); + onValidityChange?.(pendingProvider === null && secret !== undefined); if (pendingProvider === null && secret !== undefined) onSecretChange(undefined); return; } @@ -166,12 +165,12 @@ export function SecretSourceInput({ definition?.isReference(decoded.value.reference) === false ) { setRejectedReference(next); - if (onReferenceReject !== undefined) setPendingProvider(provider); - onReferenceReject?.(); + onValidityChange?.(false); return; } setRejectedReference(null); setPendingProvider(null); + onValidityChange?.(true); onSecretChange(decoded.value); }} autoComplete="off" From 855d98f9b995bcb12ea6f9572cc834b1835c956f Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:04:14 -0800 Subject: [PATCH 6/7] fix(server): clients that cannot read secret references see a saved secret instead A secret reference in a strictly typed settings field, such as a usage-limit management key, made older clients fail to decode the whole server config. Clients now announce `clientCapabilities=settingsSecretReferences` on the socket URL. For a connection without it, the server sends each reference in a secret field as the redacted marker, and a save that sends that marker back keeps the stored reference. A test lists every strictly decoded field that can hold a reference, so a new one has to be added to the older-client case. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/serverSettings.test.ts | 105 ++++++++++++++++-- apps/server/src/serverSettings.ts | 37 +++++- apps/server/src/settingsSecrets.ts | 102 ++++++++++++----- apps/server/src/ws.test.ts | 12 +- apps/server/src/ws.ts | 23 +++- .../src/connection/compatibility.test.ts | 1 + .../src/connection/compatibility.ts | 3 + packages/contracts/src/environment.ts | 5 + 8 files changed, 239 insertions(+), 49 deletions(-) diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index cc34e2f818381..23c5a5643dee5 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -26,6 +26,7 @@ import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as SchemaAST from "effect/SchemaAST"; import * as SqlClient from "effect/sql/SqlClient"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import { sourceControlHostSecretName } from "./sourceControl/sourceControlHostSecrets.ts"; @@ -33,6 +34,7 @@ import * as ServerConfig from "./config.ts"; import * as SqlitePersistence from "./persistence/Sqlite.ts"; import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import * as ServerSettingsModule from "./serverSettings.ts"; +import { SECRET_REDACTED } from "./settingsSecrets.ts"; import * as ManagedPolicy from "./policy/ManagedPolicy.ts"; import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts"; @@ -101,6 +103,36 @@ const recordProviderUsage = (provider: string, instanceId: string | null = provi `; }); +/** Paths of fields typed `SettingsSecretValue` that clients decode with the settings schema itself. */ +function strictSecretReferencePaths( + ast: SchemaAST.AST, + path = "", + seen = new Set(), +): ReadonlyArray { + if (seen.has(ast)) return []; + seen.add(ast); + const walk = (type: SchemaAST.AST, at: string): ReadonlyArray => + type.context?.annotations?.settingsSecretReference === true + ? [at] + : strictSecretReferencePaths(type, `${at}.`, seen); + if (SchemaAST.isObjects(ast)) { + return [ + ...ast.propertySignatures.flatMap((property) => + walk(property.type, `${path}${String(property.name)}`), + ), + ...ast.indexSignatures.flatMap((signature) => walk(signature.type, `${path}*`)), + ]; + } + if (SchemaAST.isArrays(ast)) { + return [...ast.elements, ...ast.rest].flatMap((element) => walk(element, `${path}[]`)); + } + if (SchemaAST.isUnion(ast)) { + return ast.types.flatMap((member) => strictSecretReferencePaths(member, path, seen)); + } + if (SchemaAST.isSuspend(ast)) return strictSecretReferencePaths(ast.thunk(), path, seen); + return []; +} + it.layer(NodeServices.layer)("server settings", (it) => { it.effect("serializes concurrent model preference edits without cancelling duplicate adds", () => Effect.gen(function* () { @@ -277,7 +309,8 @@ it.layer(NodeServices.layer)("server settings", (it) => { }, }); assert.deepStrictEqual( - ServerSettingsModule.redactServerSettingsForClient(settings).secretProviders, + ServerSettingsModule.redactServerSettingsForClient(settings, { secretReferences: true }) + .secretProviders, { personal: { kind: "one_password", label: "Personal", config: { account: "my" } }, vault: { kind: "openbao", label: "Vault" }, @@ -1330,7 +1363,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { "quota-secret", "expo-secret", ]; - const client = ServerSettingsModule.redactServerSettingsForClient(saved); + const client = ServerSettingsModule.redactServerSettingsForClient(saved, { + secretReferences: true, + }); const disk = yield* fs.readFileString(config.settingsPath); for (const value of values) { assert.include(JSON.stringify(saved), value); @@ -1364,7 +1399,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { instance: { ...client.providerInstances[instanceId]!, config: { apiKey } }, }); assert.propertyVal(changed.providerInstances[instanceId]?.config, "apiKey", apiKey); - const redacted = ServerSettingsModule.redactServerSettingsForClient(changed); + const redacted = ServerSettingsModule.redactServerSettingsForClient(changed, { + secretReferences: true, + }); assert.propertyVal( redacted.providerInstances[instanceId]?.config, "apiKey", @@ -1413,9 +1450,8 @@ it.layer(NodeServices.layer)("server settings", (it) => { }); assert.isTrue(Option.isNone(yield* store.get(secretNames[1]!))); assert.deepEqual( - ServerSettingsModule.redactServerSettingsForClient(referenced).providerInstances[ - instanceId - ]?.environment, + ServerSettingsModule.redactServerSettingsForClient(referenced, { secretReferences: true }) + .providerInstances[instanceId]?.environment, [{ name: "API_TOKEN", value: secret, sensitive: true }], ); yield* service.updateSettings({ @@ -1462,7 +1498,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { "serverPassword", "legacy-password", ); - const client = ServerSettingsModule.redactServerSettingsForClient(saved); + const client = ServerSettingsModule.redactServerSettingsForClient(saved, { + secretReferences: true, + }); for (const value of ["legacy-api-key", "legacy-password"]) { assert.notInclude(yield* fs.readFileString(config.settingsPath), value); assert.notInclude(JSON.stringify(client), value); @@ -1506,8 +1544,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { for (const secret of ["bb-access", "ghp_dotcom", "ghp_ghe"]) assert.notInclude(raw, secret); assert.include(raw, "me@example.com"); - const forClient = - ServerSettingsModule.redactServerSettingsForClient(saved).sourceControlHosts; + const forClient = ServerSettingsModule.redactServerSettingsForClient(saved, { + secretReferences: true, + }).sourceControlHosts; assert.equal(forClient.bitbucket?.email, "me@example.com"); assert.notInclude(String(forClient.bitbucket?.accessToken), "bb-access"); assert.isAbove(String(forClient.bitbucket?.accessToken).length, 0); @@ -1569,15 +1608,59 @@ it.layer(NodeServices.layer)("server settings", (it) => { assert.isTrue(Option.isNone(yield* stored("bitbucket", "accessToken", null))); assert.isTrue(Option.isNone(yield* stored("github", "tokens", "github.com"))); assert.isTrue(Option.isSome(yield* stored("github", "tokens", "ghe.acme.test"))); - const redacted = - ServerSettingsModule.redactServerSettingsForClient(referenced).sourceControlHosts; + const redacted = ServerSettingsModule.redactServerSettingsForClient(referenced, { + secretReferences: true, + }).sourceControlHosts; assert.deepEqual(redacted.bitbucket?.accessToken, reference); const tokens = redacted.github?.tokens as Record; assert.deepEqual(tokens["github.com"], gitHubReference); assert.notEqual(tokens["ghe.acme.test"], "ghp_ghe"); + + // A client that cannot decode references sees saved secrets and can send them back. + const usageReference = { + provider: SecretProviderId.make("personal"), + reference: "op://Private/Hub/key", + }; + const hub = UsageLimitSourceId.make("hub"); + const withUsage = yield* serverSettings.updateSettings({ + usageLimitSources: { + [hub]: { + kind: "cliproxy", + url: "https://hub.test", + managementKey: usageReference, + enabled: true, + }, + }, + }); + const legacy = ServerSettingsModule.redactServerSettingsForClient(withUsage, { + secretReferences: false, + }); + assert.notInclude(JSON.stringify(legacy), "op://"); + assert.equal(legacy.sourceControlHosts.bitbucket?.accessToken, SECRET_REDACTED); + assert.equal(legacy.usageLimitSources[hub]?.managementKey, SECRET_REDACTED); + const echoed = yield* serverSettings.updateSettings({ + sourceControlHosts: legacy.sourceControlHosts, + usageLimitSources: legacy.usageLimitSources, + }); + assert.deepEqual(echoed.sourceControlHosts.bitbucket?.accessToken, reference); + assert.deepEqual(echoed.sourceControlHosts.github?.tokens, { + "github.com": gitHubReference, + "ghe.acme.test": "ghp_ghe", + }); + assert.deepEqual(echoed.usageLimitSources[hub]?.managementKey, usageReference); + assert.isTrue(Option.isSome(yield* stored("github", "tokens", "ghe.acme.test"))); }).pipe(Effect.provide(layerServerSettingsWithSecrets())), ); + it.effect("knows every strictly decoded field that can hold a secret reference", () => + Effect.sync(() => { + // Shipped clients decode these as strings. Each must be covered by the older-client case above. + assert.deepEqual(strictSecretReferencePaths(ServerSettings.ast), [ + "usageLimitSources.*.managementKey", + ]); + }), + ); + it.effect("keeps the retired keys for another load when a token cannot be copied", () => { const cause = new ServerSecretStore.SecretStoreReadError({ resource: "bitbucket-access-token", diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 1ead9e27bfcf5..831a5fad4995b 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -18,6 +18,8 @@ import { type ServerSettingsPatch, type ManagedPolicySummary, globalIdleCompaction, + isSecretReference, + type SecretReference, withIdleCompaction, } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; @@ -60,7 +62,12 @@ import { sourceControlHostSecretName, } from "./sourceControl/sourceControlHostSecrets.ts"; -import { mapSettingsSecrets, SECRET_REDACTED, type SettingsSecret } from "./settingsSecrets.ts"; +import { + mapSettingsSecrets, + mapSettingsSecretValues, + SECRET_REDACTED, + type SettingsSecret, +} from "./settingsSecrets.ts"; export { resolveReviewGuideModelSelection, @@ -131,13 +138,35 @@ const normalizeServerSettings = ( ), ); -export function redactServerSettingsForClient(settings: ServerSettings): ServerSettings { +export function redactServerSettingsForClient( + settings: ServerSettings, + client: { readonly secretReferences: boolean }, +): ServerSettings { const redacted = mapSettingsSecrets(settings, (secret) => secret.sensitive ? { value: "", redacted: secret.redacted || secret.value.length > 0 } : { value: secret.value, redacted: false }, ); - return { ...redacted, secretProviders: withoutUnknownSecretProviderConfigs(redacted) }; + // Older clients decode secret fields as strings and fail on a reference, so they see a saved secret. + const shaped = client.secretReferences + ? redacted + : mapSettingsSecretValues(redacted, (value) => + isSecretReference(value) ? SECRET_REDACTED : value, + ); + return { ...shaped, secretProviders: withoutUnknownSecretProviderConfigs(shaped) }; +} + +/** A client shown a reference as a saved secret sends that marker back; it keeps the reference. */ +function withEchoedSecretReferences(current: ServerSettings, next: ServerSettings): ServerSettings { + const references = new Map(); + mapSettingsSecretValues(current, (value, secretName) => { + if (isSecretReference(value)) references.set(secretName, value); + return value; + }); + if (references.size === 0) return next; + return mapSettingsSecretValues(next, (value, secretName) => + value === SECRET_REDACTED ? (references.get(secretName) ?? value) : value, + ); } // A newer kind's config may hold credentials this build cannot recognize, so it never leaves the server. @@ -1004,7 +1033,7 @@ const make = Effect.gen(function* () { }); const changes: SecretChange[] = []; const nextSecretNames = new Set(); - const settings = mapSettingsSecrets(next, (secret) => { + const settings = mapSettingsSecrets(withEchoedSecretReferences(current, next), (secret) => { const metadata = { secretName: secret.secretName, ...(secret.providerInstanceId !== undefined diff --git a/apps/server/src/settingsSecrets.ts b/apps/server/src/settingsSecrets.ts index 3d4daa659e58a..e94b6145782f8 100644 --- a/apps/server/src/settingsSecrets.ts +++ b/apps/server/src/settingsSecrets.ts @@ -3,7 +3,9 @@ import { ClaudeSettings, CodexSettings, EasDeviceHostConfig, + type ProviderInstanceConfig, type ServerSettings, + type SettingsSecretValue, SimlockDeviceHostConfig, UsageLimitSourceConfig, isSecretReference, @@ -49,16 +51,19 @@ export interface SettingsSecret { } type SecretValue = Pick; +type SecretSlotName = (field: string, serverHost: string | null) => string; +type SecretFieldsMapper = ( + fields: Readonly>, + secretFields: ReadonlyArray, + name: SecretSlotName, +) => Record; +type ProviderEnvironment = NonNullable; export function mapSettingsSecrets( settings: ServerSettings, map: (secret: SettingsSecret) => SecretValue, ): ServerSettings { - const mapFields = ( - fields: Readonly>, - secretFields: ReadonlyArray, - name: (field: string, serverHost: string | null) => string, - ) => { + const mapFields: SecretFieldsMapper = (fields, secretFields, name) => { const next = { ...fields }; for (const field of secretFields) { if (!(field in next) || isSecretReference(next[field])) continue; @@ -79,6 +84,70 @@ export function mapSettingsSecrets( return next; }; + return mapSecretFieldGroups(settings, mapFields, (instanceId, environment) => + environment.map((variable) => { + const { valueRedacted: _omit, ...rest } = variable; + // A secret reference is not itself secret; storage sees the value cleared. + const reference = typeof variable.value === "string" ? undefined : variable.value; + const result = map({ + secretName: `provider-env-${encode(instanceId)}-${encode(variable.name)}`, + kind: "environment", + value: typeof variable.value === "string" ? variable.value : "", + redacted: reference === undefined && variable.valueRedacted === true, + sensitive: variable.sensitive, + providerInstanceId: instanceId, + environmentVariable: variable.name, + }); + if (reference !== undefined) return { ...rest, value: reference }; + return { + ...rest, + value: result.value, + ...(result.redacted ? { valueRedacted: true } : {}), + }; + }), + ); +} + +/** + * Maps each value a secret field holds, literal or reference, with the secret name of its slot. + * Provider environment variables are left as they are. + */ +export function mapSettingsSecretValues( + settings: ServerSettings, + map: (value: SettingsSecretValue, secretName: string) => SettingsSecretValue, +): ServerSettings { + const mapValue = (value: unknown, secretName: string) => + typeof value === "string" || isSecretReference(value) ? map(value, secretName) : value; + return mapSecretFieldGroups(settings, (fields, secretFields, name) => { + const next = { ...fields }; + for (const field of secretFields) { + if (!(field in next)) continue; + const value = next[field]; + next[field] = + value !== null && + typeof value === "object" && + !Array.isArray(value) && + !isSecretReference(value) + ? Object.fromEntries( + Object.entries(value).map(([serverHost, entry]) => [ + serverHost, + mapValue(entry, name(field, serverHost.trim().toLowerCase())), + ]), + ) + : mapValue(value, name(field, null)); + } + return next; + }); +} + +function mapSecretFieldGroups( + settings: ServerSettings, + mapFields: SecretFieldsMapper, + mapEnvironment: (instanceId: string, environment: ProviderEnvironment) => ProviderEnvironment = ( + _instanceId, + environment, + ) => environment, +): ServerSettings { const providerInstances = Object.fromEntries( Object.entries(settings.providerInstances).map(([instanceId, instance]) => { const config = instance.config; @@ -98,28 +167,7 @@ export function mapSettingsSecrets( } : {}), ...(instance.environment - ? { - environment: instance.environment.map((variable) => { - const { valueRedacted: _omit, ...rest } = variable; - // A secret reference is not itself secret; storage sees the value cleared. - const reference = typeof variable.value === "string" ? undefined : variable.value; - const result = map({ - secretName: `provider-env-${encode(instanceId)}-${encode(variable.name)}`, - kind: "environment", - value: typeof variable.value === "string" ? variable.value : "", - redacted: reference === undefined && variable.valueRedacted === true, - sensitive: variable.sensitive, - providerInstanceId: instanceId, - environmentVariable: variable.name, - }); - if (reference !== undefined) return { ...rest, value: reference }; - return { - ...rest, - value: result.value, - ...(result.redacted ? { valueRedacted: true } : {}), - }; - }), - } + ? { environment: mapEnvironment(instanceId, instance.environment) } : {}), }, ]; diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index caa06c2e97a80..9e52cec74c04d 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -760,6 +760,7 @@ it.effect.each([ {}, yield* ServerBrowser.ServerBrowser, () => Effect.void, + { secretReferences: true }, ).pipe(Layer.provide(configRpcServices)), ), ); @@ -804,8 +805,8 @@ it.effect.each([ key: "mod+alt+z", command: "terminal.split", }); - const expectedSettings = [duringDiscovery, latestSettings].map( - ServerSettings.redactServerSettingsForClient, + const expectedSettings = [duringDiscovery, latestSettings].map((value) => + ServerSettings.redactServerSettingsForClient(value, { secretReferences: true }), ); const expectedKeybindings = [duringAck.keybindings, latestKeybindings.keybindings]; // The two sources can interleave, but each must retain its own publication order. @@ -833,7 +834,7 @@ it.effect.each([ assert.deepEqual(resolved.config.remoteOpenTargets, targets); assert.deepEqual( resolved.config.settings, - ServerSettings.redactServerSettingsForClient(latestSettings), + ServerSettings.redactServerSettingsForClient(latestSettings, { secretReferences: true }), ); assert.deepEqual(resolved.config.keybindings, latestKeybindings.keybindings); yield* acknowledge; @@ -848,7 +849,9 @@ it.effect.each([ assert.deepEqual(resolved.config.directEndpoints, endpoints); assert.deepEqual( resolved.config.settings, - ServerSettings.redactServerSettingsForClient(latestSettings), + ServerSettings.redactServerSettingsForClient(latestSettings, { + secretReferences: true, + }), ); assert.deepEqual(resolved.config.keybindings, latestKeybindings.keybindings); yield* acknowledge; @@ -978,6 +981,7 @@ it.effect("on-demand clients get provider config without workspace snapshots", ( {}, yield* ServerBrowser.ServerBrowser, () => Effect.void, + { secretReferences: true }, ).pipe(Layer.provide(configRpcServices)), ), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 76a4a47a49110..7aac3a5deb1c4 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -55,6 +55,8 @@ import { OrchestrationGetTurnDiffError, ORCHESTRATION_V2_WS_METHODS, ORCHESTRATION_PROTOCOL_QUERY_PARAM, + CLIENT_CAPABILITIES_QUERY_PARAM, + SETTINGS_SECRET_REFERENCES_CAPABILITY, ORCHESTRATION_PROTOCOL_VERSION, OrchestrationV2DispatchCommandError, OrchestrationV2GetShellSnapshotError, @@ -697,6 +699,11 @@ function hasCompatibleOrchestrationProtocol(url: URL): boolean { ); } +function readSettingsClient(url: URL) { + const capabilities = url.searchParams.get(CLIENT_CAPABILITIES_QUERY_PARAM)?.split(",") ?? []; + return { secretReferences: capabilities.includes(SETTINGS_SECRET_REFERENCES_CAPABILITY) }; +} + function shouldUseBoundedThreadSnapshot(input: { readonly acceptBoundedSnapshot?: boolean; readonly acceptDeferredWork?: boolean; @@ -1282,6 +1289,7 @@ export const layerWsRpc = ( clientAnalyticsProps: Readonly>, serverBrowser: ServerBrowser.ServerBrowser["Service"], reportProfiles: ServerBrowser.ProfileReporter, + settingsClient: { readonly secretReferences: boolean }, ) => Layer.effectContext( Effect.gen(function* () { @@ -1797,6 +1805,7 @@ export const layerWsRpc = ( : commandProviders; const settings = ServerSettings.redactServerSettingsForClient( yield* serverSettings.getSettings, + settingsClient, ); const environment = yield* serverEnvironment.getDescriptor; const auth = yield* serverAuth.getDescriptor(); @@ -2591,7 +2600,7 @@ export const layerWsRpc = ( const settings = yield* providerInstanceMutation === undefined ? serverSettings.updateSettings(nextPatch) : serverSettings.updateProviderInstance(providerInstanceMutation, nextPatch); - return ServerSettings.redactServerSettingsForClient(settings); + return ServerSettings.redactServerSettingsForClient(settings, settingsClient); }), [WS_METHODS.serverDiscoverSourceControl]: (_input) => sourceControlDiscovery.discover, [WS_METHODS.serverGetTraceDiagnostics]: (_input) => @@ -3249,7 +3258,9 @@ export const layerWsRpc = ( ) : Stream.empty; const settingsUpdates = settingsChanges.pipe( - Stream.map((settings) => ServerSettings.redactServerSettingsForClient(settings)), + Stream.map((settings) => + ServerSettings.redactServerSettingsForClient(settings, settingsClient), + ), Stream.map((settings) => ({ version: 1 as const, type: "settingsUpdated" as const, @@ -3436,7 +3447,13 @@ export const layer = Layer.unwrap( return httpEffect; }).pipe( Effect.provide( - layerWsRpc(session, clientAnalyticsProps, serverBrowser, reportProfiles).pipe( + layerWsRpc( + session, + clientAnalyticsProps, + serverBrowser, + reportProfiles, + readSettingsClient(requestUrl.value), + ).pipe( Layer.provideMerge(RpcSerialization.layerJson), // Handler-context reporters see only request defects, not server-wide defects. Layer.provide(DefectReporter.layer), diff --git a/packages/client-runtime/src/connection/compatibility.test.ts b/packages/client-runtime/src/connection/compatibility.test.ts index 3f9dc8f992454..239aed398d77a 100644 --- a/packages/client-runtime/src/connection/compatibility.test.ts +++ b/packages/client-runtime/src/connection/compatibility.test.ts @@ -31,6 +31,7 @@ describe("orchestration protocol compatibility", () => { expect(socketUrl.searchParams.get("orchestrationProtocol")).toBe( String(ORCHESTRATION_PROTOCOL_VERSION), ); + expect(socketUrl.searchParams.get("clientCapabilities")).toBe("settingsSecretReferences"); expect(socketUrl.searchParams.get("wsTicket")).toBe("secret"); expect(socketUrl.searchParams.get("connectionMethod")).toBe("relay"); }); diff --git a/packages/client-runtime/src/connection/compatibility.ts b/packages/client-runtime/src/connection/compatibility.ts index 3ca2e226db954..fccc68bff751e 100644 --- a/packages/client-runtime/src/connection/compatibility.ts +++ b/packages/client-runtime/src/connection/compatibility.ts @@ -1,6 +1,8 @@ import { + CLIENT_CAPABILITIES_QUERY_PARAM, ORCHESTRATION_PROTOCOL_QUERY_PARAM, ORCHESTRATION_PROTOCOL_VERSION, + SETTINGS_SECRET_REFERENCES_CAPABILITY, type ExecutionEnvironmentDescriptor, } from "@t3tools/contracts"; @@ -37,5 +39,6 @@ function canSelfUpdate(descriptor: ExecutionEnvironmentDescriptor): boolean { export function appendOrchestrationProtocol(socketUrl: string): string { const url = new URL(socketUrl); url.searchParams.set(ORCHESTRATION_PROTOCOL_QUERY_PARAM, String(ORCHESTRATION_PROTOCOL_VERSION)); + url.searchParams.set(CLIENT_CAPABILITIES_QUERY_PARAM, SETTINGS_SECRET_REFERENCES_CAPABILITY); return url.toString(); } diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index f9cd98311d19d..9ac89ef499b3f 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -15,6 +15,11 @@ export const ORCHESTRATION_PROTOCOL_VERSION_TEXT = "2"; export const ORCHESTRATION_PROTOCOL_QUERY_PARAM = "orchestrationProtocol"; export const ORCHESTRATION_PROTOCOL_HEADER = "x-t3-orchestration-protocol"; +/** Wire shapes a client understands, comma-separated; a client that omits one gets the older shape. */ +export const CLIENT_CAPABILITIES_QUERY_PARAM = "clientCapabilities"; +/** Secret fields may hold a secret provider reference instead of a string. */ +export const SETTINGS_SECRET_REFERENCES_CAPABILITY = "settingsSecretReferences"; + export const ExecutionEnvironmentPlatformOs = Schema.Literals([ "darwin", "linux", From c0c5309958d5a01a4d66d034f85d69654fe0e5eb Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:54:01 -0800 Subject: [PATCH 7/7] test(client): expect the client capability on the resolved socket URL Co-Authored-By: Claude Opus 5.5 --- packages/client-runtime/src/connection/resolver.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index b70b2ef17615f..82a2aafa00140 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -202,7 +202,7 @@ describe("ConnectionResolver", () => { label: "Primary", httpBaseUrl: "http://127.0.0.1:3777", socketUrl: - "ws://127.0.0.1:3777/ws?clientSurface=web&clientDeviceType=desktop&connectionMethod=direct&orchestrationProtocol=2", + "ws://127.0.0.1:3777/ws?clientSurface=web&clientDeviceType=desktop&connectionMethod=direct&orchestrationProtocol=2&clientCapabilities=settingsSecretReferences", httpAuthorization: null, target, });