mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-21 16:03:37 +00:00
fix(client): authenticate PTY websocket connections (#43735)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
This commit is contained in:
parent
384cff3768
commit
749d24ebc0
17 changed files with 185 additions and 125 deletions
|
|
@ -76,7 +76,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
|||
}),
|
||||
})
|
||||
})
|
||||
await page.route("**/pty*", (route) =>
|
||||
await page.route(/\/api\/pty(?:\?.*)?$/, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
|
|
|
|||
|
|
@ -15,9 +15,7 @@ import { useServerSDK } from "@/context/server-sdk"
|
|||
import { terminalFontFamily, useSettings } from "@/context/settings"
|
||||
import type { LocalPTY } from "@/context/terminal"
|
||||
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
||||
import { terminalConnectToken } from "@/utils/terminal-connect-token"
|
||||
import { terminalWriter } from "@/utils/terminal-writer"
|
||||
import { terminalWebSocketURL } from "@/utils/terminal-websocket-url"
|
||||
|
||||
const TOGGLE_TERMINAL_ID = "terminal.toggle"
|
||||
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
|
||||
|
|
@ -179,7 +177,6 @@ export const Terminal = (props: TerminalProps) => {
|
|||
const language = useLanguage()
|
||||
// Intentional mount-time capture: the imperative xterm/WebSocket lifecycle needs stable values, and Terminal remounts when the SDK scope changes.
|
||||
const directory = sdk().directory
|
||||
const url = serverSDK.url
|
||||
let container!: HTMLDivElement
|
||||
const [local, others] = splitProps(props, [
|
||||
"pty",
|
||||
|
|
@ -531,14 +528,6 @@ export const Terminal = (props: TerminalProps) => {
|
|||
})
|
||||
}
|
||||
|
||||
const connectToken = async () => {
|
||||
const result = await terminalConnectToken({ url, id, directory })
|
||||
if (result.ticket) return result.ticket
|
||||
if (result.status === 404 || result.status === 405) return
|
||||
if (result.status === 403) throw new Error(language.t("terminal.connectTicket.csrfError"))
|
||||
throw new Error(language.t("terminal.connectTicket.statusError", { status: result.status }))
|
||||
}
|
||||
|
||||
const retry = (err: unknown) => {
|
||||
if (disposed) return
|
||||
if (reconn !== undefined) return
|
||||
|
|
@ -562,23 +551,21 @@ export const Terminal = (props: TerminalProps) => {
|
|||
if (disposed) return
|
||||
drop?.()
|
||||
|
||||
const ticket = await connectToken().catch((err) => {
|
||||
fail(err)
|
||||
return undefined
|
||||
})
|
||||
if (once.value) return
|
||||
if (disposed) return
|
||||
|
||||
const socket = new WebSocket(
|
||||
terminalWebSocketURL({
|
||||
url,
|
||||
id,
|
||||
directory,
|
||||
const socket = await serverSDK.pty
|
||||
.connect({
|
||||
ptyID: id,
|
||||
location: { directory },
|
||||
cursor: seek,
|
||||
ticket,
|
||||
}),
|
||||
)
|
||||
socket.binaryType = "arraybuffer"
|
||||
})
|
||||
.catch((err) => {
|
||||
fail(err)
|
||||
return undefined
|
||||
})
|
||||
if (!socket || once.value) return
|
||||
if (disposed) {
|
||||
socket.close(1000)
|
||||
return
|
||||
}
|
||||
ws = socket
|
||||
|
||||
const handleOpen = () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { createClientConnection, type ClientConnectionStatus } from "@opencode-ai/client/solid"
|
||||
import { createClientConnection, createPtyClient, type ClientConnectionStatus } from "@opencode-ai/client/solid"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { type Accessor, onCleanup } from "solid-js"
|
||||
import { createApiForServer, type ServerApi } from "@/utils/server"
|
||||
|
|
@ -61,6 +61,7 @@ type ServerSDKBase = {
|
|||
scope: ServerScope
|
||||
url: string
|
||||
api: ServerApi
|
||||
pty: ReturnType<typeof createPtyClient>
|
||||
connection: {
|
||||
status: Accessor<ServerConnectionStatus>
|
||||
attempt: Accessor<number>
|
||||
|
|
@ -72,6 +73,7 @@ type ServerSDKBase = {
|
|||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
||||
const platform = usePlatform()
|
||||
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
|
||||
const pty = createPtyClient(api, { url: server.http.url })
|
||||
const events = createOpenCodeEventSource()
|
||||
|
||||
const connection = createClientConnection(api, {
|
||||
|
|
@ -93,6 +95,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||
scope,
|
||||
url: server.http.url,
|
||||
api,
|
||||
pty,
|
||||
connection,
|
||||
event: events.event,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { terminalConnectToken } from "./terminal-connect-token"
|
||||
|
||||
describe("terminalConnectToken", () => {
|
||||
test("requests a native V2 ticket scoped to the PTY location", async () => {
|
||||
const calls: { url: URL; init?: RequestInit }[] = []
|
||||
const result = await terminalConnectToken({
|
||||
url: "https://example.test",
|
||||
id: "pty_1",
|
||||
directory: "/repo/worktree",
|
||||
fetch: async (url, init) => {
|
||||
calls.push({ url: new URL(url instanceof Request ? url.url : url), init })
|
||||
return Response.json({ data: { ticket: "ticket-1", expires_in: 30 } })
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual({ status: 200, ticket: "ticket-1" })
|
||||
expect(calls[0]?.url.toString()).toBe(
|
||||
"https://example.test/api/pty/pty_1/connect-token?location%5Bdirectory%5D=%2Frepo%2Fworktree",
|
||||
)
|
||||
expect(calls[0]?.init).toEqual({ method: "POST", headers: { "x-opencode-ticket": "1" } })
|
||||
})
|
||||
|
||||
test("returns the response status when the ticket request fails", async () => {
|
||||
const result = await terminalConnectToken({
|
||||
url: "https://example.test",
|
||||
id: "pty_1",
|
||||
directory: "/repo",
|
||||
fetch: async () => new Response(null, { status: 403 }),
|
||||
})
|
||||
|
||||
expect(result).toEqual({ status: 403 })
|
||||
})
|
||||
})
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
export async function terminalConnectToken(input: {
|
||||
url: string
|
||||
id: string
|
||||
directory: string
|
||||
fetch?: (input: string | URL | Request, init?: RequestInit) => Promise<Response>
|
||||
}) {
|
||||
const url = new URL(`${input.url}/api/pty/${input.id}/connect-token`)
|
||||
url.searchParams.set("location[directory]", input.directory)
|
||||
|
||||
// TODO: Luke should check this for stupidity once special PTY endpoints have generated client support.
|
||||
const response = await (input.fetch ?? fetch)(url, {
|
||||
method: "POST",
|
||||
headers: { "x-opencode-ticket": "1" },
|
||||
})
|
||||
if (!response.ok) return { status: response.status }
|
||||
|
||||
const result = (await response.json()) as { data?: { ticket?: string } }
|
||||
return { status: response.status, ticket: result.data?.ticket }
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { terminalWebSocketURL } from "./terminal-websocket-url"
|
||||
|
||||
describe("terminalWebSocketURL", () => {
|
||||
test("uses the current ticketed PTY route", () => {
|
||||
const url = terminalWebSocketURL({
|
||||
url: "http://127.0.0.1:49365",
|
||||
id: "pty_test",
|
||||
directory: "/tmp/project",
|
||||
cursor: 0,
|
||||
ticket: "connect-ticket",
|
||||
})
|
||||
|
||||
expect(url.protocol).toBe("ws:")
|
||||
expect(url.username).toBe("")
|
||||
expect(url.password).toBe("")
|
||||
expect(url.pathname).toBe("/api/pty/pty_test/connect")
|
||||
expect(url.searchParams.get("location[directory]")).toBe("/tmp/project")
|
||||
expect(url.searchParams.get("cursor")).toBe("0")
|
||||
expect(url.searchParams.get("ticket")).toBe("connect-ticket")
|
||||
expect(url.searchParams.has("auth_token")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
export function terminalWebSocketURL(input: {
|
||||
url: string
|
||||
id: string
|
||||
directory: string
|
||||
cursor: number
|
||||
ticket?: string
|
||||
}) {
|
||||
const next = new URL(`${input.url}/api/pty/${input.id}/connect`)
|
||||
next.searchParams.set("location[directory]", input.directory)
|
||||
next.searchParams.set("cursor", String(input.cursor))
|
||||
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
|
||||
if (input.ticket) {
|
||||
next.searchParams.set("ticket", input.ticket)
|
||||
return next
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ import type { FileSystem } from "@opencode-ai/schema/filesystem"
|
|||
import type { Command } from "@opencode-ai/schema/command"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
|
||||
import type { Pty } from "@opencode-ai/schema/pty"
|
||||
import type { PtyTicket } from "@opencode-ai/schema/pty-ticket"
|
||||
import type { Reference } from "@opencode-ai/schema/reference"
|
||||
import type { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import type { Vcs } from "@opencode-ai/schema/vcs"
|
||||
|
|
@ -1431,12 +1432,21 @@ export type Endpoint20_4Input = {
|
|||
export type Endpoint20_4Output = void
|
||||
export type PtyRemoveOperation<E = never> = (input: Endpoint20_4Input) => Effect.Effect<Endpoint20_4Output, E>
|
||||
|
||||
export type Endpoint20_5Input = {
|
||||
readonly ptyID: Pty.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly "x-opencode-ticket"?: string | undefined
|
||||
}
|
||||
export type Endpoint20_5Output = { readonly location: Location.Info; readonly data: PtyTicket.ConnectToken }
|
||||
export type PtyConnectTokenOperation<E = never> = (input: Endpoint20_5Input) => Effect.Effect<Endpoint20_5Output, E>
|
||||
|
||||
export interface PtyApi<E = never> {
|
||||
readonly list: PtyListOperation<E>
|
||||
readonly create: PtyCreateOperation<E>
|
||||
readonly get: PtyGetOperation<E>
|
||||
readonly update: PtyUpdateOperation<E>
|
||||
readonly remove: PtyRemoveOperation<E>
|
||||
readonly connect: { readonly token: PtyConnectTokenOperation<E> }
|
||||
}
|
||||
|
||||
export type Endpoint21_0Input = {
|
||||
|
|
|
|||
|
|
@ -186,6 +186,8 @@ import type {
|
|||
Endpoint20_3Output,
|
||||
Endpoint20_4Input,
|
||||
Endpoint20_4Output,
|
||||
Endpoint20_5Input,
|
||||
Endpoint20_5Output,
|
||||
Endpoint21_0Input,
|
||||
Endpoint21_0Output,
|
||||
Endpoint21_1Input,
|
||||
|
|
@ -1093,12 +1095,22 @@ const Endpoint20_4 = (raw: RawClient["server.pty"]) => (input: Endpoint20_4Input
|
|||
),
|
||||
)
|
||||
|
||||
const Endpoint20_5 = (raw: RawClient["server.pty"]) => (input: Endpoint20_5Input) =>
|
||||
preserveEffect<Endpoint20_5Output>()(
|
||||
raw["pty.connectToken"]({
|
||||
params: { ptyID: input["ptyID"] },
|
||||
query: { location: input["location"] },
|
||||
headers: { "x-opencode-ticket": input["x-opencode-ticket"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup20 = (raw: RawClient["server.pty"]) => ({
|
||||
list: Endpoint20_0(raw),
|
||||
create: Endpoint20_1(raw),
|
||||
get: Endpoint20_2(raw),
|
||||
update: Endpoint20_3(raw),
|
||||
remove: Endpoint20_4(raw),
|
||||
connect: { token: Endpoint20_5(raw) },
|
||||
})
|
||||
|
||||
const Endpoint21_0 = (raw: RawClient["server.shell"]) => (input?: Endpoint21_0Input) =>
|
||||
|
|
|
|||
|
|
@ -182,6 +182,8 @@ import type {
|
|||
PtyUpdateOutput,
|
||||
PtyRemoveInput,
|
||||
PtyRemoveOutput,
|
||||
PtyConnectTokenInput,
|
||||
PtyConnectTokenOutput,
|
||||
ShellListInput,
|
||||
ShellListOutput,
|
||||
ShellCreateInput,
|
||||
|
|
@ -1570,6 +1572,21 @@ export function make(options: ClientOptions) {
|
|||
},
|
||||
requestOptions,
|
||||
),
|
||||
connect: {
|
||||
token: (input: PtyConnectTokenInput, requestOptions?: RequestOptions) =>
|
||||
request<PtyConnectTokenOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/pty/${encodeURIComponent(input.ptyID)}/connect-token`,
|
||||
query: { location: input["location"] },
|
||||
headers: { "x-opencode-ticket": input["x-opencode-ticket"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [403, 404, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
},
|
||||
shell: {
|
||||
list: (input?: ShellListInput, requestOptions?: RequestOptions) =>
|
||||
|
|
|
|||
|
|
@ -156,6 +156,8 @@ export type SessionStatus =
|
|||
}
|
||||
| { type: "busy" }
|
||||
|
||||
export type PtyTicketConnectToken = { ticket: string; expires_in: number }
|
||||
|
||||
export type ReferenceLocalSource = { type: "local"; path: string; description?: string; hidden?: boolean }
|
||||
|
||||
export type ReferenceGitSource = {
|
||||
|
|
@ -2273,6 +2275,10 @@ export type PtyNotFoundError = { readonly _tag: "PtyNotFoundError"; readonly pty
|
|||
export const isPtyNotFoundError = (value: unknown): value is PtyNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PtyNotFoundError"
|
||||
|
||||
export type ForbiddenError = { readonly _tag: "ForbiddenError"; readonly message: string }
|
||||
export const isForbiddenError = (value: unknown): value is ForbiddenError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ForbiddenError"
|
||||
|
||||
export type ShellNotFoundError = { readonly _tag: "ShellNotFoundError"; readonly id: string; readonly message: string }
|
||||
export const isShellNotFoundError = (value: unknown): value is ShellNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ShellNotFoundError"
|
||||
|
|
@ -5467,6 +5473,19 @@ export type PtyRemoveInput = {
|
|||
|
||||
export type PtyRemoveOutput = void
|
||||
|
||||
export type PtyConnectTokenInput = {
|
||||
readonly ptyID: { readonly ptyID: string }["ptyID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly "x-opencode-ticket"?: { readonly "x-opencode-ticket"?: string | undefined }["x-opencode-ticket"]
|
||||
}
|
||||
|
||||
export type PtyConnectTokenOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: PtyTicketConnectToken
|
||||
}
|
||||
|
||||
export type ShellListInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export * from "./data"
|
||||
export * from "./connection"
|
||||
export * from "./pty"
|
||||
|
|
|
|||
34
packages/client/src/solid/pty.ts
Normal file
34
packages/client/src/solid/pty.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import type { OpenCodeClient, PtyConnectTokenInput } from "../promise"
|
||||
|
||||
export type PtyClientOptions = {
|
||||
readonly url: string
|
||||
readonly openSocket?: (url: URL) => WebSocket
|
||||
}
|
||||
|
||||
export type PtyConnectInput = {
|
||||
readonly ptyID: PtyConnectTokenInput["ptyID"]
|
||||
readonly location?: PtyConnectTokenInput["location"]
|
||||
readonly cursor?: number
|
||||
}
|
||||
|
||||
export function createPtyClient(api: OpenCodeClient, options: PtyClientOptions) {
|
||||
return {
|
||||
async connect(input: PtyConnectInput) {
|
||||
const result = await api.pty.connect.token({
|
||||
ptyID: input.ptyID,
|
||||
location: input.location,
|
||||
"x-opencode-ticket": "1",
|
||||
})
|
||||
const url = new URL(`/api/pty/${encodeURIComponent(input.ptyID)}/connect`, options.url)
|
||||
if (input.location?.directory) url.searchParams.set("location[directory]", input.location.directory)
|
||||
if (input.location?.workspace) url.searchParams.set("location[workspace]", input.location.workspace)
|
||||
if (input.cursor !== undefined) url.searchParams.set("cursor", String(input.cursor))
|
||||
url.searchParams.set("ticket", result.data.ticket)
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||
|
||||
const socket = options.openSocket?.(url) ?? new WebSocket(url)
|
||||
socket.binaryType = "arraybuffer"
|
||||
return socket
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -47,7 +47,8 @@ test("exposes every standard HTTP API group", () => {
|
|||
expect(Object.keys(client.websearch)).toEqual(["providers", "query"])
|
||||
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
|
||||
expect(Object.keys(client.vcs)).toEqual(["get", "status", "diff"])
|
||||
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
|
||||
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove", "connect"])
|
||||
expect(Object.keys(client.pty.connect)).toEqual(["token"])
|
||||
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
|
||||
expect(Object.keys(client.project)).toEqual(["list", "current"])
|
||||
expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"])
|
||||
|
|
|
|||
68
packages/client/test/solid-pty.test.ts
Normal file
68
packages/client/test/solid-pty.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { OpenCode } from "../src/promise"
|
||||
import { createPtyClient } from "../src/solid"
|
||||
|
||||
describe("createPtyClient", () => {
|
||||
test("mints an authenticated ticket before opening the terminal socket", async () => {
|
||||
let request: Request | undefined
|
||||
let socketURL: URL | undefined
|
||||
const socket = { binaryType: "blob" } as unknown as WebSocket
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "https://server.example/base",
|
||||
headers: { Authorization: "Basic credential" },
|
||||
fetch: async (input, init) => {
|
||||
request = input instanceof Request ? input : new Request(input, init)
|
||||
return Response.json({
|
||||
location: {
|
||||
directory: "/repo/worktree",
|
||||
project: { id: "project_1", directory: "/repo", canonical: "/repo" },
|
||||
},
|
||||
data: { ticket: "ticket-1", expires_in: 60 },
|
||||
})
|
||||
},
|
||||
})
|
||||
const pty = createPtyClient(api, {
|
||||
url: "https://server.example/base",
|
||||
openSocket(url) {
|
||||
socketURL = url
|
||||
return socket
|
||||
},
|
||||
})
|
||||
|
||||
expect(
|
||||
await pty.connect({
|
||||
ptyID: "pty_1",
|
||||
location: { directory: "/repo/worktree", workspace: "workspace_1" },
|
||||
cursor: 42,
|
||||
}),
|
||||
).toBe(socket)
|
||||
expect(request?.method).toBe("POST")
|
||||
expect(request?.url).toBe(
|
||||
"https://server.example/api/pty/pty_1/connect-token?location%5Bdirectory%5D=%2Frepo%2Fworktree&location%5Bworkspace%5D=workspace_1",
|
||||
)
|
||||
expect(request?.headers.get("authorization")).toBe("Basic credential")
|
||||
expect(request?.headers.get("x-opencode-ticket")).toBe("1")
|
||||
expect(socketURL?.toString()).toBe(
|
||||
"wss://server.example/api/pty/pty_1/connect?location%5Bdirectory%5D=%2Frepo%2Fworktree&location%5Bworkspace%5D=workspace_1&cursor=42&ticket=ticket-1",
|
||||
)
|
||||
expect(socket.binaryType).toBe("arraybuffer")
|
||||
})
|
||||
|
||||
test("does not open a socket when ticket minting fails", async () => {
|
||||
let opened = false
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://localhost:4096",
|
||||
fetch: async () => new Response(null, { status: 401 }),
|
||||
})
|
||||
const pty = createPtyClient(api, {
|
||||
url: "http://localhost:4096",
|
||||
openSocket() {
|
||||
opened = true
|
||||
return { binaryType: "blob" } as unknown as WebSocket
|
||||
},
|
||||
})
|
||||
|
||||
await expect(pty.connect({ ptyID: "pty_1", location: { directory: "/repo" } })).rejects.toThrow()
|
||||
expect(opened).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -64,5 +64,5 @@ export const groupNames = {
|
|||
"server.config": "config",
|
||||
} as const
|
||||
|
||||
export const promiseOmitEndpoints = new Set(["pty.connect", "pty.connectToken"])
|
||||
export const effectOmitEndpoints = new Set(["fs.read", "pty.connect", "pty.connectToken"])
|
||||
export const promiseOmitEndpoints = new Set(["pty.connect"])
|
||||
export const effectOmitEndpoints = new Set(["fs.read", "pty.connect"])
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty")
|
|||
HttpApiEndpoint.post("pty.connectToken", "/api/pty/:ptyID/connect-token", {
|
||||
params: { ptyID: Pty.ID },
|
||||
query: LocationQuery,
|
||||
headers: Schema.Struct({ [PTY_CONNECT_TOKEN_HEADER]: Schema.optional(Schema.String) }),
|
||||
success: Location.response(PtyTicket.ConnectToken),
|
||||
error: [ForbiddenError, PtyNotFoundError],
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue