diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt index fb0ea708d79f..e7e0b5e59eb5 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt @@ -539,6 +539,8 @@ enum class GatewayMethod( SecretsStoreDelete("secrets.store.delete"), UsersPrefsGet("users.prefs.get"), UsersPrefsSet("users.prefs.set"), + ProjectsAdd("projects.add"), + ProjectsSearchRemote("projects.searchRemote"), } enum class GatewayEvent( diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 13619e663bf2..a452c6140789 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -1583,6 +1583,24 @@ public struct WizardNotFoundErrorDetails: Codable, Sendable { } } +public struct ProjectCloneErrorDetails: Codable, Sendable { + public let code: String + public let cause: String + + public init( + code: String, + cause: String) + { + self.code = code + self.cause = cause + } + + private enum CodingKeys: String, CodingKey { + case code + case cause + } +} + public struct GatewaySuspendTaskBlocker: Codable, Sendable { public let taskid: String public let status: String @@ -3150,11 +3168,11 @@ public struct ProjectRecentProject: Codable, Sendable { public struct ProjectsListParams: Codable, Sendable {} public struct ProjectsListResult: Codable, Sendable { - public let projects: [ProjectsRegisterResult] + public let projects: [ProjectsAddResult] public let recents: [ProjectRecent]? public init( - projects: [ProjectsRegisterResult], + projects: [ProjectsAddResult], recents: [ProjectRecent]? = nil) { self.projects = projects @@ -3219,17 +3237,139 @@ public struct ProjectsRegisterResult: Codable, Sendable { } } -public struct ProjectsRemoveParams: Codable, Sendable { - public let id: String +public struct ProjectsAddParams: Codable, Sendable { + public let giturl: String + public let name: String? public init( - id: String) + giturl: String, + name: String? = nil) + { + self.giturl = giturl + self.name = name + } + + private enum CodingKeys: String, CodingKey { + case giturl = "gitUrl" + case name + } +} + +public struct ProjectsAddResult: Codable, Sendable { + public let id: String + public let displayname: String + public let reporoot: String? + public let originurl: String? + public let source: String + public let agentid: String? + + public init( + id: String, + displayname: String, + reporoot: String? = nil, + originurl: String? = nil, + source: String, + agentid: String? = nil) { self.id = id + self.displayname = displayname + self.reporoot = reporoot + self.originurl = originurl + self.source = source + self.agentid = agentid } private enum CodingKeys: String, CodingKey { case id + case displayname = "displayName" + case reporoot = "repoRoot" + case originurl = "originUrl" + case source + case agentid = "agentId" + } +} + +public struct RemoteProject: Codable, Sendable { + public let name: String + public let fullname: String + public let description: String? + public let cloneurl: String + public let weburl: String + public let _private: Bool + + public init( + name: String, + fullname: String, + description: String? = nil, + cloneurl: String, + weburl: String, + _private: Bool) + { + self.name = name + self.fullname = fullname + self.description = description + self.cloneurl = cloneurl + self.weburl = weburl + self._private = _private + } + + private enum CodingKeys: String, CodingKey { + case name + case fullname = "fullName" + case description + case cloneurl = "cloneUrl" + case weburl = "webUrl" + case _private = "private" + } +} + +public struct ProjectsSearchRemoteParams: Codable, Sendable { + public let query: String + + public init( + query: String) + { + self.query = query + } + + private enum CodingKeys: String, CodingKey { + case query + } +} + +public struct ProjectsSearchRemoteResult: Codable, Sendable { + public let credential: AnyCodable + public let projects: [RemoteProject] + + public init( + credential: AnyCodable, + projects: [RemoteProject]) + { + self.credential = credential + self.projects = projects + } + + private enum CodingKeys: String, CodingKey { + case credential + case projects + } +} + +public struct ProjectsRemoveParams: Codable, Sendable { + public let id: String + public let deletecheckout: Bool? + + public init( + id: String, + deletecheckout: Bool? = nil) + { + self.id = id + self.deletecheckout = deletecheckout + } + + private enum CodingKeys: String, CodingKey { + case id + case deletecheckout = "deleteCheckout" } } @@ -18764,6 +18904,7 @@ public enum GatewayErrorDetails: Codable, Sendable { case missingScope(MissingScopeErrorDetails) case mcpAppViewExpired(McpAppViewExpiredErrorDetails) case userPrefsLimitExceeded(UserPrefsLimitExceededErrorDetails) + case projectCloneFailed(ProjectCloneErrorDetails) case unknownAgentId(UnknownAgentIdErrorDetails) case wizardNotFound(WizardNotFoundErrorDetails) @@ -18782,6 +18923,7 @@ public enum GatewayErrorDetails: Codable, Sendable { case .missingScope(let value): value.code case .mcpAppViewExpired(let value): value.code case .userPrefsLimitExceeded(let value): value.code + case .projectCloneFailed(let value): value.code case .unknownAgentId(let value): value.code case .wizardNotFound(let value): value.code } @@ -18808,6 +18950,7 @@ public enum GatewayErrorDetails: Codable, Sendable { case "MISSING_SCOPE": self = try .missingScope(MissingScopeErrorDetails(from: decoder)) case "MCP_APP_VIEW_EXPIRED": self = try .mcpAppViewExpired(McpAppViewExpiredErrorDetails(from: decoder)) case "USER_PREFS_LIMIT_EXCEEDED": self = try .userPrefsLimitExceeded(UserPrefsLimitExceededErrorDetails(from: decoder)) + case "PROJECT_CLONE_FAILED": self = try .projectCloneFailed(ProjectCloneErrorDetails(from: decoder)) case "UNKNOWN_AGENT_ID": self = try .unknownAgentId(UnknownAgentIdErrorDetails(from: decoder)) case "WIZARD_NOT_FOUND": self = try .wizardNotFound(WizardNotFoundErrorDetails(from: decoder)) default: @@ -18824,6 +18967,7 @@ public enum GatewayErrorDetails: Codable, Sendable { case .missingScope(let value): try value.encode(to: encoder) case .mcpAppViewExpired(let value): try value.encode(to: encoder) case .userPrefsLimitExceeded(let value): try value.encode(to: encoder) + case .projectCloneFailed(let value): try value.encode(to: encoder) case .unknownAgentId(let value): try value.encode(to: encoder) case .wizardNotFound(let value): try value.encode(to: encoder) } diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index 156454e1d64e..7443356e117f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -1 +1 @@ -{"contentHash":"b017f177c844e59f8c9771773fd9fe13bd49777097859ecec3e90057d6cc3709","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"d0683c63808cdede1cd0272e9ff5e8e65e84aec2919f3023a0105e3d518ef88b","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index 76fa3e9df474..8725cec2083f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"9695a1112df503dd92e36a5502aa3930289d6828a562917ea5b8d6b7c25a0755","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"4f9331793861042e293a88fe55780dbe412e9723e0b21b9c2d8928af99677e08","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index 4ab239a09843..519c11520f3b 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"96449168bf95c5c22ff75ba7d50d21cb2d9c73666c5ceef95450d76273cd36ca","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"cbd5e1cbd7760bd7d83855602340c240b4ea31f3f6b5ec3a8a047f175d49aca1","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index 91b056073041..4253accf5c1d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -1 +1 @@ -{"contentHash":"f1e6d92d125cdaca72317819b5ba7619cbfcb67ee72752bb009af78a8f753b38","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"38d99e882f343de8f14b8a9f3744797f21c8ba57e1b47e0f6023616187830e07","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index 5f54cddd6ffc..803356dce6b8 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"1b3770f22431dbbce8ffcbffd4496a95d2afaac82172aa48b73c9c8bceac7ca5","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"74a4b83f8d211cbac98f76776cc87428849d82e25cdcd435fa39ac54090a5656","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index 6a635241420d..c0fbb222618e 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"fdea77b1259c9799177f9c2b327a6d759058d0df0ef339834b6c0a6a9d04b130","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"6b3ff49b6f02bf2bd819c9f54e2c19c303c0fbbd5477aa957c52e613c627eeb3","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index 707a539677b4..60a2ff3a8ad1 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -1 +1 @@ -{"contentHash":"ddc4dd3f139c5858912bad00560051731ef386eaec43957343b01f76224ecca3","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"63eeb2549909670226a3b54130dc07a4648d32315177f5b0f00f59a428f65e22","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index 90e16f9335a5..e3abd66a0bc6 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"8f806aa43ddd9298176cacb5971b8ddac7f2c1d25101deaa92b3c9e1f788e09a","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"93f1ce35c883984c29344931f34b01734de2a98e46fd04274f962dca57eb8cfe","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index e581f4e8c2aa..5def6533cf55 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"11138710259d030a81c55a9e506254428328931945b6c6b79fc969e832d56901","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"8efd73d7cce61a8ded5c1d503724c0eb5a1347d563457da7838abc3a4cc93cf7","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json index 6a38fc05d724..e9f89f06dc91 100644 --- a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json @@ -1 +1 @@ -{"contentHash":"0b8f513b3563d19ec9fa306d79c645c27d25ede0e83c99c394dd90e121fb1e74","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} +{"contentHash":"f963767c9c08f64d9c1dcddf0a1dfcfda5c3d2e44f9bf14fd01f4a51fb51a474","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index aa9ae29cf581..480d057425d4 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -1 +1 @@ -{"contentHash":"180d24e66df9e1242f1ba78f4aea182c79340280d022db048b227071892fbb59","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"02eda1e2d2eeba34b383ec049ef352056c8e5493d318edabf8e222966ec124cf","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index 000067354712..190944565b45 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"5a99888e906478be43c13f67f99fb2f74c7396eb834202e7d872f0e2ee60f144","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"3b4327fc3220ed4f7de35a61f1bcaab43bde6dce69bf39b23b97417a742b9cbf","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index bd28a0155379..fd3c00c7c03e 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"1896a2342b27ee5f055bf77f9a826f76ddc6520c9dce44789d6d612cfcdd17dc","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"f118c2cf99da8026045993446453a0815cd5c08faebb8383505adf3362a83825","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index 047163253053..e9ac90387b14 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"fcdd433db990480373e6caea357c69cbbc754bcbf492fe3371cd21491d9bd04f","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"a32fad46afdac98569830abb5696202ae2bc0ed737347411b99f936d9b9f7be0","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index baf8a2143932..15ae2be3d2bb 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -1 +1 @@ -{"contentHash":"9459eb7103be5db943a12ebbf69b62f9a06b60cf5a24df9a8b72fa2bd4242dce","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"3e424f4a5e8054f02925f65c4e94ca8bc6a157e9c4d18f16667235ce235d994d","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index 2fe2b0a9603d..8658b3c3fe84 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"1d4374297f908a74d78873c746fc0624240182a96c28a064a47f91f6260049bb","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"86b29041a2312ae701e7c9e2e7a772526499b5d4dd0886a8b11edc1bce78da5f","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index 5c2e2fcb23b4..3f0ba0091b7b 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"f5bf0f3889cd0e4a5b1de4e110098400573d46c508c962c386db3334246bc9fe","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"ed0bdbdc3e327b6a97bf04cb7d57631f819949c7ff65e970dc72ce8cf0a0c5ba","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 2447aad14c3e..3b89bd032aae 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -244,6 +244,8 @@ The **+** in the sidebar session-list header opens a full-page draft at `/new`: **Projects.** The Place picker lists configured agent workspaces and repositories recorded with `projects.register`. Read-only connections receive project names and IDs; checkout paths and origin URLs are included only at `operator.write`. An admin can browse to a Git checkout and choose **Register as project**; write-only operators see a hint directing them to that flow. Choosing a project sends its ID through `sessions.create`, so it can run directly or supply the source for optional Worktree isolation without submitting a raw path. If its checkout was moved or removed, re-register it or run `openclaw doctor --fix` before starting another session there. +**Projects from GitHub.** Search the same picker or paste a GitHub HTTPS or `git@github.com` repository URL to clone it into the Gateway-managed projects area and select it. Public repository search and cloning work anonymously; set `GH_TOKEN` in the Gateway [environment](/help/environment) and restart the Gateway to include affiliated and private repositories. Search requires `operator.read`, cloning requires `operator.write`, and deleting a Gateway-managed cloned checkout requires `operator.admin`. Clone deletion refuses while a live session or managed worktree still references the checkout. + On multi-user gateways, only admin-scope connections can create or view incognito threads, and other sessions cannot reach them through agent session tools or transcript search. Incognito protects against storage and other gateway-mediated users, not against the gateway owner or process operator, who can always observe live sessions. **Browse folders** opens the Place picker's inline directory browser through `fs.listDir`. Write-scope Gateway browsing starts at the configured agent workspace and cannot navigate above it; realpath checks also reject symlinks that escape the workspace. Admin connections can browse arbitrary Gateway paths and browse-capable nodes. An execution-capable node without `fs.listDir` still accepts a typed absolute path for admins. Recent places restore only folders the current connection can submit, and node recents remain admin-only. Submitting calls `sessions.create` with the first message, so the run starts in the same round-trip and the UI jumps to the new session's chat. If the Gateway creates the session but rejects that first send, the chat preserves the prompt and error across reloads; **Retry** sends it through the already-created session instead of creating another one. diff --git a/packages/gateway-protocol/src/gateway-error-details.ts b/packages/gateway-protocol/src/gateway-error-details.ts index 1938fe45fcc7..27d1dd120f55 100644 --- a/packages/gateway-protocol/src/gateway-error-details.ts +++ b/packages/gateway-protocol/src/gateway-error-details.ts @@ -27,6 +27,7 @@ export const GatewayErrorDetailCodes = { MCP_APP_VIEW_EXPIRED: "MCP_APP_VIEW_EXPIRED", USER_PREFS_LIMIT_EXCEEDED: "USER_PREFS_LIMIT_EXCEEDED", SESSION_COMPANION_BUSY: "SESSION_COMPANION_BUSY", + PROJECT_CLONE_FAILED: "PROJECT_CLONE_FAILED", UNKNOWN_AGENT_ID: "UNKNOWN_AGENT_ID", WIZARD_NOT_FOUND: "WIZARD_NOT_FOUND", } as const; @@ -60,11 +61,25 @@ export type WizardNotFoundErrorDetails = { code: typeof GatewayErrorDetailCodes.WIZARD_NOT_FOUND; }; +export type ProjectCloneFailureCause = + | "invalid_url" + | "auth_required" + | "not_found" + | "network" + | "target_exists" + | "clone_failed"; + +export type ProjectCloneErrorDetails = { + code: typeof GatewayErrorDetailCodes.PROJECT_CLONE_FAILED; + cause: ProjectCloneFailureCause; +}; + /** Structured details emitted by method-level failures. */ export type GatewayErrorDetails = | MissingScopeErrorDetails | McpAppViewExpiredErrorDetails | UserPrefsLimitExceededErrorDetails + | ProjectCloneErrorDetails | UnknownAgentIdErrorDetails | WizardNotFoundErrorDetails; diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index ee215426fa76..a89b94a37e7e 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -22,6 +22,8 @@ export type { McpAppViewExpiredErrorDetails, MissingScopeErrorDetails, UserPrefsLimitExceededErrorDetails, + ProjectCloneErrorDetails, + ProjectCloneFailureCause, WizardNotFoundErrorDetails, } from "./schema/error-codes.js"; export * from "./schema/board.js"; @@ -74,6 +76,7 @@ export { GatewayErrorDetailsSchema, MissingScopeErrorDetailsSchema, UserPrefsLimitExceededErrorDetailsSchema, + ProjectCloneErrorDetailsSchema, WizardNotFoundErrorDetailsSchema, WorkerAdmissionFailureReasonSchema, WorkerAdmissionHandshakeSchema, @@ -642,6 +645,11 @@ export { ProjectsListResultSchema, ProjectsRegisterParamsSchema, ProjectsRegisterResultSchema, + ProjectsAddParamsSchema, + ProjectsAddResultSchema, + RemoteProjectSchema, + ProjectsSearchRemoteParamsSchema, + ProjectsSearchRemoteResultSchema, ProjectsRemoveParamsSchema, ProjectsRemoveResultSchema, WorktreeRecordSchema, diff --git a/packages/gateway-protocol/src/schema/error-codes.test.ts b/packages/gateway-protocol/src/schema/error-codes.test.ts index 9e7fa88381c2..810fec060fc2 100644 --- a/packages/gateway-protocol/src/schema/error-codes.test.ts +++ b/packages/gateway-protocol/src/schema/error-codes.test.ts @@ -7,6 +7,7 @@ import { isMcpAppViewExpiredError, McpAppViewExpiredErrorDetailsSchema, MissingScopeErrorDetailsSchema, + ProjectCloneErrorDetailsSchema, missingScopeErrorShape, readMissingScopeError, readMissingScopeErrorDetails, @@ -53,6 +54,18 @@ describe("gateway error details", () => { ); }); + it("validates typed project clone failures", () => { + const details = { + code: GatewayErrorDetailCodes.PROJECT_CLONE_FAILED, + cause: "auth_required", + }; + expect(Value.Check(ProjectCloneErrorDetailsSchema, details)).toBe(true); + expect(Value.Check(GatewayErrorDetailsSchema, details)).toBe(true); + expect(Value.Check(ProjectCloneErrorDetailsSchema, { ...details, cause: "unknown" })).toBe( + false, + ); + }); + it("builds a distinct forbidden missing-scope response", () => { expect( missingScopeErrorShape({ diff --git a/packages/gateway-protocol/src/schema/error-codes.ts b/packages/gateway-protocol/src/schema/error-codes.ts index 5e0aa6526d58..0a1be5d3199c 100644 --- a/packages/gateway-protocol/src/schema/error-codes.ts +++ b/packages/gateway-protocol/src/schema/error-codes.ts @@ -18,6 +18,8 @@ export { type McpAppViewExpiredErrorDetails, type MissingScopeErrorDetails, type UserPrefsLimitExceededErrorDetails, + type ProjectCloneErrorDetails, + type ProjectCloneFailureCause, type UnknownAgentIdErrorDetails, type WizardNotFoundErrorDetails, isMcpAppViewExpiredError, @@ -51,11 +53,19 @@ export const WizardNotFoundErrorDetailsSchema = closedObject({ code: Type.Literal(GatewayErrorDetailCodes.WIZARD_NOT_FOUND), }); +export const ProjectCloneErrorDetailsSchema = closedObject({ + code: Type.Literal(GatewayErrorDetailCodes.PROJECT_CLONE_FAILED), + cause: Type.String({ + enum: ["invalid_url", "auth_required", "not_found", "network", "target_exists", "clone_failed"], + }), +}); + /** Structured details emitted by method-level failures. */ export const GatewayErrorDetailsSchema = Type.Union([ MissingScopeErrorDetailsSchema, McpAppViewExpiredErrorDetailsSchema, UserPrefsLimitExceededErrorDetailsSchema, + ProjectCloneErrorDetailsSchema, UnknownAgentIdErrorDetailsSchema, WizardNotFoundErrorDetailsSchema, ]); diff --git a/packages/gateway-protocol/src/schema/projects.test.ts b/packages/gateway-protocol/src/schema/projects.test.ts index f8237d577379..a67ffe651df9 100644 --- a/packages/gateway-protocol/src/schema/projects.test.ts +++ b/packages/gateway-protocol/src/schema/projects.test.ts @@ -2,10 +2,14 @@ import { Value } from "typebox/value"; import { describe, expect, it } from "vitest"; import { ProjectRecordSchema, + ProjectsAddResultSchema, ProjectsListResultSchema, + ProjectsSearchRemoteResultSchema, + validateProjectsAddParams, validateProjectsListParams, validateProjectsRegisterParams, validateProjectsRemoveParams, + validateProjectsSearchRemoteParams, validateSessionsCreateParams, } from "../index.js"; @@ -15,10 +19,42 @@ describe("project protocol schemas", () => { expect(validateProjectsListParams({ extra: true })).toBe(false); expect(validateProjectsRegisterParams({ path: "/repo", name: "OpenClaw" })).toBe(true); expect(validateProjectsRegisterParams({ path: "" })).toBe(false); - expect(validateProjectsRemoveParams({ id: "openclaw-2" })).toBe(true); + expect(validateProjectsAddParams({ gitUrl: "https://github.com/openclaw/openclaw.git" })).toBe( + true, + ); + expect(validateProjectsAddParams({ gitUrl: "", unexpected: true })).toBe(false); + expect(validateProjectsSearchRemoteParams({ query: "openclaw" })).toBe(true); + expect(validateProjectsSearchRemoteParams({ query: "" })).toBe(false); + expect(validateProjectsRemoveParams({ id: "openclaw-2", deleteCheckout: true })).toBe(true); expect(validateProjectsRemoveParams({ id: "workspace:main" })).toBe(false); }); + it("accepts bounded remote search and clone results", () => { + const project = { + id: "openclaw", + displayName: "OpenClaw", + repoRoot: "/state/projects/fingerprint/openclaw", + originUrl: "https://github.com/openclaw/openclaw.git", + source: "cloned", + }; + expect(Value.Check(ProjectsAddResultSchema, project)).toBe(true); + expect( + Value.Check(ProjectsSearchRemoteResultSchema, { + credential: "missing", + projects: [ + { + name: "openclaw", + fullName: "openclaw/openclaw", + description: "Personal AI assistant", + cloneUrl: "https://github.com/openclaw/openclaw.git", + webUrl: "https://github.com/openclaw/openclaw", + private: false, + }, + ], + }), + ).toBe(true); + }); + it("accepts workspace and stored project records", () => { expect( Value.Check(ProjectRecordSchema, { diff --git a/packages/gateway-protocol/src/schema/projects.ts b/packages/gateway-protocol/src/schema/projects.ts index fe899b662f30..b9052ad35add 100644 --- a/packages/gateway-protocol/src/schema/projects.ts +++ b/packages/gateway-protocol/src/schema/projects.ts @@ -56,7 +56,32 @@ export const ProjectsRegisterParamsSchema = closedObject({ }); export const ProjectsRegisterResultSchema = ProjectRecordSchema; -export const ProjectsRemoveParamsSchema = closedObject({ id: StoredProjectIdSchema }); +export const ProjectsAddParamsSchema = closedObject({ + gitUrl: Type.String({ minLength: 1, maxLength: 2048 }), + name: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })), +}); +export const ProjectsAddResultSchema = ProjectRecordSchema; + +export const RemoteProjectSchema = closedObject({ + name: Type.String({ minLength: 1, maxLength: 100 }), + fullName: Type.String({ minLength: 1, maxLength: 200 }), + description: Type.Optional(Type.String({ maxLength: 500 })), + cloneUrl: Type.String({ minLength: 1, maxLength: 2048 }), + webUrl: Type.String({ minLength: 1, maxLength: 2048 }), + private: Type.Boolean(), +}); +export const ProjectsSearchRemoteParamsSchema = closedObject({ + query: Type.String({ minLength: 1, maxLength: 200 }), +}); +export const ProjectsSearchRemoteResultSchema = closedObject({ + credential: Type.Union([Type.Literal("configured"), Type.Literal("missing")]), + projects: Type.Array(RemoteProjectSchema, { maxItems: 10 }), +}); + +export const ProjectsRemoveParamsSchema = closedObject({ + id: StoredProjectIdSchema, + deleteCheckout: Type.Optional(Type.Boolean()), +}); export const ProjectsRemoveResultSchema = closedObject({ removed: Type.Boolean() }); export type ProjectRecord = Static; @@ -65,5 +90,10 @@ export type ProjectsListParams = Static; export type ProjectsListResult = Static; export type ProjectsRegisterParams = Static; export type ProjectsRegisterResult = Static; +export type ProjectsAddParams = Static; +export type ProjectsAddResult = Static; +export type RemoteProject = Static; +export type ProjectsSearchRemoteParams = Static; +export type ProjectsSearchRemoteResult = Static; export type ProjectsRemoveParams = Static; export type ProjectsRemoveResult = Static; diff --git a/packages/gateway-protocol/src/schema/protocol-schema-fragment-agent-control.ts b/packages/gateway-protocol/src/schema/protocol-schema-fragment-agent-control.ts index 882d4b3e15ea..29e4fe56a3b9 100644 --- a/packages/gateway-protocol/src/schema/protocol-schema-fragment-agent-control.ts +++ b/packages/gateway-protocol/src/schema/protocol-schema-fragment-agent-control.ts @@ -53,6 +53,11 @@ export const AgentControlProtocolSchemas = { ProjectsListResult: projects.ProjectsListResultSchema, ProjectsRegisterParams: projects.ProjectsRegisterParamsSchema, ProjectsRegisterResult: projects.ProjectsRegisterResultSchema, + ProjectsAddParams: projects.ProjectsAddParamsSchema, + ProjectsAddResult: projects.ProjectsAddResultSchema, + RemoteProject: projects.RemoteProjectSchema, + ProjectsSearchRemoteParams: projects.ProjectsSearchRemoteParamsSchema, + ProjectsSearchRemoteResult: projects.ProjectsSearchRemoteResultSchema, ProjectsRemoveParams: projects.ProjectsRemoveParamsSchema, ProjectsRemoveResult: projects.ProjectsRemoveResultSchema, WorktreeRecord: worktrees.WorktreeRecordSchema, diff --git a/packages/gateway-protocol/src/schema/protocol-schema-fragment-transport.ts b/packages/gateway-protocol/src/schema/protocol-schema-fragment-transport.ts index 79e1264b2266..b44e432c70ff 100644 --- a/packages/gateway-protocol/src/schema/protocol-schema-fragment-transport.ts +++ b/packages/gateway-protocol/src/schema/protocol-schema-fragment-transport.ts @@ -21,6 +21,7 @@ export const TransportProtocolSchemas = { UnknownAgentIdErrorDetails: errorCodes.UnknownAgentIdErrorDetailsSchema, WizardNotFoundErrorDetails: errorCodes.WizardNotFoundErrorDetailsSchema, GatewayErrorDetails: errorCodes.GatewayErrorDetailsSchema, + ProjectCloneErrorDetails: errorCodes.ProjectCloneErrorDetailsSchema, GatewaySuspendTaskBlocker: gatewaySuspend.GatewaySuspendTaskBlockerSchema, GatewaySuspendBlocker: gatewaySuspend.GatewaySuspendBlockerSchema, GatewaySuspendPrepareParams: gatewaySuspend.GatewaySuspendPrepareParamsSchema, diff --git a/packages/gateway-protocol/src/validator-registry.ts b/packages/gateway-protocol/src/validator-registry.ts index 368c4415c00e..f3c5ec1593bb 100644 --- a/packages/gateway-protocol/src/validator-registry.ts +++ b/packages/gateway-protocol/src/validator-registry.ts @@ -110,6 +110,8 @@ export const validateWakeParams = compile(S.WakeParamsSchema); export const validateAgentsListParams = compile(S.AgentsListParamsSchema); export const validateProjectsListParams = compile(S.ProjectsListParamsSchema); export const validateProjectsRegisterParams = compile(S.ProjectsRegisterParamsSchema); +export const validateProjectsAddParams = compile(S.ProjectsAddParamsSchema); +export const validateProjectsSearchRemoteParams = compile(S.ProjectsSearchRemoteParamsSchema); export const validateProjectsRemoveParams = compile(S.ProjectsRemoveParamsSchema); export const validateWorktreesListParams = compile(S.WorktreesListParamsSchema); export const validateBoardGetParams = compile(S.BoardGetParamsSchema); diff --git a/scripts/protocol-gen-swift.ts b/scripts/protocol-gen-swift.ts index d20837ec79a6..df116efdcac5 100644 --- a/scripts/protocol-gen-swift.ts +++ b/scripts/protocol-gen-swift.ts @@ -610,7 +610,10 @@ function swiftUnionCaseName(value: boolean | number | string | null, fallback: s return safeName(String(value)); } -function emitDiscriminatedUnionCompatibility(name: string): string[] { +function emitDiscriminatedUnionCompatibility( + name: string, + cases: readonly { caseName: string }[], +): string[] { if (name !== "GatewayErrorDetails") { return []; } @@ -629,11 +632,7 @@ function emitDiscriminatedUnionCompatibility(name: string): string[] { "", " public var code: String {", " switch self {", - " case .missingScope(let value): value.code", - " case .mcpAppViewExpired(let value): value.code", - " case .userPrefsLimitExceeded(let value): value.code", - " case .unknownAgentId(let value): value.code", - " case .wizardNotFound(let value): value.code", + ...cases.map((entry) => ` case .${entry.caseName}(let value): value.code`), " }", " }", "", @@ -709,7 +708,7 @@ function emitDiscriminatedUnion(name: string, schema: JsonSchema): string | unde `public enum ${name}: Codable, Sendable {`, ...resolvedCases.map((entry) => ` case ${entry.caseName}(${entry.branchName})`), "", - ...emitDiscriminatedUnionCompatibility(name), + ...emitDiscriminatedUnionCompatibility(name, resolvedCases), " private enum CodingKeys: String, CodingKey {", ` case discriminator = "${discriminator}"`, " }", diff --git a/src/flows/doctor-health-contribution-runners.gateway.ts b/src/flows/doctor-health-contribution-runners.gateway.ts index 31d074f49fd2..8d233b860588 100644 --- a/src/flows/doctor-health-contribution-runners.gateway.ts +++ b/src/flows/doctor-health-contribution-runners.gateway.ts @@ -84,6 +84,16 @@ export async function runWebFetchProxyHealth(ctx: DoctorHealthFlowContext): Prom await noteWebFetchProxyDiagnostic({ cfg: ctx.cfg, env: ctx.env ?? process.env }); } +export async function runGitHubProjectHealth(ctx: DoctorHealthFlowContext): Promise { + const { githubApiToken } = await import("../gateway/control-ui-github-api.js"); + if (!githubApiToken(ctx.env ?? process.env)) { + note( + "Set GH_TOKEN in the Gateway environment to enable authenticated GitHub project search, including private repositories.", + "GitHub projects", + ); + } +} + export async function runBrowserHealth(ctx: DoctorHealthFlowContext): Promise { const { noteChromeMcpBrowserReadiness } = await import("../commands/doctor-browser.js"); await runCoreContributionHealth(ctx, ["core/doctor/browser-clawd-profile-residue"]); diff --git a/src/flows/doctor-health-contributions-final.ts b/src/flows/doctor-health-contributions-final.ts index ed18e0625ec5..0502e5a69391 100644 --- a/src/flows/doctor-health-contributions-final.ts +++ b/src/flows/doctor-health-contributions-final.ts @@ -11,6 +11,7 @@ import { runDevicePairingHealth, runGatewayDaemonHealth, runGatewayServicesHealth, + runGitHubProjectHealth, runOpenAIOAuthTlsHealth, runSecurityHealth, runStartupChannelMaintenanceHealth, @@ -120,6 +121,11 @@ export function resolveFinalDoctorHealthContributions(params: { label: "Web fetch proxy", run: runWebFetchProxyHealth, }), + createDoctorHealthContribution({ + id: "doctor:github-projects", + label: "GitHub projects", + run: runGitHubProjectHealth, + }), createDoctorHealthContribution({ id: "doctor:browser", label: "Browser", diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index 825e08693a87..5f74df0fa84d 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -1731,6 +1731,26 @@ describe("doctor health contributions", () => { ); }); + it("hints how to enable authenticated GitHub project search", async () => { + const contribution = requireDoctorContribution("doctor:github-projects"); + const ctx = { + cfg: {}, + configResult: {}, + sourceConfigValid: true, + prompter: buildDoctorPrompter(false), + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + options: {}, + env: {}, + } as unknown as DoctorContributionRunContext; + + await contribution.run(ctx); + expect(mocks.note).toHaveBeenCalledWith(expect.stringContaining("GH_TOKEN"), "GitHub projects"); + + mocks.note.mockClear(); + await contribution.run({ ...ctx, env: { GH_TOKEN: "configured" } }); + expect(mocks.note).not.toHaveBeenCalled(); + }); + it("passes the active config into legacy state migration", async () => { const contribution = requireDoctorContribution("doctor:legacy-state"); const legacyStateCheck = CORE_HEALTH_CHECKS.find( diff --git a/src/gateway/control-ui-github-api.ts b/src/gateway/control-ui-github-api.ts index bdd7df4be979..dfa88ab243b9 100644 --- a/src/gateway/control-ui-github-api.ts +++ b/src/gateway/control-ui-github-api.ts @@ -41,8 +41,8 @@ export function optionalNumber(record: Record, key: string): nu return typeof value === "number" && Number.isFinite(value) ? value : undefined; } -export function githubApiToken(): string | undefined { - return process.env.GH_TOKEN?.trim() || process.env.GITHUB_TOKEN?.trim() || undefined; +export function githubApiToken(env: NodeJS.ProcessEnv = process.env): string | undefined { + return env.GH_TOKEN?.trim() || env.GITHUB_TOKEN?.trim() || undefined; } function githubApiHeaders(token?: string): Record { diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index a91c214c8de1..4d235237ae6c 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -79,6 +79,8 @@ describe("method scope resolution", () => { ["users.prefs.set", ["operator.write"]], ["projects.register", ["operator.admin"]], ["projects.remove", ["operator.admin"]], + ["projects.add", ["operator.write"]], + ["projects.searchRemote", ["operator.read"]], ["sessions.groups.list", ["operator.read"]], ["sessions.groups.put", ["operator.write"]], ["sessions.groups.rename", ["operator.write"]], diff --git a/src/gateway/methods/core-descriptors.since.test.ts b/src/gateway/methods/core-descriptors.since.test.ts index fc2837959460..a8ef2bec0a25 100644 --- a/src/gateway/methods/core-descriptors.since.test.ts +++ b/src/gateway/methods/core-descriptors.since.test.ts @@ -88,6 +88,8 @@ const CURRENT_TRAIN_METHODS = [ "projects.list", "projects.register", "projects.remove", + "projects.add", + "projects.searchRemote", "worker.desktop.launch", "secrets.store.list", "secrets.store.set", @@ -123,7 +125,13 @@ describe("core gateway method release trains", () => { expect(methods.find((method) => method.name === "worker.desktop.observe")?.since).toBe( "2026.8", ); - for (const method of ["projects.list", "projects.register", "projects.remove"]) { + for (const method of [ + "projects.list", + "projects.register", + "projects.remove", + "projects.add", + "projects.searchRemote", + ]) { expect(methods.find((candidate) => candidate.name === method)?.since).toBe("2026.8"); } expect(methods.find((method) => method.name === "worker.desktop.launch")?.since).toBe("2026.8"); diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index 9190c6649aca..1dfa3aabde84 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -17,12 +17,13 @@ type CoreGatewayMethodSpec = { startup?: true; controlPlaneWrite?: true; compatibilityRestored?: true; + description?: string; }; type CoreGatewayMethodMetadata = Pick; type CoreGatewayMethodPolicy = Pick< CoreGatewayMethodSpec, - "advertise" | "startup" | "controlPlaneWrite" | "compatibilityRestored" + "advertise" | "startup" | "controlPlaneWrite" | "compatibilityRestored" | "description" >; type CoreGatewayMethodSpecRow = readonly [ name: string, @@ -505,6 +506,14 @@ const CORE_GATEWAY_METHOD_SPECS = [ // Self-scoped preferences append so every older advertised index remains stable. ["users.prefs.get", "users", "operator.read", "2026.8"], ["users.prefs.set", "users", "operator.write", "2026.8"], + ["projects.add", "projects", "operator.write", "2026.8", { controlPlaneWrite: true }], + [ + "projects.searchRemote", + "projects", + "operator.read", + "2026.8", + { description: "Search GitHub repositories that can be cloned as managed projects." }, + ], ] as const satisfies readonly CoreGatewayMethodSpecRow[]; export type CoreGatewayHandlerFamily = Exclude<(typeof CORE_GATEWAY_METHOD_SPECS)[number][1], null>; @@ -528,6 +537,9 @@ const CORE_GATEWAY_METHOD_SPEC_LIST: readonly CoreGatewayMethodSpec[] = if (normalizedPolicy?.compatibilityRestored === true) { spec.compatibilityRestored = true; } + if (normalizedPolicy?.description) { + spec.description = normalizedPolicy.description; + } return spec; }); @@ -624,6 +636,7 @@ export function createCoreGatewayMethodDescriptors( ...(spec.advertise === false ? { advertise: false } : {}), ...(spec.startup === true ? { startup: "unavailable-until-sidecars" } : {}), ...(spec.controlPlaneWrite === true ? { controlPlaneWrite: true } : {}), + ...(spec.description ? { description: spec.description } : {}), }); } for (const name of Object.keys(handlers)) { diff --git a/src/gateway/project-github-search.test.ts b/src/gateway/project-github-search.test.ts new file mode 100644 index 000000000000..2a898629748e --- /dev/null +++ b/src/gateway/project-github-search.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from "vitest"; +import { searchRemoteProjects } from "./project-github-search.js"; + +function repository(fullName: string, updatedAt: string, description?: string) { + const [owner, name] = fullName.split("/"); + return { + id: fullName, + name, + full_name: fullName, + private: false, + html_url: `https://github.com/${owner}/${name}`, + clone_url: `https://github.com/${owner}/${name}.git`, + description: description ?? null, + updated_at: updatedAt, + }; +} + +function json(value: unknown, status = 200): Response { + return new Response(JSON.stringify(value), { + status, + headers: { "content-type": "application/json" }, + }); +} + +describe("project GitHub search", () => { + it("returns anonymous public results with a typed missing-credential state", async () => { + const fetchImpl = vi.fn().mockResolvedValue( + json({ + total_count: 1, + incomplete_results: false, + items: [repository("openclaw/openclaw", "2026-08-10T00:00:00Z")], + }), + ); + + const result = await searchRemoteProjects("anonymous-openclaw", { + env: {}, + fetchImpl, + now: 100, + }); + + expect(result).toMatchObject({ + credential: "missing", + projects: [{ fullName: "openclaw/openclaw" }], + }); + expect(fetchImpl).toHaveBeenCalledOnce(); + expect(fetchImpl.mock.calls[0]?.[0]).toContain("/search/repositories?"); + }); + + it("prioritizes matching affiliated repositories and fills from global search", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + json([ + repository("acme/matching-private", "2026-01-01T00:00:00Z", "configured-query"), + repository("acme/unrelated", "2026-08-10T00:00:00Z"), + ]), + ) + .mockResolvedValueOnce( + json({ + items: [ + repository("acme/matching-private", "2026-08-11T00:00:00Z"), + repository("public/configured-query", "2026-08-10T00:00:00Z"), + ], + }), + ); + + const result = await searchRemoteProjects("configured-query", { + env: { GH_TOKEN: "test-github-token" }, + fetchImpl, + now: 200, + }); + + expect(result).toEqual({ + credential: "configured", + projects: [ + expect.objectContaining({ fullName: "acme/matching-private" }), + expect.objectContaining({ fullName: "public/configured-query" }), + ], + }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(fetchImpl.mock.calls[0]?.[0]).toContain("/user/repos?"); + expect(fetchImpl.mock.calls[0]?.[1]?.headers).toHaveProperty( + "Authorization", + "Bearer test-github-token", + ); + }); + + it("caches normalized queries for 60 seconds and refetches after expiry", async () => { + const fetchImpl = vi + .fn() + .mockImplementation(async () => + json({ items: [repository("acme/cache-query", "2026-08-10")] }), + ); + const options = { env: {}, fetchImpl, now: 1_000 }; + + const first = await searchRemoteProjects("Cache-Query", options); + const cached = await searchRemoteProjects(" cache-query ", { ...options, now: 60_999 }); + const refreshed = await searchRemoteProjects("cache-query", { ...options, now: 61_001 }); + + expect(cached).toBe(first); + expect(refreshed).toEqual(first); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/gateway/project-github-search.ts b/src/gateway/project-github-search.ts new file mode 100644 index 000000000000..a739896160b0 --- /dev/null +++ b/src/gateway/project-github-search.ts @@ -0,0 +1,194 @@ +import type { + RemoteProject, + ProjectsSearchRemoteResult, +} from "../../packages/gateway-protocol/src/index.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; +import { parseProjectGitUrl } from "../projects/project-git-url.js"; +import { + ControlUiGitHubError, + fetchGitHubApi, + fetchGitHubJson, + GITHUB_API_ORIGIN, + githubApiToken, + isRecord, + readOptionalGitHubString, + readGitHubJsonResponse, + requiredString, +} from "./control-ui-github-api.js"; + +const SEARCH_CACHE_MS = 60_000; +const SEARCH_CACHE_LIMIT = 100; +const SEARCH_RESULT_LIMIT = 10; +const AFFILIATED_RESULT_LIMIT = 10; + +type SearchCandidate = { + project: RemoteProject; + affiliated: boolean; + updatedAt: string; +}; + +type SearchCacheEntry = { + expiresAt: number; + promise: Promise; +}; + +const searchCache = new Map(); + +function boundedString(value: string | undefined, maxLength: number): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed.slice(0, maxLength) : undefined; +} + +function parseRepository(value: unknown, affiliated: boolean): SearchCandidate | null { + if (!isRecord(value)) { + return null; + } + let fullName: string; + let name: string; + try { + fullName = requiredString(value, "full_name"); + name = requiredString(value, "name"); + } catch { + return null; + } + const clone = parseProjectGitUrl(readOptionalGitHubString(value, "clone_url") ?? ""); + const webUrl = boundedString(readOptionalGitHubString(value, "html_url"), 2048); + if (!clone || !webUrl) { + return null; + } + return { + affiliated, + updatedAt: readOptionalGitHubString(value, "updated_at") ?? "", + project: { + name: name.slice(0, 100), + fullName: fullName.slice(0, 200), + cloneUrl: clone.url, + webUrl, + private: value.private === true, + ...(boundedString(readOptionalGitHubString(value, "description"), 500) + ? { description: boundedString(readOptionalGitHubString(value, "description"), 500) } + : {}), + }, + }; +} + +function candidateSort(left: SearchCandidate, right: SearchCandidate): number { + if (left.affiliated !== right.affiliated) { + return left.affiliated ? -1 : 1; + } + if (left.updatedAt !== right.updatedAt) { + return left.updatedAt > right.updatedAt ? -1 : 1; + } + const leftName = left.project.fullName.toLowerCase(); + const rightName = right.project.fullName.toLowerCase(); + return leftName < rightName ? -1 : leftName > rightName ? 1 : 0; +} + +function repositoryArray(value: unknown, affiliated: boolean): SearchCandidate[] { + const items = Array.isArray(value) + ? value + : isRecord(value) && Array.isArray(value.items) + ? value.items + : []; + return items.flatMap((item) => { + const parsed = parseRepository(item, affiliated); + return parsed ? [parsed] : []; + }); +} + +function matchesAffiliatedQuery(candidate: SearchCandidate, query: string): boolean { + const needle = query.toLowerCase(); + return [candidate.project.name, candidate.project.fullName, candidate.project.description ?? ""] + .join("\n") + .toLowerCase() + .includes(needle); +} + +async function loadAffiliatedRepositories( + fetchImpl: typeof fetch, + token: string, +): Promise { + const url = new URL("/user/repos", GITHUB_API_ORIGIN); + url.searchParams.set("affiliation", "owner,collaborator,organization_member"); + url.searchParams.set("sort", "updated"); + url.searchParams.set("direction", "desc"); + url.searchParams.set("per_page", String(AFFILIATED_RESULT_LIMIT)); + try { + const response = await fetchGitHubApi(url.href, fetchImpl, token); + return repositoryArray(await readGitHubJsonResponse(response), true); + } catch (error) { + if (error instanceof ControlUiGitHubError) { + return []; + } + throw error; + } +} + +async function loadRepositorySearch( + query: string, + fetchImpl: typeof fetch, + token: string | undefined, +): Promise { + const url = new URL("/search/repositories", GITHUB_API_ORIGIN); + url.searchParams.set("q", `${query} in:name,description`); + url.searchParams.set("sort", "updated"); + url.searchParams.set("order", "desc"); + url.searchParams.set("per_page", String(SEARCH_RESULT_LIMIT)); + return repositoryArray(await fetchGitHubJson(url.href, fetchImpl, token), false); +} + +async function searchProjectsUncached(params: { + query: string; + fetchImpl: typeof fetch; + token?: string; +}): Promise { + const affiliated = params.token + ? (await loadAffiliatedRepositories(params.fetchImpl, params.token)).filter((candidate) => + matchesAffiliatedQuery(candidate, params.query), + ) + : []; + const global = await loadRepositorySearch(params.query, params.fetchImpl, params.token); + const deduped = new Map(); + for (const candidate of [...affiliated, ...global].toSorted(candidateSort)) { + const key = candidate.project.fullName.toLowerCase(); + if (!deduped.has(key)) { + deduped.set(key, candidate); + } + } + return { + credential: params.token ? "configured" : "missing", + projects: [...deduped.values()] + .toSorted(candidateSort) + .slice(0, SEARCH_RESULT_LIMIT) + .map((candidate) => candidate.project), + }; +} + +/** Searches affiliated and public GitHub repositories for the project picker. */ +export function searchRemoteProjects( + query: string, + options: { env?: NodeJS.ProcessEnv; fetchImpl?: typeof fetch; now?: number } = {}, +): Promise { + const normalizedQuery = query.trim().toLowerCase(); + const now = options.now ?? Date.now(); + const cached = searchCache.get(normalizedQuery); + if (cached && cached.expiresAt > now) { + searchCache.delete(normalizedQuery); + searchCache.set(normalizedQuery, cached); + return cached.promise; + } + const token = githubApiToken(options.env); + const promise = searchProjectsUncached({ + query: query.trim(), + fetchImpl: options.fetchImpl ?? fetch, + token, + }).catch((error: unknown) => { + if (searchCache.get(normalizedQuery)?.promise === promise) { + searchCache.delete(normalizedQuery); + } + throw error; + }); + searchCache.set(normalizedQuery, { expiresAt: now + SEARCH_CACHE_MS, promise }); + pruneMapToMaxSize(searchCache, SEARCH_CACHE_LIMIT); + return promise; +} diff --git a/src/gateway/server-methods-list.test.ts b/src/gateway/server-methods-list.test.ts index e597c5389247..eade74d3317c 100644 --- a/src/gateway/server-methods-list.test.ts +++ b/src/gateway/server-methods-list.test.ts @@ -66,7 +66,7 @@ describe("listGatewayMethods", () => { }); it("appends new methods after model probing without shifting older method indices", () => { - expect(listGatewayMethods().slice(-44)).toEqual([ + expect(listGatewayMethods().slice(-46)).toEqual([ "models.probe", "migrations.memory.plan", "migrations.memory.apply", @@ -111,6 +111,8 @@ describe("listGatewayMethods", () => { "secrets.store.delete", "users.prefs.get", "users.prefs.set", + "projects.add", + "projects.searchRemote", ]); const methods = listGatewayMethods(); expect(methods.indexOf("node.pluginSurface.refresh")).toBe( @@ -202,7 +204,7 @@ describe("listGatewayMethods", () => { "exec.approval.get", ]); expect(methods).toContain("tts.speak"); - expect(coreMethods.slice(-51)).toEqual([ + expect(coreMethods.slice(-53)).toEqual([ "sessions.catalog.continue", "sessions.catalog.archive", "approval.get", @@ -254,6 +256,8 @@ describe("listGatewayMethods", () => { "secrets.store.delete", "users.prefs.get", "users.prefs.set", + "projects.add", + "projects.searchRemote", ]); expect(methods.indexOf("approval.get")).toBeGreaterThan(methods.indexOf("tts.speak")); expect(methods.indexOf("approval.resolve")).toBe(methods.indexOf("approval.get") + 1); @@ -277,6 +281,8 @@ describe("listGatewayMethods", () => { expect(methods.indexOf("secrets.store.delete")).toBe(methods.indexOf("secrets.store.set") + 1); expect(methods.indexOf("users.prefs.get")).toBe(methods.indexOf("secrets.store.delete") + 1); expect(methods.indexOf("users.prefs.set")).toBe(methods.indexOf("users.prefs.get") + 1); + expect(methods.indexOf("projects.add")).toBe(methods.indexOf("users.prefs.set") + 1); + expect(methods.indexOf("projects.searchRemote")).toBe(methods.indexOf("projects.add") + 1); }); it("advertises the versioned Talk session RPCs", () => { @@ -324,6 +330,21 @@ describe("listGatewayMethods", () => { }); }); + it("classifies project cloning as a described control-plane write", () => { + const descriptors = createCoreGatewayMethodDescriptors(coreGatewayHandlers); + + expect(descriptors.find((descriptor) => descriptor.name === "projects.add")).toMatchObject({ + scope: "operator.write", + controlPlaneWrite: true, + }); + expect( + descriptors.find((descriptor) => descriptor.name === "projects.searchRemote"), + ).toMatchObject({ + scope: "operator.read", + description: "Search GitHub repositories that can be cloned as managed projects.", + }); + }); + it("wires a dispatchable handler for every core descriptor", () => { // A descriptor without a matching entry in the lazy handler routing table // advertises a method that then dispatches as "unknown method" — exactly diff --git a/src/gateway/server-methods/projects.test.ts b/src/gateway/server-methods/projects.test.ts index 59f05f5d6840..acf394265d94 100644 --- a/src/gateway/server-methods/projects.test.ts +++ b/src/gateway/server-methods/projects.test.ts @@ -3,29 +3,32 @@ import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { expect, test } from "vitest"; +import { insertRegistryWorktree } from "../../agents/worktrees/registry.js"; import { replaceSessionEntrySync } from "../../config/sessions/session-accessor.js"; +import { upsertSessionEntryCore } from "../../config/sessions/session-accessor.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { registerProjectRegistry } from "../../projects/project-registry.js"; +import { sha256HexPrefixCore } from "../../infra/crypto-digest.js"; +import { + registerClonedProjectRegistry, + registerProjectRegistry, +} from "../../projects/project-registry.js"; import { ensureProfileForEmail, linkEmail } from "../../state/user-profiles.js"; import { createOpenClawTestState } from "../../test-utils/openclaw-test-state.js"; import { projectsHandlers } from "./projects.js"; const execFileAsync = promisify(execFile); -async function initializeRepository(root: string): Promise { - const repo = path.join(root, "registered"); +async function initializeRepository( + root: string, + name = "registered", + originUrl = "https://github.com/openclaw/openclaw.git", +): Promise { + const repo = path.join(root, name); await fs.mkdir(repo, { recursive: true }); await execFileAsync("git", ["init", "-b", "main", repo]); await execFileAsync("git", ["-C", repo, "config", "user.name", "OpenClaw Tests"]); await execFileAsync("git", ["-C", repo, "config", "user.email", "tests@openclaw.invalid"]); - await execFileAsync("git", [ - "-C", - repo, - "remote", - "add", - "origin", - "https://github.com/openclaw/openclaw.git", - ]); + await execFileAsync("git", ["-C", repo, "remote", "add", "origin", originUrl]); await fs.writeFile(path.join(repo, "README.md"), "registered\n"); await execFileAsync("git", ["-C", repo, "add", "README.md"]); await execFileAsync("git", ["-C", repo, "commit", "-m", "initial"]); @@ -229,3 +232,144 @@ test("projects.list returns only the caller's deterministic resolved recents", a await state.cleanup(); } }); + +test("projects.add returns an existing project for the same canonical remote", async () => { + const state = await createOpenClawTestState({ layout: "state-only", prefix: "projects-rpc-" }); + try { + const repo = await initializeRepository( + state.root, + "existing", + "git@github.com:OpenClaw/OpenClaw.git", + ); + const existing = await registerProjectRegistry({ path: repo, name: "Existing" }); + + expect( + await invokeProjectMethod("projects.add", { + gitUrl: "https://github.com/openclaw/openclaw.git", + }), + ).toEqual({ ok: true, payload: existing, error: undefined }); + } finally { + await state.cleanup(); + } +}); + +test("projects.add returns a typed invalid-url failure", async () => { + const state = await createOpenClawTestState({ layout: "state-only", prefix: "projects-rpc-" }); + try { + expect( + await invokeProjectMethod("projects.add", { gitUrl: "file:///tmp/repo.git" }), + ).toMatchObject({ + ok: false, + error: { + code: "INVALID_REQUEST", + details: { code: "PROJECT_CLONE_FAILED", cause: "invalid_url" }, + }, + }); + } finally { + await state.cleanup(); + } +}); + +test("projects.remove refuses to delete a cloned checkout referenced by a live worktree", async () => { + const state = await createOpenClawTestState({ layout: "state-only", prefix: "projects-rpc-" }); + try { + const originUrl = "https://github.com/acme/managed.git"; + const fingerprint = sha256HexPrefixCore(originUrl, 16); + const repo = await initializeRepository( + path.join(state.stateDir, "projects", fingerprint), + "managed", + originUrl, + ); + const project = await registerClonedProjectRegistry({ + path: repo, + name: "Managed", + originUrl, + }); + insertRegistryWorktree( + process.env, + { + id: "live-worktree", + name: "live-worktree", + repoFingerprint: fingerprint, + repoRoot: repo, + path: path.join(state.stateDir, "worktrees", fingerprint, "live-worktree"), + branch: "openclaw/live-worktree", + baseRef: "main", + ownerKind: "session", + ownerId: "agent:main:session", + createdAt: 1, + lastActiveAt: 1, + }, + { provisionedPaths: [] }, + ); + + expect( + await invokeProjectMethod("projects.remove", { id: project.id, deleteCheckout: true }), + ).toMatchObject({ + ok: false, + error: { code: "INVALID_REQUEST", message: expect.stringContaining("live-worktree") }, + }); + await expect(fs.stat(repo)).resolves.toBeDefined(); + } finally { + await state.cleanup(); + } +}); + +test("projects.remove deletes an unreferenced Gateway-managed clone", async () => { + const state = await createOpenClawTestState({ layout: "state-only", prefix: "projects-rpc-" }); + try { + const originUrl = "https://github.com/acme/removable.git"; + const fingerprint = sha256HexPrefixCore(originUrl, 16); + const repo = await initializeRepository( + path.join(state.stateDir, "projects", fingerprint), + "removable", + originUrl, + ); + const project = await registerClonedProjectRegistry({ + path: repo, + name: "Removable", + originUrl, + }); + + expect( + await invokeProjectMethod("projects.remove", { id: project.id, deleteCheckout: true }), + ).toMatchObject({ ok: true, payload: { removed: true } }); + await expect(fs.stat(repo)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await state.cleanup(); + } +}); + +test("projects.remove refuses to delete a cloned checkout used by a live direct session", async () => { + const state = await createOpenClawTestState({ layout: "state-only", prefix: "projects-rpc-" }); + try { + const originUrl = "https://github.com/acme/session-project.git"; + const fingerprint = sha256HexPrefixCore(originUrl, 16); + const repo = await initializeRepository( + path.join(state.stateDir, "projects", fingerprint), + "session-project", + originUrl, + ); + const project = await registerClonedProjectRegistry({ + path: repo, + name: "Session project", + originUrl, + }); + await upsertSessionEntryCore( + { agentId: "main", env: state.env, sessionKey: "agent:main:project-session" }, + { sessionId: "project-session", spawnedCwd: repo, updatedAt: 1 }, + ); + const cfg = { + agents: { list: [{ id: "main", default: true, workspace: state.workspaceDir }] }, + } as OpenClawConfig; + + expect( + await invokeProjectMethod("projects.remove", { id: project.id, deleteCheckout: true }, cfg), + ).toMatchObject({ + ok: false, + error: { code: "INVALID_REQUEST", message: expect.stringContaining("project-session") }, + }); + } finally { + await state.cleanup(); + } +}); diff --git a/src/gateway/server-methods/projects.ts b/src/gateway/server-methods/projects.ts index f637e50a1ef1..7e3b51961143 100644 --- a/src/gateway/server-methods/projects.ts +++ b/src/gateway/server-methods/projects.ts @@ -2,22 +2,35 @@ import path from "node:path"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { ErrorCodes, + GatewayErrorDetailCodes, errorShape, type ProjectRecent, + validateProjectsAddParams, validateProjectsListParams, validateProjectsRegisterParams, validateProjectsRemoveParams, + validateProjectsSearchRemoteParams, } from "../../../packages/gateway-protocol/src/index.js"; +import { listRegistryWorktrees } from "../../agents/worktrees/registry.js"; import { formatErrorMessage } from "../../infra/errors.js"; +import { isPathInside } from "../../infra/path-guards.js"; +import { ProjectCloneError } from "../../projects/project-clone-runtime.js"; +import { + deleteClonedProjectCheckout, + materializeProjectClone, +} from "../../projects/project-clone.js"; import { listProjectRegistry, ProjectCheckoutError, registerProjectRegistry, removeProjectRegistry, + resolveProjectRegistry, } from "../../projects/project-registry.js"; import { parseAgentSessionKey } from "../../routing/session-key.js"; import { listProfiles, resolveUserProfileId } from "../../state/user-profiles.js"; +import { githubApiToken } from "../control-ui-github-api.js"; import { WRITE_SCOPE, authorizeOperatorScopesForRequiredScope } from "../method-scopes.js"; +import { searchRemoteProjects } from "../project-github-search.js"; import { loadCombinedSessionStoreForGatewayCore } from "../session-utils.js"; import type { GatewayRequestHandlers } from "./types.js"; import { assertValidParams } from "./validation.js"; @@ -177,10 +190,131 @@ export const projectsHandlers: GatewayRequestHandlers = { ); } }, - "projects.remove": ({ params, respond }) => { + "projects.add": async ({ params, respond, context, signal }) => { + if (!assertValidParams(params, validateProjectsAddParams, "projects.add", respond)) { + return; + } + try { + respond( + true, + await materializeProjectClone( + { cfg: context.getRuntimeConfig(), gitUrl: params.gitUrl, name: params.name }, + { signal, token: githubApiToken() }, + ), + undefined, + ); + } catch (error) { + if (error instanceof ProjectCloneError) { + respond( + false, + undefined, + errorShape( + error.failure === "invalid_url" ? ErrorCodes.INVALID_REQUEST : ErrorCodes.UNAVAILABLE, + error.message, + { + details: { + code: GatewayErrorDetailCodes.PROJECT_CLONE_FAILED, + cause: error.failure, + }, + retryable: error.failure === "network" || error.failure === "clone_failed", + }, + ), + ); + return; + } + respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error))); + } + }, + "projects.searchRemote": async ({ params, respond }) => { + if ( + !assertValidParams( + params, + validateProjectsSearchRemoteParams, + "projects.searchRemote", + respond, + ) + ) { + return; + } + try { + respond(true, await searchRemoteProjects(params.query), undefined); + } catch { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, "GitHub project search is unavailable. Retry shortly.", { + retryable: true, + }), + ); + } + }, + "projects.remove": async ({ params, respond, context }) => { if (!assertValidParams(params, validateProjectsRemoveParams, "projects.remove", respond)) { return; } + const project = resolveProjectRegistry(context.getRuntimeConfig(), params.id); + if (!project || project.source === "workspace") { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `unknown project id: ${params.id}`), + ); + return; + } + if (params.deleteCheckout) { + if (project.source !== "cloned") { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "Only projects cloned by the Gateway can delete their checkout.", + ), + ); + return; + } + const normalizedRoot = path.resolve(project.repoRoot); + const worktreeReference = listRegistryWorktrees(process.env).find( + (worktree) => !worktree.removedAt && path.resolve(worktree.repoRoot) === normalizedRoot, + ); + const sessionReference = Object.entries( + loadCombinedSessionStoreForGatewayCore(context.getRuntimeConfig(), { projection: "list" }) + .store, + ).find(([, entry]) => { + if (entry.archivedAt) { + return false; + } + const sessionRoot = entry.worktree?.repoRoot; + if (sessionRoot && path.resolve(sessionRoot) === normalizedRoot) { + return true; + } + const cwd = entry.spawnedCwd; + return Boolean( + cwd && + (path.resolve(cwd) === normalizedRoot || isPathInside(normalizedRoot, path.resolve(cwd))), + ); + }); + if (worktreeReference || sessionReference) { + const reference = worktreeReference + ? `managed worktree ${worktreeReference.name}` + : `session ${sessionReference?.[0]}`; + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `Project checkout is still referenced by ${reference}. Remove that reference before deleting the checkout.`, + ), + ); + return; + } + try { + await deleteClonedProjectCheckout(project); + } catch (error) { + respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error))); + return; + } + } if (!removeProjectRegistry(params.id)) { respond( false, diff --git a/src/projects/project-clone-runtime.ts b/src/projects/project-clone-runtime.ts new file mode 100644 index 000000000000..85ac82002fda --- /dev/null +++ b/src/projects/project-clone-runtime.ts @@ -0,0 +1,134 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { ProjectCloneFailureCause } from "../../packages/gateway-protocol/src/index.js"; +import { runCommandWithTimeout } from "../process/exec.js"; + +const PROJECT_CLONE_TIMEOUT_MS = 10 * 60_000; + +export class ProjectCloneError extends Error { + constructor( + readonly failure: ProjectCloneFailureCause, + message: string, + ) { + super(message); + this.name = "ProjectCloneError"; + } +} + +function cloneCommandEnv(token: string | undefined, env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const gitEnv: NodeJS.ProcessEnv = { + ...env, + GIT_TERMINAL_PROMPT: "0", + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: os.devNull, + GIT_TEMPLATE_DIR: "", + GIT_EDITOR: "", + GIT_SEQUENCE_EDITOR: "", + GIT_EXTERNAL_DIFF: "", + GIT_ASKPASS: undefined, + SSH_ASKPASS: undefined, + GIT_DIR: undefined, + GIT_WORK_TREE: undefined, + GIT_COMMON_DIR: undefined, + GIT_INDEX_FILE: undefined, + GIT_OBJECT_DIRECTORY: undefined, + GIT_ALTERNATE_OBJECT_DIRECTORIES: undefined, + GIT_NAMESPACE: undefined, + GIT_EXEC_PATH: undefined, + GIT_SSH: undefined, + GIT_SSH_COMMAND: undefined, + GIT_SSL_NO_VERIFY: undefined, + }; + if (token) { + gitEnv.GIT_CONFIG_COUNT = "1"; + gitEnv.GIT_CONFIG_KEY_0 = "http.https://github.com/.extraHeader"; + gitEnv.GIT_CONFIG_VALUE_0 = `Authorization: Basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}`; + } + return gitEnv; +} + +function classifyCloneFailure(params: { + output: string; + tokenConfigured: boolean; + timedOut?: boolean; +}): ProjectCloneError { + const detail = params.output.toLowerCase(); + if ( + params.timedOut || + /could not resolve host|connection timed out|failed to connect/u.test(detail) + ) { + return new ProjectCloneError( + "network", + "Git clone could not reach GitHub. Check the Gateway network connection and retry.", + ); + } + if ( + /authentication failed|permission denied|could not read username|access denied/u.test(detail) + ) { + return new ProjectCloneError( + "auth_required", + params.tokenConfigured + ? "GitHub rejected the configured credential. Update GH_TOKEN in the Gateway environment and retry." + : "GitHub authentication is required. Set GH_TOKEN in the Gateway environment to clone private repositories.", + ); + } + if (/repository not found|not found/u.test(detail)) { + return params.tokenConfigured + ? new ProjectCloneError( + "not_found", + "GitHub could not find that repository. Check the URL and repository access.", + ) + : new ProjectCloneError( + "auth_required", + "The repository was not found or is private. Check the URL, or set GH_TOKEN in the Gateway environment for private repositories.", + ); + } + return new ProjectCloneError( + "clone_failed", + "Git could not clone that repository. Check the URL and Gateway Git configuration, then retry.", + ); +} + +/** Clones one already-validated source into an unoccupied managed target. */ +export async function cloneProjectCheckout( + input: { url: string; target: string }, + options: { + env?: NodeJS.ProcessEnv; + signal?: AbortSignal; + timeoutMs?: number; + token?: string; + } = {}, +): Promise { + const env = options.env ?? process.env; + const existed = await fs.lstat(input.target).then( + () => true, + () => false, + ); + if (existed) { + throw new ProjectCloneError( + "target_exists", + "A managed checkout already exists for this repository. Register or remove it before retrying.", + ); + } + await fs.mkdir(path.dirname(input.target), { recursive: true }); + const result = await runCommandWithTimeout( + ["git", "clone", "--no-recurse-submodules", "--", input.url, input.target], + { + env: cloneCommandEnv(options.token, env), + timeoutMs: options.timeoutMs ?? PROJECT_CLONE_TIMEOUT_MS, + signal: options.signal, + killProcessTree: true, + maxOutputBytes: 256 * 1024, + }, + ); + if (result.code === 0 && result.termination === "exit") { + return; + } + await fs.rm(input.target, { recursive: true, force: true }).catch(() => {}); + throw classifyCloneFailure({ + output: `${result.stderr}\n${result.stdout}`, + tokenConfigured: Boolean(options.token), + timedOut: result.termination === "timeout" || result.termination === "no-output-timeout", + }); +} diff --git a/src/projects/project-clone.ts b/src/projects/project-clone.ts new file mode 100644 index 000000000000..74458fa3a73a --- /dev/null +++ b/src/projects/project-clone.ts @@ -0,0 +1,133 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { slugifyWorktreeTitle } from "../agents/worktrees/name.js"; +import { resolveStateDir } from "../config/paths.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { sha256HexPrefixCore } from "../infra/crypto-digest.js"; +import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js"; +import { withOpenClawStateLease } from "../state/openclaw-state-lease.js"; +import { cloneProjectCheckout, ProjectCloneError } from "./project-clone-runtime.js"; +import { parseProjectGitUrl } from "./project-git-url.js"; +import { + findProjectRegistryByOrigin, + listProjectRegistry, + registerClonedProjectRegistry, + type ProjectRegistryRecord, +} from "./project-registry.js"; + +const PROJECT_CLONE_LEASE_MS = 30_000; +const PROJECT_CLONE_WAIT_MS = 30_000; + +function existingCanonicalProject( + cfg: OpenClawConfig, + canonicalUrl: string, + options: OpenClawStateDatabaseOptions, +): ProjectRegistryRecord | undefined { + return listProjectRegistry(cfg, options).find((project) => { + const origin = project.originUrl ? parseProjectGitUrl(project.originUrl) : null; + return origin?.url === canonicalUrl; + }); +} + +/** Materializes and registers a project from an accepted GitHub remote. */ +export async function materializeProjectClone( + input: { cfg: OpenClawConfig; gitUrl: string; name?: string }, + options: OpenClawStateDatabaseOptions & { + signal?: AbortSignal; + timeoutMs?: number; + token?: string; + } = {}, +): Promise { + const parsed = parseProjectGitUrl(input.gitUrl); + if (!parsed) { + throw new ProjectCloneError( + "invalid_url", + "Use a GitHub HTTPS or git@github.com repository URL. Local paths and file URLs are not accepted.", + ); + } + const existing = existingCanonicalProject(input.cfg, parsed.url, options); + if (existing) { + return existing; + } + + const env = options.env ?? process.env; + const fingerprint = sha256HexPrefixCore(parsed.url, 16); + return await withOpenClawStateLease( + { + scope: "projects.clone", + key: fingerprint, + database: { scope: "shared", options }, + leaseMs: PROJECT_CLONE_LEASE_MS, + waitMs: PROJECT_CLONE_WAIT_MS, + ...(options.signal ? { signal: options.signal } : {}), + leaseLabel: "project clone lease", + operationLabel: "projects.clone.lease", + }, + async (lease) => { + const raced = + existingCanonicalProject(input.cfg, parsed.url, options) ?? + findProjectRegistryByOrigin(parsed.url, options); + if (raced) { + return raced; + } + const displayName = input.name?.trim() || parsed.name; + const directoryName = slugifyWorktreeTitle(displayName) ?? "project"; + const target = path.join(resolveStateDir(env), "projects", fingerprint, directoryName); + await cloneProjectCheckout( + { url: parsed.url, target }, + { + env, + signal: lease.signal, + timeoutMs: options.timeoutMs, + token: options.token, + }, + ); + try { + lease.assertOwned(); + return await registerClonedProjectRegistry( + { path: target, name: displayName, originUrl: parsed.url }, + options, + ); + } catch (error) { + await fs.rm(target, { recursive: true, force: true }).catch(() => {}); + throw error; + } + }, + ); +} + +/** Deletes a checkout only when it still occupies its exact managed project slot. */ +export async function deleteClonedProjectCheckout( + project: ProjectRegistryRecord, + options: { env?: NodeJS.ProcessEnv } = {}, +): Promise { + if (project.source !== "cloned") { + throw new ProjectCloneError( + "clone_failed", + "Only projects cloned by the Gateway can delete their checkout.", + ); + } + const managedRoot = await fs.realpath(path.join(resolveStateDir(options.env), "projects")); + const checkout = await fs.realpath(project.repoRoot).catch(() => { + throw new ProjectCloneError( + "clone_failed", + "The managed project checkout is already unavailable. Remove only its registry entry instead.", + ); + }); + const relative = path.relative(managedRoot, checkout); + const segments = relative.split(path.sep); + if ( + !relative || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) || + segments.length !== 2 || + !/^[a-f0-9]{16}$/u.test(segments[0] ?? "") + ) { + throw new ProjectCloneError( + "clone_failed", + "The cloned project is outside the Gateway-managed projects area, so its checkout was not deleted.", + ); + } + await fs.rm(checkout, { recursive: true }); + await fs.rmdir(path.dirname(checkout)).catch(() => {}); +} diff --git a/src/projects/project-git-url.ts b/src/projects/project-git-url.ts new file mode 100644 index 000000000000..ff2b0d001f20 --- /dev/null +++ b/src/projects/project-git-url.ts @@ -0,0 +1,67 @@ +const GITHUB_PATH_SEGMENT = /^[A-Za-z0-9_.-]+$/u; + +type ParsedProjectGitUrl = { + url: string; + name: string; +}; + +function githubPathParts(pathname: string): { owner: string; repo: string } | null { + const segments = pathname.split("/").filter(Boolean); + const owner = segments[0]; + const repo = segments[1]?.replace(/\.git$/iu, ""); + if ( + segments.length !== 2 || + !owner || + !repo || + !GITHUB_PATH_SEGMENT.test(owner) || + !GITHUB_PATH_SEGMENT.test(repo) || + owner === "." || + owner === ".." || + repo === "." || + repo === ".." + ) { + return null; + } + return { owner, repo }; +} + +/** Canonicalizes the GitHub clone forms accepted by projects.add. */ +export function parseProjectGitUrl(raw: string): ParsedProjectGitUrl | null { + const trimmed = raw.trim(); + if (!trimmed || trimmed.startsWith("-") || trimmed.includes("\0") || /[\r\n\t ]/u.test(trimmed)) { + return null; + } + + const scp = /^git@github\.com:(.+)$/iu.exec(trimmed); + let parts: { owner: string; repo: string } | null; + if (scp) { + parts = githubPathParts(scp[1] ?? ""); + } else { + try { + const url = new URL(trimmed); + const isHttps = url.protocol === "https:"; + const isDefaultSsh = + url.protocol === "ssh:" && url.username === "git" && (!url.port || url.port === "22"); + if ( + (!isHttps && !isDefaultSsh) || + url.hostname.toLowerCase() !== "github.com" || + url.password || + (isHttps && url.username) || + url.search || + url.hash + ) { + return null; + } + parts = githubPathParts(url.pathname); + } catch { + return null; + } + } + if (!parts) { + return null; + } + return { + url: `https://github.com/${parts.owner.toLowerCase()}/${parts.repo.toLowerCase()}.git`, + name: parts.repo, + }; +} diff --git a/src/projects/project-registry.test.ts b/src/projects/project-registry.test.ts index 35b0854092b8..7b15b0dd2255 100644 --- a/src/projects/project-registry.test.ts +++ b/src/projects/project-registry.test.ts @@ -1,5 +1,6 @@ import { execFile } from "node:child_process"; import fs from "node:fs/promises"; +import http from "node:http"; import path from "node:path"; import { promisify } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; @@ -10,9 +11,13 @@ import { closeOpenClawStateDatabaseForTest, openOpenClawStateDatabase, } from "../state/openclaw-state-db.js"; +import { cloneProjectCheckout, ProjectCloneError } from "./project-clone-runtime.js"; +import { materializeProjectClone } from "./project-clone.js"; +import { parseProjectGitUrl } from "./project-git-url.js"; import { listProjectRegistry, ProjectCheckoutError, + registerClonedProjectRegistry, registerProjectRegistry, removeProjectRegistry, } from "./project-registry.js"; @@ -37,6 +42,32 @@ async function initializeRepository(root: string, name: string): Promise } describe("project registry", () => { + it.each([ + ["https://github.com/OpenClaw/OpenClaw", "https://github.com/openclaw/openclaw.git"], + ["https://github.com/OpenClaw/OpenClaw.git", "https://github.com/openclaw/openclaw.git"], + ["git@github.com:OpenClaw/OpenClaw.git", "https://github.com/openclaw/openclaw.git"], + ["ssh://git@github.com/OpenClaw/OpenClaw.git", "https://github.com/openclaw/openclaw.git"], + ["ssh://git@github.com:22/OpenClaw/OpenClaw", "https://github.com/openclaw/openclaw.git"], + ])("canonicalizes accepted GitHub clone URL %s", (input, expected) => { + expect(parseProjectGitUrl(input)?.url).toBe(expected); + }); + + it.each([ + "http://github.com/openclaw/openclaw.git", + "file:///tmp/openclaw.git", + "ssh://git@github.com:2222/openclaw/openclaw.git", + "/tmp/openclaw", + "../openclaw", + "--upload-pack=touch-pwned", + "https://token@github.com/openclaw/openclaw.git", + "https://github.com/openclaw/openclaw.git?config=evil", + "https://github.com/openclaw/openclaw/extra", + "git@github.com:../../tmp/openclaw.git", + "https://github.com/openclaw/openclaw.git --config=evil", + ])("rejects unsafe project clone URL %s", (input) => { + expect(parseProjectGitUrl(input)).toBeNull(); + }); + it("lazily ensures the additive table exactly once per database", async () => { const root = tempDirs.make("openclaw-project-schema-"); const options = { path: path.join(root, "state.sqlite") }; @@ -107,4 +138,95 @@ describe("project registry", () => { registerProjectRegistry({ path: root }, { path: path.join(root, "state.sqlite") }), ).rejects.toBeInstanceOf(ProjectCheckoutError); }); + + it("clones a local bare fixture through the internal full-history clone boundary", async () => { + const root = tempDirs.make("openclaw-project-clone-"); + const source = await initializeRepository(root, "source"); + await fs.writeFile(path.join(source, "second.txt"), "second\n"); + await execFileAsync("git", ["-C", source, "add", "second.txt"]); + await execFileAsync("git", ["-C", source, "commit", "-m", "second"]); + const bare = path.join(root, "fixture.git"); + await execFileAsync("git", ["clone", "--bare", "--", source, bare]); + const target = path.join(root, "managed", "fixture"); + + await cloneProjectCheckout({ url: bare, target }); + + expect(await fs.readFile(path.join(target, "second.txt"), "utf8")).toBe("second\n"); + const history = await execFileAsync("git", ["-C", target, "rev-list", "--count", "HEAD"]); + expect(history.stdout.trim()).toBe("2"); + const project = await registerClonedProjectRegistry( + { + path: target, + name: "Fixture", + originUrl: "https://github.com/acme/fixture.git", + }, + { path: path.join(root, "state.sqlite") }, + ); + expect(project).toMatchObject({ + source: "cloned", + originUrl: "https://github.com/acme/fixture.git", + }); + }); + + it("returns an existing registration for the same canonical remote without cloning", async () => { + const root = tempDirs.make("openclaw-project-idempotent-"); + const repo = await initializeRepository(root, "existing"); + await execFileAsync("git", [ + "-C", + repo, + "remote", + "add", + "origin", + "git@github.com:Acme/Existing.git", + ]); + const options = { path: path.join(root, "state.sqlite"), env: process.env }; + const registered = await registerProjectRegistry({ path: repo, name: "Existing" }, options); + + const added = await materializeProjectClone( + { cfg: {} as OpenClawConfig, gitUrl: "https://github.com/acme/existing.git" }, + options, + ); + + expect(added).toEqual(registered); + expect(listProjectRegistry({} as OpenClawConfig, options)).toHaveLength(2); + }); + + it("classifies authentication failures without returning credential material", async () => { + const token = "github_pat_secret-fixture-value"; + const server = http.createServer((_request, response) => { + response.writeHead(401, { "WWW-Authenticate": 'Basic realm="Git"' }); + response.end("authentication required"); + }); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve()); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("test HTTP server did not bind a TCP port"); + } + try { + const error = await cloneProjectCheckout( + { + url: `http://127.0.0.1:${address.port}/private.git`, + target: path.join(tempDirs.make("openclaw-project-auth-"), "private"), + }, + { token }, + ).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(ProjectCloneError); + expect(error).toMatchObject({ failure: "auth_required" }); + expect((error as Error).message).not.toContain(token); + expect((error as Error).message).toContain("GH_TOKEN"); + } finally { + await new Promise((resolve, reject) => { + server.close((closeError) => { + if (closeError) { + reject(closeError); + } else { + resolve(); + } + }); + }); + } + }); }); diff --git a/src/projects/project-registry.ts b/src/projects/project-registry.ts index b56b54aa82dc..a0a78a477344 100644 --- a/src/projects/project-registry.ts +++ b/src/projects/project-registry.ts @@ -18,7 +18,7 @@ import { type OpenClawStateDatabaseOptions, } from "../state/openclaw-state-db.js"; -type ProjectRegistryRecord = { +export type ProjectRegistryRecord = { id: string; displayName: string; repoRoot: string; @@ -83,6 +83,53 @@ function rowToProject(row: ProjectRow): ProjectRegistryRecord { }; } +function insertProjectRegistry( + input: { + displayName: string; + repoRoot: string; + originUrl?: string; + source: "registered" | "cloned"; + }, + options: OpenClawStateDatabaseOptions, +): ProjectRegistryRecord { + ensureProjectRegistrySchema(options); + return runOpenClawStateWriteTransaction( + ({ db: sqlite }) => { + const db = getNodeSqliteKysely(sqlite); + if (input.source === "cloned" && input.originUrl) { + const duplicate = executeSqliteQueryTakeFirstSync( + sqlite, + db.selectFrom("projects").selectAll().where("origin_url", "=", input.originUrl), + ); + if (duplicate) { + return rowToProject(duplicate); + } + } + const existing = new Set( + executeSqliteQuerySync(sqlite, db.selectFrom("projects").select("id")).rows.map( + (row) => row.id, + ), + ); + const baseId = slugifyWorktreeTitle(input.displayName) ?? "project"; + const id = allocateProjectId(baseId, existing); + const now = Date.now(); + const row = { + id, + display_name: input.displayName, + repo_root: input.repoRoot, + origin_url: input.originUrl ?? null, + source: input.source, + created_at_ms: now, + updated_at_ms: now, + }; + executeSqliteQuerySync(sqlite, db.insertInto("projects").values(row)); + return rowToProject(row); + }, + options, + { operationLabel: "projects.registry.insert" }, + ); +} + function workspaceProject(cfg: OpenClawConfig, agentId: string): ProjectRegistryRecord { const repoRoot = resolveAgentWorkspaceDir(cfg, agentId); return { @@ -150,35 +197,45 @@ export async function registerProjectRegistry( ): Promise { const checkout = await resolveProjectCheckout(input.path); const displayName = input.name?.trim() || path.basename(checkout.repoRoot) || "Project"; - const baseId = slugifyWorktreeTitle(displayName) ?? "project"; - ensureProjectRegistrySchema(options); - return runOpenClawStateWriteTransaction( - ({ db: sqlite }) => { - const db = getNodeSqliteKysely(sqlite); - const existing = new Set( - executeSqliteQuerySync(sqlite, db.selectFrom("projects").select("id")).rows.map( - (row) => row.id, - ), - ); - const id = allocateProjectId(baseId, existing); - const now = Date.now(); - const row = { - id, - display_name: displayName, - repo_root: checkout.repoRoot, - origin_url: checkout.originUrl ?? null, - source: "registered" as const, - created_at_ms: now, - updated_at_ms: now, - }; - executeSqliteQuerySync(sqlite, db.insertInto("projects").values(row)); - return rowToProject(row); + return insertProjectRegistry( + { + displayName, + repoRoot: checkout.repoRoot, + originUrl: checkout.originUrl, + source: "registered", }, options, - { operationLabel: "projects.registry.register" }, ); } +export async function registerClonedProjectRegistry( + input: { path: string; name: string; originUrl: string }, + options: OpenClawStateDatabaseOptions = {}, +): Promise { + const checkout = await resolveProjectCheckout(input.path); + return insertProjectRegistry( + { + displayName: input.name, + repoRoot: checkout.repoRoot, + originUrl: input.originUrl, + source: "cloned", + }, + options, + ); +} + +export function findProjectRegistryByOrigin( + originUrl: string, + options: OpenClawStateDatabaseOptions = {}, +): ProjectRegistryRecord | undefined { + const { sqlite, kysely } = openProjectsDatabase(options); + const row = executeSqliteQueryTakeFirstSync( + sqlite, + kysely.selectFrom("projects").selectAll().where("origin_url", "=", originUrl), + ); + return row ? rowToProject(row) : undefined; +} + export function listProjectRegistry( cfg: OpenClawConfig, options: OpenClawStateDatabaseOptions = {}, diff --git a/ui/src/e2e/new-session-page.github-projects.e2e.test.ts b/ui/src/e2e/new-session-page.github-projects.e2e.test.ts new file mode 100644 index 000000000000..4f1820f94a81 --- /dev/null +++ b/ui/src/e2e/new-session-page.github-projects.e2e.test.ts @@ -0,0 +1,121 @@ +import { expect, it } from "vitest"; +import { + WORKSPACE, + captureProjectUiProof, + captureUiProofEnabled, + createNewSessionPageE2eSuite, + installMockGateway, + pollLocatorText, + prepareProjectUiProof, + projectProofArtifactDir, +} from "./new-session-page.test-support.ts"; + +const suite = createNewSessionPageE2eSuite(); + +suite.define(() => { + it("searches GitHub, clones a remote project with progress, and starts its session", async () => { + await prepareProjectUiProof(); + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + ...(captureUiProofEnabled + ? { + recordVideo: { + dir: projectProofArtifactDir, + size: { height: 900, width: 1280 }, + }, + viewport: { height: 900, width: 1280 }, + } + : {}), + }); + const page = await context.newPage(); + const clonedProject = { + id: "openclaw", + displayName: "OpenClaw", + repoRoot: "/state/projects/fingerprint/openclaw", + originUrl: "https://github.com/openclaw/openclaw.git", + source: "cloned", + }; + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + deferredMethods: ["projects.add"], + featureMethods: [ + "chat.metadata", + "chat.startup", + "projects.add", + "projects.list", + "projects.searchRemote", + "sessions.create", + "worktrees.branches", + ], + methodResponses: { + "projects.list": { sequence: [{ projects: [] }, { projects: [clonedProject] }] }, + "projects.searchRemote": { + credential: "missing", + projects: [ + { + name: "openclaw", + fullName: "openclaw/openclaw", + description: "Personal AI assistant", + cloneUrl: "https://github.com/openclaw/openclaw.git", + webUrl: "https://github.com/openclaw/openclaw", + private: false, + }, + ], + }, + "worktrees.branches": { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + "sessions.create": { key: "agent:main:cloned-project-e2e" }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("projects.list"); + const trigger = page.locator("#new-session-place-trigger"); + const place = page.locator("wa-popover.new-session-page__place-popover"); + await trigger.click(); + const search = place.getByRole("searchbox", { + name: "Search projects or paste a Git URL", + }); + await search.fill("openclaw"); + + const searchRequest = await gateway.waitForRequest("projects.searchRemote"); + expect(searchRequest.params).toEqual({ query: "openclaw" }); + await place.getByText("GH_TOKEN is not configured; public GitHub results only.").waitFor(); + await place.getByRole("button", { name: /openclaw\/openclaw/u }).click(); + + const addRequest = await gateway.waitForRequest("projects.add"); + expect(addRequest.params).toEqual({ gitUrl: "https://github.com/openclaw/openclaw.git" }); + await place.getByRole("status").getByText("Cloning project…").waitFor(); + await captureProjectUiProof(page, "project-cloning.png"); + await gateway.resolveDeferred("projects.add", clonedProject); + + await expect.poll(async () => (await gateway.getRequests("projects.list")).length).toBe(2); + await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("OpenClaw"); + expect(await trigger.getAttribute("data-project-id")).toBe("openclaw"); + await expect + .poll(async () => (await gateway.getRequests("worktrees.branches")).at(-1)?.params) + .toEqual({ + repoRoot: "/state/projects/fingerprint/openclaw", + includeRepositoryStatus: true, + }); + + await page.locator(".new-session-page__message").fill("inspect the cloned project"); + await page.getByRole("button", { name: "Start session" }).click(); + const create = await gateway.waitForRequest("sessions.create"); + expect(create.params).toMatchObject({ + agentId: "main", + message: "inspect the cloned project", + projectId: "openclaw", + }); + expect(create.params).not.toHaveProperty("cwd"); + } finally { + await context.close(); + } + }); +}); diff --git a/ui/src/e2e/new-session-page.operator-scopes.e2e.test.ts b/ui/src/e2e/new-session-page.operator-scopes.e2e.test.ts index 81ffaddda095..19ad02291a58 100644 --- a/ui/src/e2e/new-session-page.operator-scopes.e2e.test.ts +++ b/ui/src/e2e/new-session-page.operator-scopes.e2e.test.ts @@ -70,6 +70,45 @@ suite.define(() => { } }); + it("lets read-scoped operators search projects without exposing clone actions", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + featureMethods: ["projects.add", "projects.list", "projects.searchRemote"], + operatorScopes: ["operator.read"], + methodResponses: { + "projects.list": { projects: [] }, + "projects.searchRemote": { + credential: "missing", + projects: [ + { + name: "openclaw", + fullName: "openclaw/openclaw", + cloneUrl: "https://github.com/openclaw/openclaw.git", + webUrl: "https://github.com/openclaw/openclaw", + private: false, + }, + ], + }, + }, + }); + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("projects.list"); + await page.locator("#new-session-place-trigger").click(); + const place = page.locator("wa-popover.new-session-page__place-popover"); + await place.getByRole("searchbox").fill("openclaw"); + await gateway.waitForRequest("projects.searchRemote"); + + const remote = place.getByRole("button", { name: /openclaw\/openclaw/u }); + await expect.poll(() => remote.isDisabled()).toBe(true); + await remote.click({ force: true }); + expect(await gateway.getRequests("projects.add")).toHaveLength(0); + } finally { + await context.close(); + } + }); + it("lets write-scoped operators browse and restore only workspace-contained folders", async () => { const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); const page = await context.newPage(); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 61ab54d483f9..4d00e6001a8a 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -711,6 +711,11 @@ export const en: TranslationMap = { places: "Places", projects: "Projects", projectsAdminHint: "Admins can register projects from Browse folders", + projectSearchPlaceholder: "Search projects or paste a Git URL", + githubProjects: "GitHub", + githubTokenHint: "GH_TOKEN is not configured; public GitHub results only.", + cloneProject: "Clone", + cloningProject: "Cloning project…", registerProject: "Register as project", recentFolders: "Recent", runsOn: "Runs on {place}", diff --git a/ui/src/pages/new-session/new-session-page.ts b/ui/src/pages/new-session/new-session-page.ts index 6aff40ae2b09..e31708e723fb 100644 --- a/ui/src/pages/new-session/new-session-page.ts +++ b/ui/src/pages/new-session/new-session-page.ts @@ -6,8 +6,10 @@ import type { FsListDirResult, ProjectRecord, ProjectRecent, + ProjectsAddResult, ProjectsListResult, ProjectsRegisterResult, + ProjectsSearchRemoteResult, SessionsCatalogStartTerminalResult, UsersPrefsGetResult, UsersPrefsSetResult, @@ -22,7 +24,7 @@ import "../../components/tooltip.ts"; import "../../components/web-awesome-popover.ts"; import { t } from "../../i18n/index.ts"; import { listSelectableAgents } from "../../lib/agents/display.ts"; -import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts"; +import { canCallGatewayMethod, isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts"; import { readSessionMethodAccess, type SessionMethodAccess, @@ -79,7 +81,7 @@ import { discoverGatewayName } from "./gateway-name-discovery.ts"; import { newSessionSearch, type NewSessionRouteData } from "./location.ts"; import { NewSessionModelControl } from "./model-control.ts"; import { isAbsolutePath, isKnownWorkspacePath } from "./path.ts"; -import { renderPlaceSelect } from "./place-picker.ts"; +import { projectCloneInput, renderPlaceSelect } from "./place-picker.ts"; import { decodeIdentityPreferences, encodeIdentityPreferences, @@ -94,6 +96,7 @@ import { retainRejectedInitialTurn } from "./rejected-initial-turn.ts"; import { renderAgentSelect } from "./target-controls.ts"; const CATALOG_RETRY_DELAYS_MS = [0, 1_000, 3_000] as const; +const PROJECT_SEARCH_DEBOUNCE_MS = 300; class NewSessionPage extends OpenClawLightDomElement { @property({ attribute: false }) data: NewSessionRouteData | undefined; @@ -106,6 +109,10 @@ class NewSessionPage extends OpenClawLightDomElement { @state() private projects: ProjectRecord[] = []; @state() private projectRecents: ProjectRecent[] | undefined; @state() private projectId = ""; + @state() private projectQuery = ""; + @state() private debouncedProjectQuery = ""; + @state() private projectCloneBusy = false; + @state() private projectCloneError: string | null = null; @state() private worktree = false; @state() private visibility: NewSessionVisibility = "normal"; @state() private worktreeName = ""; @@ -152,6 +159,8 @@ class NewSessionPage extends OpenClawLightDomElement { private branchesRequestToken = 0; private baseRefEditGeneration = 0; private browserRequestToken = 0; + private projectCloneRequestToken = 0; + private projectSearchTimer: ReturnType | undefined; private restoredFolderValidationToken = 0; private readonly attachmentDraft = new NewSessionAttachmentDraft(() => this.requestUpdate()); private readonly composerTextarea = new NewSessionComposerTextareaController(); @@ -249,6 +258,32 @@ class NewSessionPage extends OpenClawLightDomElement { }, }); + private readonly projectSearchTask = new Task(this, { + args: () => + [ + this.isConnected && this.gatewayConnected ? this.gatewayClient : null, + this.context + ? canCallGatewayMethod( + this.context.gateway.snapshot, + "projects.searchRemote", + "operator.read", + ) + : false, + this.debouncedProjectQuery, + this.gatewayConnectionEpoch, + ] as const, + task: ([client, advertised, query, _connectionEpoch], { signal }) => { + if (!client || !advertised || query.length < 2 || projectCloneInput(query)) { + return initialState; + } + return client.request( + "projects.searchRemote", + { query }, + { signal }, + ); + }, + }); + private readonly cloudProfileTask = new Task(this, { args: () => [ @@ -492,6 +527,7 @@ class NewSessionPage extends OpenClawLightDomElement { this.cancelRestoredFolderValidation(); this.gatewayApprovedWorkspaceRoots = []; this.folderGatewayApproved = false; + this.resetProjectSearch(); this.invalidateSubmission(submissionOutcome); if (!resetHostSelection) { return; @@ -637,6 +673,7 @@ class NewSessionPage extends OpenClawLightDomElement { this.composerTextarea.disconnect(); void this.gatewayNameTask.run([null, false, -1]); void this.projectsTask.run([null, false, -1]); + void this.projectSearchTask.run([null, false, "", -1]); void this.cloudProfileTask.run([null, -1, false, ""]); this.resetCloudProfileRetry(); super.disconnectedCallback(); @@ -727,6 +764,124 @@ class NewSessionPage extends OpenClawLightDomElement { return this.projects.find((project) => project.id === this.projectId); } + private get projectSearchResult(): ProjectsSearchRemoteResult | null { + return this.projectSearchTask.status === TaskStatus.COMPLETE && + this.debouncedProjectQuery === this.projectQuery.trim() + ? (this.projectSearchTask.value ?? null) + : null; + } + + private get projectSearchLoading(): boolean { + return ( + this.debouncedProjectQuery.length >= 2 && + this.debouncedProjectQuery === this.projectQuery.trim() && + this.projectSearchTask.status === TaskStatus.PENDING + ); + } + + private get projectSearchError(): string | null { + if ( + this.projectSearchTask.status !== TaskStatus.ERROR || + this.debouncedProjectQuery !== this.projectQuery.trim() + ) { + return null; + } + const error = this.projectSearchTask.error; + return error instanceof Error ? error.message : String(error); + } + + private clearProjectSearchTimer() { + globalThis.clearTimeout(this.projectSearchTimer); + this.projectSearchTimer = undefined; + } + + private resetProjectSearch() { + this.clearProjectSearchTimer(); + this.projectCloneRequestToken += 1; + this.projectQuery = ""; + this.debouncedProjectQuery = ""; + this.projectCloneBusy = false; + this.projectCloneError = null; + } + + private changeProjectQuery(query: string) { + this.projectQuery = query; + this.projectCloneError = null; + this.clearProjectSearchTimer(); + this.debouncedProjectQuery = ""; + void this.projectSearchTask.run([null, false, "", this.gatewayConnectionEpoch]); + const normalized = query.trim(); + if ( + normalized.length < 2 || + projectCloneInput(normalized) || + !this.gatewayConnected || + !this.gatewayClient || + !this.context || + !canCallGatewayMethod(this.context.gateway.snapshot, "projects.searchRemote", "operator.read") + ) { + return; + } + const client = this.gatewayClient; + const connectionEpoch = this.gatewayConnectionEpoch; + this.projectSearchTimer = globalThis.setTimeout(() => { + this.projectSearchTimer = undefined; + if (client !== this.gatewayClient || connectionEpoch !== this.gatewayConnectionEpoch) { + return; + } + this.debouncedProjectQuery = normalized; + void this.projectSearchTask.run([client, true, normalized, connectionEpoch]); + }, PROJECT_SEARCH_DEBOUNCE_MS); + } + + private async addRemoteProject(gitUrl: string) { + const client = this.gatewayClient; + if ( + !client || + !this.gatewayConnected || + this.projectCloneBusy || + !this.context || + !canCallGatewayMethod(this.context.gateway.snapshot, "projects.add", "operator.write") + ) { + return; + } + const requestId = ++this.projectCloneRequestToken; + const connectionEpoch = this.gatewayConnectionEpoch; + this.projectCloneBusy = true; + this.projectCloneError = null; + try { + const project = await client.request( + "projects.add", + { gitUrl }, + { timeoutMs: null }, + ); + if ( + requestId !== this.projectCloneRequestToken || + client !== this.gatewayClient || + connectionEpoch !== this.gatewayConnectionEpoch + ) { + return; + } + await this.projectsTask.run([client, true, connectionEpoch]); + if ( + requestId !== this.projectCloneRequestToken || + client !== this.gatewayClient || + connectionEpoch !== this.gatewayConnectionEpoch + ) { + return; + } + this.selectProjectId(project.id); + this.closeBrowser(); + } catch (error) { + if (requestId === this.projectCloneRequestToken && client === this.gatewayClient) { + this.projectCloneError = error instanceof Error ? error.message : String(error); + } + } finally { + if (requestId === this.projectCloneRequestToken) { + this.projectCloneBusy = false; + } + } + } + private execNodes(): DraftNode[] { return this.nodes.filter((node) => node.canExec); } @@ -1070,6 +1225,7 @@ class NewSessionPage extends OpenClawLightDomElement { this.agentSelectedByUser = false; this.folder = ""; this.projectId = ""; + this.resetProjectSearch(); this.folderSelectedByUser = false; this.folderGatewayApproved = false; this.gatewayApprovedWorkspaceRoots = []; @@ -1828,6 +1984,7 @@ class NewSessionPage extends OpenClawLightDomElement { return; } this.cancelRestoredFolderValidation(); + this.resetProjectSearch(); this.projectId = project.id; this.execNode = ""; this.cloudProfileId = ""; @@ -2114,6 +2271,23 @@ class NewSessionPage extends OpenClawLightDomElement { workspaceRoots: this.knownWorkspaceRoots(), projects: catalog.isTarget(this.data) ? [] : this.projects, recents: catalog.isTarget(this.data) ? [] : this.projectRecents, + projectQuery: this.projectQuery, + projectSearchAvailable: canCallGatewayMethod( + this.context?.gateway.snapshot, + "projects.searchRemote", + "operator.read", + ), + projectAddAvailable: canCallGatewayMethod( + this.context?.gateway.snapshot, + "projects.add", + "operator.write", + ), + remoteProjects: this.projectSearchResult?.projects ?? [], + projectSearchCredential: this.projectSearchResult?.credential ?? null, + projectSearchLoading: this.projectSearchLoading, + projectSearchError: this.projectSearchError, + projectCloneBusy: this.projectCloneBusy, + projectCloneError: this.projectCloneError, projectId: this.projectId, sessions: this.context?.sessions.state.result?.sessions ?? [], execNodes: this.isAdmin() ? execNodes : [], @@ -2136,7 +2310,7 @@ class NewSessionPage extends OpenClawLightDomElement { branchesLoading: this.repository.kind === "checking", baseRef: this.baseRef, worktreeName: this.worktreeName, - submitting: this.submitting, + submitting: this.submitting || this.projectCloneBusy, pendingCloud: Boolean(this.pendingCloud.sessionKey), // Admin gates only the discovered choices. An existing node or cloud // selection always keeps the destination axis visible — hiding it (e.g. @@ -2173,6 +2347,8 @@ class NewSessionPage extends OpenClawLightDomElement { onSelectExecNode: (nodeId) => this.selectExecNode(nodeId), onSelectCloudProfile: (profileId) => this.selectCloudProfile(profileId), onSelectProject: (projectId) => this.selectProjectId(projectId), + onProjectQueryInput: (query) => this.changeProjectQuery(query), + onCloneProject: (gitUrl) => void this.addRemoteProject(gitUrl), onApplyFolder: (folder, execNode) => this.applyFolder(folder, execNode, !execNode && this.browserListing?.path === folder), onBrowse: (target) => this.selectBrowserTarget(target), diff --git a/ui/src/pages/new-session/place-picker.test.ts b/ui/src/pages/new-session/place-picker.test.ts new file mode 100644 index 000000000000..c9444f73324b --- /dev/null +++ b/ui/src/pages/new-session/place-picker.test.ts @@ -0,0 +1,163 @@ +import { render } from "lit"; +import { describe, expect, it, vi } from "vitest"; +import { projectCloneInput, renderPlaceSelect } from "./place-picker.ts"; + +type PlaceSelectParams = Parameters[0]; + +function placeParams(overrides: Partial = {}): PlaceSelectParams { + return { + browseAvailable: true, + isAdmin: true, + canWrite: true, + folder: "/workspace", + workspace: "/workspace", + workspaceRoots: ["/workspace"], + projects: [], + projectQuery: "", + projectSearchAvailable: true, + projectAddAvailable: true, + remoteProjects: [], + projectSearchCredential: null, + projectSearchLoading: false, + projectSearchError: null, + projectCloneBusy: false, + projectCloneError: null, + projectId: "", + sessions: [], + execNodes: [], + gatewayName: "", + cloudProfiles: [], + cloudProfileId: "", + execNode: "", + syncFolder: "/workspace", + worktree: false, + worktreeVisible: false, + worktreeAvailable: false, + branches: null, + branchesLoading: false, + baseRef: "", + worktreeName: "", + submitting: false, + pendingCloud: false, + showDestinations: false, + popoverOpen: true, + popoverHiding: false, + browserTarget: null, + browserListing: null, + browserLoading: false, + browserError: null, + browserPathDraft: "", + usableBrowserPath: null, + registerProjectPath: null, + registeringProject: false, + onGuardTransition: () => undefined, + onPopoverShow: () => undefined, + onPopoverHide: () => undefined, + onPopoverAfterHide: () => undefined, + onSelectExecNode: () => undefined, + onSelectCloudProfile: () => undefined, + onSelectProject: () => undefined, + onProjectQueryInput: () => undefined, + onCloneProject: () => undefined, + onApplyFolder: () => undefined, + onBrowse: () => undefined, + onBrowserPathDraftChange: () => undefined, + onBrowserNavigate: () => undefined, + onBrowserBack: () => undefined, + onRegisterProject: () => undefined, + onClose: () => undefined, + onToggleWorktree: () => undefined, + onBaseRefInput: () => undefined, + onWorktreeNameInput: () => undefined, + ...overrides, + }; +} + +describe("project picker", () => { + it.each([ + ["https://github.com/openclaw/openclaw.git", true], + ["git@github.com:openclaw/openclaw.git", true], + ["ssh://git@github.com/openclaw/openclaw.git", true], + ["file:///tmp/openclaw.git", false], + ["/tmp/openclaw", false], + ["--upload-pack=touch-pwned", false], + ["https://github.com/openclaw/openclaw.git --config=evil", false], + ])("detects clone input %s", (value, expected) => { + expect(projectCloneInput(value) !== null).toBe(expected); + }); + + it("renders local matches before remote clone results and explains missing credentials", () => { + const onCloneProject = vi.fn(); + const container = document.createElement("div"); + render( + renderPlaceSelect( + placeParams({ + projectQuery: "openclaw", + projects: [ + { + id: "local-openclaw", + displayName: "Local OpenClaw", + repoRoot: "/workspace/openclaw", + source: "registered", + }, + ], + projectSearchCredential: "missing", + remoteProjects: [ + { + name: "openclaw", + fullName: "openclaw/openclaw", + description: "Personal AI assistant", + cloneUrl: "https://github.com/openclaw/openclaw.git", + webUrl: "https://github.com/openclaw/openclaw", + private: false, + }, + ], + onCloneProject, + }), + ), + container, + ); + + const values = [...container.querySelectorAll("[data-value]")].map( + (element) => element.dataset.value, + ); + expect(values.indexOf("project:local-openclaw")).toBeLessThan( + values.indexOf("remote-project:openclaw/openclaw"), + ); + expect(container.textContent).toContain("GH_TOKEN"); + container + .querySelector('[data-value="remote-project:openclaw/openclaw"]') + ?.click(); + expect(onCloneProject).toHaveBeenCalledWith("https://github.com/openclaw/openclaw.git"); + }); + + it("turns a pasted URL into one explicit clone affordance", () => { + const onCloneProject = vi.fn(); + const container = document.createElement("div"); + const gitUrl = "https://github.com/openclaw/openclaw.git"; + render( + renderPlaceSelect( + placeParams({ + projectQuery: gitUrl, + remoteProjects: [ + { + name: "ignored", + fullName: "ignored/remote", + cloneUrl: "https://github.com/ignored/remote.git", + webUrl: "https://github.com/ignored/remote", + private: false, + }, + ], + onCloneProject, + }), + ), + container, + ); + + expect(container.querySelector('[data-value^="remote-project:"]')).toBeNull(); + const clone = container.querySelector('[data-value="project-clone-url"]'); + expect(clone?.textContent).toContain("Clone"); + clone?.click(); + expect(onCloneProject).toHaveBeenCalledWith(gitUrl); + }); +}); diff --git a/ui/src/pages/new-session/place-picker.ts b/ui/src/pages/new-session/place-picker.ts index 35bf2e354e7f..7c581bc29799 100644 --- a/ui/src/pages/new-session/place-picker.ts +++ b/ui/src/pages/new-session/place-picker.ts @@ -3,6 +3,7 @@ import type { FsListDirResult, ProjectRecord, ProjectRecent, + RemoteProject, } from "../../../../packages/gateway-protocol/src/index.js"; import { icons } from "../../components/icons.ts"; import { t } from "../../i18n/index.ts"; @@ -22,6 +23,15 @@ function parentFolderDisplayName(path: string): string | undefined { return folderDisplayName(parent) || undefined; } +/** Detects pasted clone URLs; the Gateway remains authoritative for host validation. */ +export function projectCloneInput(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed || trimmed.startsWith("-") || /\s/u.test(trimmed)) { + return null; + } + return /^(?:https:\/\/|ssh:\/\/git@|git@[^:]+:)/iu.test(trimmed) ? trimmed : null; +} + function renderBrowseView(params: { listing: FsListDirResult | null; target: BrowserTarget; @@ -158,6 +168,15 @@ export function renderPlaceSelect(params: { workspaceRoots: readonly string[]; projects: readonly ProjectRecord[]; recents?: readonly ProjectRecent[]; + projectQuery: string; + projectSearchAvailable: boolean; + projectAddAvailable: boolean; + remoteProjects: readonly RemoteProject[]; + projectSearchCredential: "configured" | "missing" | null; + projectSearchLoading: boolean; + projectSearchError: string | null; + projectCloneBusy: boolean; + projectCloneError: string | null; projectId: string; sessions: readonly RecentPlaceSource[]; execNodes: DraftNode[]; @@ -195,6 +214,8 @@ export function renderPlaceSelect(params: { onSelectExecNode: (nodeId: string) => void; onSelectCloudProfile: (profileId: string) => void; onSelectProject: (projectId: string) => void; + onProjectQueryInput: (query: string) => void; + onCloneProject: (gitUrl: string) => void; onApplyFolder: (folder: string, execNode: string) => void; onBrowse: (target: BrowserTarget) => void; onBrowserPathDraftChange: (value: string) => void; @@ -207,6 +228,17 @@ export function renderPlaceSelect(params: { onWorktreeNameInput: (name: string) => void; }) { const folder = params.folder.trim(); + const projectQuery = params.projectQuery.trim(); + const cloneInput = projectCloneInput(params.projectQuery); + const normalizedProjectQuery = projectQuery.toLowerCase(); + const localProjects = normalizedProjectQuery + ? params.projects.filter((project) => + [project.displayName, project.originUrl ?? "", project.repoRoot ?? ""] + .join("\n") + .toLowerCase() + .includes(normalizedProjectQuery), + ) + : params.projects; const selectedProject = params.projects.find((project) => project.id === params.projectId); const folderLabel = selectedProject ? selectedProject.displayName @@ -369,31 +401,103 @@ export function renderPlaceSelect(params: { params.submitting, ) : nothing} - ${params.projects.length > 0 +
${t("newSession.projects")}
+ + ${localProjects.map((project) => + renderSessionMenuItem( + { + value: `project:${project.id}`, + label: project.displayName, + icon: icons.gitBranch, + checked: params.projectId === project.id, + title: project.repoRoot, + onSelect: () => params.onSelectProject(project.id), + }, + params.submitting || params.projectCloneBusy, + ), + )} + ${cloneInput && params.projectAddAvailable + ? renderSessionMenuItem( + { + value: "project-clone-url", + label: cloneInput, + icon: icons.gitBranch, + sub: t("newSession.cloneProject"), + checked: false, + keepOpen: true, + onSelect: () => params.onCloneProject(cloneInput), + }, + params.submitting || params.projectCloneBusy, + ) + : nothing} + ${!cloneInput && projectQuery.length >= 2 && params.projectSearchAvailable ? html` -
${t("newSession.projects")}
- ${params.projects.map((project) => +
+ ${t("newSession.githubProjects")} +
+ ${params.projectSearchCredential === "missing" + ? html`
+ ${t("newSession.githubTokenHint")} +
` + : nothing} + ${params.projectSearchLoading + ? html`
+ ${t("common.loading")} +
` + : nothing} + ${params.projectSearchError + ? html`` + : nothing} + ${params.remoteProjects.map((project) => renderSessionMenuItem( { - value: `project:${project.id}`, - label: project.displayName, + value: `remote-project:${project.fullName}`, + label: project.fullName, icon: icons.gitBranch, - checked: params.projectId === project.id, - title: project.repoRoot, - onSelect: () => params.onSelectProject(project.id), + sub: project.description ?? t("newSession.cloneProject"), + checked: false, + title: project.webUrl, + keepOpen: true, + onSelect: () => params.onCloneProject(project.cloneUrl), }, - params.submitting, + params.submitting || params.projectCloneBusy || !params.projectAddAvailable, ), )} ` - : params.canWrite && !params.isAdmin - ? html` -
${t("newSession.projects")}
-
- ${t("newSession.projectsAdminHint")} -
- ` - : nothing} + : nothing} + ${params.projectCloneBusy + ? html`
+ ${t("newSession.cloningProject")} +
` + : nothing} + ${params.projectCloneError + ? html`` + : nothing} + ${params.projects.length === 0 && params.canWrite && !params.isAdmin + ? html`
+ ${t("newSession.projectsAdminHint")} +
` + : nothing} ${recents.length > 0 ? html`
${t("newSession.recentFolders")}
diff --git a/ui/src/styles/new-session.css b/ui/src/styles/new-session.css index 4f91a96987fd..92e05df22b21 100644 --- a/ui/src/styles/new-session.css +++ b/ui/src/styles/new-session.css @@ -259,6 +259,43 @@ wa-popover.new-session-page__select::part(body) { text-transform: uppercase; } +.new-session-page__project-search { + display: block; + padding: 4px 8px 6px; +} + +.new-session-page__project-search input { + width: 100%; + min-width: 0; + padding: 6px 8px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--text); + font: inherit; + font-size: 12px; + outline: none; +} + +.new-session-page__project-search input:focus-visible { + border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); +} + +.new-session-page__project-status, +.new-session-page__project-error { + padding: 5px 8px; + font-size: 11px; + line-height: 1.4; +} + +.new-session-page__project-status { + color: var(--muted); +} + +.new-session-page__project-error { + color: var(--danger); +} + .new-session-page__menu-field { display: flex; align-items: center;