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:
Peter Steinberger
2026-08-11 18:49:16 -07:00
committed by GitHub
parent 7c83fc1729
commit 8876528f7c
54 changed files with 2175 additions and 94 deletions
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1 +1 @@
{"contentHash":"8f806aa43ddd9298176cacb5971b8ddac7f2c1d25101deaa92b3c9e1f788e09a","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
{"contentHash":"93f1ce35c883984c29344931f34b01734de2a98e46fd04274f962dca57eb8cfe","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"}
+2
View File
@@ -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;
+8
View File
@@ -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);
+6 -7
View File
@@ -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(
+2 -2
View File
@@ -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> {
+2
View File
@@ -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");
+14 -1
View File
@@ -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)) {
+104
View File
@@ -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);
});
});
+194
View File
@@ -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;
}
+23 -2
View File
@@ -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
+155 -11
View File
@@ -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();
}
});
+135 -1
View File
@@ -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,
+134
View File
@@ -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",
});
}
+133
View File
@@ -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(() => {});
}
+67
View File
@@ -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,
};
}
+122
View File
@@ -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();
}
});
});
}
});
});
+82 -25
View File
@@ -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();
+5
View File
@@ -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}",
+179 -3
View File
@@ -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);
});
});
+121 -17
View File
@@ -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>
+37
View File
@@ -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;