mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat: clone GitHub projects from session picker (#121818)
* feat(projects): add managed GitHub clones * feat(ui): clone GitHub projects from session picker * fix(projects): integrate current gateway owners * fix(protocol): derive Swift error detail accessors * fix(ui): gate project cloning by scope * test(gateway): isolate session prewarm probes * revert: drop duplicate session prewarm repair
This commit is contained in:
committed by
GitHub
parent
7c83fc1729
commit
8876528f7c
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"9695a1112df503dd92e36a5502aa3930289d6828a562917ea5b8d6b7c25a0755","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
|
||||
{"contentHash":"4f9331793861042e293a88fe55780dbe412e9723e0b21b9c2d8928af99677e08","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"96449168bf95c5c22ff75ba7d50d21cb2d9c73666c5ceef95450d76273cd36ca","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
|
||||
{"contentHash":"cbd5e1cbd7760bd7d83855602340c240b4ea31f3f6b5ec3a8a047f175d49aca1","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"1b3770f22431dbbce8ffcbffd4496a95d2afaac82172aa48b73c9c8bceac7ca5","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
|
||||
{"contentHash":"74a4b83f8d211cbac98f76776cc87428849d82e25cdcd435fa39ac54090a5656","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"fdea77b1259c9799177f9c2b327a6d759058d0df0ef339834b6c0a6a9d04b130","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
|
||||
{"contentHash":"6b3ff49b6f02bf2bd819c9f54e2c19c303c0fbbd5477aa957c52e613c627eeb3","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"8f806aa43ddd9298176cacb5971b8ddac7f2c1d25101deaa92b3c9e1f788e09a","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
|
||||
{"contentHash":"93f1ce35c883984c29344931f34b01734de2a98e46fd04274f962dca57eb8cfe","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"11138710259d030a81c55a9e506254428328931945b6c6b79fc969e832d56901","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
|
||||
{"contentHash":"8efd73d7cce61a8ded5c1d503724c0eb5a1347d563457da7838abc3a4cc93cf7","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"0b8f513b3563d19ec9fa306d79c645c27d25ede0e83c99c394dd90e121fb1e74","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
|
||||
{"contentHash":"f963767c9c08f64d9c1dcddf0a1dfcfda5c3d2e44f9bf14fd01f4a51fb51a474","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"5a99888e906478be43c13f67f99fb2f74c7396eb834202e7d872f0e2ee60f144","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
|
||||
{"contentHash":"3b4327fc3220ed4f7de35a61f1bcaab43bde6dce69bf39b23b97417a742b9cbf","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"1896a2342b27ee5f055bf77f9a826f76ddc6520c9dce44789d6d612cfcdd17dc","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
|
||||
{"contentHash":"f118c2cf99da8026045993446453a0815cd5c08faebb8383505adf3362a83825","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"fcdd433db990480373e6caea357c69cbbc754bcbf492fe3371cd21491d9bd04f","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
|
||||
{"contentHash":"a32fad46afdac98569830abb5696202ae2bc0ed737347411b99f936d9b9f7be0","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"1d4374297f908a74d78873c746fc0624240182a96c28a064a47f91f6260049bb","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
|
||||
{"contentHash":"86b29041a2312ae701e7c9e2e7a772526499b5d4dd0886a8b11edc1bce78da5f","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"f5bf0f3889cd0e4a5b1de4e110098400573d46c508c962c386db3334246bc9fe","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
|
||||
{"contentHash":"ed0bdbdc3e327b6a97bf04cb7d57631f819949c7ff65e970dc72ce8cf0a0c5ba","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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<typeof ProjectRecordSchema>;
|
||||
@@ -65,5 +90,10 @@ export type ProjectsListParams = Static<typeof ProjectsListParamsSchema>;
|
||||
export type ProjectsListResult = Static<typeof ProjectsListResultSchema>;
|
||||
export type ProjectsRegisterParams = Static<typeof ProjectsRegisterParamsSchema>;
|
||||
export type ProjectsRegisterResult = Static<typeof ProjectsRegisterResultSchema>;
|
||||
export type ProjectsAddParams = Static<typeof ProjectsAddParamsSchema>;
|
||||
export type ProjectsAddResult = Static<typeof ProjectsAddResultSchema>;
|
||||
export type RemoteProject = Static<typeof RemoteProjectSchema>;
|
||||
export type ProjectsSearchRemoteParams = Static<typeof ProjectsSearchRemoteParamsSchema>;
|
||||
export type ProjectsSearchRemoteResult = Static<typeof ProjectsSearchRemoteResultSchema>;
|
||||
export type ProjectsRemoveParams = Static<typeof ProjectsRemoveParamsSchema>;
|
||||
export type ProjectsRemoveResult = Static<typeof ProjectsRemoveResultSchema>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}"`,
|
||||
" }",
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
const { noteChromeMcpBrowserReadiness } = await import("../commands/doctor-browser.js");
|
||||
await runCoreContributionHealth(ctx, ["core/doctor/browser-clawd-profile-residue"]);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -41,8 +41,8 @@ export function optionalNumber(record: Record<string, unknown>, 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<string, string> {
|
||||
|
||||
@@ -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"]],
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -17,12 +17,13 @@ type CoreGatewayMethodSpec = {
|
||||
startup?: true;
|
||||
controlPlaneWrite?: true;
|
||||
compatibilityRestored?: true;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
type CoreGatewayMethodMetadata = Pick<CoreGatewayMethodSpec, "name" | "scope" | "since">;
|
||||
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)) {
|
||||
|
||||
@@ -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<typeof fetch>().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<typeof fetch>()
|
||||
.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<typeof fetch>()
|
||||
.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);
|
||||
});
|
||||
});
|
||||
@@ -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<ProjectsSearchRemoteResult>;
|
||||
};
|
||||
|
||||
const searchCache = new Map<string, SearchCacheEntry>();
|
||||
|
||||
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<SearchCandidate[]> {
|
||||
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<SearchCandidate[]> {
|
||||
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<ProjectsSearchRemoteResult> {
|
||||
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<string, SearchCandidate>();
|
||||
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<ProjectsSearchRemoteResult> {
|
||||
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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<string> {
|
||||
const repo = path.join(root, "registered");
|
||||
async function initializeRepository(
|
||||
root: string,
|
||||
name = "registered",
|
||||
originUrl = "https://github.com/openclaw/openclaw.git",
|
||||
): Promise<string> {
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> {
|
||||
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",
|
||||
});
|
||||
}
|
||||
@@ -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<ProjectRegistryRecord> {
|
||||
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<void> {
|
||||
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(() => {});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<string>
|
||||
}
|
||||
|
||||
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<void>((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<void>((resolve, reject) => {
|
||||
server.close((closeError) => {
|
||||
if (closeError) {
|
||||
reject(closeError);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ProjectsDatabase>(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<ProjectRegistryRecord> {
|
||||
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<ProjectsDatabase>(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<ProjectRegistryRecord> {
|
||||
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 = {},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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<typeof globalThis.setTimeout> | 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<ProjectsSearchRemoteResult>(
|
||||
"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<ProjectsAddResult>(
|
||||
"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),
|
||||
|
||||
@@ -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<typeof renderPlaceSelect>[0];
|
||||
|
||||
function placeParams(overrides: Partial<PlaceSelectParams> = {}): 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<HTMLElement>("[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<HTMLButtonElement>('[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<HTMLButtonElement>('[data-value="project-clone-url"]');
|
||||
expect(clone?.textContent).toContain("Clone");
|
||||
clone?.click();
|
||||
expect(onCloneProject).toHaveBeenCalledWith(gitUrl);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
<div class="new-session-page__menu-title">${t("newSession.projects")}</div>
|
||||
<label class="new-session-page__project-search">
|
||||
<span class="sr-only">${t("newSession.projectSearchPlaceholder")}</span>
|
||||
<input
|
||||
type="search"
|
||||
placeholder=${t("newSession.projectSearchPlaceholder")}
|
||||
.value=${params.projectQuery}
|
||||
?disabled=${params.submitting || params.pendingCloud || params.projectCloneBusy}
|
||||
@input=${(event: Event) =>
|
||||
params.onProjectQueryInput((event.target as HTMLInputElement).value)}
|
||||
@keydown=${(event: KeyboardEvent) => {
|
||||
if (event.key === "Enter" && cloneInput && params.projectAddAvailable) {
|
||||
event.preventDefault();
|
||||
params.onCloneProject(cloneInput);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
${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`
|
||||
<div class="new-session-page__menu-title">${t("newSession.projects")}</div>
|
||||
${params.projects.map((project) =>
|
||||
<div class="new-session-page__menu-title">
|
||||
${t("newSession.githubProjects")}
|
||||
</div>
|
||||
${params.projectSearchCredential === "missing"
|
||||
? html`<div class="new-session-page__menu-note">
|
||||
${t("newSession.githubTokenHint")}
|
||||
</div>`
|
||||
: nothing}
|
||||
${params.projectSearchLoading
|
||||
? html`<div class="new-session-page__project-status" role="status">
|
||||
${t("common.loading")}
|
||||
</div>`
|
||||
: nothing}
|
||||
${params.projectSearchError
|
||||
? html`<div class="new-session-page__project-error" role="alert">
|
||||
${params.projectSearchError}
|
||||
</div>`
|
||||
: 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`
|
||||
<div class="new-session-page__menu-title">${t("newSession.projects")}</div>
|
||||
<div class="new-session-page__menu-note">
|
||||
${t("newSession.projectsAdminHint")}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
: nothing}
|
||||
${params.projectCloneBusy
|
||||
? html`<div class="new-session-page__project-status" role="status">
|
||||
${t("newSession.cloningProject")}
|
||||
</div>`
|
||||
: nothing}
|
||||
${params.projectCloneError
|
||||
? html`<div class="new-session-page__project-error" role="alert">
|
||||
${params.projectCloneError}
|
||||
</div>`
|
||||
: nothing}
|
||||
${params.projects.length === 0 && params.canWrite && !params.isAdmin
|
||||
? html`<div class="new-session-page__menu-note">
|
||||
${t("newSession.projectsAdminHint")}
|
||||
</div>`
|
||||
: nothing}
|
||||
${recents.length > 0
|
||||
? html`
|
||||
<div class="new-session-page__menu-title">${t("newSession.recentFolders")}</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user