pty.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
  1. import { Pty } from "@opencode-ai/schema/pty"
  2. import { PtyTicket } from "@opencode-ai/schema/pty-ticket"
  3. import { Location } from "@opencode-ai/schema/location"
  4. import { Schema } from "effect"
  5. import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
  6. import { ForbiddenError, PtyNotFoundError } from "../errors"
  7. import { LocationQuery, locationQueryOpenApi } from "./location"
  8. export const PTY_CONNECT_TICKET_QUERY = "ticket"
  9. export const PTY_CONNECT_TOKEN_HEADER = "x-opencode-ticket"
  10. export const PTY_CONNECT_TOKEN_HEADER_VALUE = "1"
  11. const PTY_CONNECT_PATH = /^\/api\/pty\/[^/]+\/connect$/
  12. // Authorization middleware skips credential checks when this matches; the PTY connect handler
  13. // is then responsible for consuming and validating the ticket.
  14. export function hasPtyConnectTicketURL(url: URL) {
  15. return PTY_CONNECT_PATH.test(url.pathname) && !!url.searchParams.get(PTY_CONNECT_TICKET_QUERY)
  16. }
  17. export const PtyGroup = HttpApiGroup.make("server.pty")
  18. .add(
  19. HttpApiEndpoint.get("pty.list", "/api/pty", {
  20. query: LocationQuery,
  21. success: Location.response(Schema.Array(Pty.Info)),
  22. })
  23. .annotateMerge(locationQueryOpenApi)
  24. .annotateMerge(
  25. OpenApi.annotations({
  26. identifier: "v2.pty.list",
  27. summary: "List PTY sessions",
  28. description: "List PTY sessions for a location, including exited sessions retained until removal.",
  29. }),
  30. ),
  31. )
  32. .add(
  33. HttpApiEndpoint.post("pty.create", "/api/pty", {
  34. query: LocationQuery,
  35. payload: Pty.CreateInput,
  36. success: Location.response(Pty.Info),
  37. })
  38. .annotateMerge(locationQueryOpenApi)
  39. .annotateMerge(
  40. OpenApi.annotations({
  41. identifier: "v2.pty.create",
  42. summary: "Create PTY session",
  43. description: "Create a pseudo-terminal session for a location.",
  44. }),
  45. ),
  46. )
  47. .add(
  48. HttpApiEndpoint.get("pty.get", "/api/pty/:ptyID", {
  49. params: { ptyID: Pty.ID },
  50. query: LocationQuery,
  51. success: Location.response(Pty.Info),
  52. error: PtyNotFoundError,
  53. })
  54. .annotateMerge(locationQueryOpenApi)
  55. .annotateMerge(
  56. OpenApi.annotations({
  57. identifier: "v2.pty.get",
  58. summary: "Get PTY session",
  59. description: "Get one PTY session, including its exit code once exited.",
  60. }),
  61. ),
  62. )
  63. .add(
  64. HttpApiEndpoint.put("pty.update", "/api/pty/:ptyID", {
  65. params: { ptyID: Pty.ID },
  66. query: LocationQuery,
  67. payload: Pty.UpdateInput,
  68. success: Location.response(Pty.Info),
  69. error: PtyNotFoundError,
  70. })
  71. .annotateMerge(locationQueryOpenApi)
  72. .annotateMerge(
  73. OpenApi.annotations({
  74. identifier: "v2.pty.update",
  75. summary: "Update PTY session",
  76. description: "Update the title or viewport size of one PTY session.",
  77. }),
  78. ),
  79. )
  80. .add(
  81. HttpApiEndpoint.delete("pty.remove", "/api/pty/:ptyID", {
  82. params: { ptyID: Pty.ID },
  83. query: LocationQuery,
  84. success: HttpApiSchema.NoContent,
  85. error: PtyNotFoundError,
  86. })
  87. .annotateMerge(locationQueryOpenApi)
  88. .annotateMerge(
  89. OpenApi.annotations({
  90. identifier: "v2.pty.remove",
  91. summary: "Remove PTY session",
  92. description: "Terminate and remove one PTY session.",
  93. }),
  94. ),
  95. )
  96. .add(
  97. HttpApiEndpoint.post("pty.connectToken", "/api/pty/:ptyID/connect-token", {
  98. params: { ptyID: Pty.ID },
  99. query: LocationQuery,
  100. success: Location.response(PtyTicket.ConnectToken),
  101. error: [ForbiddenError, PtyNotFoundError],
  102. })
  103. .annotateMerge(locationQueryOpenApi)
  104. .annotateMerge(
  105. OpenApi.annotations({
  106. identifier: "v2.pty.connectToken",
  107. summary: "Create PTY WebSocket token",
  108. description: "Create a short-lived single-use ticket for opening a PTY WebSocket connection.",
  109. }),
  110. ),
  111. )
  112. .add(
  113. // Query fields are decoded in the raw handler after the existence check so a missing
  114. // session responds with an empty 404 before any upgrade work.
  115. HttpApiEndpoint.get("pty.connect", "/api/pty/:ptyID/connect", {
  116. params: { ptyID: Pty.ID },
  117. success: Schema.Boolean,
  118. error: [ForbiddenError, PtyNotFoundError],
  119. }).annotateMerge(
  120. OpenApi.annotations({
  121. identifier: "v2.pty.connect",
  122. summary: "Connect to PTY session",
  123. description: "Establish a WebSocket connection streaming PTY output and accepting terminal input.",
  124. transform: (operation) => ({
  125. ...operation,
  126. parameters: [
  127. ...(operation.parameters ?? []),
  128. ...["location[directory]", "location[workspace]", "cursor", PTY_CONNECT_TICKET_QUERY].map((name) => ({
  129. in: "query",
  130. name,
  131. schema: { type: "string" },
  132. })),
  133. ],
  134. }),
  135. }),
  136. ),
  137. )
  138. .annotateMerge(OpenApi.annotations({ title: "pty", description: "Experimental location-scoped PTY routes." }))