fix: allow direct sessions in non-Git projects (#125657)

Restore direct project sessions regressed by registered-project placement. Git remains required for managed worktrees, while direct sessions validate only that the project directory is available.

Refs #112242
This commit is contained in:
Peter Steinberger
2026-08-17 23:45:18 -07:00
committed by GitHub
parent 1cf7ec90d0
commit d69f0aaaeb
4 changed files with 42 additions and 23 deletions
+1 -1
View File
@@ -240,7 +240,7 @@ The **+** in the sidebar session-list header opens a full-page draft at `/new`:
Unsent text and staged attachments can be recovered only in the same browser profile and Gateway credential scope; they are never stored on the Gateway or synced across devices. The browser keeps the 20 most recently edited draft scopes per Gateway credential scope for up to seven days, with at most 25 MiB of attachment data per draft. A successful send or New Session creation, explicit attachment removal, confirmed session deletion, or clearing browser site data removes the corresponding durable data. If a draft's attachments exceed the cap, the current tab keeps them and shows the existing storage warning, but only the text is restart-recoverable. Incognito and private-browser drafts are excluded from durable restart recovery. The **Incognito** toggle in the new-session page's top-right control rail retires that browser draft and creates a web-only thread whose session entry, transcript, and compaction state stay in memory until the Gateway restarts; OpenClaw also skips its automatic memory flush. The agent keeps its normal tools, so an explicit save request or tool-driven file write can still persist data. The model provider still processes messages, and content-free audit metadata is still recorded. Cloud starts persist their model and reasoning choices before dispatching the session to its worker.
**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.** 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 an agent workspace was moved or removed, update that agent's configured workspace path. If a recorded checkout was moved or removed, re-register it 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. For affiliated and private repositories, prefer the explicit `gateway.controlUi.github.token` SecretRef so this service access has a clear runtime owner. When it is omitted, the Gateway still uses its shipped `GH_TOKEN` then `GITHUB_TOKEN` fallback from the shared process environment. When it is explicit, its exact environment or store name is excluded from agent execution without clearing unrelated native GitHub CLI variables. 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. SecretRef ownership is not an OS-user security boundary; use a sandbox, dedicated host, or dedicated OS user when same-account processes are not trusted.
@@ -21,6 +21,7 @@ import { formatErrorMessage } from "../../infra/errors.js";
import {
ProjectCheckoutError,
resolveProjectCheckout,
resolveProjectDirectory,
resolveProjectRegistry,
} from "../../projects/project-registry.js";
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
@@ -258,11 +259,12 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
return;
}
try {
const checkout = await resolveProjectCheckout(project.repoRoot);
if (project.source !== "workspace" && checkout.path !== checkout.repoRoot) {
const checkout =
p.worktree === true ? await resolveProjectCheckout(project.repoRoot) : undefined;
projectRoot = checkout?.path ?? (await resolveProjectDirectory(project.repoRoot));
if (checkout && project.source !== "workspace" && checkout.path !== checkout.repoRoot) {
throw new ProjectCheckoutError(`project root is no longer a git checkout`);
}
projectRoot = checkout.path;
} catch (error) {
const detail =
error instanceof ProjectCheckoutError ? error.message : formatErrorMessage(error);
@@ -271,7 +273,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
undefined,
errorShape(
ErrorCodes.UNAVAILABLE,
`project ${requestedProjectId} is unavailable (${detail}); re-register it or run openclaw doctor --fix`,
`project ${requestedProjectId} is unavailable (${detail}); update the agent workspace path or re-register the project`,
),
);
return;
@@ -35,9 +35,10 @@ async function initializeRepository(root: string, name: string): Promise<string>
return await fs.realpath(repo);
}
test("sessions.create starts directly in a synthesized workspace project", async () => {
test("sessions.create starts directly in a synthesized non-Git workspace project", async () => {
const root = tempDirs.make("openclaw-session-workspace-project-");
const workspace = await initializeRepository(root, "workspace");
const workspace = path.join(root, "workspace");
await fs.mkdir(workspace);
testState.agentConfig = { workspace };
await createSessionStoreDir();
@@ -47,7 +48,7 @@ test("sessions.create starts directly in a synthesized workspace project", async
{ client: { connect: { scopes: ["operator.write"] } } as never },
);
expect(created.ok).toBe(true);
expect(created.ok, JSON.stringify(created.error)).toBe(true);
expect(created.payload?.entry?.spawnedCwd).toBe(workspace);
});
@@ -132,17 +133,25 @@ test("sessions.create returns a typed error for an unknown project", async () =>
});
});
test("sessions.create reports a stale registered project as unavailable with repair guidance", async () => {
const root = tempDirs.make("openclaw-session-stale-project-");
const repo = await initializeRepository(root, "project");
const project = await registerProjectRegistry({ path: repo });
await fs.rm(repo, { recursive: true, force: true });
test.each(["missing", "non-directory"] as const)(
"sessions.create reports an unavailable %s registered project with truthful recovery guidance",
async (state) => {
const root = tempDirs.make("openclaw-session-stale-project-");
const repo = await initializeRepository(root, "project");
const project = await registerProjectRegistry({ path: repo });
await fs.rm(repo, { recursive: true, force: true });
if (state === "non-directory") {
await fs.writeFile(repo, "not a directory\n");
}
const created = await directSessionReq("sessions.create", { projectId: project.id });
expect(created.ok).toBe(false);
expect(created.error?.code).toBe("UNAVAILABLE");
expect(created.error?.message).toContain("re-register it or run openclaw doctor --fix");
});
const created = await directSessionReq("sessions.create", { projectId: project.id });
expect(created.ok).toBe(false);
expect(created.error?.code).toBe("UNAVAILABLE");
expect(created.error?.message).toMatch(
/; update the agent workspace path or re-register the project$/u,
);
},
);
test("sessions.create rejects an outside project for a sandboxed agent", async () => {
const root = tempDirs.make("openclaw-session-sandbox-project-");
+13 -5
View File
@@ -163,16 +163,24 @@ function allocateProjectId(base: string, existing: ReadonlySet<string>): string
}
}
export async function resolveProjectDirectory(projectPath: string): Promise<string> {
const requested = await fs.realpath(projectPath).catch(() => {
throw new ProjectCheckoutError(`project path does not exist: ${projectPath}`);
});
const stat = await fs.stat(requested).catch(() => null);
if (!stat?.isDirectory()) {
throw new ProjectCheckoutError(`project path is not a directory: ${projectPath}`);
}
return requested;
}
export async function resolveProjectCheckout(projectPath: string): Promise<{
path: string;
repoRoot: string;
originUrl?: string;
}> {
const requested = await fs.realpath(projectPath).catch(() => {
throw new ProjectCheckoutError(`project path does not exist: ${projectPath}`);
});
const stat = await fs.stat(requested).catch(() => null);
if (!stat?.isDirectory() || !insideGitCheckout(requested)) {
const requested = await resolveProjectDirectory(projectPath);
if (!insideGitCheckout(requested)) {
throw new ProjectCheckoutError(`project path is not a git checkout: ${projectPath}`);
}
const rootResult = await runGit(requested, ["rev-parse", "--show-toplevel"]);