Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
5c9cedb
feat(server): agents ask the user for a webhook signing secret privately
juliusmarminge Oct 5, 2026
5fd2754
feat(client-runtime): shared command and display state for agent secr…
juliusmarminge Oct 5, 2026
0acf068
feat(client-runtime): forked threads show a pending secret request wi…
juliusmarminge Oct 5, 2026
f4bce45
feat(web): answer an agent's secret request from a card in the timeline
juliusmarminge Oct 5, 2026
6971bd6
feat(mobile): answer an agent's secret request from a card in the thr…
juliusmarminge Oct 5, 2026
058313c
docs(server): secret request comments name the answerSecretRequest RPC
juliusmarminge Oct 5, 2026
48d72d5
refactor: secret requests are generic and return a one-use secretRef
juliusmarminge Oct 5, 2026
a3a21d6
feat(server): secret requests can be monitored
juliusmarminge Oct 5, 2026
5e1d2c5
fix: secret requests work in every thread and never strand a value
juliusmarminge Oct 5, 2026
a11a561
feat(server): a thread waiting on a secret shows as needing input
juliusmarminge Oct 5, 2026
54bddc0
fix: secret requests survive retries and stay out of password managers
juliusmarminge Oct 5, 2026
a1e83b9
fix(server): unused secret values are deleted once they expire
juliusmarminge Oct 5, 2026
1557c97
fix(server): secret request retries and cleanup can't lose or move a …
juliusmarminge Oct 5, 2026
5db85a2
fix(server): a decline that races the secret request timeout is repor…
juliusmarminge Oct 5, 2026
89964e0
refactor(server): secret request errors carry a reason instead of fre…
juliusmarminge Oct 5, 2026
95b3662
fix(server): a secretRef is handed out once even under concurrent use
juliusmarminge Oct 5, 2026
0afd06f
test(server): the delegation re-probe test provides SecretRequests
juliusmarminge Oct 5, 2026
146ce1f
fix(server): a secret request card closes when the wait fails, not on…
juliusmarminge Oct 5, 2026
f71850c
fix(server): a secret card closed by recovery or a racing cancel take…
juliusmarminge Oct 5, 2026
6eee1e7
fix: a failed save can be retried, and the card can't be submitted twice
juliusmarminge Oct 5, 2026
ba6e3d1
fix(server): saving again finishes a save whose record and cleanup bo…
juliusmarminge Oct 5, 2026
cae32ed
fix(server): request_secret's saved result always carries its secretRef
juliusmarminge Oct 5, 2026
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
170 changes: 170 additions & 0 deletions apps/mobile/src/features/threads/SecretRequestCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import {
SECRET_REQUEST_DEFAULT_PLACEHOLDER,
SECRET_REQUEST_PRIVACY_NOTE,
secretRequestAnswerInput,
secretRequestDisplay,
secretRequestFailureMessage,
type SecretRequestItem,
} from "@t3tools/client-runtime/secret-request";
import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
import type { EnvironmentId, OrchestrationV2ProjectedTurnItem } from "@t3tools/contracts";
import { useRef, useState } from "react";
import { Pressable, View, type ColorValue } from "react-native";

import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";
import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
import { serverEnvironment } from "../../state/server";
import { useAtomCommand } from "../../state/use-atom-command";
import { RequestActionButton } from "./RequestActionButton";

/**
* Feed card for a secret an agent asked the user for. The typed value lives
* only in this component's state and the RPC payload: it is never logged,
* alerted, or persisted, and the field clears once the answer is sent.
*/
const LOCK_SYMBOL: AppSymbolName = { ios: "lock", android: "lock" };
const PRIVATE_SYMBOL: AppSymbolName = { ios: "checkmark.shield", android: "lock" };

export function SecretRequestCard(props: {
readonly environmentId: EnvironmentId;
readonly projectedItem: OrchestrationV2ProjectedTurnItem;
readonly iconColor: ColorValue;
}) {
const { item, visibility } = props.projectedItem;
if (item.type !== "secret_request") return null;
const display = secretRequestDisplay(item, visibility);
if (display.kind === "pending") {
return (
<PendingSecretRequestForm
environmentId={props.environmentId}
item={item}
iconColor={props.iconColor}
/>
);
}
const icon: AppSymbolName =
display.kind === "pending-elsewhere"
? LOCK_SYMBOL
: display.outcome === "saved"
? "checkmark"
: "minus";
return (
<View className="mb-3 min-h-9 flex-row items-center gap-2 px-1">
<SymbolView name={icon} size={13} tintColor={props.iconColor} type="monochrome" />
<Text className="flex-1 font-sans text-sm text-foreground-muted" numberOfLines={2}>
{item.label} · {display.label}
</Text>
</View>
);
}

function PendingSecretRequestForm(props: {
readonly environmentId: EnvironmentId;
readonly item: SecretRequestItem;
readonly iconColor: ColorValue;
}) {
const { item } = props;
const answer = useAtomCommand(serverEnvironment.answerSecretRequest, {
label: "answer secret request",
// The failure cause holds the request; keep it out of the console.
reportFailure: false,
reportDefect: false,
});
const [secret, setSecret] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Submit then a tap can both run before a re-render; this guard is synchronous.
const inFlight = useRef(false);

const send = async (
reply: { readonly type: "save"; readonly secret: string } | { readonly type: "decline" },
) => {
const input = secretRequestAnswerInput(item, reply);
if (input === null || inFlight.current) return;
inFlight.current = true;
setSubmitting(true);
setError(null);
const result = await answer({ environmentId: props.environmentId, input }).finally(() => {
inFlight.current = false;
setSubmitting(false);
});
if (result._tag === "Success") {
// The card switches to its answered row once the item updates.
setSecret("");
return;
}
if (!isAtomCommandInterrupted(result)) {
setError(secretRequestFailureMessage(squashAtomCommandFailure(result)));
}
};

// Same hierarchy as web: what is asked, why, the field, then the promise
// about where the value goes.
return (
<View className="mb-3 gap-3 rounded-[20px] border border-border bg-card-alt p-4">
<View className="gap-1">
<Text className="font-t3-bold text-base text-foreground">{item.label}</Text>
{item.reason.trim() ? (
<Text className="font-sans text-sm leading-5 text-foreground-muted">{item.reason}</Text>
) : null}
</View>
<TextInput
accessibilityLabel={item.label}
placeholder={item.placeholder ?? SECRET_REQUEST_DEFAULT_PLACEHOLDER}
value={secret}
onChangeText={setSecret}
editable={!submitting}
secureTextEntry
autoCorrect={false}
autoCapitalize="none"
autoComplete="off"
textContentType="none"
importantForAutofill="no"
spellCheck={false}
returnKeyType="done"
onSubmitEditing={() => void send({ type: "save", secret })}
/>
{error !== null ? (
<Text
accessibilityRole="alert"
accessibilityLiveRegion="polite"
className="font-sans text-sm text-danger-foreground"
>
{error}
</Text>
) : null}
<RequestActionButton
label="Save securely"
disabled={submitting || secret.trim().length === 0}
onPress={() => void send({ type: "save", secret })}
/>
<View className="flex-row items-center justify-between gap-2">
<View className="flex-1 flex-row items-center gap-1.5">
<SymbolView
name={PRIVATE_SYMBOL}
size={13}
tintColor={props.iconColor}
type="monochrome"
/>
<Text className="flex-1 font-sans text-xs text-foreground-muted">
{SECRET_REQUEST_PRIVACY_NOTE}
</Text>
</View>
{/* Quiet like the web card's: the field and Save are the action. */}
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: submitting }}
disabled={submitting}
hitSlop={8}
className="px-1 py-1 active:opacity-60 disabled:opacity-50"
onPress={() => void send({ type: "decline" })}
>
<Text className="font-sans text-xs text-foreground-muted">Decline</Text>
</Pressable>
</View>
</View>
);
}
12 changes: 12 additions & 0 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ThreadContextDivider } from "./thread-context-divider";
import { ThreadHandoffRow } from "./thread-handoff-row";
import { SecretRequestCard } from "./SecretRequestCard";
import {
WorktreeWorkingHeader,
WorktreeSetupCard,
Expand Down Expand Up @@ -162,6 +163,7 @@ import {
threadFeedRunIsUnsettled,
isContextCompactionActivityGroup,
isContextHandoffActivityGroup,
isSecretRequestActivityGroup,
type ThreadFeedEntry,
type ThreadFeedLatestRun,
} from "../../lib/threadActivity";
Expand Down Expand Up @@ -1621,6 +1623,16 @@ function renderFeedEntry(
);
}

if (entry.type === "activity-group" && isSecretRequestActivityGroup(entry)) {
return (
<SecretRequestCard
environmentId={props.environmentId}
projectedItem={entry.activities[0]!.projectedItem}
iconColor={iconSubtleColor}
/>
);
}

if (entry.type === "activity-group" && isContextCompactionActivityGroup(entry)) {
const label = entry.activities[0]!.summary;
const active =
Expand Down
21 changes: 20 additions & 1 deletion apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,13 @@ export function isContextHandoffActivityGroup(entry: ThreadFeedActivityGroup): b
);
}

export function isSecretRequestActivityGroup(entry: ThreadFeedActivityGroup): boolean {
return (
entry.activities.length === 1 &&
entry.activities[0]?.projectedItem.item.type === "secret_request"
);
}

function isUserInputActivityGroup(entry: ThreadFeedActivityGroup): boolean {
return entry.activities.some((activity) => activity.workEntry.questionAnswer !== undefined);
}
Expand Down Expand Up @@ -420,7 +427,13 @@ function itemIsToolLike(item: OrchestrationV2TurnItem): boolean {
}

function itemIsProminent(item: OrchestrationV2TurnItem): boolean {
return item.type === "fork" || item.type === "thread_created" || item.type === "system_notice";
return (
item.type === "fork" ||
item.type === "thread_created" ||
item.type === "system_notice" ||
// An answerable card: it must stand alone and never fold away with the run.
item.type === "secret_request"
);
}

function itemStatus(item: OrchestrationV2TurnItem): ThreadFeedActivity["status"] {
Expand Down Expand Up @@ -537,6 +550,8 @@ function itemIcon(item: OrchestrationV2TurnItem): ThreadFeedActivity["icon"] {
case "fork":
case "thread_created":
return "zap";
case "secret_request":
return "lock";
}
}

Expand Down Expand Up @@ -590,6 +605,8 @@ function itemSummary(
return "Thread forked";
case "thread_created":
return "Thread created";
case "secret_request":
return item.label;
case "dynamic_tool": {
const classified = classifyToolActivity({
itemType: "dynamic_tool_call",
Expand Down Expand Up @@ -647,6 +664,8 @@ function itemPreview(item: OrchestrationV2TurnItem): string | null {
case "fork":
case "thread_created":
return item.targetThreadId;
case "secret_request":
return item.reason || null;
case "subagent":
return item.result ?? item.progress ?? item.prompt;
case "dynamic_tool":
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.scheduledTasksDelete]: AuthOrchestrationOperateScope,
[WS_METHODS.scheduledTasksRunNow]: AuthOrchestrationOperateScope,
[WS_METHODS.scheduledTasksRotateWebhookToken]: AuthOrchestrationOperateScope,
[WS_METHODS.secretsAnswerRequest]: AuthOrchestrationOperateScope,
// Delivery logs hold request bodies, so they need the same scope as the URL.
[WS_METHODS.scheduledTasksListWebhookDeliveries]: AuthOrchestrationOperateScope,
[WS_METHODS.scheduledTasksGetWebhookDelivery]: AuthOrchestrationOperateScope,
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/mcp/OrchestratorMcpService.activity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterReg
import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";
import * as ProjectService from "../project/ProjectService.ts";
import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
import * as SecretRequests from "../secrets/SecretRequests.ts";
import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
import type * as McpInvocationContext from "./McpInvocationContext.ts";
import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";
Expand Down Expand Up @@ -149,6 +150,7 @@ it("readThread prefers activity-run status over a newer cancelled queued run", a
getProviders: Effect.succeed([]),
} satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
Layer.mock(ProjectService.ProjectService)({}),
Layer.mock(SecretRequests.SecretRequests)({}),
Layer.mock(ScheduledTaskService.ScheduledTaskService)({
list: () => Effect.succeed({ tasks: [] }),
} satisfies Partial<ScheduledTaskService.ScheduledTaskService["Service"]>),
Expand Down Expand Up @@ -213,6 +215,7 @@ it("readThread prefers waiting activity status over a newer cancelled queued run
getProviders: Effect.succeed([]),
} satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
Layer.mock(ProjectService.ProjectService)({}),
Layer.mock(SecretRequests.SecretRequests)({}),
Layer.mock(ScheduledTaskService.ScheduledTaskService)({
list: () => Effect.succeed({ tasks: [] }),
} satisfies Partial<ScheduledTaskService.ScheduledTaskService["Service"]>),
Expand Down Expand Up @@ -325,6 +328,7 @@ it("taskStatus returns task.providerInstanceId rather than the driver kind", asy
getProviders: Effect.succeed([]),
} satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
Layer.mock(ProjectService.ProjectService)({}),
Layer.mock(SecretRequests.SecretRequests)({}),
Layer.mock(ScheduledTaskService.ScheduledTaskService)({
list: () => Effect.succeed({ tasks: [] }),
} satisfies Partial<ScheduledTaskService.ScheduledTaskService["Service"]>),
Expand Down Expand Up @@ -447,6 +451,7 @@ it("readThread and sendToThread reach threads in other projects", async () => {
getProviders: Effect.succeed([]),
} satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
Layer.mock(ProjectService.ProjectService)({}),
Layer.mock(SecretRequests.SecretRequests)({}),
Layer.mock(ScheduledTaskService.ScheduledTaskService)({
list: () =>
Effect.succeed({
Expand Down
Loading
Loading