mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(ui): keep Where picker destinations live (#122531)
* feat(gateway): enrich environment summaries * chore(sdk): refresh plugin API surface surface drift approved: additive EnvironmentSummary enrichment (runners plan rev 2, milestone 4) * feat(ui): keep where picker destinations live * docs: link picker liveness milestone PR * fix(ui): deduplicate picker locale keys * test(ui): update picker section expectations * fix(ssrf): narrow lookup resolver contract * fix(gateway): publish authoritative environment facts
This commit is contained in:
committed by
GitHub
parent
fc14a5a587
commit
40cb6f0e3b
@@ -1904,6 +1904,9 @@ public struct EnvironmentSummary: Codable, Sendable {
|
||||
public let type: String
|
||||
public let label: String?
|
||||
public let status: EnvironmentStatus
|
||||
public let platform: String?
|
||||
public let sessionhost: Bool?
|
||||
public let trust: String?
|
||||
public let capabilities: [String]?
|
||||
public let worker: WorkerEnvironmentMetadata?
|
||||
|
||||
@@ -1912,6 +1915,9 @@ public struct EnvironmentSummary: Codable, Sendable {
|
||||
type: String,
|
||||
label: String? = nil,
|
||||
status: EnvironmentStatus,
|
||||
platform: String? = nil,
|
||||
sessionhost: Bool? = nil,
|
||||
trust: String? = nil,
|
||||
capabilities: [String]? = nil,
|
||||
worker: WorkerEnvironmentMetadata? = nil)
|
||||
{
|
||||
@@ -1919,6 +1925,9 @@ public struct EnvironmentSummary: Codable, Sendable {
|
||||
self.type = type
|
||||
self.label = label
|
||||
self.status = status
|
||||
self.platform = platform
|
||||
self.sessionhost = sessionhost
|
||||
self.trust = trust
|
||||
self.capabilities = capabilities
|
||||
self.worker = worker
|
||||
}
|
||||
@@ -1928,6 +1937,9 @@ public struct EnvironmentSummary: Codable, Sendable {
|
||||
case type
|
||||
case label
|
||||
case status
|
||||
case platform
|
||||
case sessionhost = "sessionHost"
|
||||
case trust
|
||||
case capabilities
|
||||
case worker
|
||||
}
|
||||
@@ -1956,6 +1968,9 @@ public struct EnvironmentsCreateResult: Codable, Sendable {
|
||||
public let type: String
|
||||
public let label: String?
|
||||
public let status: EnvironmentStatus
|
||||
public let platform: String?
|
||||
public let sessionhost: Bool?
|
||||
public let trust: String?
|
||||
public let capabilities: [String]?
|
||||
public let worker: WorkerEnvironmentMetadata?
|
||||
|
||||
@@ -1964,6 +1979,9 @@ public struct EnvironmentsCreateResult: Codable, Sendable {
|
||||
type: String,
|
||||
label: String? = nil,
|
||||
status: EnvironmentStatus,
|
||||
platform: String? = nil,
|
||||
sessionhost: Bool? = nil,
|
||||
trust: String? = nil,
|
||||
capabilities: [String]? = nil,
|
||||
worker: WorkerEnvironmentMetadata? = nil)
|
||||
{
|
||||
@@ -1971,6 +1989,9 @@ public struct EnvironmentsCreateResult: Codable, Sendable {
|
||||
self.type = type
|
||||
self.label = label
|
||||
self.status = status
|
||||
self.platform = platform
|
||||
self.sessionhost = sessionhost
|
||||
self.trust = trust
|
||||
self.capabilities = capabilities
|
||||
self.worker = worker
|
||||
}
|
||||
@@ -1980,6 +2001,9 @@ public struct EnvironmentsCreateResult: Codable, Sendable {
|
||||
case type
|
||||
case label
|
||||
case status
|
||||
case platform
|
||||
case sessionhost = "sessionHost"
|
||||
case trust
|
||||
case capabilities
|
||||
case worker
|
||||
}
|
||||
@@ -2008,6 +2032,9 @@ public struct EnvironmentsDestroyResult: Codable, Sendable {
|
||||
public let type: String
|
||||
public let label: String?
|
||||
public let status: EnvironmentStatus
|
||||
public let platform: String?
|
||||
public let sessionhost: Bool?
|
||||
public let trust: String?
|
||||
public let capabilities: [String]?
|
||||
public let worker: WorkerEnvironmentMetadata?
|
||||
|
||||
@@ -2016,6 +2043,9 @@ public struct EnvironmentsDestroyResult: Codable, Sendable {
|
||||
type: String,
|
||||
label: String? = nil,
|
||||
status: EnvironmentStatus,
|
||||
platform: String? = nil,
|
||||
sessionhost: Bool? = nil,
|
||||
trust: String? = nil,
|
||||
capabilities: [String]? = nil,
|
||||
worker: WorkerEnvironmentMetadata? = nil)
|
||||
{
|
||||
@@ -2023,6 +2053,9 @@ public struct EnvironmentsDestroyResult: Codable, Sendable {
|
||||
self.type = type
|
||||
self.label = label
|
||||
self.status = status
|
||||
self.platform = platform
|
||||
self.sessionhost = sessionhost
|
||||
self.trust = trust
|
||||
self.capabilities = capabilities
|
||||
self.worker = worker
|
||||
}
|
||||
@@ -2032,6 +2065,9 @@ public struct EnvironmentsDestroyResult: Codable, Sendable {
|
||||
case type
|
||||
case label
|
||||
case status
|
||||
case platform
|
||||
case sessionhost = "sessionHost"
|
||||
case trust
|
||||
case capabilities
|
||||
case worker
|
||||
}
|
||||
@@ -2076,6 +2112,9 @@ public struct EnvironmentsStatusResult: Codable, Sendable {
|
||||
public let type: String
|
||||
public let label: String?
|
||||
public let status: EnvironmentStatus
|
||||
public let platform: String?
|
||||
public let sessionhost: Bool?
|
||||
public let trust: String?
|
||||
public let capabilities: [String]?
|
||||
public let worker: WorkerEnvironmentMetadata?
|
||||
|
||||
@@ -2084,6 +2123,9 @@ public struct EnvironmentsStatusResult: Codable, Sendable {
|
||||
type: String,
|
||||
label: String? = nil,
|
||||
status: EnvironmentStatus,
|
||||
platform: String? = nil,
|
||||
sessionhost: Bool? = nil,
|
||||
trust: String? = nil,
|
||||
capabilities: [String]? = nil,
|
||||
worker: WorkerEnvironmentMetadata? = nil)
|
||||
{
|
||||
@@ -2091,6 +2133,9 @@ public struct EnvironmentsStatusResult: Codable, Sendable {
|
||||
self.type = type
|
||||
self.label = label
|
||||
self.status = status
|
||||
self.platform = platform
|
||||
self.sessionhost = sessionhost
|
||||
self.trust = trust
|
||||
self.capabilities = capabilities
|
||||
self.worker = worker
|
||||
}
|
||||
@@ -2100,6 +2145,9 @@ public struct EnvironmentsStatusResult: Codable, Sendable {
|
||||
case type
|
||||
case label
|
||||
case status
|
||||
case platform
|
||||
case sessionhost = "sessionHost"
|
||||
case trust
|
||||
case capabilities
|
||||
case worker
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"59fb241bbe47669d5d7f99a945a18d86f318b9af8c4c281bb11f01506c84ebbf","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
|
||||
{"contentHash":"e4a7ffbb2daf39077b19f28d0dbf9267bb645be42ce18e24161966e34b16bdc7","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"b7195e12bbe582de01faacc9d3a22f5bc30e0210f7b0c03d3b6242b969f7a0be","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
|
||||
{"contentHash":"517a4d6efc324c135d1761b9d8e77ed573162bcc8b662372cebdfd7c5e3083c1","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"04848d6e3e592c5edf260afa6ab27452770f99ef09e110809fab0813fd1671b0","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
|
||||
{"contentHash":"007c23cfcc4af2cc62967750beabdb9825352b54afc3057c989c7e9179790ad0","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"656e29f955e218c9c35843a876eecd769b722e64fb8ea1849f266fa9a430ffd1","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
|
||||
{"contentHash":"454a8b3a3ee15a1a691970f4b5cb15878b8f2f4e166d6600bf639f57292232f3","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"efed0dfd7d5ade67b9f4549b1505e8def917262e1940c9cbe10f761ff154811b","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"}
|
||||
{"contentHash":"aaadbf677c001d81a784acbe65ce9acb4d76794be31fecff9d2c9b8d984ec37c","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"f57838a5ab613e8e168c74cb309beef813587f5465ff80f18b8171320f579e7a","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
|
||||
{"contentHash":"8bafa462130e5c1da30329c7face6a483957d4a0c3cc689b1632f1974420b69f","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"0b81e57bcb56659f98b52d55a1dea6f987bae23500a62d56daf4e86065929ac9","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
|
||||
{"contentHash":"9e02fe7d5c9f80328c7f557420ee84687954f753dfd84b19e1469eb94b8dd168","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"7375a14d1a2ead1dce9d13bc9a6c5c94dc7e809cde37860a7ace9fce480b6751","entrypoint":"channel-pairing","importSpecifier":"openclaw/plugin-sdk/channel-pairing"}
|
||||
{"contentHash":"982f42cd1ef5594aff26969ac256e5cf2f124f409b7e020fcad5117fc8322055","entrypoint":"channel-pairing","importSpecifier":"openclaw/plugin-sdk/channel-pairing"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"57eaf2d515736c9a6880da9434dd6456e7744e75ac8a946709d9e5f30b166aed","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
|
||||
{"contentHash":"a0b2dd06b690fe208c45c02e858934b527ed3c419f1a7ebd394423fc295cbff9","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"bb239ef24687ac9b89335858dff0008f3b861546c242e3af3545fbcae1878309","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
|
||||
{"contentHash":"402dc7fb02d64dc390510abbf1eb240ccb807b1bf3e26ddaa582c9393bdfb57a","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"34a3ec8d182434d7682a6ae2258a4b1797ffc20f62a7a0449ca3055ccc8c69c5","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
|
||||
{"contentHash":"4b0344b65821cc88ea69e7d6e8ca37ddfd21d258035e6ba7d613748df743c761","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"38dbab19f1c23c82c20443339807822f5dfc626d42171dbb71f3322992d01097","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
|
||||
{"contentHash":"ffbc6c5c809ed0ceb135ea42926e321edf4265eb96c80e0f9df536d7eb472811","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"080a0e85aaff22d1e860781d779254461b05e40e4af84b37197b9ae6281ca566","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
|
||||
{"contentHash":"0ebc21dbb6435d83c96ddae60291abbf36a47292288533e1b921bf9501581895","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"d83147f1ddbab6a62f7afc6ec7bbf5ec960b3abf428e6c9abe4a0f9518b0a8c7","entrypoint":"infra-runtime","importSpecifier":"openclaw/plugin-sdk/infra-runtime"}
|
||||
{"contentHash":"463ab9088e5cbf0f008716d00676aaf2a4bdf0f13a6621cd42a76aac34f5b7a0","entrypoint":"infra-runtime","importSpecifier":"openclaw/plugin-sdk/infra-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"04ca7bf038cd8ad69f3145ebd3c774b8b27b18ed96fa8227e289abcecdbd83b5","entrypoint":"media-runtime","importSpecifier":"openclaw/plugin-sdk/media-runtime"}
|
||||
{"contentHash":"fe35d8eebcbfbdb20fc4a1e3b903e2dc7891dc2f12feb3e6130fec5dcd605376","entrypoint":"media-runtime","importSpecifier":"openclaw/plugin-sdk/media-runtime"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"ecf1ca74a42f003b046e3bf2578bae03c609050c6cb16fa741f763d692937e67","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
|
||||
{"contentHash":"458825733f53d1c06d468287d7059d2400202572082a8300d7806c71856498c7","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"a8e907e41641913ebb36b982bb320090197af1408a0196672c15d038883cf1e0","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
|
||||
{"contentHash":"0cbd356b5509eb2bd0f744987a48e34726525e8932fe5a6bb9c7eb5884b55329","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"8d4c89bf2968217111f333c149b51bb6798dd961a62fbafeef1c19f2473e7ab6","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
|
||||
{"contentHash":"f662a31a8c5db27d767489160e9d443df45190429e8f4fb9f6e184d12bac3f19","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"5772bc63ec00a1de6e76827a9a8f79c1ed61493d07f6c2bb21f50c18a806e187","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
|
||||
{"contentHash":"d0c70224a6b22aef9680ea81d32d071b11a2533c885dc8665080c6c00255ea4d","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"9a243e9ff6e6512ef4b635ed503159631e4e000a5dbc4d7743ffd091dfb1ef1d","entrypoint":"runtime-store","importSpecifier":"openclaw/plugin-sdk/runtime-store"}
|
||||
{"contentHash":"babd3de9e3fbbc52a328d73ae815f4df4dbc57377aa0a0ec5de08636b87ab542","entrypoint":"runtime-store","importSpecifier":"openclaw/plugin-sdk/runtime-store"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"ea0c884272571c2494865e709f61fd3a33e0da4e2096c301ab5d292783b923bb","entrypoint":"security-runtime","importSpecifier":"openclaw/plugin-sdk/security-runtime"}
|
||||
{"contentHash":"d293aefd15df648be5808cdbe6194cb363cc3410e9e0ae01cf1d2162f8a475c5","entrypoint":"security-runtime","importSpecifier":"openclaw/plugin-sdk/security-runtime"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"382193e8ef6f05d7bc0d872abefba471c374737bcc86db3810be780b893b72b4","entrypoint":"session-catalog","importSpecifier":"openclaw/plugin-sdk/session-catalog"}
|
||||
{"contentHash":"e843ae3223098e08b57d4af6efd78cc1693bb5ed3b7f589dee10c223d914b1bb","entrypoint":"session-catalog","importSpecifier":"openclaw/plugin-sdk/session-catalog"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"c0619235a9636dc3fc22ab5ac74e7d14a01bf9c40594ced596a17143b6eb90c4","entrypoint":"ssrf-policy","importSpecifier":"openclaw/plugin-sdk/ssrf-policy"}
|
||||
{"contentHash":"f15daa0e828f7f32901d14f7174b31883799c1bd6c1498d4295a85cbd045eeb6","entrypoint":"ssrf-policy","importSpecifier":"openclaw/plugin-sdk/ssrf-policy"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"77efaea8a61aa4e0dccdffcf2d6e6bb4bcc758587691126c17905ffdf11ddd23","entrypoint":"ssrf-runtime","importSpecifier":"openclaw/plugin-sdk/ssrf-runtime"}
|
||||
{"contentHash":"9df7566aad3ff6c1d41e693351d47e09f77f30a0b6d43a828afe7439b84fe36f","entrypoint":"ssrf-runtime","importSpecifier":"openclaw/plugin-sdk/ssrf-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"993018e34f2e7951c2a0412a4df0cd4be50256da04f3d6a539942c3a8168da24","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
|
||||
{"contentHash":"4962f66547dd18b573f4c3ee7ad08f35f220417559437ed2653ef1e3f07fb4be","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"079ce3652967694b58caa87ed04cdaba07b3766d82c491faa9a6f974d584e18b","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
|
||||
{"contentHash":"326217df400252a236ce5f5e1f98adfcbbec79d3a5d068cf44fc3b7a70e0debe","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
|
||||
|
||||
+15
-15
@@ -13,21 +13,21 @@ Proposal, revision 2. Supersedes revision 1 in place (2026-08-11, operator
|
||||
decision). Implementation in progress; update this table in every PR that
|
||||
advances a milestone.
|
||||
|
||||
| # | Milestone | Status | PRs |
|
||||
| --- | ---------------------------------------------------------- | ----------- | ------- |
|
||||
| 0 | This plan (revision 2) | landed | #122454 |
|
||||
| 1a | Naming: session copy revert | landed | #120667 |
|
||||
| 1b | Naming: devices consolidation | landed | #120689 |
|
||||
| 1c | Cleanup: node-pairing → device-pairing merge | not started | — |
|
||||
| 2 | `openclaw resume` + web Continue in terminal | in progress | #120664 |
|
||||
| 3 | `openclaw connect` one-paste onboarding + `/j/` join route | not started | — |
|
||||
| 4 | Picker: grouping, placement, liveness, enrichment | in progress | #120804 |
|
||||
| 5 | Public worker ingress path | not started | — |
|
||||
| 6 | Node worker provider (device runners) | not started | — |
|
||||
| 7 | Bundle push consent + runner updates | not started | — |
|
||||
| 8 | Stop-and-continue moves | not started | — |
|
||||
| 9 | Deletions (ssh sandbox, openshell, exec-host clones, …) | not started | — |
|
||||
| 10 | Cloud convergence (provisioners run `openclaw connect`) | not started | — |
|
||||
| # | Milestone | Status | PRs |
|
||||
| --- | ---------------------------------------------------------- | ----------- | ---------------- |
|
||||
| 0 | This plan (revision 2) | landed | #122454 |
|
||||
| 1a | Naming: session copy revert | landed | #120667 |
|
||||
| 1b | Naming: devices consolidation | landed | #120689 |
|
||||
| 1c | Cleanup: node-pairing → device-pairing merge | not started | — |
|
||||
| 2 | `openclaw resume` + web Continue in terminal | in progress | #120664 |
|
||||
| 3 | `openclaw connect` one-paste onboarding + `/j/` join route | not started | — |
|
||||
| 4 | Picker: grouping, placement, liveness, enrichment | in progress | #120804, #122531 |
|
||||
| 5 | Public worker ingress path | not started | — |
|
||||
| 6 | Node worker provider (device runners) | not started | — |
|
||||
| 7 | Bundle push consent + runner updates | not started | — |
|
||||
| 8 | Stop-and-continue moves | not started | — |
|
||||
| 9 | Deletions (ssh sandbox, openshell, exec-host clones, …) | not started | — |
|
||||
| 10 | Cloud convergence (provisioners run `openclaw connect`) | not started | — |
|
||||
|
||||
Revision history: revision 1 (2026-08-08) established the session/runner
|
||||
vocabulary, the naming rulings, and the milestone skeleton after a
|
||||
|
||||
@@ -35,10 +35,9 @@ type RegistrationFetchOptions = {
|
||||
|
||||
const HERMETIC_PUBLIC_LOOKUP_ADDRESS = "93.184.216.34";
|
||||
|
||||
const hermeticPublicLookup: LookupFn = (async (_hostname: string, _options?: unknown) => ({
|
||||
address: HERMETIC_PUBLIC_LOOKUP_ADDRESS,
|
||||
family: 4,
|
||||
})) as LookupFn;
|
||||
const hermeticPublicLookup: LookupFn = async () => [
|
||||
{ address: HERMETIC_PUBLIC_LOOKUP_ADDRESS, family: 4 },
|
||||
];
|
||||
|
||||
async function startLocalServer(
|
||||
handler: (req: IncomingMessage, res: ServerResponse) => void,
|
||||
|
||||
@@ -43,10 +43,9 @@ type StreamingRequest = {
|
||||
const serverStops: Array<() => Promise<void>> = [];
|
||||
const HERMETIC_PUBLIC_LOOKUP_ADDRESS = "93.184.216.34";
|
||||
|
||||
const hermeticPublicLookup: LookupFn = (async (_hostname: string, _options?: unknown) => ({
|
||||
address: HERMETIC_PUBLIC_LOOKUP_ADDRESS,
|
||||
family: 4,
|
||||
})) as LookupFn;
|
||||
const hermeticPublicLookup: LookupFn = async () => [
|
||||
{ address: HERMETIC_PUBLIC_LOOKUP_ADDRESS, family: 4 },
|
||||
];
|
||||
|
||||
async function readRequestBody(req: IncomingMessage): Promise<string> {
|
||||
let body = "";
|
||||
|
||||
@@ -677,6 +677,10 @@ async function safeFetch(params: {
|
||||
}
|
||||
|
||||
if (!hasDispatcher) {
|
||||
const lookupFn: LookupFn = async (hostname) => {
|
||||
const resolved = await resolveFn(hostname);
|
||||
return [{ ...resolved, family: resolved.address.includes(":") ? 6 : 4 }];
|
||||
};
|
||||
const guarded = await fetchWithSsrFGuard({
|
||||
url: currentUrl,
|
||||
fetchImpl: resolveGuardedFetchImpl({
|
||||
@@ -690,7 +694,7 @@ async function safeFetch(params: {
|
||||
maxRedirects: MAX_SAFE_REDIRECTS,
|
||||
requireHttps: true,
|
||||
policy: resolveMediaSsrfPolicy(params.allowHosts),
|
||||
lookupFn: resolveFn as LookupFn,
|
||||
lookupFn,
|
||||
retainAuthorizationRedirectHostnameAllowlist:
|
||||
resolveRetainedAuthorizationRedirectHostnameAllowlist(params.authorizationAllowHosts),
|
||||
auditContext: "msteams.attachment",
|
||||
|
||||
@@ -82,7 +82,12 @@ describe("worker environment protocol schemas", () => {
|
||||
});
|
||||
|
||||
it("accepts worker metadata additively across summary and mutation results", () => {
|
||||
const requested = workerSummary("requested");
|
||||
const requested = {
|
||||
...workerSummary("requested"),
|
||||
platform: "linux",
|
||||
sessionHost: false,
|
||||
trust: "disposable",
|
||||
};
|
||||
const destroyedBase = workerSummary("destroyed", "unavailable");
|
||||
const destroyed = {
|
||||
...destroyedBase,
|
||||
@@ -140,7 +145,7 @@ describe("worker environment protocol schemas", () => {
|
||||
expect(
|
||||
Value.Check(EnvironmentsListResultSchema, {
|
||||
environments: [],
|
||||
profiles: [{ id: "aws", providerId: "crabbox" }],
|
||||
profiles: [{ id: "aws", providerId: "crabbox", trust: "disposable" }],
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
@@ -149,6 +154,12 @@ describe("worker environment protocol schemas", () => {
|
||||
profiles: [{ id: "aws", providerId: "crabbox", settings: { token: "hidden" } }],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
Value.Check(EnvironmentsListResultSchema, {
|
||||
environments: [],
|
||||
profiles: [{ id: "aws", providerId: "crabbox", trust: "temporary" }],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves summaries without worker metadata and rejects malformed worker metadata", () => {
|
||||
@@ -180,5 +191,11 @@ describe("worker environment protocol schemas", () => {
|
||||
worker: { ...workerSummary("failed").worker, error: "" },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
Value.Check(EnvironmentSummarySchema, {
|
||||
...workerSummary("ready", "available"),
|
||||
trust: "temporary",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,10 @@ export const EnvironmentStatusSchema = Type.String({
|
||||
enum: ["available", "unavailable", "starting", "stopping", "error"],
|
||||
});
|
||||
|
||||
const EnvironmentTrustSchema = Type.String({
|
||||
enum: ["persistent", "disposable"],
|
||||
});
|
||||
|
||||
/** Durable lifecycle states for plugin-provisioned worker environments. */
|
||||
export const WorkerEnvironmentStateSchema = Type.Union([
|
||||
Type.Literal("requested"),
|
||||
@@ -65,6 +69,9 @@ function createEnvironmentSummarySchema() {
|
||||
type: NonEmptyString,
|
||||
label: Type.Optional(NonEmptyString),
|
||||
status: EnvironmentStatusSchema,
|
||||
platform: Type.Optional(NonEmptyString),
|
||||
sessionHost: Type.Optional(Type.Boolean()),
|
||||
trust: Type.Optional(EnvironmentTrustSchema),
|
||||
capabilities: Type.Optional(Type.Array(NonEmptyString)),
|
||||
worker: Type.Optional(WorkerEnvironmentMetadataSchema),
|
||||
});
|
||||
@@ -80,6 +87,7 @@ export const EnvironmentsListParamsSchema = closedObject({});
|
||||
const WorkerEnvironmentProfileSummarySchema = closedObject({
|
||||
id: NonEmptyString,
|
||||
providerId: NonEmptyString,
|
||||
trust: Type.Optional(EnvironmentTrustSchema),
|
||||
});
|
||||
|
||||
/** List response containing all gateway-visible environment summaries. */
|
||||
|
||||
@@ -756,6 +756,9 @@ async function proveRealGatewayContracts(): Promise<void> {
|
||||
type: "local",
|
||||
label: "Gateway local",
|
||||
status: "available",
|
||||
platform: process.platform,
|
||||
sessionHost: true,
|
||||
trust: "persistent",
|
||||
capabilities: ["agent.run", "sessions", "tools", "workspace"],
|
||||
});
|
||||
const gatewayEnvironment = await oc.environments.status("gateway");
|
||||
|
||||
@@ -79,6 +79,9 @@ export type EnvironmentSummary = {
|
||||
type: "local" | "gateway" | "node" | "managed" | "ephemeral" | (string & {});
|
||||
label?: string;
|
||||
status: "available" | "unavailable" | "starting" | "stopping" | "error";
|
||||
platform?: string;
|
||||
sessionHost?: boolean;
|
||||
trust?: "persistent" | "disposable";
|
||||
capabilities?: string[];
|
||||
worker?: WorkerEnvironmentMetadata;
|
||||
};
|
||||
@@ -91,6 +94,7 @@ export type EnvironmentCreateParams = {
|
||||
export type WorkerEnvironmentProfileSummary = {
|
||||
id: string;
|
||||
providerId: string;
|
||||
trust?: "persistent" | "disposable";
|
||||
};
|
||||
|
||||
export type EnvironmentsListResult = {
|
||||
|
||||
@@ -202,7 +202,7 @@ export async function prepareGatewayLifecycle(params: {
|
||||
instanceId: session.nodeId,
|
||||
reason: "connect",
|
||||
});
|
||||
incrementPresenceVersion();
|
||||
broadcastPresenceSnapshot({ broadcast, incrementPresenceVersion, getHealthVersion });
|
||||
recordRemoteNodeInfo({
|
||||
nodeId: session.nodeId,
|
||||
connId: session.connId,
|
||||
|
||||
@@ -99,6 +99,7 @@ function workerRecord(overrides: Partial<TestWorkerRecord> = {}): TestWorkerReco
|
||||
profileSnapshot: { settings: {} },
|
||||
provisionOperationId: "provision:worker-1",
|
||||
leaseId: "lease-1",
|
||||
sharedHost: false,
|
||||
desktop: null,
|
||||
sshEndpoint: {
|
||||
host: "worker.example.test",
|
||||
@@ -208,6 +209,9 @@ describe("environment gateway methods", () => {
|
||||
type: "local",
|
||||
label: "Gateway local",
|
||||
status: "available",
|
||||
platform: process.platform,
|
||||
sessionHost: true,
|
||||
trust: "persistent",
|
||||
capabilities: ["agent.run", "sessions", "tools", "workspace"],
|
||||
},
|
||||
{
|
||||
@@ -215,6 +219,9 @@ describe("environment gateway methods", () => {
|
||||
type: "node",
|
||||
label: "Live Node",
|
||||
status: "available",
|
||||
platform: "ios",
|
||||
sessionHost: false,
|
||||
trust: "persistent",
|
||||
capabilities: ["camera", "system.run"],
|
||||
},
|
||||
{
|
||||
@@ -222,6 +229,8 @@ describe("environment gateway methods", () => {
|
||||
type: "node",
|
||||
label: "Offline Node",
|
||||
status: "unavailable",
|
||||
sessionHost: false,
|
||||
trust: "persistent",
|
||||
capabilities: ["camera.snap", "screen"],
|
||||
},
|
||||
],
|
||||
@@ -254,6 +263,7 @@ describe("environment gateway methods", () => {
|
||||
id: "worker-1",
|
||||
type: "worker",
|
||||
status: "available",
|
||||
trust: "disposable",
|
||||
worker: {
|
||||
providerId: "static-ssh",
|
||||
leaseId: "lease-1",
|
||||
@@ -285,6 +295,18 @@ describe("environment gateway methods", () => {
|
||||
expect(summarizeWorkerEnvironment(workerRecord({ state }), NOW).status).toBe(status);
|
||||
});
|
||||
|
||||
it("projects trust from recorded worker isolation without guessing unknown leases", () => {
|
||||
expect(summarizeWorkerEnvironment(workerRecord({ sharedHost: true }), NOW).trust).toBe(
|
||||
"persistent",
|
||||
);
|
||||
expect(summarizeWorkerEnvironment(workerRecord({ sharedHost: false }), NOW).trust).toBe(
|
||||
"disposable",
|
||||
);
|
||||
expect(summarizeWorkerEnvironment(workerRecord({ sharedHost: null }), NOW)).not.toHaveProperty(
|
||||
"trust",
|
||||
);
|
||||
});
|
||||
|
||||
it("projects recorded errors only for terminal error states", () => {
|
||||
expect(
|
||||
summarizeWorkerEnvironment(
|
||||
@@ -324,6 +346,9 @@ describe("environment gateway methods", () => {
|
||||
type: "node",
|
||||
label: "Live Node",
|
||||
status: "available",
|
||||
platform: "ios",
|
||||
sessionHost: false,
|
||||
trust: "persistent",
|
||||
capabilities: ["camera", "system.run"],
|
||||
});
|
||||
});
|
||||
@@ -341,6 +366,7 @@ describe("environment gateway methods", () => {
|
||||
expect(payload).toMatchObject({
|
||||
id: "worker-1",
|
||||
status: "available",
|
||||
trust: "disposable",
|
||||
worker: { state: "attached", ageMs: 9_000 },
|
||||
});
|
||||
expect(get).toHaveBeenCalledWith("worker-1");
|
||||
|
||||
@@ -25,6 +25,9 @@ const GATEWAY_ENVIRONMENT: EnvironmentSummary = {
|
||||
type: "local",
|
||||
label: "Gateway local",
|
||||
status: "available",
|
||||
platform: process.platform,
|
||||
sessionHost: true,
|
||||
trust: "persistent",
|
||||
capabilities: ["agent.run", "sessions", "tools", "workspace"],
|
||||
};
|
||||
const WORKER_STATUS: Record<WorkerEnvironmentState, EnvironmentSummary["status"]> = {
|
||||
@@ -54,11 +57,15 @@ function summarizeNodeEnvironment(node: NodeListNode): EnvironmentSummary {
|
||||
// Expose both declared capabilities and command names so older node
|
||||
// runtimes still advertise useful execution surfaces in one stable list.
|
||||
const capabilities = uniqueSortedStrings(node.caps, node.commands);
|
||||
const platform = node.platform?.trim();
|
||||
return {
|
||||
id: `node:${node.nodeId}`,
|
||||
type: "node",
|
||||
label: node.displayName ?? node.nodeId,
|
||||
status: node.connected ? "available" : "unavailable",
|
||||
...(platform ? { platform } : {}),
|
||||
sessionHost: false,
|
||||
trust: "persistent",
|
||||
...(capabilities.length > 0 ? { capabilities } : {}),
|
||||
};
|
||||
}
|
||||
@@ -71,6 +78,9 @@ export function summarizeWorkerEnvironment(
|
||||
id: record.environmentId,
|
||||
type: "worker",
|
||||
status: WORKER_STATUS[record.state],
|
||||
...(record.sharedHost === null
|
||||
? {}
|
||||
: { trust: record.sharedHost ? "persistent" : "disposable" }),
|
||||
worker: {
|
||||
providerId: record.providerId,
|
||||
...(record.leaseId ? { leaseId: record.leaseId } : {}),
|
||||
|
||||
@@ -24,6 +24,7 @@ export type WorkerEnvironmentServiceRecord = {
|
||||
environmentId: string;
|
||||
providerId: string;
|
||||
leaseId: string | null;
|
||||
sharedHost: boolean | null;
|
||||
state: WorkerEnvironmentState;
|
||||
ownerEpoch: number;
|
||||
createdAtMs: number;
|
||||
|
||||
@@ -43,7 +43,7 @@ export class SsrFBlockedError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export type LookupFn = typeof dnsLookup;
|
||||
export type LookupFn = (hostname: string, options: { all: true }) => Promise<LookupAddress[]>;
|
||||
|
||||
export type SsrFPolicy = {
|
||||
allowPrivateNetwork?: boolean;
|
||||
@@ -607,9 +607,7 @@ export async function resolvePinnedHostnameWithPolicy(
|
||||
);
|
||||
|
||||
const lookupFn = params.lookupFn ?? dnsLookup;
|
||||
const results = normalizeLookupResults(
|
||||
(await lookupFn(normalized, { all: true })) as LookupResult,
|
||||
);
|
||||
const results = normalizeLookupResults(await lookupFn(normalized, { all: true }));
|
||||
if (results.length === 0) {
|
||||
throw new Error(`Unable to resolve hostname: ${hostname}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
WORKSPACE,
|
||||
captureUiProof,
|
||||
captureUiProofEnabled,
|
||||
createNewSessionPageE2eSuite,
|
||||
installMockGateway,
|
||||
pollLocatorText,
|
||||
} from "./new-session-page.test-support.ts";
|
||||
|
||||
const suite = createNewSessionPageE2eSuite();
|
||||
|
||||
suite.define(() => {
|
||||
it("hides the destination axis when the Gateway is the only place", async () => {
|
||||
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
workspace: WORKSPACE,
|
||||
workspaceGit: true,
|
||||
methodResponses: {
|
||||
"node.list": { nodes: [] },
|
||||
"environments.list": { environments: [], profiles: [] },
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}new`);
|
||||
await gateway.waitForRequest("node.list");
|
||||
const trigger = page.locator("#new-session-place-trigger");
|
||||
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("openclaw");
|
||||
await trigger.click();
|
||||
const place = page.locator("wa-popover.new-session-page__place-popover");
|
||||
expect(await place.getByText("This gateway", { exact: true }).count()).toBe(0);
|
||||
await place.getByText("Runs on Gateway · local", { exact: true }).waitFor();
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("refreshes destinations from gateway events while the picker stays open", async () => {
|
||||
const context = await suite.browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
...(captureUiProofEnabled
|
||||
? {
|
||||
recordVideo: {
|
||||
dir: ".artifacts/control-ui-e2e/picker-liveness",
|
||||
size: { height: 900, width: 1280 },
|
||||
},
|
||||
viewport: { height: 900, width: 1280 },
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
workspace: WORKSPACE,
|
||||
workspaceGit: true,
|
||||
methodResponses: {
|
||||
"node.list": {
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "existing-mac",
|
||||
displayName: "Existing Mac",
|
||||
connected: true,
|
||||
commands: ["system.run"],
|
||||
},
|
||||
],
|
||||
},
|
||||
"environments.list": { environments: [], profiles: [] },
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}new`);
|
||||
await gateway.waitForRequest("node.list");
|
||||
await gateway.waitForRequest("environments.list");
|
||||
const trigger = page.locator("#new-session-place-trigger");
|
||||
const place = page.locator("wa-popover.new-session-page__place-popover");
|
||||
await trigger.click();
|
||||
await place.getByRole("button", { name: "Existing Mac" }).waitFor();
|
||||
const nodeRequests = (await gateway.getRequests("node.list")).length;
|
||||
const environmentRequests = (await gateway.getRequests("environments.list")).length;
|
||||
await gateway.setMethodResponse("node.list", {
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "existing-mac",
|
||||
displayName: "Existing Mac",
|
||||
connected: true,
|
||||
commands: ["system.run"],
|
||||
},
|
||||
{
|
||||
nodeId: "new-mac",
|
||||
displayName: "New Mac",
|
||||
connected: true,
|
||||
commands: ["system.run"],
|
||||
},
|
||||
],
|
||||
});
|
||||
await gateway.emitGatewayEvent("presence", {
|
||||
presence: [
|
||||
{ deviceId: "existing-mac", mode: "node", reason: "connect", ts: 1 },
|
||||
{ deviceId: "new-mac", mode: "node", reason: "connect", ts: 2 },
|
||||
],
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("node.list")).length)
|
||||
.toBeGreaterThan(nodeRequests);
|
||||
await place.getByRole("button", { name: "New Mac" }).waitFor();
|
||||
await place.getByText("This gateway", { exact: true }).waitFor();
|
||||
await place.getByText("Your devices", { exact: true }).waitFor();
|
||||
expect(await place.getAttribute("open")).not.toBeNull();
|
||||
expect(await gateway.getRequests("environments.list")).toHaveLength(environmentRequests);
|
||||
|
||||
await gateway.setMethodResponse("environments.list", {
|
||||
environments: [],
|
||||
profiles: [{ id: "aws", providerId: "crabbox", trust: "disposable" }],
|
||||
});
|
||||
await gateway.emitGatewayEvent("config.changed", {
|
||||
path: "/tmp/openclaw.json",
|
||||
hash: "picker-cloud-refresh",
|
||||
ts: 3,
|
||||
});
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("environments.list")).length)
|
||||
.toBeGreaterThan(environmentRequests);
|
||||
await place.getByText("Cloud", { exact: true }).waitFor();
|
||||
await place.getByRole("button", { name: "Cloud · aws" }).waitFor();
|
||||
expect(await place.getAttribute("open")).not.toBeNull();
|
||||
await captureUiProof(page, "picker-after-live-regroup.png");
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -522,32 +522,6 @@ suite.define(() => {
|
||||
}
|
||||
});
|
||||
|
||||
it("hides the destination axis when the Gateway is the only place", async () => {
|
||||
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
workspace: WORKSPACE,
|
||||
workspaceGit: true,
|
||||
methodResponses: {
|
||||
"node.list": { nodes: [] },
|
||||
"environments.list": { environments: [], profiles: [] },
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}new`);
|
||||
await gateway.waitForRequest("node.list");
|
||||
const trigger = page.locator("#new-session-place-trigger");
|
||||
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("openclaw");
|
||||
await trigger.click();
|
||||
const place = page.locator("wa-popover.new-session-page__place-popover");
|
||||
expect(await place.getByText("Places", { exact: true }).count()).toBe(0);
|
||||
await place.getByText("Runs on Gateway · local", { exact: true }).waitFor();
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses advertised system info for Gateway place labels", async () => {
|
||||
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
|
||||
const page = await context.newPage();
|
||||
@@ -925,7 +899,7 @@ suite.define(() => {
|
||||
const placeLabel = placeTrigger.locator(".new-session-page__trigger-label");
|
||||
const browserEntries = page.locator(".new-session-page__browser-list");
|
||||
|
||||
// Pick the node from Places.
|
||||
// Pick the node from Your devices.
|
||||
await placeTrigger.click();
|
||||
await placeSelect.getByRole("button", { name: "MacBook" }).click();
|
||||
await pollLocatorText(placeLabel).toBe("Agent workspace · MacBook");
|
||||
@@ -950,9 +924,9 @@ suite.define(() => {
|
||||
placeSelect.evaluate((element) => (element as HTMLElement & { open: boolean }).open),
|
||||
)
|
||||
.toBe(true);
|
||||
await placeSelect.getByText("Places", { exact: true }).waitFor();
|
||||
await placeSelect.getByText("Your devices", { exact: true }).waitFor();
|
||||
|
||||
// Destination selection stays in Places; browsing is fixed to the current target.
|
||||
// Destination selection stays in the destination sections; browsing is fixed to the target.
|
||||
await placeSelect.getByRole("button", { name: "Gateway · local" }).click();
|
||||
await pollLocatorText(placeLabel).toBe("openclaw · Gateway · local");
|
||||
await placeTrigger.click();
|
||||
|
||||
@@ -249,7 +249,7 @@ suite.define(() => {
|
||||
await gateway.waitForRequest("environments.list");
|
||||
await page.locator("#new-session-place-trigger").click();
|
||||
const place = page.locator("wa-popover.new-session-page__place-popover");
|
||||
await place.getByText("Devices", { exact: true }).waitFor();
|
||||
await place.getByText("Your devices", { exact: true }).waitFor();
|
||||
await place.getByRole("button", { name: "Fallback device" }).waitFor();
|
||||
await captureUiProof(page, "04-catalog-unavailable-device-fallback.png");
|
||||
} finally {
|
||||
@@ -368,7 +368,7 @@ suite.define(() => {
|
||||
.locator(".new-session-page__menu-title")
|
||||
.getByText("This gateway", { exact: true })
|
||||
.waitFor();
|
||||
await place.getByText("Devices", { exact: true }).waitFor();
|
||||
await place.getByText("Your devices", { exact: true }).waitFor();
|
||||
const first = page.locator('[data-value="node:11111111aaaaaaaa"]');
|
||||
const second = page.locator('[data-value="node:22222222bbbbbbbb"]');
|
||||
const phone = page.locator('[data-value="node:33333333cccccccc"]');
|
||||
@@ -713,7 +713,7 @@ suite.define(() => {
|
||||
.locator(".new-session-page__menu-title")
|
||||
.getByText("This gateway", { exact: true })
|
||||
.waitFor();
|
||||
await placeSelect.getByText("Devices", { exact: true }).waitFor();
|
||||
await placeSelect.getByText("Your devices", { exact: true }).waitFor();
|
||||
await placeSelect.getByRole("button", { name: "MacBook" }).click();
|
||||
await pollLocatorText(placeLabel).toBe("Agent workspace · MacBook");
|
||||
// Node sessions cannot use managed worktrees, so the menu drops the item.
|
||||
|
||||
@@ -717,7 +717,7 @@ export const en: TranslationMap = {
|
||||
cloudSyncsFolder: "Syncs {folder} to the cloud worker",
|
||||
folder: "Folder",
|
||||
folderPlaceholder: "Agent workspace",
|
||||
places: "Places",
|
||||
yourDevices: "Your devices",
|
||||
projects: "Projects",
|
||||
projectsAdminHint: "Admins can register projects from Browse folders",
|
||||
projectSearchPlaceholder: "Search projects or paste a Git URL",
|
||||
|
||||
@@ -38,17 +38,20 @@ describe("readDraftCloudProfiles", () => {
|
||||
{
|
||||
id: " zeta ",
|
||||
providerId: " static-ssh ",
|
||||
trust: "disposable",
|
||||
settings: { token: "hidden" },
|
||||
},
|
||||
{ id: "aws", providerId: "crabbox" },
|
||||
{ id: "aws", providerId: "crabbox", trust: "persistent" },
|
||||
{ id: "legacy", providerId: "static-ssh" },
|
||||
{ id: "invalid-trust", providerId: "crabbox", trust: "temporary" },
|
||||
{ id: "", providerId: "crabbox" },
|
||||
{ id: "missing-provider" },
|
||||
]),
|
||||
).toEqual([
|
||||
{ id: "aws", providerId: "crabbox" },
|
||||
{ id: "legacy", providerId: "static-ssh" },
|
||||
{ id: "zeta", providerId: "static-ssh" },
|
||||
{ id: "aws", providerId: "crabbox", trust: "persistent" },
|
||||
{ id: "invalid-trust", providerId: "crabbox", trust: undefined },
|
||||
{ id: "legacy", providerId: "static-ssh", trust: undefined },
|
||||
{ id: "zeta", providerId: "static-ssh", trust: "disposable" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ export type DraftNode = {
|
||||
export type DraftCloudProfile = {
|
||||
id: string;
|
||||
providerId: string;
|
||||
trust?: "persistent" | "disposable";
|
||||
};
|
||||
|
||||
export type DraftEnvironment = {
|
||||
@@ -92,16 +93,17 @@ export function readDraftCloudProfiles(value: unknown): DraftCloudProfile[] {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return [];
|
||||
}
|
||||
const profile = raw as {
|
||||
id?: unknown;
|
||||
providerId?: unknown;
|
||||
};
|
||||
const profile = raw as { id?: unknown; providerId?: unknown; trust?: unknown };
|
||||
const id = normalizeOptionalString(profile.id);
|
||||
const providerId = normalizeOptionalString(profile.providerId);
|
||||
if (!id || !providerId) {
|
||||
return [];
|
||||
}
|
||||
return [{ id, providerId }];
|
||||
const trust: DraftCloudProfile["trust"] =
|
||||
profile.trust === "persistent" || profile.trust === "disposable"
|
||||
? profile.trust
|
||||
: undefined;
|
||||
return [{ id, providerId, trust }];
|
||||
})
|
||||
.toSorted((left, right) => left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
@@ -188,6 +188,10 @@ export class DraftGatewayState {
|
||||
return this.preferenceModeValue === "loading";
|
||||
}
|
||||
|
||||
refreshCloudProfiles() {
|
||||
return this.cloudProfileTask.run();
|
||||
}
|
||||
|
||||
synchronize(gateway: ApplicationContext["gateway"]) {
|
||||
const snapshot = gateway.snapshot;
|
||||
const connected = snapshot.phase === "connected";
|
||||
|
||||
@@ -151,6 +151,10 @@ export class DraftPlaceState {
|
||||
);
|
||||
}
|
||||
|
||||
refreshNodes() {
|
||||
return this.loadNodes({ quiet: true });
|
||||
}
|
||||
|
||||
isAdmin(): boolean {
|
||||
return hasOperatorAdminAccess(this.read().context?.gateway.snapshot.hello?.auth ?? null);
|
||||
}
|
||||
@@ -592,9 +596,11 @@ export class DraftPlaceState {
|
||||
});
|
||||
}
|
||||
|
||||
private async loadNodes() {
|
||||
private async loadNodes(options: { quiet?: boolean } = {}) {
|
||||
const requestId = ++this.nodesRequestToken;
|
||||
this.nodesHydrated = false;
|
||||
if (!options.quiet) {
|
||||
this.nodesHydrated = false;
|
||||
}
|
||||
const snapshot = this.read().context?.gateway.snapshot;
|
||||
const client = snapshot?.client;
|
||||
if (snapshot?.phase !== "connected" || !client || !this.isAdmin()) {
|
||||
@@ -626,7 +632,7 @@ export class DraftPlaceState {
|
||||
}
|
||||
this.callbacks.requestUpdate();
|
||||
} catch {
|
||||
if (requestId === this.nodesRequestToken) {
|
||||
if (requestId === this.nodesRequestToken && !options.quiet) {
|
||||
this.nodesValue = [];
|
||||
this.nodesHydrated = true;
|
||||
this.callbacks.requestUpdate();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { html, nothing, type ReactiveController, type ReactiveControllerHost } from "lit";
|
||||
import { property } from "lit/decorators.js";
|
||||
import type { PresenceEntry } from "../../api/types.ts";
|
||||
import { selectApplicationSession } from "../../app/agent-selection.ts";
|
||||
import { applicationContext, type ApplicationContext } from "../../app/context.ts";
|
||||
import { beginNativeWindowDragFromTopInset } from "../../app/native-window-drag.ts";
|
||||
@@ -29,6 +30,24 @@ import type { NewSessionRouteData } from "./location.ts";
|
||||
import { renderPlaceSelect } from "./place-picker.ts";
|
||||
import { renderAgentSelect } from "./target-controls.ts";
|
||||
|
||||
function readPresence(value: unknown): PresenceEntry[] | null {
|
||||
const presence =
|
||||
value && typeof value === "object" ? (value as { presence?: unknown }).presence : null;
|
||||
return Array.isArray(presence) ? (presence as PresenceEntry[]) : null;
|
||||
}
|
||||
|
||||
function presenceConnectivitySignature(entries: PresenceEntry[]): string {
|
||||
const states = new Map<string, "connected" | "offline">();
|
||||
for (const entry of entries) {
|
||||
const id = (entry.deviceId ?? entry.instanceId)?.trim().toLowerCase();
|
||||
if (!id || entry.mode?.trim().toLowerCase() === "gateway") {
|
||||
continue;
|
||||
}
|
||||
states.set(id, entry.reason?.trim().toLowerCase() === "disconnect" ? "offline" : "connected");
|
||||
}
|
||||
return JSON.stringify([...states].toSorted(([left], [right]) => left.localeCompare(right)));
|
||||
}
|
||||
|
||||
function controllerHost(element: OpenClawLightDomElement): ReactiveControllerHost {
|
||||
return {
|
||||
addController: (controller: ReactiveController) => element.addController(controller),
|
||||
@@ -49,6 +68,7 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
private openedFor: string | null = null;
|
||||
private openedAgentId = "";
|
||||
private messageOwnerKey = "";
|
||||
private presenceSignature = "";
|
||||
private readonly gateway: DraftGatewayState;
|
||||
private readonly browser: DraftPlaceBrowser;
|
||||
private readonly place: DraftPlaceState;
|
||||
@@ -147,6 +167,41 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
(gateway, notify) => gateway.subscribe(notify),
|
||||
(gateway) => this.gateway.synchronize(gateway),
|
||||
)
|
||||
.effect(
|
||||
() => this.context?.gateway,
|
||||
(gateway) => {
|
||||
this.presenceSignature = presenceConnectivitySignature(
|
||||
readPresence(gateway.snapshot.hello?.snapshot) ?? [],
|
||||
);
|
||||
return gateway.subscribeEvents((event) => {
|
||||
if (this.context?.gateway !== gateway) {
|
||||
return;
|
||||
}
|
||||
if (event.event === "config.changed") {
|
||||
void this.gateway.refreshCloudProfiles();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.event === "node.pair.requested" ||
|
||||
event.event === "node.pair.resolved" ||
|
||||
event.event === "device.pair.requested" ||
|
||||
event.event === "device.pair.resolved"
|
||||
) {
|
||||
void this.place.refreshNodes();
|
||||
return;
|
||||
}
|
||||
const presence = event.event === "presence" ? readPresence(event.payload) : null;
|
||||
if (!presence) {
|
||||
return;
|
||||
}
|
||||
const signature = presenceConnectivitySignature(presence);
|
||||
if (signature !== this.presenceSignature) {
|
||||
this.presenceSignature = signature;
|
||||
void this.place.refreshNodes();
|
||||
}
|
||||
});
|
||||
},
|
||||
)
|
||||
.watch(
|
||||
() => this.context?.agents,
|
||||
(agents, notify) => agents.subscribe(notify),
|
||||
|
||||
@@ -88,6 +88,41 @@ describe("project picker", () => {
|
||||
expect(projectCloneInput(value) !== null).toBe(expected);
|
||||
});
|
||||
|
||||
it("groups gateway, device, and cloud destinations without status copy", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderPlaceSelect(
|
||||
placeParams({
|
||||
showDestinations: true,
|
||||
worktreeAvailable: true,
|
||||
execNodes: [
|
||||
{
|
||||
nodeId: "macbook",
|
||||
displayName: "MacBook",
|
||||
connected: true,
|
||||
canExec: true,
|
||||
canBrowse: true,
|
||||
},
|
||||
],
|
||||
cloudProfiles: [{ id: "aws", providerId: "crabbox", trust: "disposable" }],
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const destinationHeadings = [
|
||||
...container.querySelectorAll<HTMLElement>(".new-session-page__menu-title"),
|
||||
]
|
||||
.map((element) => element.textContent?.trim())
|
||||
.filter((label) => ["This gateway", "Your devices", "Cloud", "Places"].includes(label ?? ""));
|
||||
expect(destinationHeadings).toEqual(["This gateway", "Your devices", "Cloud"]);
|
||||
expect(container.querySelector('[data-value="gateway"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-value="node:macbook"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-value="cloud:aws"]')).not.toBeNull();
|
||||
expect(container.textContent).not.toContain("persistent");
|
||||
expect(container.textContent).not.toContain("disposable");
|
||||
});
|
||||
|
||||
it("renders local matches before remote clone results and explains missing credentials", () => {
|
||||
const onCloneProject = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
@@ -258,7 +293,7 @@ describe("Where picker", () => {
|
||||
const titles = [...container.querySelectorAll(".new-session-page__menu-title")].map((element) =>
|
||||
element.textContent?.trim(),
|
||||
);
|
||||
expect(titles).toEqual(["Folder", "Projects", "Places", "This gateway", "Devices", "Cloud"]);
|
||||
expect(titles).toEqual(["Folder", "Projects", "This gateway", "Your devices", "Cloud"]);
|
||||
expect(container.querySelector('[data-value="node:macbook"]')).not.toBeNull();
|
||||
for (const nodeId of [
|
||||
"worker",
|
||||
|
||||
@@ -533,7 +533,6 @@ export function renderPlaceSelect(params: {
|
||||
|
||||
${params.showDestinations
|
||||
? html`
|
||||
<div class="new-session-page__menu-title">${t("newSession.places")}</div>
|
||||
<div class="new-session-page__menu-title">${t("newSession.thisGateway")}</div>
|
||||
${renderSessionMenuItem(
|
||||
{
|
||||
@@ -547,7 +546,9 @@ export function renderPlaceSelect(params: {
|
||||
)}
|
||||
${deviceNodes.length > 0
|
||||
? html`
|
||||
<div class="new-session-page__menu-title">${t("tabs.devices")}</div>
|
||||
<div class="new-session-page__menu-title">
|
||||
${t("newSession.yourDevices")}
|
||||
</div>
|
||||
${deviceNodes.map((node, index) =>
|
||||
renderSessionMenuItem(
|
||||
{
|
||||
@@ -567,41 +568,42 @@ export function renderPlaceSelect(params: {
|
||||
`
|
||||
: nothing}
|
||||
${cloudProfiles.length > 0 || (params.cloudProfileId && !activeProfile)
|
||||
? html`<div class="new-session-page__menu-title">
|
||||
${t("newSession.cloud")}
|
||||
</div>`
|
||||
: nothing}
|
||||
${renderCloudProfileMenuItems({
|
||||
profiles: cloudProfiles,
|
||||
selectedId: params.cloudProfileId,
|
||||
submitting: params.submitting,
|
||||
icon: icons.server,
|
||||
disabled: !params.worktreeAvailable || Boolean(params.cloudDisabledReason),
|
||||
disabledReason: params.cloudDisabledReason,
|
||||
onSelect: params.onSelectCloudProfile,
|
||||
})}
|
||||
${params.cloudProfileId && !activeProfile
|
||||
? renderSessionMenuItem(
|
||||
{
|
||||
value: `cloud:${params.cloudProfileId}`,
|
||||
label: t("newSession.cloudWorker", {
|
||||
profile: params.cloudProfileId,
|
||||
}),
|
||||
? html`
|
||||
<div class="new-session-page__menu-title">${t("newSession.cloud")}</div>
|
||||
${renderCloudProfileMenuItems({
|
||||
profiles: cloudProfiles,
|
||||
selectedId: params.cloudProfileId,
|
||||
submitting: params.submitting,
|
||||
icon: icons.server,
|
||||
checked: true,
|
||||
disabled: true,
|
||||
title: t("newSession.catalogUnavailable"),
|
||||
onSelect: () => undefined,
|
||||
},
|
||||
params.submitting,
|
||||
)
|
||||
: nothing}
|
||||
${params.cloudProfileId && params.syncFolder
|
||||
? html`<div class="new-session-page__menu-note">
|
||||
${t("newSession.cloudSyncsFolder", {
|
||||
folder: folderDisplayName(params.syncFolder),
|
||||
disabled:
|
||||
!params.worktreeAvailable || Boolean(params.cloudDisabledReason),
|
||||
disabledReason: params.cloudDisabledReason,
|
||||
onSelect: params.onSelectCloudProfile,
|
||||
})}
|
||||
</div>`
|
||||
${params.cloudProfileId && !activeProfile
|
||||
? renderSessionMenuItem(
|
||||
{
|
||||
value: `cloud:${params.cloudProfileId}`,
|
||||
label: t("newSession.cloudWorker", {
|
||||
profile: params.cloudProfileId,
|
||||
}),
|
||||
icon: icons.server,
|
||||
checked: true,
|
||||
disabled: true,
|
||||
title: t("newSession.catalogUnavailable"),
|
||||
onSelect: () => undefined,
|
||||
},
|
||||
params.submitting,
|
||||
)
|
||||
: nothing}
|
||||
${params.cloudProfileId && params.syncFolder
|
||||
? html`<div class="new-session-page__menu-note">
|
||||
${t("newSession.cloudSyncsFolder", {
|
||||
folder: folderDisplayName(params.syncFolder),
|
||||
})}
|
||||
</div>`
|
||||
: nothing}
|
||||
`
|
||||
: nothing}
|
||||
`
|
||||
: nothing}
|
||||
|
||||
Reference in New Issue
Block a user