From 9a96375e60ba1122cb881d6942bf85545f560409 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 9 Aug 2026 08:13:39 -0700 Subject: [PATCH] feat(gateway): session-catalog terminal start plans behind cliAgents gate (#121020) * feat(gateway): add session-catalog terminal start plans * refactor(gateway): split catalog terminal start handler * fix(gateway): enforce catalog terminal start eligibility * test(gateway): split session catalog snapshot coverage --- .../openclaw/app/gateway/GatewayProtocol.kt | 1 + .../OpenClawProtocol/GatewayModels.swift | 64 ++ .../.generated/plugin-sdk-api-baseline.sha256 | 74 +-- docs/gateway/configuration-reference.md | 3 + docs/plugins/sdk-entrypoints.md | 35 +- extensions/anthropic/lazy-import.test.ts | 1 + .../anthropic/session-catalog-registration.ts | 2 + .../anthropic/session-catalog-terminal.ts | 28 + extensions/anthropic/session-catalog.test.ts | 48 ++ extensions/anthropic/session-catalog.ts | 8 +- .../codex/src/session-catalog-terminal.ts | 32 + extensions/codex/src/session-catalog.test.ts | 68 ++- extensions/codex/src/session-catalog.ts | 7 + packages/gateway-protocol/src/index.test.ts | 2 + .../src/public-session-catalog.ts | 2 + .../src/schema-export-registry.ts | 2 + .../protocol-schema-fragment-sessions-core.ts | 2 + .../src/schema/sessions-catalog.test.ts | 36 +- .../src/schema/sessions-catalog.ts | 31 +- .../src/validator-registry.ts | 3 + scripts/plugin-sdk-surface-report.mts | 3 +- src/gateway/method-scopes.test.ts | 1 + .../methods/core-descriptors.since.test.ts | 3 + src/gateway/methods/core-descriptors.ts | 2 + src/gateway/server-methods-list.test.ts | 9 +- .../session-catalog-entry-snapshot.test.ts | 173 ++++++ .../session-catalog-terminal-start.test.ts | 358 ++++++++++++ .../session-catalog-terminal-start.ts | 170 ++++++ .../server-methods/session-catalog.test.ts | 176 +++--- src/gateway/server-methods/session-catalog.ts | 13 +- src/gateway/server-methods/terminal.ts | 548 +++++++++++------- src/plugin-sdk/session-catalog.ts | 3 + src/plugins/session-catalog.ts | 11 + 33 files changed, 1547 insertions(+), 372 deletions(-) create mode 100644 src/gateway/server-methods/session-catalog-entry-snapshot.test.ts create mode 100644 src/gateway/server-methods/session-catalog-terminal-start.test.ts create mode 100644 src/gateway/server-methods/session-catalog-terminal-start.ts diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt index 63056cd6f83c..a0a6a596b559 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt @@ -510,6 +510,7 @@ enum class GatewayMethod( AuditRunInspect("audit.run.inspect"), SessionsPatchMany("sessions.patchMany"), UpdateHold("update.hold"), + SessionsCatalogStartTerminal("sessions.catalog.startTerminal"), } enum class GatewayEvent( diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index b3d7b5d9eb2f..ead4e72a8200 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -4616,6 +4616,70 @@ public struct SessionsCatalogArchiveResult: Codable, Sendable { } } +public struct SessionsCatalogStartTerminalParams: Codable, Sendable { + public let catalogid: String + public let hostid: String? + public let agentid: String + public let cwd: String + public let initialmessage: String? + + public init( + catalogid: String, + hostid: String? = nil, + agentid: String, + cwd: String, + initialmessage: String? = nil) + { + self.catalogid = catalogid + self.hostid = hostid + self.agentid = agentid + self.cwd = cwd + self.initialmessage = initialmessage + } + + private enum CodingKeys: String, CodingKey { + case catalogid = "catalogId" + case hostid = "hostId" + case agentid = "agentId" + case cwd + case initialmessage = "initialMessage" + } +} + +public struct SessionsCatalogStartTerminalResult: Codable, Sendable { + public let sessionid: String + public let agentid: String + public let shell: String + public let cwd: String + public let confined: Bool + public let title: String? + + public init( + sessionid: String, + agentid: String, + shell: String, + cwd: String, + confined: Bool, + title: String? = nil) + { + self.sessionid = sessionid + self.agentid = agentid + self.shell = shell + self.cwd = cwd + self.confined = confined + self.title = title + } + + private enum CodingKeys: String, CodingKey { + case sessionid = "sessionId" + case agentid = "agentId" + case shell + case cwd + case confined + case title + } +} + public struct SessionsCleanupParams: Codable, Sendable { public let agent: String? public let allagents: Bool? diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 7b32cb991717..46cf903d718b 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -3,22 +3,22 @@ c38b59ef4745b7447295baf17900078bf460fe36d98b3516e0f37aa2c25fda5c module/account 71522995185b956a0cc4927a472cc8d1153e5e998874bfd9a750513175174713 module/account-id 62e64563d598ebb16411a0248da2f2d7fc61ca42602335c54b27586d1a4dc3bd module/account-resolution 3fe118210b885af40088457ed81ffa5ede18c8e695295731a2ee059af46843cc module/agent-config-primitives -bf64b124edf54fac9d0296197299fa8218a369959ccf9ce813e8feca615fad0e module/agent-harness -f4212394d8c3a1955a2c332c6b8f90e8619bbd46ee2f792dc185b957aaae85c9 module/agent-harness-runtime +e70c4e1352947a816b093df26be9a128dd17352f1e11d76925cc96eb5091ec21 module/agent-harness +43cad5e5d2d167a86e7032989d2ebfacfb414b125326e861797acf90827f9e62 module/agent-harness-runtime 773943f0f5cc26d4bfd1dc6cfdac5effc2bec14f1bde927e9e407ab4c9701ba0 module/agent-media-payload -769b2dfc03475942c5e4fcf018de830b17aa3c08eb054700c774a15bfb2d2cc5 module/agent-runtime +70104561669cc51fcaf4d7a9cba21cb81bfa4448a67edb65e731397e1a4c7b12 module/agent-runtime 241d467d0af5f81d8a535fe0c65d226356daf8325a037cb23092b8dd82aaa456 module/agent-scope-runtime 8fecb210e22bce4532b6ab649b09465f0bd2c857a44abf40db7d683d6491e6da module/allow-from ad09805cfb46d6155fb54807ea9794ec3980a5eac62a9d1e4d718dae8d0b61df module/allowlist-config-edit 05e9015f0b462f67be33831ca58e31af3cf99a13ec103a00c3111bc1eff989b0 module/approval-auth-runtime f06655c8a4524497abe4fac09808a3c18c10260fdcf90f97698efce6cf5d62cf module/approval-client-runtime 5cdacacc7cb9950bd8fa3b4332691adf36e69cd3f9e2928dc59e70897560f86e module/approval-delivery-runtime -2d670f3e370ce6e03937b7ac8502d546bf0a4903b2f93c3957bd5ef39e136b24 module/approval-gateway-runtime +0efcfa871dfeaa2f4563ca5d178fc4df4c1a397c635e1d0d802735073875afb2 module/approval-gateway-runtime 5da9a30393531c72d5df3ea256eaec71ca7ad323ffe9539d27a7bfc4c75d279f module/approval-handler-adapter-runtime -d38d69f95305ddaad8f059d16f6593938ef385991b00f0909434d0855c1e769c module/approval-handler-runtime +f09c3f27c32752980a135018353c07260153c3df7601da39fe7326eeafe515e9 module/approval-handler-runtime 040dd9bbd3d1235c4679fcf219bdca65551f014b6ef08eb94935399f3d83972f module/approval-native-runtime 573a09bfa745b9128aeb73cf736ff0e5718c51ea5fad050bae795ed31c8120f3 module/approval-reply-runtime -de70e007063b51cef54bf7c48150c2eb295d4a14c9c2a60409d3dc036bdc0f9f module/approval-runtime +8209d1ee046f37bbbdeefe2733a560f3e2762dc0e2b188399fc8147f35a9391a module/approval-runtime 01ca912836b8dec672f705e294f72d346e778557e4c591317d67558ea7669c0b module/archive d7e53de63b0ac11a266e4abdc18ba6e9401b80309f5c8f5f6a72a00f65dfe3bd module/boolean-param 39769190fc9d790ae5e2c4ebd5de0a78b45b3d529aaf264c84eba4e7f86a3066 module/channel-actions @@ -26,19 +26,19 @@ d7e53de63b0ac11a266e4abdc18ba6e9401b80309f5c8f5f6a72a00f65dfe3bd module/boolean c2cc71d5070b6071c51248b0648d1ad1a9468d3737df890adc77ec02025e8853 module/channel-config-primitives 76ad615d374431580ea1755594e2ce3ce047ac1de9fd4621053dca0fbc3afc4d module/channel-config-schema cd873744a01a47c5284227d8af5c50d2f6606a815e8586e11c2012f9c301429a module/channel-contract -f57191ecbc801dbce50e4b3f0fa449de07663280b405dd29d742cba093c1bbde module/channel-core +55b8b0cd777348526de7cdfaa3085968fd60305d1cf0f0b9c3ee2a21a1db583b module/channel-core caa54fde5a2a515491019ec163ed278e09bdc95b9de9926aff04c1743ca0c966 module/channel-dm-policy -bccffec9da8f7c58dfcf765516b272ecacb343f00828ee7ea1eaf4bda7452f4e module/channel-entry-contract +f300dd4668c35a474ae98d4114fe0e871fde05ae2a1f8f28d775a36e8e08e375 module/channel-entry-contract 2c55b3f3d1d275f760a7e1c78764e4030e7a06c4273a4d51d8f3c82b55ea818d module/channel-feedback -1d3056a8709387121c4e99bc8744847f5742cd95e6265a8f3434ce82331fd7e0 module/channel-inbound +819b60a7f7d9cf2e99525a30bdc09a30bc9ec4669d63a91b17f026972d8d17bb module/channel-inbound d7e21bb831ad5125e6498e88fcc27e820ed296784eb2296c22795c08d55ade06 module/channel-inbound-debounce 79a8f244b0627ce4b601231bf71f0ee539f6ac7692e490847ec8e7ace7d29f1c module/channel-ingress-runtime e8e08fd9dfcc15758c552fd594055cde15457fac07688553e613de27a6909d98 module/channel-lifecycle 0e47457e38d1df0bd572e1408cde2ca6a788b65205f43c585316b5ad3a8f2f16 module/channel-logging -7db3229db75404d4051268d0586d6d21942768d840907038722aa9098a057c72 module/channel-message -00b3eb03a22523e799d8112ee6e0d60f490082e3347a3c19a9ef6edf4f837290 module/channel-outbound -eef03825bb6d321ed4390a9379eaec19b7d3a5440b644589e380363bc3763953 module/channel-pairing -d7207ec3dfe8622ae1ce4298b42a370362dc35387a7073d91689becc9ea50b81 module/channel-plugin-common +3a1d01d678225010da678942e47b1b3a8c000bdf5fa70a75a5d7bd737b4f3c6a module/channel-message +762bdfdfabc44dcef108bc70be716e40c7937ae58090af68fc0c181fc85555fa module/channel-outbound +20fe9fc24997d3e6b04565058bfb52626ad2d07136ef35194b06911750fbb553 module/channel-pairing +8d56978df5534615b43f0ec7afc57edc6f9d01c0f31fed1afd75f5db9af85f6d module/channel-plugin-common 39e460870b572913c321680b6a4ca2fed963b8c8c71bc2de9193ec68b74ef12b module/channel-policy dd291daf278dbe9110ad9007048830b6f0bd6701dca09e25094ce98e4642d972 module/channel-reply-pipeline 482370e60135db9bfaf07f24bab549e5fde09ab265a6061a1f587c5d93929e91 module/channel-runtime-context @@ -51,8 +51,8 @@ e99d6f57a89503e67035da553b0ae62b893722e564031c050f3097cf5fa3bf5f module/channel 67df67da5ae72e9eaeb19d41b6bd2432ec4fd8b7b63b2b616fb98f3b4e0ec41d module/channel-streaming-config 1303df5cc58539c6941e2cd159c93259804c925795219f1630f4d740896a77c1 module/cli-argv ad12670dbfe538f8d0ebf4fb2b68080e93a760278278e6b1ce9bb129d4b2d533 module/collection-runtime -0883877ab26a6cdc30d31281d2b7aafd075657d7885f16de45fc35872d0ebb97 module/command-auth -eb0803d88af9a0fd8b41d8633ef1f784b9189b8ecaf71fdfb5c86571d54ba3ab module/command-auth-native +e6b30586e42bdad2a14d2c9739c00e5b3a50e54e20a83372fec9654ae3381d23 module/command-auth +ea7eb2d2d38c9ff95f15350c57576635daca0bd2e079f3a39b648e4c863376f4 module/command-auth-native a41d9effc1656cbc131611099bdeb81423ab48adce8c1b1ba07100dff8d32818 module/command-detection 31044216c6495728a36dbe60da31f7c1ede2b069cd0d0685d887ebca31814d5f module/command-primitives-runtime e42cc234ace96a64cec0bdb8051ddded77618e384ae2bbce8b633bf35dbd0288 module/command-status @@ -60,23 +60,23 @@ e42cc234ace96a64cec0bdb8051ddded77618e384ae2bbce8b633bf35dbd0288 module/command 5e866c4f8dea30045a8a94e037f0e202d597afb1ca221d9854b15bc416503643 module/config-mutation a5328945c964794236201aa2c947d783f210f49af34d213d5a188b1e6c40b1a4 module/config-runtime c6d742c9e6027647502399560b4264bed80bb8c069c2d08ba8527336f58dc13b module/conversation-runtime -258c06ace77a84ae28d2976bab77dd7b28b48e9e670f5cab536e3ba87bf87767 module/core -f0802bd2172418d41e4aebafe6a35fc37daab8cd3620f6d73d0cb510e8ca23e8 module/dedupe-runtime +387f3cb6ffa65b076f724b5ec45e8f0ace7db912831eba08b156936878580066 module/core +aac69e9d9382eb40d93ef07d906edfe7c64d7dfa259798f961e3bb3e1b6daa43 module/dedupe-runtime ebef0e650ab45e44c9335e2b3e15588c968cea6dadd125364a076f9c50ad1e8c module/device-bootstrap fd7c489415aa272af724ac15e95d297eac0edeb229e30d02c4061d5276f874b2 module/diagnostic-runtime 6dba2e37cfd962cc8c0aaae2d74d82407fa660fa56a4cf146b8674dc64239608 module/directory-runtime -3dcd8f6f7f65ddc2532ab2deea93272db066ca707ae55a91381d470e0cefdd20 module/discord +60836fc08d3272564460939f1f5a5485d3d396bf6001766efb4d2672e6452e1b module/discord c468c0ca5e5fc093ef5bd0cd15c57e19059bbe5c453d68f2664e9aaa35661cab module/error-runtime 6d9b6396888d7cddded108e211053559b10d79397d20098e0616767be4a4bfb3 module/extension-shared dd9f6e0fd33cc88b22543c1ee30cc09cf4de4d8f30dff7b7f9cebef885c21543 module/gateway-method-runtime -7bd8de3a6bfa3c0a3d7a6c4e38bb7fc31ebe25e275d126be02cc4c9ccd0a4de8 module/gateway-runtime +371a254cbc44fc6e63e8cc2b61de26f39455976701a44cbbf4c206aa05af5f27 module/gateway-runtime 575656e5e0195c8d1813a4e2e3a271e800bb97d44f2dd78c242e3b6714ffa097 module/group-access 92566a68cbf1c635fe3dc29afcab042e2b06aaaa0438c8cbd2c001b07730aa9c module/health 70abcc263a1f320faf7e589ba238bb1b4c7a602d52af42461420523460804aec module/hook-runtime 185a5acedbd7f1a73e5cc773e22bf494b124a09bcd68b5a756ff0f881abf431b module/inbound-envelope 4928af5d2509f696b896f53ac790303a0742202dbcdae3e44fe6d1b434a9c1ba module/inbound-event-delivery -0a8fe52421723605b7d882b3d13f4be7ec10cd9e7e4d0ac01ff453f18e7867ba module/inbound-reply-dispatch -05df434589f317935ef1c251cf95c18406455f04c6c573daed0289ca629c4f22 module/infra-runtime +dd1f0078bff0f6f7aed724c1fba25d68217c00b6f662d716bc9467b38f29166a module/inbound-reply-dispatch +ee37c72512c245c1eb9637e8e9a788f128749fcb5d0308d45fa442a45dfc18c5 module/infra-runtime ce73721421f1b903dd04ead4df173582e59ea3e9990248102c448b419cc6d272 module/ingress-effect-once dd7a5a732737c74cfe287ab40d62dd380ab4a3b78eeea6cd52574c0613a874cf module/interactive-runtime 408d257ab5cc4b88a22b7e7595039cb8fc524b261c44141b294fbd0100ba62ee module/json-store @@ -89,28 +89,28 @@ dfddf0032904cf003c325578bc4b2789b2b695c4f7aaa9d08d2f932df30477cb module/media-r 6a52f93107335f88751704352cc01e62add06f854a5b7d765e2a5ee87c0313b6 module/media-store 7dd4a69b33196e946b03a3579e3b569176ff32db22fa668d5eacc06227574cd1 module/media-understanding 151c5fb10718764a03a913b3d6a8a6716cdc12fb95010f306b228a512d3a3d91 module/media-understanding-runtime -591bd88843dcdc21967d6098dad5489e41e15b70a8fca08e5fc5634b56fba71e module/meeting-runtime +9c88de9566447f79a9e784fde80534da1081c828bff98e79ad1a08651661b04c module/meeting-runtime d16cbced4f2e6672ac9a032ac41691fe7ff4994e328d44ed6ac8a46dbbec834f module/memory-core-host-engine-foundation -5869a92060114a270f02b76972999d9ba5b31d4d755b0aedf2d4808e50202490 module/memory-host-core +0cc997644fdc0047cd6bfb12716c8b7abe85cd7bdb167f196efff835c09aa334 module/memory-host-core 1efa0aadc4261d1c6073058cbf3dcc9fa681424819bdd14333e19b249bbc4b18 module/messaging-targets a3c8b86036354d27ae5c669502de05b823f3fb5fe4ef06a4defd4ed1fbcbf9a0 module/model-session-runtime -0372d5d52682bcec06b1c5736276f9b8f54147107f0957fdb80858ca27bdc57f module/models-provider-runtime +b5d10ad7f079481ff4f8e67d2213fa5dc12a8d312dbdc124e41c692a4efc78e8 module/models-provider-runtime 06b3bd19f3dde06b77cb1b0e8e389a0bc69b90f2aca4da86ddbc0e29ef8a33d8 module/native-command-config-runtime faaa22538f3459cef412c52088cb40dc732aa560fba2e70655938460022287d0 module/native-command-registry 5b968ecbef95fda927d0944409994e355ec878608dd084358970078d2ac545a9 module/param-readers ca7a56bb1a6169b4cf9befbf5aa21da280a8086fdc49fca4eec520a7a7c98549 module/persistent-dedupe 1bf4d4dfe5a4b264cf6fb8fbd0c7bc76f520ff9845cffad6da4b3a3c2bc3f6f6 module/plugin-config-runtime -a8b04f9efdf7a5923e20dd87579b608f5ffcc925d8587dbbc224a1ac1da44f63 module/plugin-entry -0cb30aac2840b9cb54032e745f6a354c0366852436232aa43dc5626b571c5ba1 module/plugin-runtime -b7b684a81f769f63ff2ec6ab515ea0337339d9f166fc7b3945e65e0bdac4e100 module/provider-auth -395a600ca6fb645f1be130a95cdb834c83073dd36fd98639739107fc48396b29 module/provider-catalog-runtime +86c083e3829e5e9dd11e8b65be31a13fd603a28b564112bb7825fcb16381cda7 module/plugin-entry +7a860d980c9ad73a4286587dd8e2dc7952c16094e15568c444271c0e5b81569f module/plugin-runtime +6a5672fbdf989aaa819cead8f030aaa8a72b05b688d049006e45af057b18f98b module/provider-auth +ac88277ad893bc1c10ba7cfada20e0e022fe42a08cbdb3e3486b749c820d5138 module/provider-catalog-runtime 8131147d699394bd06503e2ea2f5f1a50b1594a87dded6d118b74a8d0328c8f6 module/proxy-capture -69a2931ee70410d6e782c0e6f4ea1964a9f72651999070c7453eb6c992ba6cc8 module/question-gateway-runtime +784c3c5c5dbb1e2c33ccccc62f850b740d2adcbde5e10e4e891d8c0f78aaeb99 module/question-gateway-runtime 158d7fa58b45efc8569684cbb8cd2d0a8e9d331911c9eacd6e1323c4b764cbb5 module/reply-chunking -20f592eb816da5b2e75e38d8f3dfe0c0c01059e1b37d45b05fe438f2fc07b980 module/reply-dispatch-runtime +90f0ffff74ee7eec2602502a3fb14abc26f1572b1d45953e410bc570044b74c5 module/reply-dispatch-runtime 73f861fa3179d5af1159853c5acab0eec7a6c8f9398dcb75ea770e784fca6727 module/reply-history c4633871d5982f7b3d447ea77afa90031fd2faf90750f6f5dea8367e3f57d52a module/reply-payload -ad838aea4708728b912e64024b75ba6e975cebe6f84897af22daa903599691cb module/reply-runtime +4033c9d0c701fafdc294e618d7085e87f440b26b081b619b20354338eece45f8 module/reply-runtime aa07d85d99fdd2b1e0cbe9975fb6dcae66b8bdce2607c6bd5402ae68bb15118c module/root-walk e26cc92679c768fa1474f15828f545aec87f32718a6b35f7907c4f56f65542fc module/routing 7877a7e58fa32a64107154e5b714c6d165e96989d4aa5f43e0afac085a187af0 module/run-command @@ -118,20 +118,20 @@ c83779c80c9e7b196b31a39ec4b986098bd701b089bf6eb2f53c368fb982ea77 module/runtime 159b563aad773cef67f18bf9bd2653420bec94b599e052f2a1d5a238bf341e16 module/runtime-config-snapshot 9fe5bcb52b462010214eda1c01f60b3a018837d9f95dc864f6d457cb3da001cf module/runtime-env 7e871b7319745678bb83fcfc1b54c8751b0ab1af92ff06c01d0659ac92863c11 module/runtime-group-policy -21ab9f99fc7c530caaec1ca15334b598ed7b2f1d01fed5e05b1ec4a69563a756 module/runtime-store +13542a594ad95bd194b7670bd38b03e8a2799e7332bb831e098040a3e355957e module/runtime-store d17862c40825af1ddf0257b44f1e1cbb9c375e8e5ed668fae75d530d1a465cf9 module/secret-file 8e2ac4d3973d8d8ce4478e3440d66ee5c0d9213b0fe9e927c421d14fd31e5e86 module/secret-input e7672788f052a1249839c8fa24d6a9a45967c4e3fd94e890b0f90296026cd1ca module/secret-input-runtime 026631cbf010d0325be2c4fa4ccd8bbdf8e1008f0904c742c270811f578b07b8 module/secret-ref-runtime 536d3196e17422652d755286b9921133562907537c906b067a568b874221e7a0 module/security-runtime -2ddfb6d7aa51bc45edb68d005400ff6934162fd4fe32e3190d7b8231b8583960 module/session-catalog -56813dc7cf43f2b662caf2daaa0ba1918c3a40ea45f6aa4d7c73c27344b9866d module/session-discussion +f4eb6f6caedc651fb4dcbaf90eaaf849f10cdcea7468875286b6d862c2b64eaa module/session-catalog +33670dc092160cadf8fac8e6d58d151d14687d1f7cffe50ac6033b0114b27b76 module/session-discussion 7262e6e6dee725b4d9b8226bbb31f24460d7a4d5956aa6d60df81181c4221c07 module/session-store-runtime e68e6edb57b7dc978431495ab53712d38a02d61620ab272b46a38c3b94199cfa module/setup f188bdb868523aa17457c9338b02fcdf0df548d2fe776750b39d36c702edc061 module/setup-runtime 44d37e0d9131ad2859f41068f2604090c784e65f1bd6ebda8e051b6f2e5e1660 module/setup-tools 3601dd9f28de70915005457146881685b73690ef82c9bfed80f35d0cf9b645b1 module/skill-commands-runtime -cdf1ec944678336dcc289f795f2004867148ebd4bbe73a938b6443deb7abe294 module/speech-settings +0f22ed63e0ac5129c9d210e871c131c526d763e80ffa07b56cc5c27f598db065 module/speech-settings 4a1bcc606805b5a20d04e048fc268764df9b140be9d161ad981d213343b87fd6 module/ssrf-policy 3ad3f12186d9cf44600904f22d0659b3a72737d0ce7fb33d1177372f44db827c module/ssrf-runtime 5501c65f90feec38049ce100cb320b2a629ebf1ad04b21f015a0d44aa1e4c448 module/state-paths @@ -141,11 +141,11 @@ c2aca425088c2bc9a74035f34d8b541354792eb0c44b988e314d59c25ac3aa7f module/telegra aef35bee2502cd6ed8765409b758e452aff8ac9469fd773e6a2a44c9a1bc3f66 module/temp-path 87fa81b9e58d8fc04a4b4202d2d37fca339615f5225687d9db905151439e0f4d module/text-chunking 434804a2166f6bf2e872bff0f03f3af850e44dea04b49b1d13b41df872bb71d1 module/text-runtime -c407126a0ab30c77cb37bba55f747385bde6db7f59f04004c4a7f97a8d7de538 module/tool-plugin +e68bac256d5ff7f2199b9c8398f4ec5f7f17a2f5bfedcf1d9839216b06c487ae module/tool-plugin dc1a073c59ab61e2789533b777b3f0cb9af689d64a97796b10e8aa82552510db module/tool-results 788e35ecca74d535b95b109f6837025b97cd2b4854008166b9f2e4832c31210a module/tool-send cda105b721d498df23a554c6b68be150b8fe66b8b9172185c31a0b3b0646b1dc module/web-media -72862995b712c423731878c013bfc610415c030a10510799fd4f8567b944b7aa module/webhook-ingress +51176054606504ef27fdba5f3922ca15730b3749d225f85dec8a21a1f00f250d module/webhook-ingress a107b97d3c1bb7494e516760d613950d30dbf57ccaea4b037e2e832ca6115839 module/webhook-request-guards de59e86e126b75d13251cba7ebbe27b44d9b5588785d98df5ff4d6722374c81f module/widget-html 9161b36ec0ab062ea41b363c894fcd672a7727f21cb726739f99f9c184fce69d module/zod diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index dd8d6cbcd1af..4ca174adc9fb 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -683,6 +683,9 @@ See [Plugins](/tools/plugin). - `controlUi.toolTitles`: opt in to AI-generated purpose titles for tool calls in Control UI chat. Default: `false` (tool rendering stays fully deterministic with no background model calls). When enabled, the `chat.toolTitles` method labels complex calls through standard utility-model routing — the agent's `utilityModel` (an operator decision that may send bounded tool arguments to the chosen provider, like every utility task), or the session provider's declared small-model default (OpenAI → `gpt-5.6-luna`, Anthropic → `claude-haiku-4-5`) — and caches results in the per-agent state database so repeat views never re-bill. `utilityModel: \"\"` disables titles like every other utility task; titles never fall back to the primary model. - `controlUi.dangerouslyAllowHostHeaderOriginFallback`: dangerous mode that enables Host-header origin fallback for deployments that intentionally rely on Host-header origin policy. - `cliAgents.enabled`: opt in to the experimental **CLI agents** group in the Control UI new-session model picker. Default: `false`. The group appears only when the Gateway advertises `sessions.catalog.list`, and it includes only catalog providers that support creating sessions. Selecting one opens the same catalog-target new-session flow used by the sidebar catalog action. + + Catalog providers can also advertise terminal-based session creation. The method is available only when Labs `cliAgents.enabled` is on, the Gateway terminal is available, and the selected provider exposes the capability. Callers supply `cwd`; create a fresh worktree first with `worktrees.create` when needed, because terminal start does not provision one. + - `terminal.enabled`: the admin-scoped operator terminal. Default: `true`; set `false` to opt out. The terminal starts a host PTY in the selected agent workspace, inherits the Gateway process environment, and is refused for agents with `sandbox.mode: "all"`. Disable it on deployments where admin operators should not get a host shell; changing it restarts the Gateway and updates the Control UI content security policy. - `terminal.shell`: optional shell executable. When unset, OpenClaw uses `$SHELL` on Unix and `%ComSpec%` on Windows. - `terminal.detachedSessionTimeoutSeconds`: how long a terminal session survives after its connection drops (page reload, laptop sleep), staying reattachable via `terminal.attach` with its recent output replayed. Default: `300`. Set `0` to kill sessions the moment their connection drops. Detached sessions keep running their commands, so shorten this on shared or exposed hosts. diff --git a/docs/plugins/sdk-entrypoints.md b/docs/plugins/sdk-entrypoints.md index 8bfe9e9c2bb7..f1acc53edc92 100644 --- a/docs/plugins/sdk-entrypoints.md +++ b/docs/plugins/sdk-entrypoints.md @@ -144,13 +144,34 @@ export default definePluginEntry({ - `id` must match your `openclaw.plugin.json` manifest. - External session catalogs use - `openclaw/plugin-sdk/session-catalog` and - `api.registerSessionCatalog({ id, label, list, read, continueSession?, archive? })`. - Core owns the `sessions.catalog.*` Gateway methods; providers return host, - session, and normalized transcript projections without registering RPCs. A - list provider should call the optional `onHost(host)` callback as each host - settles; the returned host array remains required as the final compatibility - snapshot. + `openclaw/plugin-sdk/session-catalog` and register a + `SessionCatalogProvider` with `api.registerSessionCatalog(...)`. Required + provider fields are `id`, `label`, `list`, and `read`; optional hooks are + `resolveCreateSession`, `continueSession`, `checkUpstreamActivity`, `archive`, + `openTerminal`, and `startTerminalSession`. Core owns the + `sessions.catalog.*` Gateway methods; providers return host, session, + transcript, and terminal-plan projections without registering RPCs. A list + provider should call the optional + `onHost(host)` callback as each host settles; the returned host array remains + required as the final compatibility snapshot. + `resolveCreateSession({ agentId })` must return a config-derived model/runtime + target before OpenClaw advertises creation or calls `startTerminalSession`. + Use + [`api.runtime.agent.resolveSessionCatalogCreateTarget(...)`](/plugins/sdk-runtime#apiruntimeagent) + to apply the host's runtime and model-allowlist policy instead of duplicating + it. + + `startTerminalSession({ agentId, cwd, initialMessage?, nodeId? })` creates a + fresh CLI terminal plan. Return either a local plan (`kind: "local"`, `argv`, + and the exact `cwd`, plus optional `env`, `pathEnv`, and `title`) or a paired-node + plan (`kind: "node"`, `nodeId`, `command`, `paramsJSON`, and the exact `cwd`). + The `sessions.catalog.startTerminal` RPC requires `operator.admin` plus + `gateway.cliAgents.enabled` and `gateway.terminal.enabled`. The caller + provisions `cwd`; the Gateway requires an existing absolute local directory, + rejects a changed plan cwd or host, and applies the normal agent-sandbox, + node-pairing, deadline, and connection-ownership checks before opening the + PTY. + - `kind` is deprecated: declare an exclusive slot (`"memory"` or `"context-engine"`) in the `openclaw.plugin.json` manifest `kind` field instead. Runtime-entry `kind` remains only as a compatibility fallback for diff --git a/extensions/anthropic/lazy-import.test.ts b/extensions/anthropic/lazy-import.test.ts index 58abb0657294..2c84aa69668e 100644 --- a/extensions/anthropic/lazy-import.test.ts +++ b/extensions/anthropic/lazy-import.test.ts @@ -24,6 +24,7 @@ describe("anthropic session catalog lazy imports", () => { list: async () => [], read: async () => ({ hostId: "gateway:local", label: "Local", threadId: "", items: [] }), continueSession: async () => ({ sessionKey: "agent:main:test" }), + startTerminalSession: async () => ({ kind: "local", argv: ["claude"] }), openTerminal: async () => ({ kind: "local", argv: ["claude"] }), checkUpstreamActivity: async () => [], }), diff --git a/extensions/anthropic/session-catalog-registration.ts b/extensions/anthropic/session-catalog-registration.ts index 9618492dc28d..cb5bf14631fd 100644 --- a/extensions/anthropic/session-catalog-registration.ts +++ b/extensions/anthropic/session-catalog-registration.ts @@ -73,6 +73,8 @@ function registerClaudeSessionCatalog(api: OpenClawPluginApi): void { list: async (query) => await (await loadCatalogRuntime()).list(query), read: async (request) => await (await loadCatalogRuntime()).read(request), continueSession: async (request) => await (await loadCatalogRuntime()).continueSession(request), + startTerminalSession: async (request) => + await (await loadCatalogRuntime()).startTerminalSession(request), openTerminal: async (request) => await (await loadCatalogRuntime()).openTerminal(request), checkUpstreamActivity: async (probes) => await (await loadCatalogRuntime()).checkUpstreamActivity(probes), diff --git a/extensions/anthropic/session-catalog-terminal.ts b/extensions/anthropic/session-catalog-terminal.ts index 90a43d2d43da..e191a53aca63 100644 --- a/extensions/anthropic/session-catalog-terminal.ts +++ b/extensions/anthropic/session-catalog-terminal.ts @@ -67,6 +67,34 @@ export function terminalEligibility( }; } +export async function startClaudeCatalogTerminal(params: { + cwd: string; + initialMessage?: string; + nodeId?: string; +}): Promise { + if (params.nodeId) { + throw new ClaudeCatalogParamsError( + "Paired-node Claude terminal start is unavailable; omit hostId to start on the gateway host", + ); + } + const resolution = resolveClaudeTerminalExecutable(); + if (!resolution) { + throw new ClaudeCatalogParamsError( + "Claude CLI is unavailable; install Claude Code or add claude to PATH, then restart the gateway", + ); + } + return { + kind: "local", + argv: [ + resolution.executable, + ...(params.initialMessage !== undefined ? ["--", params.initialMessage] : []), + ], + cwd: params.cwd, + ...(resolution.pathEnv ? { pathEnv: resolution.pathEnv } : {}), + title: "claude", + }; +} + export async function openClaudeCatalogTerminal( params: { api: OpenClawPluginApi; diff --git a/extensions/anthropic/session-catalog.test.ts b/extensions/anthropic/session-catalog.test.ts index 64bee44d9916..278d05062e0b 100644 --- a/extensions/anthropic/session-catalog.test.ts +++ b/extensions/anthropic/session-catalog.test.ts @@ -2282,6 +2282,54 @@ describe("Claude session catalog", () => { ); }); + it("builds a local Claude terminal start plan with the initial prompt", async () => { + const home = await createHome(); + process.env.HOME = home; + const binDir = path.join(home, "bin"); + await fs.mkdir(binDir); + const executable = path.join(binDir, process.platform === "win32" ? "claude.cmd" : "claude"); + await fs.writeFile(executable, process.platform === "win32" ? "@echo off\r\n" : "#!/bin/sh\n"); + if (process.platform !== "win32") { + await fs.chmod(executable, 0o755); + } + process.env.PATH = binDir; + nodeHostMocks.userShellPaths.set("claude", binDir); + const provider = captureCatalogProvider(createPluginRuntimeMock()); + + await expect( + provider.startTerminalSession?.({ + agentId: "main", + cwd: "/work/new-session", + initialMessage: "--help", + }), + ).resolves.toEqual({ + kind: "local", + argv: [executable, "--", "--help"], + cwd: "/work/new-session", + pathEnv: binDir, + title: "claude", + }); + await expect( + provider.startTerminalSession?.({ + agentId: "main", + cwd: "/work/command-prompt", + initialMessage: "mcp", + }), + ).resolves.toMatchObject({ argv: [executable, "--", "mcp"] }); + await expect( + provider.startTerminalSession?.({ agentId: "main", cwd: "/work/blank-session" }), + ).resolves.toMatchObject({ argv: [executable], cwd: "/work/blank-session" }); + await expect( + provider.startTerminalSession?.({ + agentId: "main", + cwd: "/work/new-session", + nodeId: "paired-node", + }), + ).rejects.toThrow( + "Paired-node Claude terminal start is unavailable; omit hostId to start on the gateway host", + ); + }); + it("resolves Claude terminal eligibility and cwd from the node-owned catalog", async () => { const home = await createHome(); process.env.HOME = home; diff --git a/extensions/anthropic/session-catalog.ts b/extensions/anthropic/session-catalog.ts index f5c3b2f9e935..e172d21f1e07 100644 --- a/extensions/anthropic/session-catalog.ts +++ b/extensions/anthropic/session-catalog.ts @@ -1906,7 +1906,12 @@ function toGenericClaudeHost( type ClaudeSessionCatalogRuntime = Required< Pick< SessionCatalogProvider, - "list" | "read" | "continueSession" | "openTerminal" | "checkUpstreamActivity" + | "list" + | "read" + | "continueSession" + | "startTerminalSession" + | "openTerminal" + | "checkUpstreamActivity" > >; @@ -1940,6 +1945,7 @@ export function createClaudeSessionCatalogRuntime( }, continueSession: async (request) => await continueClaudeSession(api, request.hostId, request.threadId), + startTerminalSession: (request) => catalogTerminal.startClaudeCatalogTerminal(request), openTerminal: (request) => catalogTerminal.openClaudeCatalogTerminal({ api, diff --git a/extensions/codex/src/session-catalog-terminal.ts b/extensions/codex/src/session-catalog-terminal.ts index c4aaf905ecff..050a0ab1786f 100644 --- a/extensions/codex/src/session-catalog-terminal.ts +++ b/extensions/codex/src/session-catalog-terminal.ts @@ -275,3 +275,35 @@ export async function openCodexCatalogTerminal( title, }; } + +export async function startCodexCatalogTerminal( + params: { + agentId: string; + cwd: string; + initialMessage?: string; + nodeId?: string; + } & CodexTerminalConfigSources, +): Promise { + if (params.nodeId) { + throw new CatalogParamsError( + "Paired-node Codex terminal start is unavailable; omit hostId to start on the gateway host", + ); + } + const resolution = resolveLocalCodexTerminalResolution(); + if (!resolution) { + throw new CatalogParamsError( + "Codex CLI is unavailable; install Codex or add codex to PATH, then try again", + ); + } + return { + kind: "local", + argv: [ + resolution.executable, + ...(params.initialMessage !== undefined ? ["--", params.initialMessage] : []), + ], + cwd: params.cwd, + env: { CODEX_HOME: resolveCodexCatalogTerminalHome(params) }, + ...(resolution.pathEnv ? { pathEnv: resolution.pathEnv } : {}), + title: "codex", + }; +} diff --git a/extensions/codex/src/session-catalog.test.ts b/extensions/codex/src/session-catalog.test.ts index 49f55449f254..a4ba6d6ac72d 100644 --- a/extensions/codex/src/session-catalog.test.ts +++ b/extensions/codex/src/session-catalog.test.ts @@ -95,6 +95,7 @@ const transcriptMirrorMocks = vi.hoisted(() => ({ })); const nodeHostMocks = vi.hoisted(() => ({ runNodePtyCommand: vi.fn(async () => ({ exitCode: 0 })), + userShellPaths: new Map(), })); vi.mock("./command-rpc.js", () => ({ @@ -121,15 +122,33 @@ vi.mock("openclaw/plugin-sdk/node-host", async (importOriginal) => { env?: NodeJS.ProcessEnv; pathEnv?: string; includeExtensionless?: boolean; + strategy: "direct" | "fallback" | "prefer"; }, ) => { const env = options.env ?? process.env; - return actual.resolveNodeHostExecutable(command, { + const pathEnv = options.pathEnv ?? env.PATH ?? env.Path ?? ""; + const direct = actual.resolveNodeHostExecutable(command, { env, - pathEnv: options.pathEnv ?? env.PATH ?? env.Path ?? "", + pathEnv, includeExtensionless: options.includeExtensionless, strategy: "direct", }); + if (direct && options.strategy !== "prefer") { + return direct; + } + const shellPath = nodeHostMocks.userShellPaths.get(command); + if (!shellPath) { + return direct; + } + const shellExecutable = actual.resolveNodeHostExecutable(command, { + env, + pathEnv: shellPath, + includeExtensionless: options.includeExtensionless, + strategy: "direct", + }); + return shellExecutable + ? { executable: shellExecutable.executable, pathEnv: shellPath } + : direct; }, }; }); @@ -424,6 +443,7 @@ function createGatewayApi(runtime: PluginRuntime, apiConfig: OpenClawConfig = {} beforeEach(() => { nodeHostMocks.runNodePtyCommand.mockClear(); + nodeHostMocks.userShellPaths.clear(); commandRpcMocks.codexControlRequest.mockReset(); pinnedConnectionMocks.getClient.mockReset(); pinnedConnectionMocks.getClient.mockResolvedValue(pinnedConnectionMocks.client); @@ -3743,7 +3763,8 @@ describe("Codex supervision actions", () => { const threadId = "123e4567-e89b-12d3-a456-426614174000"; const binDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-terminal-")); tempDirs.push(binDir); - process.env.PATH = binDir; + process.env.PATH = ""; + nodeHostMocks.userShellPaths.set("codex", binDir); let now = Date.now(); vi.spyOn(Date, "now").mockImplementation(() => now); const executable = path.join(binDir, process.platform === "win32" ? "codex.cmd" : "codex"); @@ -3819,6 +3840,20 @@ describe("Codex supervision actions", () => { await expect( getProvider()?.openTerminal?.({ hostId: CODEX_LOCAL_SESSION_HOST_ID, threadId }), ).rejects.toThrow("Codex CLI is unavailable"); + await expect( + getProvider()?.startTerminalSession?.({ + agentId: "main", + cwd: "/workspace/new", + initialMessage: "--help", + }), + ).rejects.toThrow("install Codex or add codex to PATH"); + await expect( + getProvider()?.startTerminalSession?.({ + agentId: "main", + cwd: "/workspace/node-new", + nodeId: "devbox", + }), + ).rejects.toThrow("omit hostId to start on the gateway host"); await fs.writeFile(executable, process.platform === "win32" ? "@echo off\r\n" : "#!/bin/sh\n"); if (process.platform !== "win32") { @@ -3843,10 +3878,37 @@ describe("Codex supervision actions", () => { kind: "local", argv: [executable, "resume", threadId], cwd: "/workspace/local", + pathEnv: binDir, env: { CODEX_HOME: resolveCodexAppServerHomeDir(resolveDefaultAgentDir(config)), }, }); + await expect( + getProvider()?.startTerminalSession?.({ + agentId: "main", + cwd: "/workspace/new", + initialMessage: "--help", + }), + ).resolves.toEqual({ + kind: "local", + argv: [executable, "--", "--help"], + cwd: "/workspace/new", + env: { + CODEX_HOME: resolveCodexAppServerHomeDir(resolveDefaultAgentDir(config)), + }, + pathEnv: binDir, + title: "codex", + }); + await expect( + getProvider()?.startTerminalSession?.({ + agentId: "main", + cwd: "/workspace/command-prompt", + initialMessage: "resume", + }), + ).resolves.toMatchObject({ argv: [executable, "--", "resume"] }); + await expect( + getProvider()?.startTerminalSession?.({ agentId: "main", cwd: "/workspace/blank" }), + ).resolves.toMatchObject({ argv: [executable], cwd: "/workspace/blank" }); pluginConfig = { appServer: { homeScope: "user" } }; await expect( getProvider()?.openTerminal?.({ hostId: CODEX_LOCAL_SESSION_HOST_ID, threadId }), diff --git a/extensions/codex/src/session-catalog.ts b/extensions/codex/src/session-catalog.ts index de30aa5cbf0e..74498d2ae458 100644 --- a/extensions/codex/src/session-catalog.ts +++ b/extensions/codex/src/session-catalog.ts @@ -101,6 +101,7 @@ import { openCodexCatalogTerminal, requireCatalogEligibleThread, resolveLocalCodexTerminalExecutable, + startCodexCatalogTerminal, type CodexTerminalConfigSources, } from "./session-catalog-terminal.js"; import { toGenericTranscriptItem } from "./session-catalog-transcript-item.js"; @@ -1539,6 +1540,12 @@ function registerCodexSessionCatalog(params: { parseCatalogPage, ...request, }), + startTerminalSession: (request) => + startCodexCatalogTerminal({ + getPluginConfig: params.getPluginConfig, + getRuntimeConfig: params.getRuntimeConfig, + ...request, + }), }; params.api.registerSessionCatalog(provider); } diff --git a/packages/gateway-protocol/src/index.test.ts b/packages/gateway-protocol/src/index.test.ts index 0a25fc89a6fa..4843ef17ddab 100644 --- a/packages/gateway-protocol/src/index.test.ts +++ b/packages/gateway-protocol/src/index.test.ts @@ -49,6 +49,7 @@ import type { ConfigSchemaLookupParams, ModelsListParams, SessionsCatalogListParams, + SessionsCatalogStartTerminalParams, TalkEvent, } from "./index.js"; import * as schemaExportRegistry from "./schema-export-registry.js"; @@ -122,6 +123,7 @@ describe("protocol export registries", () => { expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); }); diff --git a/packages/gateway-protocol/src/public-session-catalog.ts b/packages/gateway-protocol/src/public-session-catalog.ts index 04894c6ff028..84820058fede 100644 --- a/packages/gateway-protocol/src/public-session-catalog.ts +++ b/packages/gateway-protocol/src/public-session-catalog.ts @@ -17,4 +17,6 @@ export type { SessionsCatalogHostEvent, SessionsCatalogReadParams, SessionsCatalogReadResult, + SessionsCatalogStartTerminalParams, + SessionsCatalogStartTerminalResult, } from "./schema/sessions-catalog.js"; diff --git a/packages/gateway-protocol/src/schema-export-registry.ts b/packages/gateway-protocol/src/schema-export-registry.ts index 7009ae64b077..35b2e273fcaf 100644 --- a/packages/gateway-protocol/src/schema-export-registry.ts +++ b/packages/gateway-protocol/src/schema-export-registry.ts @@ -148,6 +148,8 @@ export { SessionsCatalogContinueResultSchema, SessionsCatalogArchiveParamsSchema, SessionsCatalogArchiveResultSchema, + SessionsCatalogStartTerminalParamsSchema, + SessionsCatalogStartTerminalResultSchema, SessionsSearchHitSchema, SessionsSearchParamsSchema, SessionsSearchResultSchema, diff --git a/packages/gateway-protocol/src/schema/protocol-schema-fragment-sessions-core.ts b/packages/gateway-protocol/src/schema/protocol-schema-fragment-sessions-core.ts index c306a49800e4..7d7ef77c4434 100644 --- a/packages/gateway-protocol/src/schema/protocol-schema-fragment-sessions-core.ts +++ b/packages/gateway-protocol/src/schema/protocol-schema-fragment-sessions-core.ts @@ -18,6 +18,8 @@ export const SessionCoreProtocolSchemas = { SessionsCatalogContinueResult: sessionsCatalog.SessionsCatalogContinueResultSchema, SessionsCatalogArchiveParams: sessionsCatalog.SessionsCatalogArchiveParamsSchema, SessionsCatalogArchiveResult: sessionsCatalog.SessionsCatalogArchiveResultSchema, + SessionsCatalogStartTerminalParams: sessionsCatalog.SessionsCatalogStartTerminalParamsSchema, + SessionsCatalogStartTerminalResult: sessionsCatalog.SessionsCatalogStartTerminalResultSchema, SessionsCleanupParams: sessions.SessionsCleanupParamsSchema, SessionsPreviewParams: sessions.SessionsPreviewParamsSchema, SessionsDescribeParams: sessions.SessionsDescribeParamsSchema, diff --git a/packages/gateway-protocol/src/schema/sessions-catalog.test.ts b/packages/gateway-protocol/src/schema/sessions-catalog.test.ts index 64716d8b0970..7c806a51eb1b 100644 --- a/packages/gateway-protocol/src/schema/sessions-catalog.test.ts +++ b/packages/gateway-protocol/src/schema/sessions-catalog.test.ts @@ -4,6 +4,8 @@ import { SessionsCatalogHostEventSchema, SessionsCatalogListParamsSchema, SessionsCatalogListResultSchema, + SessionsCatalogStartTerminalParamsSchema, + SessionsCatalogStartTerminalResultSchema, } from "./sessions-catalog.js"; describe("SessionsCatalogListResultSchema", () => { @@ -17,7 +19,10 @@ describe("SessionsCatalogListResultSchema", () => { capabilities: { continueSession: true, archive: false, - createSession: { model: "anthropic/claude-opus-4-8" }, + createSession: { + model: "anthropic/claude-opus-4-8", + startTerminal: true, + }, openTerminal: true, }, hosts: [ @@ -46,6 +51,35 @@ describe("SessionsCatalogListResultSchema", () => { }); }); +describe("SessionsCatalogStartTerminal schemas", () => { + it("accepts the terminal start contract and rejects unknown fields", () => { + const params = { + catalogId: "codex", + hostId: "gateway:local", + agentId: "main", + cwd: "/tmp/worktree", + initialMessage: "Inspect the failing test", + }; + const result = { + sessionId: "terminal-1", + agentId: "main", + shell: "/bin/zsh", + cwd: "/tmp/worktree", + confined: false, + title: "Codex", + }; + + expect(Value.Check(SessionsCatalogStartTerminalParamsSchema, params)).toBe(true); + expect( + Value.Check(SessionsCatalogStartTerminalParamsSchema, { ...params, unexpected: true }), + ).toBe(false); + expect(Value.Check(SessionsCatalogStartTerminalResultSchema, result)).toBe(true); + expect( + Value.Check(SessionsCatalogStartTerminalResultSchema, { ...result, unexpected: true }), + ).toBe(false); + }); +}); + describe("SessionsCatalogListParamsSchema", () => { it("accepts an optional progressive stream id without a catalog selector", () => { expect( diff --git a/packages/gateway-protocol/src/schema/sessions-catalog.ts b/packages/gateway-protocol/src/schema/sessions-catalog.ts index d1fce8b6ad17..ec18d7792cae 100644 --- a/packages/gateway-protocol/src/schema/sessions-catalog.ts +++ b/packages/gateway-protocol/src/schema/sessions-catalog.ts @@ -16,7 +16,12 @@ export const SessionCatalogLocatorSchema = closedObject({ export const SessionCatalogCapabilitiesSchema = closedObject({ continueSession: Type.Boolean(), archive: Type.Boolean(), - createSession: Type.Optional(closedObject({ model: NonEmptyString })), + createSession: Type.Optional( + closedObject({ + model: NonEmptyString, + startTerminal: Type.Optional(Type.Boolean()), + }), + ), openTerminal: Type.Optional(Type.Boolean()), }); @@ -154,6 +159,24 @@ export const SessionsCatalogArchiveParamsSchema = closedObject({ export const SessionsCatalogArchiveResultSchema = closedObject({ ok: Type.Literal(true) }); +export const SessionsCatalogStartTerminalParamsSchema = closedObject({ + catalogId: NonEmptyString, + hostId: Type.Optional(NonEmptyString), + agentId: NonEmptyString, + cwd: NonEmptyString, + initialMessage: Type.Optional(Type.String()), +}); + +// Mirrors terminal.open so callers can hand the new session to the same terminal UI. +export const SessionsCatalogStartTerminalResultSchema = closedObject({ + sessionId: NonEmptyString, + agentId: NonEmptyString, + shell: NonEmptyString, + cwd: NonEmptyString, + confined: Type.Boolean(), + title: Type.Optional(NonEmptyString), +}); + export type SessionCatalogCapabilities = Static; export type SessionCatalogLocator = Static; export type SessionCatalogDescriptor = Static; @@ -173,3 +196,9 @@ export type SessionsCatalogContinueParams = Static; export type SessionsCatalogArchiveParams = Static; export type SessionsCatalogArchiveResult = Static; +export type SessionsCatalogStartTerminalParams = Static< + typeof SessionsCatalogStartTerminalParamsSchema +>; +export type SessionsCatalogStartTerminalResult = Static< + typeof SessionsCatalogStartTerminalResultSchema +>; diff --git a/packages/gateway-protocol/src/validator-registry.ts b/packages/gateway-protocol/src/validator-registry.ts index 2a184833ec85..c378a70378ff 100644 --- a/packages/gateway-protocol/src/validator-registry.ts +++ b/packages/gateway-protocol/src/validator-registry.ts @@ -173,6 +173,9 @@ export const validateSessionsCatalogListParams = compile(S.SessionsCatalogListPa export const validateSessionsCatalogReadParams = compile(S.SessionsCatalogReadParamsSchema); export const validateSessionsCatalogContinueParams = compile(S.SessionsCatalogContinueParamsSchema); export const validateSessionsCatalogArchiveParams = compile(S.SessionsCatalogArchiveParamsSchema); +export const validateSessionsCatalogStartTerminalParams = compile( + S.SessionsCatalogStartTerminalParamsSchema, +); export const validateSessionsSearchParams = compile(S.SessionsSearchParamsSchema); export const validateSessionsCleanupParams = compile(S.SessionsCleanupParamsSchema); export const validateSessionsPreviewParams = compile(S.SessionsPreviewParamsSchema); diff --git a/scripts/plugin-sdk-surface-report.mts b/scripts/plugin-sdk-surface-report.mts index a9778c9ac538..8a335c051eb0 100644 --- a/scripts/plugin-sdk-surface-report.mts +++ b/scripts/plugin-sdk-surface-report.mts @@ -248,7 +248,8 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env // +4: focused agent scope functions for doctor migration enumeration. // +1: shared transcript credential-safety prompt for plugin-owned agent harnesses. // +3: channel streaming config reader re-exports and session-agent scope resolver. - 4844, + // +3: session-catalog terminal-start provider request and Gateway params/result contracts. + 4847, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index 2b5c73e22740..34e1988f1c00 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -82,6 +82,7 @@ describe("method scope resolution", () => { ["sessions.catalog.read", ["operator.read"]], ["sessions.catalog.continue", ["operator.write"]], ["sessions.catalog.archive", ["operator.write"]], + ["sessions.catalog.startTerminal", ["operator.admin"]], ["session.discussion.info", ["operator.read"]], ["session.discussion.open", ["operator.write"]], ["environments.status", ["operator.read"]], diff --git a/src/gateway/methods/core-descriptors.since.test.ts b/src/gateway/methods/core-descriptors.since.test.ts index 24318472ff95..9c1fb043f896 100644 --- a/src/gateway/methods/core-descriptors.since.test.ts +++ b/src/gateway/methods/core-descriptors.since.test.ts @@ -96,5 +96,8 @@ describe("core gateway method release trains", () => { .toSorted(), ).toEqual(CURRENT_TRAIN_METHODS.toSorted()); expect(methods.find((method) => method.name === "update.hold")?.since).toBe("2026.8"); + expect(methods.find((method) => method.name === "sessions.catalog.startTerminal")?.since).toBe( + "2026.8", + ); }); }); diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index f3c512d7605a..dead84830982 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -489,6 +489,8 @@ const CORE_GATEWAY_METHOD_SPECS = [ ["sessions.patchMany", "sessions-mutations", "dynamic", "2026.8"], // Update campaign mutations share update.run's admin and control-plane write policy. ["update.hold", "update", "operator.admin", "2026.8", { controlPlaneWrite: true }], + // Additive catalog terminal start appends so older advertised indices stay stable. + ["sessions.catalog.startTerminal", "session-catalog", "operator.admin", "2026.8"], ] as const satisfies readonly CoreGatewayMethodSpecRow[]; export type CoreGatewayHandlerFamily = Exclude<(typeof CORE_GATEWAY_METHOD_SPECS)[number][1], null>; diff --git a/src/gateway/server-methods-list.test.ts b/src/gateway/server-methods-list.test.ts index 5cf179677248..a6ac136eb80c 100644 --- a/src/gateway/server-methods-list.test.ts +++ b/src/gateway/server-methods-list.test.ts @@ -66,7 +66,7 @@ describe("listGatewayMethods", () => { }); it("appends new methods after model probing without shifting older method indices", () => { - expect(listGatewayMethods().slice(-33)).toEqual([ + expect(listGatewayMethods().slice(-34)).toEqual([ "models.probe", "migrations.memory.plan", "migrations.memory.apply", @@ -100,6 +100,7 @@ describe("listGatewayMethods", () => { "audit.run.inspect", "sessions.patchMany", "update.hold", + "sessions.catalog.startTerminal", ]); const methods = listGatewayMethods(); expect(methods.indexOf("node.pluginSurface.refresh")).toBe( @@ -172,7 +173,7 @@ describe("listGatewayMethods", () => { "exec.approval.get", ]); expect(methods).toContain("tts.speak"); - expect(coreMethods.slice(-40)).toEqual([ + expect(coreMethods.slice(-41)).toEqual([ "sessions.catalog.continue", "sessions.catalog.archive", "approval.get", @@ -213,12 +214,16 @@ describe("listGatewayMethods", () => { "audit.run.inspect", "sessions.patchMany", "update.hold", + "sessions.catalog.startTerminal", ]); expect(methods.indexOf("approval.get")).toBeGreaterThan(methods.indexOf("tts.speak")); expect(methods.indexOf("approval.resolve")).toBe(methods.indexOf("approval.get") + 1); expect(methods.indexOf("audit.run.inspect")).toBe(methods.indexOf("tasks.dismiss") + 1); expect(methods.indexOf("sessions.patchMany")).toBe(methods.indexOf("audit.run.inspect") + 1); expect(methods.indexOf("update.hold")).toBe(methods.indexOf("sessions.patchMany") + 1); + expect(methods.indexOf("sessions.catalog.startTerminal")).toBe( + methods.indexOf("update.hold") + 1, + ); }); it("advertises the versioned Talk session RPCs", () => { diff --git a/src/gateway/server-methods/session-catalog-entry-snapshot.test.ts b/src/gateway/server-methods/session-catalog-entry-snapshot.test.ts new file mode 100644 index 000000000000..a900ad50cd13 --- /dev/null +++ b/src/gateway/server-methods/session-catalog-entry-snapshot.test.ts @@ -0,0 +1,173 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js"; +import type { PluginRegistry } from "../../plugins/registry-types.js"; +import { createPluginRuntime } from "../../plugins/runtime/index.js"; +import { + listSessionCatalogEntries, + type SessionCatalogProvider, +} from "../../plugins/session-catalog.js"; + +type TestPluginRegistry = Omit & { + sessionCatalogs: Array<{ provider: SessionCatalogProvider }>; +}; + +const hoisted = vi.hoisted(() => ({ + activeRegistry: {} as TestPluginRegistry, + listSessionEntriesReadOnly: vi.fn< + (scope?: { agentId?: string; clone?: boolean; projection?: "full" | "list" }) => Array<{ + sessionKey: string; + entry: { + createdActor?: { type: "human" | "agent" | "system"; id?: string }; + updatedAt?: number; + }; + }> + >(() => []), +})); + +vi.mock("../../plugins/runtime.js", () => ({ + getActivePluginRegistry: () => hoisted.activeRegistry, +})); +vi.mock("../../config/sessions/session-accessor.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, listSessionEntriesReadOnly: hoisted.listSessionEntriesReadOnly }; +}); + +const { sessionCatalogHandlers } = await import("./session-catalog.js"); + +function provider(id: string, sessionKey: string): SessionCatalogProvider { + return { + id, + label: id.toUpperCase(), + read: vi.fn(async ({ hostId, threadId }) => ({ hostId, threadId, items: [] })), + list: vi.fn(async ({ sessionEntries }) => { + const entries = listSessionCatalogEntries({ + config: {}, + runtime: createPluginRuntime(), + sessionEntries, + }); + const adopted = entries.find((candidate) => candidate.sessionKey === sessionKey); + return [ + { + hostId: `gateway:${id}`, + label: `${id} host`, + kind: "gateway" as const, + connected: true, + sessions: adopted + ? [ + { + threadId: `${id}-thread`, + status: "stored" as const, + archived: false, + sessionKey: adopted.sessionKey, + canContinue: true, + canArchive: false, + }, + ] + : [], + }, + ]; + }), + }; +} + +describe("session catalog entry snapshots", () => { + beforeEach(() => { + hoisted.activeRegistry = createEmptyPluginRegistry() as TestPluginRegistry; + hoisted.listSessionEntriesReadOnly.mockReset(); + }); + + it("shares one flattened entry snapshot across catalogs and creator projection", async () => { + hoisted.listSessionEntriesReadOnly.mockReturnValue([ + { + sessionKey: "agent:main:alpha-adopted", + entry: { createdActor: { type: "agent", id: "worker-alpha" }, updatedAt: 2 }, + }, + { + sessionKey: "agent:main:zeta-adopted", + entry: { createdActor: { type: "system", id: "scheduler" }, updatedAt: 1 }, + }, + ]); + const flattenedEntries: unknown[] = []; + for (const catalog of [ + provider("zeta", "agent:main:zeta-adopted"), + provider("alpha", "agent:main:alpha-adopted"), + ]) { + const list = catalog.list; + catalog.list = vi.fn(async (params) => { + const result = await list(params); + flattenedEntries.push( + listSessionCatalogEntries({ + config: {}, + runtime: createPluginRuntime(), + sessionEntries: params.sessionEntries, + }), + ); + return result; + }); + hoisted.activeRegistry.sessionCatalogs.push({ provider: catalog }); + } + + const respond = vi.fn(); + await sessionCatalogHandlers["sessions.catalog.list"]?.({ + params: {}, + respond, + context: { getRuntimeConfig: () => ({}) }, + } as never); + + expect(hoisted.listSessionEntriesReadOnly).toHaveBeenCalledOnce(); + expect(flattenedEntries).toHaveLength(2); + expect(flattenedEntries[0]).toBe(flattenedEntries[1]); + expect(respond).toHaveBeenCalledWith(true, { + catalogs: [ + { + id: "alpha", + label: "ALPHA", + capabilities: { continueSession: false, archive: false }, + hosts: [ + { + hostId: "gateway:alpha", + label: "alpha host", + kind: "gateway", + connected: true, + sessions: [ + { + threadId: "alpha-thread", + status: "stored", + archived: false, + sessionKey: "agent:main:alpha-adopted", + canContinue: true, + canArchive: false, + createdActor: { type: "agent", id: "worker-alpha" }, + }, + ], + }, + ], + }, + { + id: "zeta", + label: "ZETA", + capabilities: { continueSession: false, archive: false }, + hosts: [ + { + hostId: "gateway:zeta", + label: "zeta host", + kind: "gateway", + connected: true, + sessions: [ + { + threadId: "zeta-thread", + status: "stored", + archived: false, + sessionKey: "agent:main:zeta-adopted", + canContinue: true, + canArchive: false, + createdActor: { type: "system", id: "scheduler" }, + }, + ], + }, + ], + }, + ], + }); + }); +}); diff --git a/src/gateway/server-methods/session-catalog-terminal-start.test.ts b/src/gateway/server-methods/session-catalog-terminal-start.test.ts new file mode 100644 index 000000000000..359ea6d6c1a8 --- /dev/null +++ b/src/gateway/server-methods/session-catalog-terminal-start.test.ts @@ -0,0 +1,358 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { ErrorCodes } from "../../../packages/gateway-protocol/src/index.js"; +import type { SessionCatalogProvider } from "../../plugins/session-catalog.js"; +import { catalogStartHandler } from "./session-catalog-terminal-start.js"; + +function provider(overrides: Partial = {}): SessionCatalogProvider { + return { + id: "codex", + label: "Codex", + list: vi.fn(async () => []), + read: vi.fn(async ({ hostId, threadId }) => ({ hostId, threadId, items: [] })), + ...overrides, + }; +} + +let activeProvider: SessionCatalogProvider; +const resolveCreateTarget = vi.fn((): { ok: true } | { ok: false; message: string } => ({ + ok: true, +})); +const handler = catalogStartHandler( + (catalogId) => (activeProvider.id === catalogId ? activeProvider : undefined), + resolveCreateTarget, +); + +function startCall( + params: unknown, + config: Record = {}, + client?: { connect?: { scopes?: string[] }; connId?: string }, + contextOverrides: Record = {}, +) { + const respond = vi.fn(); + const completion = Promise.resolve( + handler({ + params, + respond, + client, + context: { getRuntimeConfig: () => config, ...contextOverrides }, + } as never), + ); + return { completion, respond }; +} + +async function call( + params: unknown, + config: Record = {}, + client?: { connect?: { scopes?: string[] }; connId?: string }, + contextOverrides: Record = {}, +) { + const pending = startCall(params, config, client, contextOverrides); + await pending.completion; + return pending.respond; +} + +describe("sessions.catalog.startTerminal", () => { + beforeAll(async () => { + await import("./terminal.js"); + }); + + beforeEach(() => { + activeProvider = provider(); + resolveCreateTarget.mockReset(); + resolveCreateTarget.mockReturnValue({ ok: true }); + }); + + it("requires the cliAgents opt-in before terminal start", async () => { + const startTerminalSession = vi.fn(); + activeProvider = provider({ startTerminalSession }); + + const respond = await call({ + catalogId: "codex", + agentId: "main", + cwd: process.cwd(), + }); + + expect(startTerminalSession).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + code: ErrorCodes.UNAVAILABLE, + message: "CLI agent terminal start is disabled; enable gateway.cliAgents.enabled and retry", + }), + ); + }); + + it("refuses terminal start when the terminal is disabled", async () => { + const startTerminalSession = vi.fn(); + activeProvider = provider({ startTerminalSession }); + + const respond = await call( + { catalogId: "codex", agentId: "main", cwd: process.cwd() }, + { gateway: { cliAgents: { enabled: true } } }, + { connId: "conn-1" }, + { isTerminalEnabled: () => false }, + ); + + expect(startTerminalSession).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + code: ErrorCodes.UNAVAILABLE, + message: "terminal is disabled; enable gateway.terminal.enabled and retry", + }), + ); + }); + + it("refuses missing local cwd instead of falling back to home", async () => { + const startTerminalSession = vi.fn(); + activeProvider = provider({ startTerminalSession }); + + const respond = await call( + { catalogId: "codex", agentId: "main", cwd: "relative/missing" }, + { gateway: { cliAgents: { enabled: true } } }, + { connId: "conn-1" }, + { isTerminalEnabled: () => true, terminalSessions: {} }, + ); + + expect(startTerminalSession).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + code: ErrorCodes.INVALID_REQUEST, + message: + "cwd must be an existing absolute directory; create or choose a worktree and retry", + }), + ); + }); + + it("rechecks local cwd after the provider plan resolves", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-catalog-start-")); + let releasePlan!: () => void; + const planGate = new Promise((resolve) => { + releasePlan = resolve; + }); + const startTerminalSession = vi.fn(async () => { + await planGate; + return { kind: "local" as const, argv: ["codex"], cwd }; + }); + const open = vi.fn(); + activeProvider = provider({ startTerminalSession }); + + try { + const pending = startCall( + { catalogId: "codex", agentId: "main", cwd }, + { gateway: { cliAgents: { enabled: true } } }, + { connId: "conn-1", connect: { scopes: ["operator.admin"] } }, + { + isTerminalEnabled: () => true, + terminalSessions: { open }, + resolveTerminalLaunchPolicy: () => ({ + ok: true, + plan: { agentId: "main", cwd: "/agent/workspace", shell: "/bin/zsh", args: [] }, + }), + isConnectionActive: () => true, + }, + ); + await vi.waitFor(() => expect(startTerminalSession).toHaveBeenCalledOnce()); + await fs.rm(cwd, { recursive: true }); + releasePlan(); + await pending.completion; + + expect(open).not.toHaveBeenCalled(); + expect(pending.respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + code: ErrorCodes.INVALID_REQUEST, + message: expect.stringContaining( + "cwd is no longer available; recreate or choose the worktree and retry", + ), + }), + ); + } finally { + releasePlan(); + await fs.rm(cwd, { recursive: true, force: true }); + } + }); + + it("keeps the terminal recovery hint on provider errors", async () => { + const cwd = process.cwd(); + const startTerminalSession = vi.fn(async () => { + throw new Error("provider failed to build a start plan"); + }); + const open = vi.fn(); + activeProvider = provider({ startTerminalSession }); + + const respond = await call( + { catalogId: "codex", agentId: "main", cwd }, + { gateway: { cliAgents: { enabled: true } } }, + { connId: "conn-1", connect: { scopes: ["operator.admin"] } }, + { + isTerminalEnabled: () => true, + terminalSessions: { open }, + resolveTerminalLaunchPolicy: () => ({ + ok: true, + plan: { agentId: "main", cwd: "/agent/workspace", shell: "/bin/zsh", args: [] }, + }), + }, + ); + + expect(open).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + code: ErrorCodes.INVALID_REQUEST, + message: + "provider failed to build a start plan; check the selected CLI, host, and terminal configuration, then retry", + }), + ); + }); + + it("reuses terminal.open admission and manager ownership for terminal start", async () => { + const cwd = process.cwd(); + const startTerminalSession = vi.fn(async () => ({ + kind: "local" as const, + argv: ["codex", "--", "Inspect the failing test"], + cwd, + title: "Codex", + env: { CODEX_HOME: "/tmp/codex-home" }, + pathEnv: "/usr/local/bin:/usr/bin:/bin", + })); + const open = vi.fn(async () => ({ + ok: true as const, + sessionId: "terminal-1", + agentId: "research", + cwd, + shell: "/bin/zsh", + })); + activeProvider = provider({ startTerminalSession }); + + const config = { gateway: { cliAgents: { enabled: true } } }; + const respond = await call( + { + catalogId: "codex", + hostId: "gateway:local", + agentId: "research", + cwd, + initialMessage: "Inspect the failing test", + }, + config, + { connId: "conn-1", connect: { scopes: ["operator.admin"] } }, + { + isTerminalEnabled: () => true, + terminalSessions: { open }, + resolveTerminalLaunchPolicy: () => ({ + ok: true, + plan: { agentId: "research", cwd: "/agent/workspace", shell: "/bin/zsh", args: [] }, + }), + isConnectionActive: () => true, + logGateway: { info: vi.fn() }, + }, + ); + + expect(resolveCreateTarget).toHaveBeenCalledWith("codex", "research", config); + expect(startTerminalSession).toHaveBeenCalledWith({ + agentId: "research", + cwd, + initialMessage: "Inspect the failing test", + }); + expect(open).toHaveBeenCalledWith( + expect.objectContaining({ + owner: { kind: "conn", connId: "conn-1" }, + agentId: "research", + cwd, + shell: "/bin/zsh", + args: ["-il", "-c", "'codex' '--' 'Inspect the failing test'"], + cols: 80, + rows: 24, + env: expect.objectContaining({ + CODEX_HOME: "/tmp/codex-home", + PATH: "/usr/local/bin:/usr/bin:/bin", + }), + }), + ); + expect(respond).toHaveBeenCalledWith(true, { + sessionId: "terminal-1", + agentId: "research", + cwd, + shell: "/bin/zsh", + confined: false, + title: "Codex", + }); + }); + + it("does not fall back to local when a node host was requested", async () => { + const startTerminalSession = vi.fn(async ({ cwd }: { cwd: string }) => ({ + kind: "local" as const, + argv: ["codex"], + cwd, + })); + const open = vi.fn(); + activeProvider = provider({ startTerminalSession }); + + const respond = await call( + { catalogId: "codex", hostId: "node:remote", agentId: "main", cwd: "/remote/worktree" }, + { gateway: { cliAgents: { enabled: true } } }, + { connId: "conn-1", connect: { scopes: ["operator.admin"] } }, + { + isTerminalEnabled: () => true, + terminalSessions: { open }, + resolveTerminalLaunchPolicy: () => ({ + ok: true, + plan: { agentId: "main", cwd: "/agent/workspace", shell: "/bin/zsh", args: [] }, + }), + }, + ); + + expect(startTerminalSession).toHaveBeenCalledWith({ + agentId: "main", + cwd: "/remote/worktree", + nodeId: "remote", + }); + expect(open).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + code: ErrorCodes.INVALID_REQUEST, + message: expect.stringContaining("cannot start on the selected node"), + }), + ); + }); + + it("refuses sandboxed agents before requesting a terminal plan", async () => { + const startTerminalSession = vi.fn(); + activeProvider = provider({ startTerminalSession }); + + const respond = await call( + { catalogId: "codex", agentId: "locked", cwd: process.cwd() }, + { gateway: { cliAgents: { enabled: true } } }, + { connId: "conn-1", connect: { scopes: ["operator.admin"] } }, + { + isTerminalEnabled: () => true, + terminalSessions: { open: vi.fn() }, + resolveTerminalLaunchPolicy: () => ({ + ok: false, + block: { kind: "sandboxed", agentId: "locked", mode: "all" }, + }), + }, + ); + + expect(startTerminalSession).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + code: ErrorCodes.INVALID_REQUEST, + message: expect.stringContaining('agent "locked" runs in a sandbox'), + }), + ); + }); +}); diff --git a/src/gateway/server-methods/session-catalog-terminal-start.ts b/src/gateway/server-methods/session-catalog-terminal-start.ts new file mode 100644 index 000000000000..ada13472495b --- /dev/null +++ b/src/gateway/server-methods/session-catalog-terminal-start.ts @@ -0,0 +1,170 @@ +import { statSync } from "node:fs"; +import path from "node:path"; +import { + ErrorCodes, + errorShape, + type SessionsCatalogStartTerminalParams, + validateSessionsCatalogStartTerminalParams, +} from "../../../packages/gateway-protocol/src/index.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { SessionCatalogProvider } from "../../plugins/session-catalog.js"; +import type { GatewayRequestHandlers } from "./types.js"; +import { assertValidParams } from "./validation.js"; + +type SessionCatalogProviderResolver = (catalogId: string) => SessionCatalogProvider | undefined; +type SessionCatalogCreateTargetResolver = ( + catalogId: string, + agentId: string, + config: OpenClawConfig, +) => { ok: true } | { ok: false; message: string }; + +/** Builds the catalog terminal-start handler around the active provider registry. */ +export function catalogStartHandler( + resolveProvider: SessionCatalogProviderResolver, + resolveCreateTarget: SessionCatalogCreateTargetResolver, +): GatewayRequestHandlers["sessions.catalog.startTerminal"] { + return async (opts) => { + const { params, respond, context } = opts; + if ( + !assertValidParams( + params, + validateSessionsCatalogStartTerminalParams, + "sessions.catalog.startTerminal", + respond, + ) + ) { + return; + } + const request = params as SessionsCatalogStartTerminalParams; + const config = context.getRuntimeConfig(); + if (config.gateway?.cliAgents?.enabled !== true) { + respond( + false, + undefined, + errorShape( + ErrorCodes.UNAVAILABLE, + "CLI agent terminal start is disabled; enable gateway.cliAgents.enabled and retry", + ), + ); + return; + } + if (!context.isTerminalEnabled()) { + respond( + false, + undefined, + errorShape( + ErrorCodes.UNAVAILABLE, + "terminal is disabled; enable gateway.terminal.enabled and retry", + ), + ); + return; + } + if (!context.terminalSessions) { + respond( + false, + undefined, + errorShape( + ErrorCodes.UNAVAILABLE, + "terminal is not available; restart the Gateway with terminal support and retry", + ), + ); + return; + } + const provider = resolveProvider(request.catalogId); + if (!provider) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `unknown session catalog: ${request.catalogId}`), + ); + return; + } + if (!provider.startTerminalSession) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "session catalog cannot start terminal sessions; choose a catalog that advertises createSession.startTerminal", + ), + ); + return; + } + const createTarget = resolveCreateTarget(request.catalogId, request.agentId, config); + if (!createTarget.ok) { + respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, createTarget.message)); + return; + } + let nodeId: string | undefined; + if (request.hostId && request.hostId !== "gateway:local") { + nodeId = request.hostId.startsWith("node:") + ? request.hostId.slice("node:".length).trim() + : undefined; + if (!nodeId || request.hostId !== `node:${nodeId}`) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + 'invalid catalog host; choose "gateway:local" or a listed "node:" host and retry', + ), + ); + return; + } + } + if (!nodeId) { + let cwdIsDirectory = false; + try { + cwdIsDirectory = path.isAbsolute(request.cwd) && statSync(request.cwd).isDirectory(); + } catch { + // The caller owns worktree provisioning; missing/unreadable paths must not fall back home. + } + if (!cwdIsDirectory) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "cwd must be an existing absolute directory; create or choose a worktree and retry", + ), + ); + return; + } + } + const startTerminalSession = provider.startTerminalSession; + const { openTerminalSession, CATALOG_TERMINAL_INITIAL_SIZE } = await import("./terminal.js"); + await openTerminalSession(opts, { + agentId: request.agentId, + ...CATALOG_TERMINAL_INITIAL_SIZE, + ...(!nodeId ? { requiredCwd: request.cwd } : {}), + failureHint: "check the selected CLI, host, and terminal configuration, then retry", + resolveCatalogPlan: async () => { + const plan = await startTerminalSession.call(provider, { + agentId: request.agentId, + cwd: request.cwd, + ...(request.initialMessage !== undefined + ? { initialMessage: request.initialMessage } + : {}), + ...(nodeId ? { nodeId } : {}), + }); + if (plan.cwd !== request.cwd) { + throw new Error( + "session catalog did not preserve the requested cwd; choose the worktree again and retry", + ); + } + if (nodeId && (plan.kind !== "node" || plan.nodeId !== nodeId)) { + throw new Error( + "session catalog cannot start on the selected node; choose a supported host and retry", + ); + } + if (!nodeId && plan.kind !== "local") { + throw new Error( + 'session catalog returned a remote plan for the local host; select its "node:" host and retry', + ); + } + return plan; + }, + catalogFailureMessage: "catalog terminal start failed", + }); + }; +} diff --git a/src/gateway/server-methods/session-catalog.test.ts b/src/gateway/server-methods/session-catalog.test.ts index 0e55c7c8d685..01afa03a4db4 100644 --- a/src/gateway/server-methods/session-catalog.test.ts +++ b/src/gateway/server-methods/session-catalog.test.ts @@ -4,10 +4,7 @@ import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js"; import { bindPluginRegistryRuntime } from "../../plugins/registry-runtime-binding.js"; import type { PluginRegistry } from "../../plugins/registry-types.js"; import { createPluginRuntime } from "../../plugins/runtime/index.js"; -import { - listSessionCatalogEntries, - type SessionCatalogProvider, -} from "../../plugins/session-catalog.js"; +import type { SessionCatalogProvider } from "../../plugins/session-catalog.js"; type TestPluginRegistry = Omit & { sessionCatalogs: Array<{ @@ -415,111 +412,6 @@ describe("session catalog Gateway methods", () => { } }); - it("shares one flattened entry snapshot across catalogs and creator projection", async () => { - hoisted.listSessionEntriesReadOnly.mockReturnValue([ - { - sessionKey: "agent:main:alpha-adopted", - entry: { createdActor: { type: "agent", id: "worker-alpha" }, updatedAt: 2 }, - }, - { - sessionKey: "agent:main:zeta-adopted", - entry: { createdActor: { type: "system", id: "scheduler" }, updatedAt: 1 }, - }, - ]); - const flattenedEntries: unknown[] = []; - const runtime = createPluginRuntime(); - const catalogProvider = (id: string, sessionKey: string) => - provider(id, { - list: vi.fn(async ({ sessionEntries }) => { - const entries = listSessionCatalogEntries({ config: {}, runtime, sessionEntries }); - flattenedEntries.push(entries); - const adopted = entries.find((candidate) => candidate.sessionKey === sessionKey); - return [ - { - hostId: `gateway:${id}`, - label: `${id} host`, - kind: "gateway" as const, - connected: true, - sessions: adopted - ? [ - { - threadId: `${id}-thread`, - status: "stored" as const, - archived: false, - sessionKey: adopted.sessionKey, - canContinue: true, - canArchive: false, - }, - ] - : [], - }, - ]; - }), - }); - hoisted.activeRegistry.sessionCatalogs = [ - { provider: catalogProvider("zeta", "agent:main:zeta-adopted") }, - { provider: catalogProvider("alpha", "agent:main:alpha-adopted") }, - ]; - - const respond = await call("sessions.catalog.list", {}); - - expect(hoisted.listSessionEntriesReadOnly).toHaveBeenCalledOnce(); - expect(flattenedEntries).toHaveLength(2); - expect(flattenedEntries[0]).toBe(flattenedEntries[1]); - expect(respond).toHaveBeenCalledWith(true, { - catalogs: [ - { - id: "alpha", - label: "ALPHA", - capabilities: { continueSession: false, archive: false }, - hosts: [ - { - hostId: "gateway:alpha", - label: "alpha host", - kind: "gateway", - connected: true, - sessions: [ - { - threadId: "alpha-thread", - status: "stored", - archived: false, - sessionKey: "agent:main:alpha-adopted", - canContinue: true, - canArchive: false, - createdActor: { type: "agent", id: "worker-alpha" }, - }, - ], - }, - ], - }, - { - id: "zeta", - label: "ZETA", - capabilities: { continueSession: false, archive: false }, - hosts: [ - { - hostId: "gateway:zeta", - label: "zeta host", - kind: "gateway", - connected: true, - sessions: [ - { - threadId: "zeta-thread", - status: "stored", - archived: false, - sessionKey: "agent:main:zeta-adopted", - canContinue: true, - canArchive: false, - createdActor: { type: "system", id: "scheduler" }, - }, - ], - }, - ], - }, - ], - }); - }); - it("shares one lazy Gateway node snapshot across catalog providers", async () => { const dispatchNodeList = vi.fn(async () => ({ nodes: [{ nodeId: "shared-node", connected: true }], @@ -640,6 +532,72 @@ describe("session catalog Gateway methods", () => { }); }); + it("advertises terminal start only inside implemented create capabilities", async () => { + const createTarget = () => ({ model: "openai/gpt-5.6-sol", agentRuntime: "codex" }); + hoisted.activeRegistry.sessionCatalogs = [ + { + provider: provider("codex", { + resolveCreateSession: createTarget, + startTerminalSession: async ({ cwd }) => ({ kind: "local", argv: ["codex"], cwd }), + }), + }, + { + provider: provider("readonly", { resolveCreateSession: createTarget }), + }, + ]; + + const respond = await call("sessions.catalog.list", {}); + + expect(respond).toHaveBeenCalledWith(true, { + catalogs: [ + expect.objectContaining({ + id: "codex", + capabilities: expect.objectContaining({ + createSession: { model: "openai/gpt-5.6-sol", startTerminal: true }, + }), + }), + expect.objectContaining({ + id: "readonly", + capabilities: expect.objectContaining({ + createSession: { model: "openai/gpt-5.6-sol" }, + }), + }), + ], + }); + }); + + it("refuses direct terminal start when the requested agent has no create target", async () => { + const resolveCreateSession = vi.fn(() => undefined); + const startTerminalSession = vi.fn(); + const open = vi.fn(); + hoisted.activeRegistry.sessionCatalogs = [ + { + provider: provider("codex", { resolveCreateSession, startTerminalSession }), + }, + ]; + const config = { gateway: { cliAgents: { enabled: true } } }; + + const respond = await call( + "sessions.catalog.startTerminal", + { catalogId: "codex", agentId: "restricted", cwd: process.cwd() }, + config, + { connId: "conn-1", connect: { scopes: ["operator.admin"] } }, + { isTerminalEnabled: () => true, terminalSessions: { open } }, + ); + + expect(resolveCreateSession).toHaveBeenCalledWith({ agentId: "restricted" }); + expect(startTerminalSession).not.toHaveBeenCalled(); + expect(open).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + code: ErrorCodes.UNAVAILABLE, + message: "session catalog codex cannot create sessions", + }), + ); + }); + it("memoizes a provider's create target until runtime config identity changes", async () => { let createSession: { model: string; agentRuntime: string } | undefined = { model: "anthropic/claude-opus-4-8", diff --git a/src/gateway/server-methods/session-catalog.ts b/src/gateway/server-methods/session-catalog.ts index 8bc33852d49f..baa832523760 100644 --- a/src/gateway/server-methods/session-catalog.ts +++ b/src/gateway/server-methods/session-catalog.ts @@ -32,6 +32,7 @@ import type { GatewayBroadcastToConnIdsFn } from "../server-broadcast-types.js"; import { resolveAgentIdOrRespondError } from "./agent-id-shared.js"; import { createSessionCatalogRequestEntrySnapshot } from "./session-catalog-entry-snapshot.js"; import { SessionCatalogListAdmission } from "./session-catalog-list-admission.js"; +import { catalogStartHandler } from "./session-catalog-terminal-start.js"; import type { GatewayRequestHandlers, RespondFn } from "./types.js"; import { assertValidParams } from "./validation.js"; @@ -410,7 +411,12 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = { const catalogList = await Promise.all( selected.map(async (provider): Promise => { const createTarget = resolveProviderCreateTarget(provider, resolvedAgent.agentId, config); - const createSession = createTarget.ok ? { model: createTarget.target.model } : undefined; + const createSession = createTarget.ok + ? { + model: createTarget.target.model, + ...(provider.startTerminalSession ? { startTerminal: true as const } : {}), + } + : undefined; const onHost = (host: SessionCatalog["hosts"][number]) => { const catalog = catalogResult( provider, @@ -588,6 +594,11 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = { } }, + "sessions.catalog.startTerminal": catalogStartHandler( + resolveSessionCatalogProvider, + resolveSessionCatalogCreateTarget, + ), + "sessions.catalog.archive": async ({ params, respond }) => { if ( !assertValidParams( diff --git a/src/gateway/server-methods/terminal.ts b/src/gateway/server-methods/terminal.ts index 0b89b1c37274..a6941cb31a94 100644 --- a/src/gateway/server-methods/terminal.ts +++ b/src/gateway/server-methods/terminal.ts @@ -62,8 +62,19 @@ function terminalEnabled(context: GatewayRequestHandlerOptions["context"]): bool export { TERMINAL_OPEN_DEADLINE_MS } from "../terminal/open-deadline.js"; -function respondTerminalOpenTimeout(respond: GatewayRequestHandlerOptions["respond"]): void { - respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "terminal open timed out")); +function terminalFailureMessage(message: string, hint?: string): string { + return hint ? `${message}; ${hint}` : message; +} + +function respondTerminalOpenTimeout( + respond: GatewayRequestHandlerOptions["respond"], + hint?: string, +): void { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, terminalFailureMessage("terminal open timed out", hint)), + ); } function parseNodePayload(payload: unknown, payloadJSON?: string | null): unknown { @@ -105,16 +116,24 @@ async function stageNodeTerminalUpload( function respondLaunchBlocked( respond: GatewayRequestHandlerOptions["respond"], block: Extract["block"], + hint?: string, ): void { if (block.kind === "disabled") { - respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "terminal is disabled")); + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, terminalFailureMessage("terminal is disabled", hint)), + ); return; } if (block.kind === "unknown-agent") { respond( false, undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `unknown agent "${block.agentId}"`), + errorShape( + ErrorCodes.INVALID_REQUEST, + terminalFailureMessage(`unknown agent "${block.agentId}"`, hint), + ), ); return; } @@ -124,46 +143,324 @@ function respondLaunchBlocked( undefined, errorShape( ErrorCodes.INVALID_REQUEST, - `terminal unavailable: agent "${block.agentId}" runs in a sandbox (mode "${block.mode}"); in-sandbox terminals are not supported yet`, + terminalFailureMessage( + `terminal unavailable: agent "${block.agentId}" runs in a sandbox (mode "${block.mode}"); in-sandbox terminals are not supported yet`, + hint, + ), ), ); } +// A start RPC has no emulator dimensions yet. Match the Control UI's existing +// fallback grid; terminal.resize replaces it once the new tab is mounted. +export const CATALOG_TERMINAL_INITIAL_SIZE = { cols: 80, rows: 24 } as const; + +type TerminalSessionOpenRequest = { + agentId?: string; + cols: number; + rows: number; + requiredCwd?: string; + resolveCatalogPlan?: () => Promise; + catalogFailureMessage?: string; + failureHint?: string; +}; + +/** Canonical terminal admission and launch path shared by shell, resume, and start RPCs. */ +export async function openTerminalSession( + opts: GatewayRequestHandlerOptions, + request: TerminalSessionOpenRequest, +): Promise { + const { respond, context } = opts; + const connId = requireConnId(opts); + if (!connId) { + return; + } + const manager = context.terminalSessions; + if (!manager) { + respond( + false, + undefined, + errorShape( + ErrorCodes.UNAVAILABLE, + terminalFailureMessage("terminal is not available", request.failureHint), + ), + ); + return; + } + const launch = context.resolveTerminalLaunchPolicy(request.agentId); + if (!launch.ok) { + respondLaunchBlocked(respond, launch.block, request.failureHint); + return; + } + const deadline = createTerminalOpenDeadline(); + + let catalogPlan: SessionCatalogTerminalPlan | undefined; + let title: string | undefined; + let createBackend: (() => ReturnType) | undefined; + let nodeRelay: + | { + plan: Extract; + params: Record; + } + | undefined; + let stageUpload: ((file: TerminalUploadFile) => Promise) | undefined; + if (request.resolveCatalogPlan) { + try { + catalogPlan = await waitForTerminalOpenDeadline(request.resolveCatalogPlan, deadline); + } catch (error) { + if (error instanceof TerminalOpenDeadlineError) { + respondTerminalOpenTimeout(respond, request.failureHint); + return; + } + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + error instanceof Error + ? terminalFailureMessage(error.message, request.failureHint) + : terminalFailureMessage( + request.catalogFailureMessage ?? "catalog terminal open failed", + request.failureHint, + ), + ), + ); + return; + } + title = catalogPlan.title; + if (catalogPlan.kind === "local") { + if (catalogPlan.argv.length === 0) { + invalid( + respond, + terminalFailureMessage("catalog terminal plan has no command", request.failureHint), + ); + return; + } + } else { + const nodeCatalogPlan = catalogPlan; + const access = authorizeCatalogTerminalNode(context, nodeCatalogPlan); + if (!access.ok) { + respond( + false, + undefined, + errorShape( + ErrorCodes.UNAVAILABLE, + terminalFailureMessage(access.message, request.failureHint), + ), + ); + return; + } + let nodeParams: Record; + try { + const parsed = JSON.parse(catalogPlan.paramsJSON) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("invalid params"); + } + nodeParams = { + ...(parsed as Record), + cols: request.cols, + rows: request.rows, + }; + } catch { + invalid( + respond, + terminalFailureMessage("catalog terminal plan has invalid params", request.failureHint), + ); + return; + } + let policyResult: Awaited>; + try { + policyResult = await waitForTerminalOpenDeadline( + () => + applyPluginNodeInvokePolicy({ + context, + client: opts.client, + nodeSession: access.node, + command: nodeCatalogPlan.command, + params: nodeParams, + }), + deadline, + ); + } catch (error) { + if (error instanceof TerminalOpenDeadlineError) { + respondTerminalOpenTimeout(respond, request.failureHint); + return; + } + throw error; + } + if (policyResult && !policyResult.ok) { + respond( + false, + undefined, + errorShape( + ErrorCodes.UNAVAILABLE, + terminalFailureMessage(policyResult.message, request.failureHint), + ), + ); + return; + } + nodeRelay = { plan: nodeCatalogPlan, params: nodeParams }; + stageUpload = async (file) => + await stageNodeTerminalUpload(context, nodeCatalogPlan.nodeId, file); + } + } + + if (context.isConnectionActive?.(connId) === false) { + respond( + false, + undefined, + errorShape( + ErrorCodes.UNAVAILABLE, + terminalFailureMessage("terminal connection closed", request.failureHint), + ), + ); + return; + } + if (!terminalEnabled(context)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.UNAVAILABLE, + terminalFailureMessage("terminal is disabled", request.failureHint), + ), + ); + return; + } + const refreshedLaunch = context.resolveTerminalLaunchPolicy(request.agentId); + if (!refreshedLaunch.ok) { + respondLaunchBlocked(respond, refreshedLaunch.block, request.failureHint); + return; + } + if (nodeRelay) { + const relay = nodeRelay; + const access = authorizeCatalogTerminalNode(context, relay.plan); + if (!access.ok) { + respond( + false, + undefined, + errorShape( + ErrorCodes.UNAVAILABLE, + terminalFailureMessage(access.message, request.failureHint), + ), + ); + return; + } + createBackend = async () => + await createNodeRelayBackend({ + registry: context.nodeRegistry, + nodeId: relay.plan.nodeId, + expectedConnId: access.node.connId, + expectedPairingGeneration: access.node.pairingGeneration, + command: relay.plan.command, + params: relay.params, + }); + } + const spawnPlan = resolveTerminalOpenSpawnPlan(refreshedLaunch.plan, catalogPlan); + if (request.requiredCwd !== undefined && spawnPlan.cwd !== request.requiredCwd) { + invalid( + respond, + terminalFailureMessage( + "cwd is no longer available; recreate or choose the worktree and retry", + request.failureHint, + ), + ); + return; + } + const terminalEnv = + catalogPlan?.kind === "local" + ? mergeProcessEnv([ + buildTerminalEnv(process.env), + catalogPlan.env, + // Preserve the PATH that found a login-shell CLI so env-based shebangs + // can resolve their interpreter inside the spawned terminal process. + catalogPlan.pathEnv ? { PATH: catalogPlan.pathEnv } : undefined, + ]) + : buildTerminalEnv(process.env); + let openingTerminal: ReturnType | undefined; + let outcome: Awaited>; + try { + outcome = await waitForTerminalOpenDeadline(() => { + openingTerminal = manager.open({ + owner: { kind: "conn", connId }, + agentId: spawnPlan.agentId, + cwd: spawnPlan.cwd, + shell: spawnPlan.shell, + args: spawnPlan.args, + cols: request.cols, + rows: request.rows, + env: terminalEnv, + signal: deadline.controller.signal, + ...(createBackend ? { createBackend } : {}), + ...(stageUpload ? { stageUpload } : {}), + }); + return openingTerminal; + }, deadline); + } catch (error) { + if (error instanceof TerminalOpenDeadlineError) { + // The backend can register immediately before deadline arbitration. + // Close a late success by id so timeout never leaves an unreachable PTY. + if (openingTerminal) { + void openingTerminal.then( + (lateOutcome) => { + if (lateOutcome.ok) { + manager.close(connId, lateOutcome.sessionId); + } + }, + () => undefined, + ); + } + respondTerminalOpenTimeout(respond, request.failureHint); + return; + } + throw error; + } + if (!outcome.ok) { + const code = outcome.code === "limit" ? ErrorCodes.INVALID_REQUEST : ErrorCodes.UNAVAILABLE; + respond( + false, + undefined, + errorShape(code, terminalFailureMessage(outcome.message, request.failureHint)), + ); + return; + } + if (context.isConnectionActive?.(connId) === false) { + // A browser deadline can close the socket while PTY creation is still + // finishing. Release the raced session instead of leaving an orphan. + manager.close(connId, outcome.sessionId); + respond( + false, + undefined, + errorShape( + ErrorCodes.UNAVAILABLE, + terminalFailureMessage("terminal connection closed", request.failureHint), + ), + ); + return; + } + context.logGateway.info( + `terminal opened session=${outcome.sessionId} agent=${outcome.agentId} conn=${connId} shell=${outcome.shell}`, + ); + respond(true, { + sessionId: outcome.sessionId, + agentId: outcome.agentId, + shell: outcome.shell, + cwd: outcome.cwd, + confined: false, + ...(title ? { title } : {}), + }); +} + /** Handlers for the operator terminal method family. */ export const terminalHandlers: GatewayRequestHandlers = { ...terminalUploadHandlers, "terminal.open": async (opts) => { - const { params, respond, context } = opts; + const { params, respond } = opts; if (!assertValidParams(params, validateTerminalOpenParams, "terminal.open", respond)) { return; } - const connId = requireConnId(opts); - if (!connId) { - return; - } - const manager = context.terminalSessions; - if (!manager) { - respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "terminal is not available")); - return; - } const p = params as TerminalOpenParams; - const launch = context.resolveTerminalLaunchPolicy(p.agentId); - if (!launch.ok) { - respondLaunchBlocked(respond, launch.block); - return; - } - const deadline = createTerminalOpenDeadline(); - - let catalogPlan: SessionCatalogTerminalPlan | undefined; - let title: string | undefined; - let createBackend: (() => ReturnType) | undefined; - let nodeRelay: - | { - plan: Extract; - params: Record; - } - | undefined; - let stageUpload: ((file: TerminalUploadFile) => Promise) | undefined; + let resolveCatalogPlan: (() => Promise) | undefined; if (p.catalog) { const provider = resolveSessionCatalogProvider(p.catalog.catalogId); if (!provider) { @@ -184,185 +481,18 @@ export const terminalHandlers: GatewayRequestHandlers = { } const openTerminal = provider.openTerminal; const catalog = p.catalog; - try { - catalogPlan = await waitForTerminalOpenDeadline( - () => - openTerminal.call(provider, { - hostId: catalog.hostId, - threadId: catalog.threadId, - }), - deadline, - ); - } catch (error) { - if (error instanceof TerminalOpenDeadlineError) { - respondTerminalOpenTimeout(respond); - return; - } - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - error instanceof Error ? error.message : "catalog terminal open failed", - ), - ); - return; - } - title = catalogPlan.title; - if (catalogPlan.kind === "local") { - if (catalogPlan.argv.length === 0) { - invalid(respond, "catalog terminal plan has no command"); - return; - } - } else { - const nodeCatalogPlan = catalogPlan; - const access = authorizeCatalogTerminalNode(context, nodeCatalogPlan); - if (!access.ok) { - respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, access.message)); - return; - } - let nodeParams: Record; - try { - const parsed = JSON.parse(catalogPlan.paramsJSON) as unknown; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new Error("invalid params"); - } - nodeParams = { ...(parsed as Record), cols: p.cols, rows: p.rows }; - } catch { - invalid(respond, "catalog terminal plan has invalid params"); - return; - } - let policyResult: Awaited>; - try { - policyResult = await waitForTerminalOpenDeadline( - () => - applyPluginNodeInvokePolicy({ - context, - client: opts.client, - nodeSession: access.node, - command: nodeCatalogPlan.command, - params: nodeParams, - }), - deadline, - ); - } catch (error) { - if (error instanceof TerminalOpenDeadlineError) { - respondTerminalOpenTimeout(respond); - return; - } - throw error; - } - if (policyResult && !policyResult.ok) { - respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, policyResult.message)); - return; - } - nodeRelay = { plan: nodeCatalogPlan, params: nodeParams }; - stageUpload = async (file) => - await stageNodeTerminalUpload(context, nodeCatalogPlan.nodeId, file); - } - } - - if (context.isConnectionActive?.(connId) === false) { - respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "terminal connection closed")); - return; - } - if (!terminalEnabled(context)) { - respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "terminal is disabled")); - return; - } - const refreshedLaunch = context.resolveTerminalLaunchPolicy(p.agentId); - if (!refreshedLaunch.ok) { - respondLaunchBlocked(respond, refreshedLaunch.block); - return; - } - if (nodeRelay) { - const relay = nodeRelay; - const access = authorizeCatalogTerminalNode(context, relay.plan); - if (!access.ok) { - respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, access.message)); - return; - } - createBackend = async () => - await createNodeRelayBackend({ - registry: context.nodeRegistry, - nodeId: relay.plan.nodeId, - expectedConnId: access.node.connId, - expectedPairingGeneration: access.node.pairingGeneration, - command: relay.plan.command, - params: relay.params, + resolveCatalogPlan = async () => + await openTerminal.call(provider, { + hostId: catalog.hostId, + threadId: catalog.threadId, }); } - const spawnPlan = resolveTerminalOpenSpawnPlan(refreshedLaunch.plan, catalogPlan); - const terminalEnv = - catalogPlan?.kind === "local" - ? mergeProcessEnv([ - buildTerminalEnv(process.env), - catalogPlan.env, - // Preserve the PATH that found a login-shell CLI so env-based shebangs - // can resolve their interpreter inside the spawned terminal process. - catalogPlan.pathEnv ? { PATH: catalogPlan.pathEnv } : undefined, - ]) - : buildTerminalEnv(process.env); - let openingTerminal: ReturnType | undefined; - let outcome: Awaited>; - try { - outcome = await waitForTerminalOpenDeadline(() => { - openingTerminal = manager.open({ - owner: { kind: "conn", connId }, - agentId: spawnPlan.agentId, - cwd: spawnPlan.cwd, - shell: spawnPlan.shell, - args: spawnPlan.args, - cols: p.cols, - rows: p.rows, - env: terminalEnv, - signal: deadline.controller.signal, - ...(createBackend ? { createBackend } : {}), - ...(stageUpload ? { stageUpload } : {}), - }); - return openingTerminal; - }, deadline); - } catch (error) { - if (error instanceof TerminalOpenDeadlineError) { - // The backend can register immediately before deadline arbitration. - // Close a late success by id so timeout never leaves an unreachable PTY. - if (openingTerminal) { - void openingTerminal.then( - (lateOutcome) => { - if (lateOutcome.ok) { - manager.close(connId, lateOutcome.sessionId); - } - }, - () => undefined, - ); - } - respondTerminalOpenTimeout(respond); - return; - } - throw error; - } - if (!outcome.ok) { - const code = outcome.code === "limit" ? ErrorCodes.INVALID_REQUEST : ErrorCodes.UNAVAILABLE; - respond(false, undefined, errorShape(code, outcome.message)); - return; - } - if (context.isConnectionActive?.(connId) === false) { - // A browser deadline can close the socket while PTY creation is still - // finishing. Release the raced session instead of leaving an orphan. - manager.close(connId, outcome.sessionId); - respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "terminal connection closed")); - return; - } - context.logGateway.info( - `terminal opened session=${outcome.sessionId} agent=${outcome.agentId} conn=${connId} shell=${outcome.shell}`, - ); - respond(true, { - sessionId: outcome.sessionId, - agentId: outcome.agentId, - shell: outcome.shell, - cwd: outcome.cwd, - confined: false, - ...(title ? { title } : {}), + await openTerminalSession(opts, { + ...(p.agentId ? { agentId: p.agentId } : {}), + cols: p.cols, + rows: p.rows, + ...(resolveCatalogPlan ? { resolveCatalogPlan } : {}), + catalogFailureMessage: "catalog terminal open failed", }); }, diff --git a/src/plugin-sdk/session-catalog.ts b/src/plugin-sdk/session-catalog.ts index 1f5e8c4929cd..e7d133886ae1 100644 --- a/src/plugin-sdk/session-catalog.ts +++ b/src/plugin-sdk/session-catalog.ts @@ -7,6 +7,7 @@ export type { SessionCatalogListProviderParams, SessionCatalogProvider, SessionCatalogReadProviderParams, + SessionCatalogStartTerminalProviderParams, SessionCatalogTerminalPlan, SessionUpstreamActivity, SessionUpstreamJsonValue, @@ -40,6 +41,8 @@ export type { SessionsCatalogListResult, SessionsCatalogReadParams, SessionsCatalogReadResult, + SessionsCatalogStartTerminalParams, + SessionsCatalogStartTerminalResult, } from "../../packages/gateway-protocol/src/schema/sessions-catalog.js"; export { deleteSessionUpstreamLink, diff --git a/src/plugins/session-catalog.ts b/src/plugins/session-catalog.ts index 67c8ab50afbb..2abe5d5b9f73 100644 --- a/src/plugins/session-catalog.ts +++ b/src/plugins/session-catalog.ts @@ -34,6 +34,14 @@ export type SessionCatalogContinueProviderParams = Omit< }; export type SessionCatalogArchiveProviderParams = Omit; +export type SessionCatalogStartTerminalProviderParams = { + agentId: string; + cwd: string; + initialMessage?: string; + /** Present only when the caller selected a catalog host backed by this node. */ + nodeId?: string; +}; + export type SessionCatalogTerminalPlan = | { kind: "local"; @@ -156,6 +164,9 @@ export type SessionCatalogProvider = { hostId: string; threadId: string; }) => Promise; + startTerminalSession?: ( + request: SessionCatalogStartTerminalProviderParams, + ) => Promise; }; type SessionCatalogAdoptedSource = { hostId: string; threadId: string };