feat: correlate web terminal sessions by client_session_id (#27677)

## What

Implements the web terminal client half of the
[Connection log collection and correlation
RFC](https://www.notion.so/coderhq/Connection-log-collection-and-correlation-36ed579be5928025a56cd11fe58661fb)
(`DEVEX-663`). Generates a per-session correlation ID and attaches it to
the
web terminal's requests and client logs so a single session can be
traced end
to end.

> Stacked on #27671 (`DEVEX-659`, the coderd tracing middleware that
reads the
> `client_session_id` baggage). Review/merge that first.

## Changes

**Session ID**
- 16-byte value encoded as a 32-character hex string, per RFC
requirement 1.
Generated with the `generateConnectionSessionId` function added in
#27935
- Minted once per web terminal session: on `TerminalPage` load and on
`AgentsPage` terminal panel mount. Unlike the reconnection token, it is
**not**
persisted in the URL, so a reload (or a new tab) is a new session,
matching the
  RFC's session definition.

**Propagation**
- **HTTP API request:** the reconnecting-pty signed-token request
carries the ID
via W3C baggage (`baggage: client_session_id=<hex>`), which the
DEVEX-659 middleware
  reads.
- **PTY WebSocket:** browsers cannot set the `baggage` header on a
WebSocket
handshake (the codebase already works around this for the session
token), so
the ID is sent as a `client_session_id` query parameter instead. The
reconnecting-pty
WebSocket handler reads and validates it and attaches it to the request
and PTY
  logs.

**Client logs**
- The terminal's connection-error `console.error` logs now include
`client_session_id`.

**Telemetry:** the web terminal emits none today, so there is nothing to
tag
(confirmed with the issue reporter).

## Testing

- `site`: unit tests for `generateSessionId` (format + uniqueness) and
`terminalWebsocketUrl` (query param). Updated `TerminalPage.test.tsx`
(mocks the
generator to a fixed ID and asserts the WebSocket URL includes
`client_session_id`).
  `tsc`, Biome, and the React Compiler check pass.
- `coderd`: `go test ./coderd/tracing/...` and `go vet
./coderd/workspaceapps/...`
  pass; new `ValidSessionID` export reused by the PTY handler.

<details>
<summary>Design notes / decision log</summary>

- **Session vs reconnection token.** The existing `reconnect` token is
deliberately persisted in the URL to survive reloads.
`client_session_id` is the
opposite: a fresh value per page load, matching the RFC (a reload is a
new
  session). They are separate identifiers.
- **Per-request baggage, not a global axios default.** The frontend
axios
instance is a singleton shared by the whole app; a global `baggage`
default
would tag unrelated requests. The header is attached only to the
terminal's
  signed-token request.
- **WebSocket uses a query param.** Browser `WebSocket` cannot send
custom
headers, so baggage is impossible on the PTY handshake. The
`client_session_id` query
  parameter is the counterpart, read server-side in `workspaceAgentPTY`.
- **Server-side scope.** Reading the query param is localized to the web
terminal's own WebSocket handler rather than broadening the shared
tracing
  middleware to trust query params on every route.
- **Validation.** Both the baggage and query-param paths validate the
value as a
32-char hex string (`tracing.ValidSessionID`) before logging, to avoid
logging
  arbitrary client-controlled input.
- **AgentsPage compiler constraint.** `AgentsPage` is React Compiler
optimized
(no `useMemo`/`useCallback`), so the panel mints its ID with `useState`
lazy
  init instead.
- **Out of scope (other RFC tickets):** agent-side middleware,
`connection_logs`
columns, Tailnet state-change logging, and the CLI
`CODER_TRACE_SESSION_ID`
  env var.

</details>

---

_Opened by Coder Agents on behalf of @aqandrew._

---------

Co-authored-by: Danielle Maywood <danielle@themaywoods.com>
This commit is contained in:
Andrew Aquino
2026-08-27 12:55:33 -07:00
committed by GitHub
co-authored by Danielle Maywood
parent e5fa18b58e
commit 197c814070
10 changed files with 342 additions and 234 deletions
+2
View File
@@ -2753,10 +2753,12 @@ class ApiMethods {
issueReconnectingPTYSignedToken = async (
params: TypesGen.IssueReconnectingPTYSignedTokenRequest,
sessionId: string,
): Promise<TypesGen.IssueReconnectingPTYSignedTokenResponse> => {
const response = await this.axios.post(
"/api/v2/applications/reconnecting-pty-signed-token",
params,
{ headers: { baggage: `client_session_id=${sessionId}` } },
);
return response.data;
@@ -52,6 +52,12 @@ type WorkspaceTerminalProps = {
onError?: (error: Error) => void;
onContentReady?: () => void;
reconnectionToken: string;
/**
* The session ID correlates all logs, requests, and telemetry for this
* terminal session. Unlike the reconnection token, it is not persisted in
* the URL: a new page load (including a reload) is a new session.
*/
sessionId: string;
baseUrl?: string;
terminalFontFamily?: string;
renderer?: string;
@@ -83,6 +89,7 @@ export const WorkspaceTerminal = ({
onError,
onContentReady,
reconnectionToken,
sessionId,
baseUrl,
terminalFontFamily = DEFAULT_TERMINAL_FONT_FAMILY,
renderer,
@@ -458,6 +465,7 @@ export const WorkspaceTerminal = ({
initialDimensions.width,
containerName,
containerUser,
sessionId,
)
.then((url) => {
if (disposed) {
@@ -504,7 +512,10 @@ export const WorkspaceTerminal = ({
if (disposed) {
return;
}
console.error("WebSocket error:", event);
console.error("WebSocket error:", {
client_session_id: sessionId,
event,
});
terminal.options.disableStdin = true;
handleStatusChange("disconnected");
});
@@ -550,7 +561,10 @@ export const WorkspaceTerminal = ({
if (disposed) {
return;
}
console.error("WebSocket connection failed:", error);
console.error("WebSocket connection failed:", {
client_session_id: sessionId,
error,
});
reportTerminalError(
error instanceof Error ? error : new Error(String(error)),
);
@@ -577,6 +591,7 @@ export const WorkspaceTerminal = ({
loading,
operatingSystem,
reconnectionToken,
sessionId,
refit,
terminal,
]);
+231 -218
View File
@@ -28,7 +28,7 @@ import { findWorkspaceAppWithAgent } from "#/modules/apps/workspaceApps";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { cn } from "#/utils/cn";
import { pageTitle } from "#/utils/page";
import { generateUUID } from "#/utils/random";
import { generateConnectionSessionId, generateUUID } from "#/utils/random";
import { findWorkspaceAgent } from "#/utils/workspace";
import {
AgentChatInput,
@@ -60,6 +60,7 @@ import { RightPanelAddTabControl } from "./components/RightPanel/RightPanelAddTa
import { getWorkspaceStatus, StatusIcon } from "./components/StatusIcon";
import { TerminalPanel } from "./components/TerminalPanel";
import { ChatWorkspaceContext } from "./context/ChatWorkspaceContext";
import { TerminalClientSessionContext } from "./context/TerminalClientSessionContext";
import { chatWidthClass, useChatFullWidth } from "./hooks/useChatFullWidth";
import {
getPersistedDefaultTerminalHidden,
@@ -451,6 +452,10 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
const [defaultTerminalHidden, setDefaultTerminalHiddenState] =
useState<boolean>(() => getPersistedDefaultTerminalHidden(agentId));
const [pendingTabId, setPendingTabId] = useState<string | null>(null);
// One client session ID per page visit, shared by every terminal in this
// chat. It regenerates when this view remounts (switching chats or
// reloading), independent of any terminal's reconnection token.
const [clientSessionId] = useState(generateConnectionSessionId);
const setSidebarTabId = (tabId: string) => {
setSidebarTabIdState(tabId);
@@ -857,232 +862,240 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
);
return (
<ChatWorkspaceContext
value={{ workspaceId: workspace?.id, buildId: chatBuildId }}
>
<DesktopPanelContext value={desktopPanelCtx}>
<div
className={cn(
"relative flex min-h-0 min-w-0 flex-1 sm:[--agents-chat-panel-min-width:360px]",
shouldShowSidebar && !visualExpanded && "flex-row",
)}
>
{titleElement}
<TerminalClientSessionContext value={clientSessionId}>
<ChatWorkspaceContext
value={{ workspaceId: workspace?.id, buildId: chatBuildId }}
>
<DesktopPanelContext value={desktopPanelCtx}>
<div
data-testid="agents-chat-panel"
className={cn(
"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden sm:min-w-[var(--agents-chat-panel-min-width,0px)]",
visualExpanded && "hidden",
shouldShowSidebar && "max-lg:hidden",
"relative flex min-h-0 min-w-0 flex-1 sm:[--agents-chat-panel-min-width:360px]",
shouldShowSidebar && !visualExpanded && "flex-row",
)}
>
<div className="relative z-10 shrink-0 overflow-visible">
{" "}
<ChatTopBar
chatTitle={chatTitle}
parentChat={parentChat}
panel={{
showSidebarPanel,
onToggleSidebar: () =>
onSetShowSidebarPanel(!showSidebarPanel),
}}
onArchiveAgent={handleArchiveAgentAction}
onUnarchiveAgent={handleUnarchiveAgentAction}
onArchiveAndDeleteWorkspace={
handleArchiveAndDeleteWorkspaceAction
}
onPinAgent={handlePinAgentAction}
onUnpinAgent={handleUnpinAgentAction}
onOpenRenameDialog={handleOpenRenameDialogAction}
isPinned={isPinned}
isChildChat={isChildChat}
isArchiving={isArchivingThisChat}
isArchiveBlocked={isArchiveBlocked}
hasWorkspace={Boolean(workspace)}
isArchived={isArchived}
diffStatusData={diffStatusData}
isSharedChat={isSharedChat}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
renderChatSharingContent={
canOpenChatSharing
? (open) => (
<ChatSharingPopoverContent
chatId={agentId}
organizationId={organizationId}
open={open}
/>
)
: undefined
}
/>
{modelCatalogError != null && (
<ErrorAlert error={modelCatalogError} />
{titleElement}
<div
data-testid="agents-chat-panel"
className={cn(
"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden sm:min-w-[var(--agents-chat-panel-min-width,0px)]",
visualExpanded && "hidden",
shouldShowSidebar && "max-lg:hidden",
)}
{unavailableModelNotice && (
<div
role="status"
aria-label={unavailableModelNotice}
aria-live="polite"
className="flex shrink-0 items-center gap-2 border-b border-border-warning bg-surface-orange px-4 py-2 text-xs text-content-primary"
>
<TriangleAlertIcon className="size-4 shrink-0 text-content-warning" />
{unavailableModelNotice}
</div>
)}
{chatOwnerWarning && (
<div
role="status"
aria-live="polite"
className="flex shrink-0 items-center gap-2 border-b border-border-warning bg-surface-orange px-4 py-2 text-xs text-content-primary"
>
<TriangleAlertIcon className="size-4 shrink-0 text-content-warning" />
{chatOwnerWarning}
</div>
)}
{isArchived && (
<div className="flex shrink-0 items-center gap-2 border-b border-border-default bg-surface-secondary px-4 py-2 text-xs text-content-secondary">
<ArchiveIcon className="size-4 shrink-0" />
This agent has been archived and is read-only.
</div>
)}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-full z-10 h-3 sm:h-6 bg-surface-primary"
style={{
maskImage:
"linear-gradient(to bottom, black 0%, rgba(0,0,0,0.6) 40%, rgba(0,0,0,0.2) 70%, transparent 100%)",
WebkitMaskImage:
"linear-gradient(to bottom, black 0%, rgba(0,0,0,0.6) 40%, rgba(0,0,0,0.2) 70%, transparent 100%)",
}}
/>
</div>
<ChatPageTimeline
key={agentId}
organizationId={organizationId}
store={store}
initialActiveTurnMaxMessageId={initialActiveTurnMaxMessageId}
persistedError={persistedError}
hasMoreMessages={hasMoreMessages}
isFetchingMoreMessages={isFetchingMoreMessages}
isHydratingMessages={isHydratingMessages}
hasFetchMoreError={hasFetchMoreError}
onFetchMoreMessages={onFetchMoreMessages}
onEditUserMessage={
isOtherUserReadOnly ? undefined : editing.handleEditUserMessage
}
editingMessageId={editing.editingMessageId}
urlTransform={urlTransform}
mcpServers={mcpServers}
onImplementPlan={
isOtherUserReadOnly ? undefined : onImplementPlan
}
onSendAskUserQuestionResponse={
isOtherUserReadOnly ? undefined : canSendAskUserQuestionResponse
}
footer={
queuedForCapacity ? (
<QueuedForCapacityCallout
hasLicense={hasLicense}
canManageLicenses={canManageLicenses}
agentHoursHardLimit={agentHoursHardLimit}
/>
) : undefined
}
/>
<div className="shrink-0 overflow-y-auto px-4 pb-3 md:pb-0 [scrollbar-gutter:stable] [scrollbar-width:thin]">
<ChatPageInput
organizationId={organizationId}
sendShortcut={sendShortcut}
store={store}
compressionThreshold={compressionThreshold}
onSend={editing.handleSendFromInput}
onDeleteQueuedMessage={handleDeleteQueuedMessage}
onPromoteQueuedMessage={handlePromoteQueuedMessage}
onInterrupt={handleInterrupt}
isInputDisabled={isInputDisabled}
isSendPending={isSubmissionPending}
isInterruptPending={isInterruptPending}
hasModelOptions={hasModelOptions}
canConfigureAgentSetup={canConfigureAgentSetup}
providerCount={providerCount}
modelCount={modelCount}
unsupportedProviderNames={unsupportedProviderNames}
aiGatewayDisabled={aiGatewayDisabled}
selectedModel={effectiveSelectedModel}
onModelChange={setSelectedModel}
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
modelSelectorHelp={modelSelectorHelp}
reasoningEffort={reasoningEffort}
onReasoningEffortChange={onReasoningEffortChange}
planModeEnabled={planModeEnabled}
onPlanModeToggle={onPlanModeToggle}
isModelCatalogLoading={isModelCatalogLoading}
workspaceOptions={workspaceOptions}
chatOrganizationId={organizationId}
selectedWorkspaceId={selectedWorkspaceId}
onWorkspaceChange={onWorkspaceChange}
isWorkspaceLoading={isWorkspaceLoading}
inputRef={editing.chatInputRef}
initialValue={editing.editorInitialValue}
initialEditorState={editing.initialEditorState}
remountKey={editing.remountKey}
onContentChange={editing.handleContentChange}
isEditing={isEditing}
onCancelHistoryEdit={editing.handleCancelHistoryEdit}
editingFileBlocks={editing.editingFileBlocks}
mcpServers={mcpServers}
selectedMCPServerIds={selectedMCPServerIds}
onMCPSelectionChange={onMCPSelectionChange}
onMCPAuthComplete={onMCPAuthComplete}
chatContext={chatContext}
workspaceSkills={workspaceSkills}
workspace={workspace}
workspaceAgent={workspaceAgent}
chatId={agentId}
sshCommand={sshCommand}
attachedWorkspace={attachedWorkspace}
folder={preferredFolder}
/>
</div>
</div>
<RightPanel
isOpen={shouldShowSidebar}
isExpanded={showSidebarPanel && isRightPanelExpanded}
onToggleExpanded={() => setIsRightPanelExpanded((prev) => !prev)}
onClose={() => onSetShowSidebarPanel(false)}
onVisualExpandedChange={setDragVisualExpanded}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
>
<SidebarTabView
effectiveTabId={effectiveSidebarTabId}
onActiveTabChange={handleActiveTabChange}
tabs={sidebarTabs}
addTabControl={
<RightPanelAddTabControl
workspace={workspace}
agent={workspaceAgent}
host={wildcardHostname}
isRunning={workspace?.latest_build.status === "running"}
onNewTerminal={handleAddTerminalTab}
onOpenWorkspaceApp={handleOpenWorkspaceAppTab}
onOpenCommandApp={handleOpenCommandAppTab}
onOpenPort={handleOpenPortTab}
>
<div className="relative z-10 shrink-0 overflow-visible">
{" "}
<ChatTopBar
chatTitle={chatTitle}
parentChat={parentChat}
panel={{
showSidebarPanel,
onToggleSidebar: () =>
onSetShowSidebarPanel(!showSidebarPanel),
}}
onArchiveAgent={handleArchiveAgentAction}
onUnarchiveAgent={handleUnarchiveAgentAction}
onArchiveAndDeleteWorkspace={
handleArchiveAndDeleteWorkspaceAction
}
onPinAgent={handlePinAgentAction}
onUnpinAgent={handleUnpinAgentAction}
onOpenRenameDialog={handleOpenRenameDialogAction}
isPinned={isPinned}
isChildChat={isChildChat}
isArchiving={isArchivingThisChat}
isArchiveBlocked={isArchiveBlocked}
hasWorkspace={Boolean(workspace)}
isArchived={isArchived}
diffStatusData={diffStatusData}
isSharedChat={isSharedChat}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
renderChatSharingContent={
canOpenChatSharing
? (open) => (
<ChatSharingPopoverContent
chatId={agentId}
organizationId={organizationId}
open={open}
/>
)
: undefined
}
/>
}
onClose={() => onSetShowSidebarPanel(false)}
isExpanded={visualExpanded}
{modelCatalogError != null && (
<ErrorAlert error={modelCatalogError} />
)}
{unavailableModelNotice && (
<div
role="status"
aria-label={unavailableModelNotice}
aria-live="polite"
className="flex shrink-0 items-center gap-2 border-b border-border-warning bg-surface-orange px-4 py-2 text-xs text-content-primary"
>
<TriangleAlertIcon className="size-4 shrink-0 text-content-warning" />
{unavailableModelNotice}
</div>
)}
{chatOwnerWarning && (
<div
role="status"
aria-live="polite"
className="flex shrink-0 items-center gap-2 border-b border-border-warning bg-surface-orange px-4 py-2 text-xs text-content-primary"
>
<TriangleAlertIcon className="size-4 shrink-0 text-content-warning" />
{chatOwnerWarning}
</div>
)}
{isArchived && (
<div className="flex shrink-0 items-center gap-2 border-b border-border-default bg-surface-secondary px-4 py-2 text-xs text-content-secondary">
<ArchiveIcon className="size-4 shrink-0" />
This agent has been archived and is read-only.
</div>
)}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-full z-10 h-3 sm:h-6 bg-surface-primary"
style={{
maskImage:
"linear-gradient(to bottom, black 0%, rgba(0,0,0,0.6) 40%, rgba(0,0,0,0.2) 70%, transparent 100%)",
WebkitMaskImage:
"linear-gradient(to bottom, black 0%, rgba(0,0,0,0.6) 40%, rgba(0,0,0,0.2) 70%, transparent 100%)",
}}
/>
</div>
<ChatPageTimeline
key={agentId}
organizationId={organizationId}
store={store}
initialActiveTurnMaxMessageId={initialActiveTurnMaxMessageId}
persistedError={persistedError}
hasMoreMessages={hasMoreMessages}
isFetchingMoreMessages={isFetchingMoreMessages}
isHydratingMessages={isHydratingMessages}
hasFetchMoreError={hasFetchMoreError}
onFetchMoreMessages={onFetchMoreMessages}
onEditUserMessage={
isOtherUserReadOnly
? undefined
: editing.handleEditUserMessage
}
editingMessageId={editing.editingMessageId}
urlTransform={urlTransform}
mcpServers={mcpServers}
onImplementPlan={
isOtherUserReadOnly ? undefined : onImplementPlan
}
onSendAskUserQuestionResponse={
isOtherUserReadOnly
? undefined
: canSendAskUserQuestionResponse
}
footer={
queuedForCapacity ? (
<QueuedForCapacityCallout
hasLicense={hasLicense}
canManageLicenses={canManageLicenses}
agentHoursHardLimit={agentHoursHardLimit}
/>
) : undefined
}
/>
<div className="shrink-0 overflow-y-auto px-4 pb-3 md:pb-0 [scrollbar-gutter:stable] [scrollbar-width:thin]">
<ChatPageInput
organizationId={organizationId}
sendShortcut={sendShortcut}
store={store}
compressionThreshold={compressionThreshold}
onSend={editing.handleSendFromInput}
onDeleteQueuedMessage={handleDeleteQueuedMessage}
onPromoteQueuedMessage={handlePromoteQueuedMessage}
onInterrupt={handleInterrupt}
isInputDisabled={isInputDisabled}
isSendPending={isSubmissionPending}
isInterruptPending={isInterruptPending}
hasModelOptions={hasModelOptions}
canConfigureAgentSetup={canConfigureAgentSetup}
providerCount={providerCount}
modelCount={modelCount}
unsupportedProviderNames={unsupportedProviderNames}
aiGatewayDisabled={aiGatewayDisabled}
selectedModel={effectiveSelectedModel}
onModelChange={setSelectedModel}
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
modelSelectorHelp={modelSelectorHelp}
reasoningEffort={reasoningEffort}
onReasoningEffortChange={onReasoningEffortChange}
planModeEnabled={planModeEnabled}
onPlanModeToggle={onPlanModeToggle}
isModelCatalogLoading={isModelCatalogLoading}
workspaceOptions={workspaceOptions}
chatOrganizationId={organizationId}
selectedWorkspaceId={selectedWorkspaceId}
onWorkspaceChange={onWorkspaceChange}
isWorkspaceLoading={isWorkspaceLoading}
inputRef={editing.chatInputRef}
initialValue={editing.editorInitialValue}
initialEditorState={editing.initialEditorState}
remountKey={editing.remountKey}
onContentChange={editing.handleContentChange}
isEditing={isEditing}
onCancelHistoryEdit={editing.handleCancelHistoryEdit}
editingFileBlocks={editing.editingFileBlocks}
mcpServers={mcpServers}
selectedMCPServerIds={selectedMCPServerIds}
onMCPSelectionChange={onMCPSelectionChange}
onMCPAuthComplete={onMCPAuthComplete}
chatContext={chatContext}
workspaceSkills={workspaceSkills}
workspace={workspace}
workspaceAgent={workspaceAgent}
chatId={agentId}
sshCommand={sshCommand}
attachedWorkspace={attachedWorkspace}
folder={preferredFolder}
/>
</div>
</div>
<RightPanel
isOpen={shouldShowSidebar}
isExpanded={showSidebarPanel && isRightPanelExpanded}
onToggleExpanded={() => setIsRightPanelExpanded((prev) => !prev)}
onClose={() => onSetShowSidebarPanel(false)}
onVisualExpandedChange={setDragVisualExpanded}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
chatTitle={chatTitle}
/>
</RightPanel>
</div>
</DesktopPanelContext>
</ChatWorkspaceContext>
>
<SidebarTabView
effectiveTabId={effectiveSidebarTabId}
onActiveTabChange={handleActiveTabChange}
tabs={sidebarTabs}
addTabControl={
<RightPanelAddTabControl
workspace={workspace}
agent={workspaceAgent}
host={wildcardHostname}
isRunning={workspace?.latest_build.status === "running"}
onNewTerminal={handleAddTerminalTab}
onOpenWorkspaceApp={handleOpenWorkspaceAppTab}
onOpenCommandApp={handleOpenCommandAppTab}
onOpenPort={handleOpenPortTab}
/>
}
onClose={() => onSetShowSidebarPanel(false)}
isExpanded={visualExpanded}
onToggleExpanded={() =>
setIsRightPanelExpanded((prev) => !prev)
}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
chatTitle={chatTitle}
/>
</RightPanel>
</div>
</DesktopPanelContext>
</ChatWorkspaceContext>
</TerminalClientSessionContext>
);
};
@@ -6,6 +6,7 @@ import {
MockWorkspaceAgent,
} from "#/testHelpers/entities";
import { withProxyProvider, withWebSocket } from "#/testHelpers/storybook";
import { TerminalClientSessionContext } from "../context/TerminalClientSessionContext";
import { TerminalPanel } from "./TerminalPanel";
const terminalQueries = [
@@ -43,9 +44,11 @@ const meta = {
decorators: [
withProxyProvider(),
(Story) => (
<div style={{ width: 480, height: 600 }}>
<Story />
</div>
<TerminalClientSessionContext value="0123456789abcdef0123456789abcdef">
<div style={{ width: 480, height: 600 }}>
<Story />
</div>
</TerminalClientSessionContext>
),
],
} satisfies Meta<typeof TerminalPanel>;
@@ -14,6 +14,7 @@ import {
} from "#/modules/terminal/WorkspaceTerminal";
import { WorkspaceTerminalAlerts } from "#/modules/terminal/WorkspaceTerminalAlerts";
import { openMaybePortForwardedURL } from "#/utils/portForward";
import { useTerminalClientSessionId } from "../context/TerminalClientSessionContext";
/** Promote a freshly created terminal tab after this delay if no output has painted. */
const READY_FALLBACK_MS = 100;
@@ -55,6 +56,10 @@ export const TerminalPanel: FC<TerminalPanelProps> = ({
const { proxy } = useProxy();
const { metadata } = useEmbeddedMetadata();
const terminalRef = useRef<WorkspaceTerminalHandle>(null);
// Shared by every terminal in this chat and stable across remounts, so
// reattaching to a live PTY keeps the same session ID while a reload or
// switching chats starts a new one.
const sessionId = useTerminalClientSessionId();
const [isWarm, setIsWarm] = useState(Boolean(isHot));
const [connectionStatus, setConnectionStatus] =
useState<ConnectionStatus>("initializing");
@@ -167,6 +172,7 @@ export const TerminalPanel: FC<TerminalPanelProps> = ({
onContentReady={signalReady}
onError={handleTerminalError}
reconnectionToken={reconnectionToken}
sessionId={sessionId}
initialCommand={initialCommand}
baseUrl={terminalConfig.baseUrl}
terminalFontFamily={terminalConfig.fontFamily}
@@ -0,0 +1,24 @@
import { createContext, useContext } from "react";
const TerminalClientSessionContext = createContext<string | undefined>(
undefined,
);
/**
* Returns the client session ID for the current agent chat page visit. It is
* generated once when the page mounts and stays stable until the page unmounts
* (navigating to another chat, leaving the page, or reloading), independent of
* any terminal's reconnection token. Must be used within a
* TerminalClientSessionContext provider.
*/
export const useTerminalClientSessionId = (): string => {
const sessionId = useContext(TerminalClientSessionContext);
if (sessionId === undefined) {
throw new Error(
"useTerminalClientSessionId must be used within a TerminalClientSessionContext provider",
);
}
return sessionId;
};
export { TerminalClientSessionContext };
@@ -16,8 +16,13 @@ import TerminalPage from "./TerminalPage";
const reconnectToken = "terminal-page-test-reconnect-token";
const { mockSessionId } = vi.hoisted(() => ({
mockSessionId: "0123456789abcdef0123456789abcdef",
}));
vi.mock("#/utils/random", () => ({
generateUUID: () => "terminal-page-test-reconnect-token",
generateConnectionSessionId: () => mockSessionId,
}));
vi.stubGlobal("jest", vi);
await import("jest-canvas-mock");
@@ -39,7 +44,7 @@ Object.defineProperty(window, "matchMedia", {
const createWorkspaceTerminalWebSocket = () => {
const websocketProtocol = location.protocol === "https:" ? "wss" : "ws";
const websocketUrl = `${websocketProtocol}://${location.host}/api/v2/workspaceagents/${MockWorkspaceAgent.id}/pty?reconnect=${reconnectToken}&height=24&width=80`;
const websocketUrl = `${websocketProtocol}://${location.host}/api/v2/workspaceagents/${MockWorkspaceAgent.id}/pty?reconnect=${reconnectToken}&client_session_id=${mockSessionId}&height=24&width=80`;
return new WS(websocketUrl);
};
@@ -220,7 +225,7 @@ describe("TerminalPage", () => {
const websocketProtocol =
window.location.protocol === "https:" ? "wss" : "ws";
const ws = new WS(
`${websocketProtocol}://${window.location.host}/api/v2/workspaceagents/${MockWorkspaceAgent.id}/pty?reconnect=${reconnectToken}&command=echo+hello&height=24&width=80`,
`${websocketProtocol}://${window.location.host}/api/v2/workspaceagents/${MockWorkspaceAgent.id}/pty?reconnect=${reconnectToken}&client_session_id=${mockSessionId}&command=echo+hello&height=24&width=80`,
);
renderTerminalRaw(
`/${MockUserOwner.username}/${MockWorkspace.name}/terminal?command=echo+hello`,
@@ -356,7 +361,7 @@ describe("TerminalPage", () => {
const websocketProtocol =
window.location.protocol === "https:" ? "wss" : "ws";
const ws = new WS(
`${websocketProtocol}://${window.location.host}/api/v2/workspaceagents/${MockWorkspaceAgent.id}/pty?reconnect=${reconnectToken}&command=echo+trusted&height=24&width=80`,
`${websocketProtocol}://${window.location.host}/api/v2/workspaceagents/${MockWorkspaceAgent.id}/pty?reconnect=${reconnectToken}&client_session_id=${mockSessionId}&command=echo+trusted&height=24&width=80`,
);
await renderTerminal(
`/${MockUserOwner.username}/${MockWorkspace.name}/terminal?app=my-app`,
+15 -4
View File
@@ -27,7 +27,7 @@ import { WorkspaceTerminalAlerts } from "#/modules/terminal/WorkspaceTerminalAle
import themes from "#/theme";
import { pageTitle } from "#/utils/page";
import { openMaybePortForwardedURL } from "#/utils/portForward";
import { generateUUID } from "#/utils/random";
import { generateConnectionSessionId, generateUUID } from "#/utils/random";
import { getMatchingAgentOrFirst } from "#/utils/workspace";
import { TerminalCommandConsentDialog } from "./TerminalCommandConsentDialog";
@@ -50,6 +50,10 @@ const TerminalPage: FC = () => {
// a terminal session. It's generated by the client to reduce
// a round-trip, and must be a UUIDv4.
const reconnectionToken = searchParams.get("reconnect") ?? generateUUID();
// The session ID correlates all logs, requests, and telemetry for this
// terminal session. Unlike the reconnection token, it is not persisted in
// the URL: a new page load (including a reload) is a new session.
const [sessionId] = useState(() => generateConnectionSessionId());
const command = searchParams.get("command") || undefined;
const appSlug = searchParams.get("app") || undefined;
const containerName = searchParams.get("container") || undefined;
@@ -111,9 +115,15 @@ const TerminalPage: FC = () => {
},
[workspaceAgent, workspace.data, username, proxy.preferredWildcardHostname],
);
const handleTerminalError = useCallback((error: Error) => {
console.error("WebSocket failed:", error);
}, []);
const handleTerminalError = useCallback(
(error: Error) => {
console.error("WebSocket failed:", {
client_session_id: sessionId,
error,
});
},
[sessionId],
);
const { metadata } = useEmbeddedMetadata();
const appearanceSettingsQuery = useQuery(
@@ -176,6 +186,7 @@ const TerminalPage: FC = () => {
onStatusChange={setConnectionStatus}
onError={handleTerminalError}
reconnectionToken={reconnectionToken}
sessionId={sessionId}
baseUrl={terminalConfig.baseUrl}
terminalFontFamily={terminalConfig.fontFamily}
renderer={terminalConfig.renderer}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { terminalWebsocketUrl } from "./terminal";
describe("terminalWebsocketUrl", () => {
it("includes the client_session_id query parameter", async () => {
const url = await terminalWebsocketUrl(
undefined,
"reconnect-token",
"agent-id",
undefined,
24,
80,
undefined,
undefined,
"0123456789abcdef0123456789abcdef",
);
const parsed = new URL(url);
expect(parsed.searchParams.get("client_session_id")).toBe(
"0123456789abcdef0123456789abcdef",
);
expect(parsed.searchParams.get("reconnect")).toBe("reconnect-token");
});
});
+9 -4
View File
@@ -9,8 +9,10 @@ export const terminalWebsocketUrl = async (
width: number,
containerName: string | undefined,
containerUser: string | undefined,
sessionId: string,
): Promise<string> => {
const query = new URLSearchParams({ reconnect });
query.set("client_session_id", sessionId);
if (command) {
query.set("command", command);
}
@@ -38,10 +40,13 @@ export const terminalWebsocketUrl = async (
}
// Do ticket issuance and set the query parameter.
const tokenRes = await API.issueReconnectingPTYSignedToken({
url: url.toString(),
agentID: agentId,
});
const tokenRes = await API.issueReconnectingPTYSignedToken(
{
url: url.toString(),
agentID: agentId,
},
sessionId,
);
query.set("coder_signed_app_token_23db1dde", tokenRes.signed_token);
url.search = `?${query.toString()}`;