mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-13 17:07:40 +00:00
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
This commit is contained in:
committed by
GitHub
parent
0fe49c5731
commit
9a96375e60
@@ -510,6 +510,7 @@ enum class GatewayMethod(
|
||||
AuditRunInspect("audit.run.inspect"),
|
||||
SessionsPatchMany("sessions.patchMany"),
|
||||
UpdateHold("update.hold"),
|
||||
SessionsCatalogStartTerminal("sessions.catalog.startTerminal"),
|
||||
}
|
||||
|
||||
enum class GatewayEvent(
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => [],
|
||||
}),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -67,6 +67,34 @@ export function terminalEligibility(
|
||||
};
|
||||
}
|
||||
|
||||
export async function startClaudeCatalogTerminal(params: {
|
||||
cwd: string;
|
||||
initialMessage?: string;
|
||||
nodeId?: string;
|
||||
}): Promise<SessionCatalogTerminalPlan> {
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -275,3 +275,35 @@ export async function openCodexCatalogTerminal(
|
||||
title,
|
||||
};
|
||||
}
|
||||
|
||||
export async function startCodexCatalogTerminal(
|
||||
params: {
|
||||
agentId: string;
|
||||
cwd: string;
|
||||
initialMessage?: string;
|
||||
nodeId?: string;
|
||||
} & CodexTerminalConfigSources,
|
||||
): Promise<SessionCatalogTerminalPlan> {
|
||||
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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ const transcriptMirrorMocks = vi.hoisted(() => ({
|
||||
}));
|
||||
const nodeHostMocks = vi.hoisted(() => ({
|
||||
runNodePtyCommand: vi.fn(async () => ({ exitCode: 0 })),
|
||||
userShellPaths: new Map<string, string>(),
|
||||
}));
|
||||
|
||||
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 }),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<ConfigSchemaLookupParams>().toEqualTypeOf<Schema.ConfigSchemaLookupParams>();
|
||||
expectTypeOf<ModelsListParams>().toEqualTypeOf<Schema.ModelsListParams>();
|
||||
expectTypeOf<SessionsCatalogListParams>().toEqualTypeOf<Schema.SessionsCatalogListParams>();
|
||||
expectTypeOf<SessionsCatalogStartTerminalParams>().toEqualTypeOf<Schema.SessionsCatalogStartTerminalParams>();
|
||||
expectTypeOf<TalkEvent>().toEqualTypeOf<Schema.TalkEvent>();
|
||||
});
|
||||
|
||||
|
||||
@@ -17,4 +17,6 @@ export type {
|
||||
SessionsCatalogHostEvent,
|
||||
SessionsCatalogReadParams,
|
||||
SessionsCatalogReadResult,
|
||||
SessionsCatalogStartTerminalParams,
|
||||
SessionsCatalogStartTerminalResult,
|
||||
} from "./schema/sessions-catalog.js";
|
||||
|
||||
@@ -148,6 +148,8 @@ export {
|
||||
SessionsCatalogContinueResultSchema,
|
||||
SessionsCatalogArchiveParamsSchema,
|
||||
SessionsCatalogArchiveResultSchema,
|
||||
SessionsCatalogStartTerminalParamsSchema,
|
||||
SessionsCatalogStartTerminalResultSchema,
|
||||
SessionsSearchHitSchema,
|
||||
SessionsSearchParamsSchema,
|
||||
SessionsSearchResultSchema,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<typeof SessionCatalogCapabilitiesSchema>;
|
||||
export type SessionCatalogLocator = Static<typeof SessionCatalogLocatorSchema>;
|
||||
export type SessionCatalogDescriptor = Static<typeof SessionCatalogDescriptorSchema>;
|
||||
@@ -173,3 +196,9 @@ export type SessionsCatalogContinueParams = Static<typeof SessionsCatalogContinu
|
||||
export type SessionsCatalogContinueResult = Static<typeof SessionsCatalogContinueResultSchema>;
|
||||
export type SessionsCatalogArchiveParams = Static<typeof SessionsCatalogArchiveParamsSchema>;
|
||||
export type SessionsCatalogArchiveResult = Static<typeof SessionsCatalogArchiveResultSchema>;
|
||||
export type SessionsCatalogStartTerminalParams = Static<
|
||||
typeof SessionsCatalogStartTerminalParamsSchema
|
||||
>;
|
||||
export type SessionsCatalogStartTerminalResult = Static<
|
||||
typeof SessionsCatalogStartTerminalResultSchema
|
||||
>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]],
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<PluginRegistry, "sessionCatalogs"> & {
|
||||
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<typeof import("../../config/sessions/session-accessor.js")>();
|
||||
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" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): 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<string, unknown> = {},
|
||||
client?: { connect?: { scopes?: string[] }; connId?: string },
|
||||
contextOverrides: Record<string, unknown> = {},
|
||||
) {
|
||||
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<string, unknown> = {},
|
||||
client?: { connect?: { scopes?: string[] }; connId?: string },
|
||||
contextOverrides: Record<string, unknown> = {},
|
||||
) {
|
||||
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<void>((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'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
170
src/gateway/server-methods/session-catalog-terminal-start.ts
Normal file
170
src/gateway/server-methods/session-catalog-terminal-start.ts
Normal file
@@ -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:<id>" 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:<id>" host and retry',
|
||||
);
|
||||
}
|
||||
return plan;
|
||||
},
|
||||
catalogFailureMessage: "catalog terminal start failed",
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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<PluginRegistry, "sessionCatalogs"> & {
|
||||
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",
|
||||
|
||||
@@ -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<SessionCatalog> => {
|
||||
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(
|
||||
|
||||
@@ -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<TerminalLaunchResolution, { ok: false }>["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<SessionCatalogTerminalPlan>;
|
||||
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<void> {
|
||||
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<typeof createNodeRelayBackend>) | undefined;
|
||||
let nodeRelay:
|
||||
| {
|
||||
plan: Extract<SessionCatalogTerminalPlan, { kind: "node" }>;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
| undefined;
|
||||
let stageUpload: ((file: TerminalUploadFile) => Promise<TerminalUploadResult>) | 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<string, unknown>;
|
||||
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<string, unknown>),
|
||||
cols: request.cols,
|
||||
rows: request.rows,
|
||||
};
|
||||
} catch {
|
||||
invalid(
|
||||
respond,
|
||||
terminalFailureMessage("catalog terminal plan has invalid params", request.failureHint),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let policyResult: Awaited<ReturnType<typeof applyPluginNodeInvokePolicy>>;
|
||||
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<typeof manager.open> | undefined;
|
||||
let outcome: Awaited<ReturnType<typeof manager.open>>;
|
||||
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<typeof createNodeRelayBackend>) | undefined;
|
||||
let nodeRelay:
|
||||
| {
|
||||
plan: Extract<SessionCatalogTerminalPlan, { kind: "node" }>;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
| undefined;
|
||||
let stageUpload: ((file: TerminalUploadFile) => Promise<TerminalUploadResult>) | undefined;
|
||||
let resolveCatalogPlan: (() => Promise<SessionCatalogTerminalPlan>) | 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<string, unknown>;
|
||||
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<string, unknown>), cols: p.cols, rows: p.rows };
|
||||
} catch {
|
||||
invalid(respond, "catalog terminal plan has invalid params");
|
||||
return;
|
||||
}
|
||||
let policyResult: Awaited<ReturnType<typeof applyPluginNodeInvokePolicy>>;
|
||||
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<typeof manager.open> | undefined;
|
||||
let outcome: Awaited<ReturnType<typeof manager.open>>;
|
||||
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",
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -34,6 +34,14 @@ export type SessionCatalogContinueProviderParams = Omit<
|
||||
};
|
||||
export type SessionCatalogArchiveProviderParams = Omit<SessionsCatalogArchiveParams, "catalogId">;
|
||||
|
||||
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<SessionCatalogTerminalPlan>;
|
||||
startTerminalSession?: (
|
||||
request: SessionCatalogStartTerminalProviderParams,
|
||||
) => Promise<SessionCatalogTerminalPlan>;
|
||||
};
|
||||
|
||||
type SessionCatalogAdoptedSource = { hostId: string; threadId: string };
|
||||
|
||||
Reference in New Issue
Block a user