mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbbeed88ae | ||
|
|
6353c8e4b8 | ||
|
|
bddc9a5bd5 | ||
|
|
1f8ce0ee08 | ||
|
|
cbf3bd5a80 | ||
|
|
fd7130ad44 | ||
|
|
5599f9e567 | ||
|
|
4e9a4582b7 | ||
|
|
3fea128382 | ||
|
|
53ee9cd993 | ||
|
|
767b0e970b | ||
|
|
d3c902e9fa | ||
|
|
60aff2346c | ||
|
|
5de45e2086 | ||
|
|
5764863f63 | ||
|
|
4b45bc9fc1 | ||
|
|
a8f5d969b8 | ||
|
|
23c4404b95 |
@@ -1,5 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## 0.10.2 - 2026-09-30
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed OpenCode v2 turns longer than five minutes failing with `UND_ERR_HEADERS_TIMEOUT` ([#5674](https://github.com/getpaseo/paseo/pull/5674))
|
||||
- Fixed the context meter staying empty during OpenCode v2 turns and disappearing after them ([#5710](https://github.com/getpaseo/paseo/pull/5710) by [@mcowger](https://github.com/mcowger))
|
||||
- Fixed OpenCode v2 question cards showing only the header and offering no typed answer ([#5679](https://github.com/getpaseo/paseo/pull/5679))
|
||||
- Fixed OpenCode v2 patch edits from GPT models showing as a raw Patch card instead of an edit diff ([#5696](https://github.com/getpaseo/paseo/pull/5696))
|
||||
- Fixed completed OpenCode v2 edits showing no diff ([#5609](https://github.com/getpaseo/paseo/pull/5609))
|
||||
|
||||
## 0.10.1 - 2026-09-29
|
||||
|
||||
### Added
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
|
||||
|
||||
// This fixture catalog advertises Ultrafast on Sol; another model offers Fast only.
|
||||
// The real daemon discovers both through the same Codex model/list boundary.
|
||||
test.use({
|
||||
e2eDaemonConfig: {
|
||||
version: 1,
|
||||
agents: {
|
||||
providers: {
|
||||
codex: {
|
||||
enabled: true,
|
||||
command: [process.execPath, path.resolve("e2e/fixtures/catalog-codex.mjs")],
|
||||
},
|
||||
claude: { enabled: false },
|
||||
copilot: { enabled: false },
|
||||
opencode: { enabled: false },
|
||||
pi: { enabled: false },
|
||||
omp: { enabled: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
async function openSpeedSelector(page: Page): Promise<void> {
|
||||
const speed = page.getByRole("button", { name: /^(Select speed|Speed: .+)$/ });
|
||||
const features = page.getByRole("button", { name: "Open agent features", exact: true });
|
||||
const modelSettings = page.getByRole("button", { name: /^Select model \(/ });
|
||||
await expect(speed.or(features).or(modelSettings).filter({ visible: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
if (!(await speed.isVisible())) {
|
||||
if (await features.isVisible()) await features.click();
|
||||
else await modelSettings.click();
|
||||
}
|
||||
await expect(speed).toBeVisible({ timeout: 30_000 });
|
||||
await speed.click();
|
||||
await expect(page.getByText("Normal", { exact: true }).last()).toBeVisible();
|
||||
}
|
||||
|
||||
async function selectSpeed(page: Page, label: string): Promise<void> {
|
||||
await page.getByRole("button", { name: label, exact: true }).last().click();
|
||||
const toolbarTrigger = page.getByRole("button", { name: `Speed: ${label}`, exact: true });
|
||||
const sheetTrigger = page.getByRole("button", { name: "Select speed", exact: true });
|
||||
await expect(toolbarTrigger.or(sheetTrigger)).toBeVisible();
|
||||
if (await toolbarTrigger.isVisible()) await expect(toolbarTrigger).toHaveText("");
|
||||
else await expect(sheetTrigger).toContainText(label);
|
||||
}
|
||||
|
||||
async function expectSpeedChoices(page: Page, choices: string[]): Promise<void> {
|
||||
for (const choice of ["Normal", "Fast", "Ultrafast"]) {
|
||||
const option = page.getByText(choice, { exact: true }).last();
|
||||
if (choices.includes(choice)) {
|
||||
await expect(option).toBeVisible();
|
||||
await expect(option).toBeInViewport({ ratio: 1 });
|
||||
} else await expect(option).not.toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
async function switchAgentModel(agentId: string, modelId: string): Promise<void> {
|
||||
const client = await connectDaemonClient<
|
||||
Pick<DaemonClient, "setAgentModel" | "close" | "connect">
|
||||
>({
|
||||
clientIdPrefix: "codex-speed-model",
|
||||
});
|
||||
try {
|
||||
await client.setAgentModel(agentId, modelId);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
for (const viewport of [
|
||||
{ name: "desktop", width: 1280, height: 850 },
|
||||
{ name: "compact", width: 390, height: 844 },
|
||||
]) {
|
||||
test(`Codex catalog drives speed choices on ${viewport.name}`, async ({ page }, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
await page.setViewportSize(viewport);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "codex-speed-" });
|
||||
try {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Speed settings",
|
||||
model: "gpt-6.1-sol",
|
||||
modeId: "full-access",
|
||||
initialPrompt: "Get ready to work on this project.",
|
||||
});
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
|
||||
if (viewport.name === "desktop") {
|
||||
const trigger = page.getByRole("button", { name: "Speed: Normal", exact: true });
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
await expect(trigger).toHaveText("");
|
||||
await page.screenshot({ path: testInfo.outputPath("desktop-normal-trigger.png") });
|
||||
}
|
||||
await openSpeedSelector(page);
|
||||
await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]);
|
||||
await page.screenshot({ path: testInfo.outputPath(`${viewport.name}-speed-options.png`) });
|
||||
await selectSpeed(page, "Ultrafast");
|
||||
await openSpeedSelector(page);
|
||||
await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath(`${viewport.name}-ultrafast-selected.png`),
|
||||
});
|
||||
await selectSpeed(page, "Normal");
|
||||
if (viewport.name === "desktop") {
|
||||
await switchAgentModel(agent.id, "gpt-6-sol");
|
||||
await openSpeedSelector(page);
|
||||
await expectSpeedChoices(page, ["Normal", "Fast"]);
|
||||
await page.screenshot({ path: testInfo.outputPath("desktop-fast-only.png") });
|
||||
}
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { openSettings } from "../support/helpers/app";
|
||||
import { expectAppRoute } from "../support/helpers/route-assertions";
|
||||
import {
|
||||
buildPairingLink,
|
||||
cancelHostConfirmation,
|
||||
connectToConfirmedHost,
|
||||
expectHostConfirmationFor,
|
||||
expectHostInHostPicker,
|
||||
hostConfirmation,
|
||||
openPairingLink,
|
||||
saveLinkedHostBeforeLoad,
|
||||
type LinkedHost,
|
||||
} from "../support/helpers/host-confirmation";
|
||||
|
||||
// An unreachable relay: the link saves the host without connecting first.
|
||||
const linkedHost: LinkedHost = {
|
||||
serverId: "srv_link_confirmation",
|
||||
relayEndpoint: "127.0.0.1:59998",
|
||||
daemonPublicKeyB64: "bGlua0NvbmZpcm1hdGlvbktleQ",
|
||||
};
|
||||
|
||||
test("a pairing link for a new host asks before saving it", async ({ page }) => {
|
||||
const link = buildPairingLink(linkedHost);
|
||||
|
||||
await test.step("Cancel saves nothing and stays on the page", async () => {
|
||||
await openPairingLink(page, link);
|
||||
await expectHostConfirmationFor(page, linkedHost);
|
||||
await cancelHostConfirmation(page);
|
||||
await expectAppRoute(page, "/settings/general");
|
||||
await expectHostInHostPicker(page, linkedHost.serverId, "not listed");
|
||||
});
|
||||
|
||||
await test.step("Connect saves the host and opens a project", async () => {
|
||||
await openPairingLink(page, link);
|
||||
await expectHostConfirmationFor(page, linkedHost);
|
||||
await connectToConfirmedHost(page);
|
||||
await expectAppRoute(page, "/open-project");
|
||||
await openSettings(page);
|
||||
await expectHostInHostPicker(page, linkedHost.serverId, "listed");
|
||||
});
|
||||
});
|
||||
|
||||
test("a pairing link for a saved host connects without asking", async ({ page }) => {
|
||||
await saveLinkedHostBeforeLoad(page, linkedHost);
|
||||
|
||||
await openPairingLink(page, buildPairingLink(linkedHost));
|
||||
|
||||
await expectAppRoute(page, "/open-project", { timeout: 30_000 });
|
||||
await expect(hostConfirmation(page)).toHaveCount(0);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
restoreArchivedWorkspace,
|
||||
@@ -354,6 +355,88 @@ test.describe("Worktree restore", () => {
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
test("restores a branchless workspace so its archived agent can be used again", async ({
|
||||
page,
|
||||
}) => {
|
||||
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
|
||||
createdProjectIds.add(project.projectKey);
|
||||
const backingWorktree = await createWorktreeViaDaemon(worktreeClient, {
|
||||
cwd: tempRepo.path,
|
||||
slug: `branchless-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
createdProjectIds.add(backingWorktree.projectKey);
|
||||
createdWorktreeDirectories.add(backingWorktree.workspaceDirectory);
|
||||
|
||||
execFileSync("git", ["switch", "--detach", "main"], {
|
||||
cwd: backingWorktree.workspaceDirectory,
|
||||
stdio: "pipe",
|
||||
});
|
||||
const created = await worktreeClient.createWorkspace({
|
||||
source: { kind: "directory", path: backingWorktree.workspaceDirectory },
|
||||
title: "Sample workspace",
|
||||
});
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? "Could not create the branchless workspace");
|
||||
}
|
||||
const workspaceId = created.workspace.id;
|
||||
const agent = await createIdleAgent(client, {
|
||||
cwd: backingWorktree.workspaceDirectory,
|
||||
workspaceId,
|
||||
title: "Archived agent",
|
||||
});
|
||||
|
||||
await archiveWorkspaceFromDaemon(worktreeClient, backingWorktree.workspaceDirectory, {
|
||||
scope: "worktree",
|
||||
});
|
||||
await expect
|
||||
.poll(() => existsSync(backingWorktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(false);
|
||||
await expect.poll(() => fetchAgentArchivedAt(client, agent.id)).not.toBeNull();
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
await page.getByTestId(`agent-row-${getServerId()}-${agent.id}`).click();
|
||||
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
"Restore Sample workspace to return to its agents. A new branch will start from the saved base or the repository default.",
|
||||
{ exact: true },
|
||||
),
|
||||
).toBeVisible();
|
||||
await captureBranchlessRestoreStep(page, "01-restore-workspace.png");
|
||||
|
||||
await page.getByRole("button", { name: "Restore", exact: true }).click();
|
||||
await expect
|
||||
.poll(() => existsSync(backingWorktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(true);
|
||||
await expect(page.getByRole("button", { name: "Unarchive", exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await captureBranchlessRestoreStep(page, "02-unarchive-agent.png");
|
||||
|
||||
await page.getByRole("button", { name: "Unarchive", exact: true }).click();
|
||||
await expect.poll(() => fetchAgentArchivedAt(client, agent.id)).toBeNull();
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable();
|
||||
await captureBranchlessRestoreStep(page, "03-agent-ready.png");
|
||||
|
||||
const restoredHead = execFileSync("git", ["rev-parse", "HEAD"], {
|
||||
cwd: backingWorktree.workspaceDirectory,
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
const defaultHead = execFileSync("git", ["rev-parse", "main"], {
|
||||
cwd: tempRepo.path,
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
expect(restoredHead).toBe(defaultHead);
|
||||
expect(
|
||||
execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd: backingWorktree.workspaceDirectory,
|
||||
encoding: "utf8",
|
||||
}).trim(),
|
||||
).toMatch(/^restored\//);
|
||||
});
|
||||
|
||||
test("restore failure stays visible and permits a successful retry", async ({ page }) => {
|
||||
const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-retry");
|
||||
const displacedProjectPath = `${tempRepo.path}-temporarily-unavailable`;
|
||||
@@ -422,6 +505,12 @@ test.describe("Worktree restore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
async function captureBranchlessRestoreStep(page: Page, filename: string): Promise<void> {
|
||||
const screenshotDirectory = path.join(tmpdir(), "paseo-branchless-restore-evidence");
|
||||
mkdirSync(screenshotDirectory, { recursive: true });
|
||||
await page.screenshot({ path: path.join(screenshotDirectory, filename), fullPage: true });
|
||||
}
|
||||
|
||||
function commitRestorableChange(cwd: string): void {
|
||||
writeFileSync(path.join(cwd, "restored-change.txt"), "preserved after restore\n");
|
||||
execFileSync("git", ["add", "restored-change.txt"], { cwd, stdio: "pipe" });
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import readline from "node:readline";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const fast = { id: "priority", name: "Fast", description: "Faster processing, increased usage" };
|
||||
const ultra = {
|
||||
id: "ultrafast",
|
||||
name: "Ultrafast",
|
||||
description: "Lowest latency, increased usage",
|
||||
};
|
||||
const models = [
|
||||
{ id: "gpt-6.1-sol", displayName: "GPT-6.1 Sol", isDefault: true, serviceTiers: [fast, ultra] },
|
||||
{ id: "gpt-6-sol", displayName: "GPT-6 Sol", serviceTiers: [fast] },
|
||||
].map((model) =>
|
||||
Object.assign(model, {
|
||||
model: model.id,
|
||||
defaultReasoningEffort: "medium",
|
||||
supportedReasoningEfforts: [{ reasoningEffort: "medium", description: "Medium" }],
|
||||
}),
|
||||
);
|
||||
|
||||
function send(message) {
|
||||
process.stdout.write(JSON.stringify(message) + "\n");
|
||||
}
|
||||
function respond(method, params) {
|
||||
switch (method) {
|
||||
case "initialize":
|
||||
return {};
|
||||
case "model/list":
|
||||
return { data: models };
|
||||
case "config/read":
|
||||
return { config: { model: models[0].id, model_reasoning_effort: "medium" } };
|
||||
case "collaborationMode/list":
|
||||
return { data: [] };
|
||||
case "skills/list":
|
||||
return { data: [] };
|
||||
case "thread/list":
|
||||
case "thread/loaded/list":
|
||||
return { data: [] };
|
||||
case "thread/start":
|
||||
return { thread: { id: randomUUID() }, model: params.model };
|
||||
case "thread/resume":
|
||||
return { thread: { id: params.threadId } };
|
||||
case "thread/read":
|
||||
return { thread: { id: params.threadId, turns: [] } };
|
||||
case "turn/start": {
|
||||
const turnId = randomUUID();
|
||||
setTimeout(() => {
|
||||
send({
|
||||
method: "turn/started",
|
||||
params: { threadId: params.threadId, turn: { id: turnId } },
|
||||
});
|
||||
send({
|
||||
method: "item/completed",
|
||||
params: {
|
||||
threadId: params.threadId,
|
||||
turnId,
|
||||
item: {
|
||||
id: randomUUID(),
|
||||
type: "agentMessage",
|
||||
text: "Ready to work on this project.",
|
||||
},
|
||||
},
|
||||
});
|
||||
send({
|
||||
method: "turn/completed",
|
||||
params: { threadId: params.threadId, turn: { id: turnId, status: "completed" } },
|
||||
});
|
||||
}, 50);
|
||||
return { turn: { id: turnId } };
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported Codex fixture request: ${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv.includes("--version")) {
|
||||
console.log("codex-cli 0.159.0");
|
||||
} else {
|
||||
readline.createInterface({ input: process.stdin }).on("line", (line) => {
|
||||
const request = JSON.parse(line);
|
||||
if (request.id === undefined) return;
|
||||
try {
|
||||
send({ id: request.id, result: respond(request.method, request.params ?? {}) });
|
||||
} catch (error) {
|
||||
send({ id: request.id, error: { code: -32601, message: error.message } });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -90,3 +90,48 @@ a failed provider tap leaves its modal open, so dismiss it before the next run.
|
||||
journeys remain outside the default mobile suite, alongside the compact sheet journeys above.
|
||||
|
||||
Keep generated screenshots, recordings, and run logs outside the repository.
|
||||
|
||||
## Stacked adaptive sheets
|
||||
|
||||
`stacked-pairing.ad` opens the welcome screen's pairing sheet and then the root-mounted host
|
||||
confirmation. It first types without tapping the input to verify autofocus, then cancels the
|
||||
confirmation, closes and reopens the underlying sheet, confirms again,
|
||||
and completes the password retry. `stacked-settings.ad` checks the same return and retry through
|
||||
Settings → add host → Direct connection → Advanced. Run both on phones and tablets so both the
|
||||
bottom sheet and centered dialog are exercised.
|
||||
|
||||
Use a fresh English app with notification permission granted and first-use keyboard prompts
|
||||
already dismissed. Load this checkout from an isolated Metro. Start an isolated password-protected
|
||||
daemon and a local Wrangler relay with `--var 'PASEO_RELAY_UPSTREAM:'`; the checked-in upstream
|
||||
setting otherwise forwards traffic to the hosted relay, which drops password rejection replies.
|
||||
Set `PAIRING_LINK` to the daemon's `relay://` URI with an intentionally incorrect `password` query
|
||||
parameter, and `PASSWORD` to its correct password. This exercises password retry even while the
|
||||
daemon accepts relay connections with no credential.
|
||||
|
||||
```sh
|
||||
agent-device replay packages/app/e2e/mobile/modal-sheet/stacked-pairing.ad \
|
||||
--state-dir "$SHEET_QA_DEVICE_STATE" --session stacked-ipad --platform ios \
|
||||
--udid "$SHEET_QA_IOS_UDID" \
|
||||
--env PAIRING_LINK="$SHEET_QA_LINK" --env PASSWORD="$SHEET_QA_PASSWORD" \
|
||||
--env OUT="$SHEET_QA_OUTPUT"
|
||||
```
|
||||
|
||||
For Settings, first open Settings with Add host visible. With a saved host, open Switch host
|
||||
first. Use a second loopback spelling
|
||||
(`localhost` instead of `127.0.0.1`) for the same local relay so the saved host asks for confirmation
|
||||
of its changed connection. Pass that link to `stacked-settings.ad` with the same arguments.
|
||||
For an app with no saved hosts, use the original link; Add host is visible directly in Settings.
|
||||
|
||||
Android uses the same journeys: copy each script outside the repository, change its context's
|
||||
`platform=ios` to `platform=android`, and pass `--serial` instead of `--udid`. Reverse both Metro
|
||||
(including the port advertised by its manifest) and the local relay ports. Keep driver state,
|
||||
artifacts, and sessions separate from other runs. These host-dependent journeys remain outside the
|
||||
default mobile suite, alongside the existing modal-sheet scripts.
|
||||
|
||||
On iPhone, bulk replacement or bulk keyboard entry of the advanced URI field triggers a
|
||||
repeated-update error with both the parent and stacked-sheet implementations. Entering the complete
|
||||
URI one character at a time passed with both implementations; faster typing remains unverified.
|
||||
On iPad, replacing the field a second time appends text. For this Settings journey, use the
|
||||
native clipboard Paste menu, then continue the assertions manually. Dismiss the password keyboard
|
||||
with Done to submit when the Connect button is below the keyboard. The welcome pairing regression
|
||||
runs end to end without these adjustments.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Fresh English app at Welcome, local relay host with PASSWORD, see README.md.
|
||||
# Run on phones and tablets: the confirmation lives at the root, outside the pairing sheet.
|
||||
context platform=ios timeout=180000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"welcome-paste-pairing-link\"" 20000
|
||||
screenshot "${OUT}/01-welcome.png"
|
||||
press "id=\"welcome-paste-pairing-link\""
|
||||
wait stable
|
||||
# Type without tapping the input: opening the sheet must retain its autofocus.
|
||||
wait "id=\"pair-link-input\"" 10000
|
||||
type "relay://"
|
||||
wait "id=\"pair-link-input\" value=\"relay://\"" 5000
|
||||
screenshot "${OUT}/20-pairing-autofocus-without-tap.png"
|
||||
fill "id=\"pair-link-input\"" "${PAIRING_LINK}"
|
||||
wait stable
|
||||
screenshot "${OUT}/02-pairing-input.png"
|
||||
press "id=\"pair-link-submit\""
|
||||
wait stable
|
||||
wait "id=\"host-confirmation-connect\"" 10000
|
||||
screenshot "${OUT}/03-confirmation-on-top.png"
|
||||
press "id=\"host-confirmation-cancel\""
|
||||
wait stable
|
||||
wait "id=\"pair-link-submit\" enabled=true" 10000
|
||||
screenshot "${OUT}/04-cancel-returns-to-pairing.png"
|
||||
# Close and reopen the underlying sheet, then edit it and submit again.
|
||||
press "id=\"pair-link-cancel\""
|
||||
wait stable
|
||||
wait "id=\"welcome-paste-pairing-link\"" 10000
|
||||
screenshot "${OUT}/05-underlying-sheet-closes.png"
|
||||
press "id=\"welcome-paste-pairing-link\""
|
||||
wait stable
|
||||
wait "id=\"pair-link-input\"" 10000
|
||||
fill "id=\"pair-link-input\"" "${PAIRING_LINK}"
|
||||
wait stable
|
||||
press "id=\"pair-link-submit\""
|
||||
wait stable
|
||||
wait "id=\"host-confirmation-connect\"" 10000
|
||||
screenshot "${OUT}/06-confirmation-reopens.png"
|
||||
press "id=\"host-confirmation-connect\""
|
||||
wait stable
|
||||
wait "id=\"pair-link-password-input\"" 20000
|
||||
screenshot "${OUT}/07-password-after-connect.png"
|
||||
fill "id=\"pair-link-password-input\"" "${PASSWORD}"
|
||||
wait stable
|
||||
press "id=\"pair-link-submit\""
|
||||
wait stable
|
||||
wait text "Open a folder on your machine" 30000
|
||||
screenshot "${OUT}/08-connected.png"
|
||||
@@ -0,0 +1,46 @@
|
||||
# Settings with Add host visible: no saved hosts, or the host picker already open.
|
||||
context platform=ios timeout=180000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
open "${APP_ID}"
|
||||
wait "id=\"settings-add-host\"" 20000
|
||||
screenshot "${OUT}/09-settings.png"
|
||||
press "id=\"settings-add-host\""
|
||||
wait stable
|
||||
wait "id=\"add-host-method-direct\"" 10000
|
||||
screenshot "${OUT}/10-add-host-methods.png"
|
||||
press "id=\"add-host-method-direct\""
|
||||
wait stable
|
||||
press "id=\"direct-host-advanced-toggle\""
|
||||
wait stable
|
||||
fill "id=\"direct-host-uri-input\"" "${PAIRING_LINK}"
|
||||
wait stable
|
||||
screenshot "${OUT}/11-add-host-relay-input.png"
|
||||
press "id=\"direct-host-submit\""
|
||||
wait stable
|
||||
wait "id=\"host-confirmation-connect\"" 10000
|
||||
screenshot "${OUT}/12-add-host-confirmation-on-top.png"
|
||||
press "id=\"host-confirmation-cancel\""
|
||||
wait stable
|
||||
wait "id=\"direct-host-submit\" enabled=true" 10000
|
||||
screenshot "${OUT}/13-cancel-returns-to-add-host.png"
|
||||
# Editing the underlying sheet proves it still accepts input after closing the top sheet.
|
||||
fill "id=\"direct-host-uri-input\"" "${PAIRING_LINK}"
|
||||
wait stable
|
||||
press "id=\"direct-host-submit\""
|
||||
wait stable
|
||||
wait "id=\"host-confirmation-connect\"" 10000
|
||||
screenshot "${OUT}/14-add-host-confirmation-reopens.png"
|
||||
press "id=\"host-confirmation-connect\""
|
||||
wait stable
|
||||
wait "id=\"direct-password-input\"" 20000
|
||||
screenshot "${OUT}/15-add-host-password.png"
|
||||
fill "id=\"direct-password-input\"" "${PASSWORD}"
|
||||
wait stable
|
||||
press "id=\"direct-host-submit\""
|
||||
wait stable
|
||||
open "${APP_ID}" "paseo://settings"
|
||||
wait "label=\"Connections\"" 10000
|
||||
press "label=\"Connections\""
|
||||
wait stable
|
||||
wait "id=\"host-page-connections-card\"" 30000
|
||||
screenshot "${OUT}/16-settings-connected.png"
|
||||
@@ -0,0 +1,95 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
|
||||
|
||||
export interface LinkedHost {
|
||||
serverId: string;
|
||||
relayEndpoint: string;
|
||||
daemonPublicKeyB64: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pairing link that opens on the settings page. `/` redirects to
|
||||
* `/open-project` on its own, so starting in settings leaves the pairing link
|
||||
* as the only thing that can move the app to `/open-project`.
|
||||
*/
|
||||
export function buildPairingLink(host: LinkedHost): string {
|
||||
const offer = {
|
||||
v: 2,
|
||||
serverId: host.serverId,
|
||||
daemonPublicKeyB64: host.daemonPublicKeyB64,
|
||||
relay: { endpoint: host.relayEndpoint, useTls: false },
|
||||
};
|
||||
const encoded = Buffer.from(JSON.stringify(offer), "utf8").toString("base64url");
|
||||
return `/settings/general#offer=${encoded}`;
|
||||
}
|
||||
|
||||
/** Loads the app from a pairing link, the way opening the link in a browser does. */
|
||||
export async function openPairingLink(page: Page, link: string): Promise<void> {
|
||||
// Leave the link's page first so the link always loads the app from scratch.
|
||||
await page.goto("/");
|
||||
await page.goto(link);
|
||||
}
|
||||
|
||||
/** Saves a relay host before the app loads, as if it had been paired in an earlier session. */
|
||||
export async function saveLinkedHostBeforeLoad(page: Page, host: LinkedHost): Promise<void> {
|
||||
await page.goto("/");
|
||||
const nowIso = new Date().toISOString();
|
||||
const connectionId = `relay:${host.relayEndpoint}`;
|
||||
const savedHost = {
|
||||
serverId: host.serverId,
|
||||
label: host.serverId,
|
||||
connections: [
|
||||
{
|
||||
id: connectionId,
|
||||
type: "relay",
|
||||
relayEndpoint: host.relayEndpoint,
|
||||
useTls: false,
|
||||
daemonPublicKeyB64: host.daemonPublicKeyB64,
|
||||
},
|
||||
],
|
||||
preferredConnectionId: connectionId,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
await page.evaluate(({ key, hosts }) => localStorage.setItem(key, JSON.stringify(hosts)), {
|
||||
key: EXTRA_HOSTS_KEY,
|
||||
hosts: [savedHost],
|
||||
});
|
||||
}
|
||||
|
||||
export function hostConfirmation(page: Page): Locator {
|
||||
return page.getByTestId("host-confirmation");
|
||||
}
|
||||
|
||||
export async function expectHostConfirmationFor(page: Page, host: LinkedHost): Promise<void> {
|
||||
const sheet = hostConfirmation(page);
|
||||
await expect(sheet.getByText("Connect to this host?", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(sheet.getByTestId("host-confirmation-server-id")).toHaveText(host.serverId);
|
||||
await expect(sheet.getByTestId("host-confirmation-relay")).toHaveText(host.relayEndpoint);
|
||||
}
|
||||
|
||||
export async function cancelHostConfirmation(page: Page): Promise<void> {
|
||||
await hostConfirmation(page).getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(hostConfirmation(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function connectToConfirmedHost(page: Page): Promise<void> {
|
||||
await hostConfirmation(page).getByRole("button", { name: "Connect", exact: true }).click();
|
||||
await expect(hostConfirmation(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Checks the host picker on the settings page the app is already showing. */
|
||||
export async function expectHostInHostPicker(
|
||||
page: Page,
|
||||
serverId: string,
|
||||
expected: "listed" | "not listed",
|
||||
): Promise<void> {
|
||||
await page.getByTestId("settings-host-picker").click();
|
||||
await expect(page.getByTestId(`settings-host-picker-item-${serverId}`)).toHaveCount(
|
||||
expected === "listed" ? 1 : 0,
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
|
||||
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
|
||||
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
|
||||
import { HostConfirmationSheet } from "@/hosts/host-confirmation-sheet";
|
||||
import {
|
||||
getIsElectronRuntime,
|
||||
HEADER_INNER_HEIGHT,
|
||||
@@ -606,6 +607,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
<CommandCenter />
|
||||
<AddProjectFlowHost />
|
||||
<HostChooserModal />
|
||||
<HostConfirmationSheet />
|
||||
<ProviderSettingsHost />
|
||||
<WorkspaceSetupDialog />
|
||||
<KeyboardShortcutsDialog />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
||||
@@ -7,10 +7,12 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||
import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { openPairScan } from "@/hosts/pair-scan-model";
|
||||
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import { PairLinkModal } from "@/components/pair-link-modal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
@@ -73,6 +75,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderBottomWidth: 4,
|
||||
borderBottomRightRadius: 12,
|
||||
},
|
||||
scanAgainButton: {
|
||||
marginTop: theme.spacing[6],
|
||||
},
|
||||
helperText: {
|
||||
marginTop: theme.spacing[6],
|
||||
color: theme.colors.foregroundMuted,
|
||||
@@ -108,16 +113,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function extractOfferUrlFromScan(result: BarcodeScanningResult): string | null {
|
||||
const raw = typeof result.data === "string" ? result.data.trim() : "";
|
||||
if (!raw) return null;
|
||||
|
||||
if (raw.includes("#offer=") || raw.includes("#connect=") || raw.startsWith("relay://"))
|
||||
return raw;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function PairScanScreen() {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
@@ -129,9 +124,6 @@ export default function PairScanScreen() {
|
||||
const source = typeof params.source === "string" ? params.source : "settings";
|
||||
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [isPairing, setIsPairing] = useState(false);
|
||||
const [scanError, setScanError] = useState<string | null>(null);
|
||||
const [passwordOfferUrl, setPasswordOfferUrl] = useState<string | null>(null);
|
||||
|
||||
const navigateToPairedHost = useCallback(
|
||||
(serverId: string) => {
|
||||
@@ -144,6 +136,19 @@ export default function PairScanScreen() {
|
||||
[router, source],
|
||||
);
|
||||
|
||||
const [scan] = useState(() =>
|
||||
openPairScan({
|
||||
importConnectionLink: (link) =>
|
||||
getHostRuntimeStore().importConnectionLink(
|
||||
link,
|
||||
source === "onboarding" ? "hostRoot" : "hostSettings",
|
||||
),
|
||||
onConnected: navigateToPairedHost,
|
||||
}),
|
||||
);
|
||||
const scanState = useSyncExternalStore(scan.subscribe, scan.getState, scan.getState);
|
||||
useEffect(() => () => scan.close(), [scan]);
|
||||
|
||||
const closeToSource = useCallback(() => {
|
||||
try {
|
||||
router.back();
|
||||
@@ -159,30 +164,12 @@ export default function PairScanScreen() {
|
||||
}, [permission, requestPermission]);
|
||||
|
||||
const handleScan = useCallback(
|
||||
(result: BarcodeScanningResult) => {
|
||||
if (isPairing) return;
|
||||
const offerUrl = extractOfferUrlFromScan(result);
|
||||
if (!offerUrl) return;
|
||||
|
||||
const store = getHostRuntimeStore();
|
||||
if (passwordOfferUrl) return;
|
||||
setIsPairing(true);
|
||||
setScanError(null);
|
||||
void store
|
||||
.importConnectionLink(offerUrl, source === "onboarding" ? "hostRoot" : "hostSettings")
|
||||
.then((outcome) => {
|
||||
if (outcome.status === "connected") navigateToPairedHost(outcome.serverId);
|
||||
else setPasswordOfferUrl(offerUrl);
|
||||
return outcome;
|
||||
})
|
||||
.catch((error) => setScanError(error instanceof Error ? error.message : String(error)))
|
||||
.finally(() => setIsPairing(false));
|
||||
},
|
||||
[isPairing, navigateToPairedHost, passwordOfferUrl, source],
|
||||
(result: BarcodeScanningResult) =>
|
||||
scan.scan(typeof result.data === "string" ? result.data : ""),
|
||||
[scan],
|
||||
);
|
||||
|
||||
const handleRouterBack = useCallback(() => router.back(), [router]);
|
||||
const closePasswordModal = useCallback(() => setPasswordOfferUrl(null), []);
|
||||
const savePasswordPairing = useCallback(
|
||||
({ serverId }: { serverId: string }) => navigateToPairedHost(serverId),
|
||||
[navigateToPairedHost],
|
||||
@@ -240,24 +227,38 @@ export default function PairScanScreen() {
|
||||
barcodeScannerSettings={BARCODE_SCANNER_SETTINGS}
|
||||
onBarcodeScanned={handleScan}
|
||||
/>
|
||||
<View style={styles.overlay} pointerEvents="none">
|
||||
<View style={styles.overlay} pointerEvents="box-none">
|
||||
<View style={styles.scanFrame}>
|
||||
<View style={[styles.corner, styles.cornerTL]} />
|
||||
<View style={[styles.corner, styles.cornerTR]} />
|
||||
<View style={[styles.corner, styles.cornerBL]} />
|
||||
<View style={[styles.corner, styles.cornerBR]} />
|
||||
</View>
|
||||
{isPairing ? <Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text> : null}
|
||||
{scanError ? <Text style={helperTextStyle}>{scanError}</Text> : null}
|
||||
{scanState.status === "pairing" ? (
|
||||
<Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text>
|
||||
) : null}
|
||||
{scanState.status === "stopped" && scanState.error ? (
|
||||
<Text style={helperTextStyle}>{scanState.error}</Text>
|
||||
) : null}
|
||||
{scanState.status === "stopped" ? (
|
||||
<Button
|
||||
style={styles.scanAgainButton}
|
||||
onPress={scan.scanAgain}
|
||||
testID="pair-scan-again"
|
||||
>
|
||||
{t("pairing.connectionMethods.scanQr.title")}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<PairLinkModal
|
||||
visible={passwordOfferUrl !== null}
|
||||
initialUrl={passwordOfferUrl ?? undefined}
|
||||
initialPasswordRequired
|
||||
onClose={closePasswordModal}
|
||||
visible={scanState.status === "passwordRequired"}
|
||||
passwordRequired={
|
||||
scanState.status === "passwordRequired" ? scanState.passwordRequired : undefined
|
||||
}
|
||||
onClose={scan.closePassword}
|
||||
onSaved={savePasswordPairing}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Modal, Platform, Pressable, Text, View } from "react-native";
|
||||
import { Keyboard, Pressable, Text, View } from "react-native";
|
||||
import type { DimensionValue, StyleProp, ViewStyle } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
type BottomSheetBackgroundProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { ArrowLeft, Search, X } from "lucide-react-native";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
@@ -75,11 +74,15 @@ export interface SheetHeader {
|
||||
|
||||
const SCROLL_CONTENT_GROW = { flexGrow: 1 };
|
||||
const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFillObject };
|
||||
const NATIVE_DIALOG_SNAP_POINTS = ["100%"];
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
nativeModalRoot: {
|
||||
nativeDialogSurface: {
|
||||
flex: 1,
|
||||
},
|
||||
nativeDialogBackground: {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
desktopOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0,0,0,0.55)",
|
||||
@@ -471,7 +474,7 @@ export interface AdaptiveModalSheetProps {
|
||||
contentStyle?: StyleProp<ViewStyle>;
|
||||
/** Size compact sheet content to the live snap height instead of its largest snap point. */
|
||||
sizeContentToCurrentSnapPoint?: boolean;
|
||||
/** Re-establishes caller-owned contexts inside the compact bottom-sheet portal. */
|
||||
/** Re-establishes caller-owned contexts inside the native or compact sheet portal. */
|
||||
contextBridge?: ContextBridge | null;
|
||||
}
|
||||
|
||||
@@ -526,26 +529,26 @@ export function AdaptiveModalSheet({
|
||||
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
|
||||
[theme.colors.palette.zinc],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isWeb && visible) {
|
||||
// A newly opened sheet owns input. A keyboard belonging to the sheet below
|
||||
// would cover controls in the new sheet, which has no focused input yet.
|
||||
Keyboard.dismiss();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
|
||||
visible,
|
||||
isEnabled: isMobile,
|
||||
isEnabled: isMobile || !isWeb,
|
||||
onClose,
|
||||
});
|
||||
const [shouldRenderWeb, setShouldRenderWeb] = useState(visible);
|
||||
const [isWebClosing, setIsWebClosing] = useState(false);
|
||||
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && !isMobile && shouldRenderWeb);
|
||||
const nativeModalDismissNotifiedRef = useRef(!visible);
|
||||
const handleDismiss = useCallback(() => {
|
||||
handleSheetDismiss();
|
||||
onDismiss?.();
|
||||
}, [handleSheetDismiss, onDismiss]);
|
||||
const notifyNativeModalDismiss = useCallback(() => {
|
||||
if (nativeModalDismissNotifiedRef.current) {
|
||||
return;
|
||||
}
|
||||
nativeModalDismissNotifiedRef.current = true;
|
||||
onDismiss?.();
|
||||
}, [onDismiss]);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
@@ -592,12 +595,6 @@ export function AdaptiveModalSheet({
|
||||
onKeyDown: handleWebOverlayKeyDown,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
nativeModalDismissNotifiedRef.current = false;
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWeb || isMobile) return;
|
||||
if (visible) {
|
||||
@@ -615,12 +612,6 @@ export function AdaptiveModalSheet({
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [visible, isMobile, onDismiss, shouldRenderWeb]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isWeb || isMobile || visible || Platform.OS !== "android") return;
|
||||
const timeout = setTimeout(notifyNativeModalDismiss, 0);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [visible, isMobile, notifyNativeModalDismiss]);
|
||||
|
||||
if (isMobile) {
|
||||
const sheetContent = (
|
||||
<>
|
||||
@@ -725,18 +716,28 @@ export function AdaptiveModalSheet({
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
transparent
|
||||
animationType="fade"
|
||||
visible={visible}
|
||||
onRequestClose={onClose}
|
||||
onDismiss={notifyNativeModalDismiss}
|
||||
hardwareAccelerated
|
||||
// Both native presentations share Gorhom's app-wide stack. Independent RN Modals
|
||||
// present from their React ancestor's controller, so a root-owned sibling dialog
|
||||
// cannot present while that controller already has a dialog open on iOS.
|
||||
<IsolatedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
contextBridge={contextBridge}
|
||||
snapPoints={NATIVE_DIALOG_SNAP_POINTS}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleDismiss}
|
||||
handleComponent={null}
|
||||
backgroundStyle={styles.nativeDialogBackground}
|
||||
enablePanDownToClose={false}
|
||||
enableHandlePanningGesture={false}
|
||||
enableContentPanningGesture={false}
|
||||
keyboardBehavior="extend"
|
||||
keyboardBlurBehavior="restore"
|
||||
accessible={false}
|
||||
presentation={presentation}
|
||||
>
|
||||
{/* Android Modal opens a separate window outside the app's gesture root. */}
|
||||
<GestureHandlerRootView style={styles.nativeModalRoot}>
|
||||
{desktopContent}
|
||||
</GestureHandlerRootView>
|
||||
</Modal>
|
||||
<View style={styles.nativeDialogSurface}>{desktopContent}</View>
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -294,11 +294,23 @@ export interface AddHostModalProps {
|
||||
}
|
||||
|
||||
export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
|
||||
return (
|
||||
<AddHostModalContent
|
||||
key={String(visible)}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
onCancel={onCancel}
|
||||
onSaved={onSaved}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const daemons = useHosts();
|
||||
const { probeAndUpsertDirectConnection, probeAndUpsertConnectionFromOfferUrl } =
|
||||
useHostMutations();
|
||||
const { probeAndUpsertDirectConnection, beginLinkPairing } = useHostMutations();
|
||||
const [linkPairing] = useState(() => beginLinkPairing());
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -313,18 +325,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
|
||||
const advancedTarget = useRef(new PairingTargetTracker("", true));
|
||||
|
||||
const clearInput = useCallback(() => {
|
||||
setHost("");
|
||||
setPort("6767");
|
||||
setUseTls(false);
|
||||
setPassword("");
|
||||
setIsPasswordVisible(false);
|
||||
setIsAdvancedOpen(false);
|
||||
setAdvancedUri("");
|
||||
advancedTarget.current = new PairingTargetTracker("", true);
|
||||
bumpInputResetKey();
|
||||
}, []);
|
||||
|
||||
const connectIcon = useMemo(
|
||||
() => <Link2 size={16} color={theme.colors.accentForeground} />,
|
||||
[theme.colors.accentForeground],
|
||||
@@ -358,29 +358,25 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
);
|
||||
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.direct.title") }), [t]);
|
||||
|
||||
// Each open mounts a fresh form (see AddHostModal), so closing needs no reset.
|
||||
const handleClose = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
clearInput();
|
||||
setErrorMessage("");
|
||||
onClose();
|
||||
}, [isSaving, clearInput, onClose]);
|
||||
}, [isSaving, onClose]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
clearInput();
|
||||
setErrorMessage("");
|
||||
(onCancel ?? onClose)();
|
||||
}, [isSaving, clearInput, onCancel, onClose]);
|
||||
}, [isSaving, onCancel, onClose]);
|
||||
|
||||
const handleSaveRelay = useCallback(
|
||||
async (relayUri: string) => {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
setErrorMessage("");
|
||||
const { profile, serverId, hostname } = await probeAndUpsertConnectionFromOfferUrl(
|
||||
relayUri,
|
||||
password || undefined,
|
||||
);
|
||||
const result = await linkPairing.submit(relayUri, password || undefined);
|
||||
if (result.status === "cancelled") return;
|
||||
const { profile, serverId, hostname } = result;
|
||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
|
||||
onSaved?.({ profile, serverId, hostname, isNewHost });
|
||||
handleClose();
|
||||
@@ -396,9 +392,9 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
daemons,
|
||||
directConnectionLabels.invalidConnection,
|
||||
handleClose,
|
||||
linkPairing,
|
||||
onSaved,
|
||||
password,
|
||||
probeAndUpsertConnectionFromOfferUrl,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Link } from "lucide-react-native";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||
import { useHosts, useHostMutations, type PasswordRequiredPairing } from "@/runtime/host-runtime";
|
||||
import { parseRelayConnectionUri } from "@/utils/daemon-endpoints";
|
||||
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
@@ -62,8 +62,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
|
||||
export interface PairLinkModalProps {
|
||||
visible: boolean;
|
||||
initialUrl?: string;
|
||||
initialPasswordRequired?: boolean;
|
||||
/** Continues a confirmed pairing whose host asked for a password. */
|
||||
passwordRequired?: PasswordRequiredPairing;
|
||||
onClose: () => void;
|
||||
onCancel?: () => void;
|
||||
onSaved?: (result: {
|
||||
@@ -76,18 +76,16 @@ export interface PairLinkModalProps {
|
||||
|
||||
export function PairLinkModal({
|
||||
visible,
|
||||
initialUrl,
|
||||
initialPasswordRequired = false,
|
||||
passwordRequired,
|
||||
onClose,
|
||||
onCancel,
|
||||
onSaved,
|
||||
}: PairLinkModalProps) {
|
||||
return (
|
||||
<PairLinkModalContent
|
||||
key={`${visible}:${initialUrl ?? ""}:${initialPasswordRequired}`}
|
||||
key={`${visible}:${passwordRequired?.link ?? ""}`}
|
||||
visible={visible}
|
||||
initialUrl={initialUrl}
|
||||
initialPasswordRequired={initialPasswordRequired}
|
||||
passwordRequired={passwordRequired}
|
||||
onClose={onClose}
|
||||
onCancel={onCancel}
|
||||
onSaved={onSaved}
|
||||
@@ -97,8 +95,7 @@ export function PairLinkModal({
|
||||
|
||||
function PairLinkModalContent({
|
||||
visible,
|
||||
initialUrl,
|
||||
initialPasswordRequired = false,
|
||||
passwordRequired,
|
||||
onClose,
|
||||
onCancel,
|
||||
onSaved,
|
||||
@@ -106,7 +103,9 @@ function PairLinkModalContent({
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const daemons = useHosts();
|
||||
const { probeAndUpsertConnectionFromOfferUrl } = useHostMutations();
|
||||
const { beginLinkPairing } = useHostMutations();
|
||||
const [pairing] = useState(() => passwordRequired?.pairing ?? beginLinkPairing());
|
||||
const initialUrl = passwordRequired?.link;
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
|
||||
const offerUrlRef = useRef(initialUrl ?? "");
|
||||
@@ -115,7 +114,7 @@ function PairLinkModalContent({
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [needsPassword, setNeedsPassword] = useState(initialPasswordRequired);
|
||||
const [needsPassword, setNeedsPassword] = useState(passwordRequired !== undefined);
|
||||
const [passwordResetKey, resetPasswordInput] = useReducer((key: number) => key + 1, 0);
|
||||
|
||||
const clearInput = useCallback(() => {
|
||||
@@ -162,10 +161,9 @@ function PairLinkModalContent({
|
||||
try {
|
||||
setIsSaving(true);
|
||||
setErrorMessage("");
|
||||
const { profile, serverId, hostname } = await probeAndUpsertConnectionFromOfferUrl(
|
||||
raw,
|
||||
password || undefined,
|
||||
);
|
||||
const result = await pairing.submit(raw, password || undefined);
|
||||
if (result.status === "cancelled") return;
|
||||
const { profile, serverId, hostname } = result;
|
||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
|
||||
onSaved?.({ profile, serverId, hostname, isNewHost });
|
||||
handleClose();
|
||||
@@ -184,16 +182,7 @@ function PairLinkModalContent({
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
daemons,
|
||||
handleClose,
|
||||
isMobile,
|
||||
isSaving,
|
||||
onSaved,
|
||||
password,
|
||||
t,
|
||||
probeAndUpsertConnectionFromOfferUrl,
|
||||
],
|
||||
[daemons, handleClose, isMobile, isSaving, onSaved, password, t, pairing],
|
||||
);
|
||||
|
||||
const handleChangeOfferUrl = useCallback((next: string) => {
|
||||
|
||||
@@ -1357,6 +1357,10 @@ function DesktopFeatureItem({
|
||||
if (feature.type === "select") {
|
||||
const FeatureIcon = getAgentFeatureIcon(feature.icon);
|
||||
const selectedOption = feature.options.find((o) => o.id === feature.value);
|
||||
const iconOnly = feature.desktopTrigger === "icon";
|
||||
const tooltip = iconOnly
|
||||
? `${feature.label}: ${selectedOption?.label ?? feature.label}`
|
||||
: getFeatureTooltip(feature);
|
||||
return (
|
||||
<>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
@@ -1367,15 +1371,16 @@ function DesktopFeatureItem({
|
||||
surface="toolbar"
|
||||
label={feature.label}
|
||||
value={selectedOption?.label ?? feature.label}
|
||||
showToolbarLabel={!iconOnly}
|
||||
open={openSelector === featureSelector}
|
||||
disabled={disabled}
|
||||
onPress={handleSelectPress}
|
||||
accessibilityLabel={getFeatureTooltip(feature)}
|
||||
accessibilityLabel={tooltip}
|
||||
testID={`agent-feature-${feature.id}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{getFeatureTooltip(feature)}</Text>
|
||||
<Text style={styles.tooltipText}>{tooltip}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Combobox
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { useHostConfirmation } from "@/runtime/host-runtime";
|
||||
|
||||
/** Shows the host confirmation the host runtime is waiting on. Mount once at the app root. */
|
||||
export function HostConfirmationSheet() {
|
||||
const { t } = useTranslation();
|
||||
const { pending, answer } = useHostConfirmation();
|
||||
|
||||
const cancel = useCallback(() => answer(false), [answer]);
|
||||
const connect = useCallback(() => answer(true), [answer]);
|
||||
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.hostConfirmation.title") }), [t]);
|
||||
|
||||
if (!pending) return null;
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet header={header} visible onClose={cancel} testID="host-confirmation">
|
||||
<Text style={styles.confirmText}>
|
||||
{pending.kind === "changedConnection"
|
||||
? t("pairing.hostConfirmation.descriptionChanged")
|
||||
: t("pairing.hostConfirmation.description")}
|
||||
</Text>
|
||||
<View style={settingsStyles.card}>
|
||||
<DetailRow
|
||||
label={t("pairing.hostConfirmation.hostLabel")}
|
||||
value={pending.serverId}
|
||||
testID="host-confirmation-server-id"
|
||||
/>
|
||||
<DetailRow
|
||||
label={t("pairing.hostConfirmation.fingerprintLabel")}
|
||||
value={pending.keyFingerprint}
|
||||
testID="host-confirmation-fingerprint"
|
||||
showBorder
|
||||
/>
|
||||
<DetailRow
|
||||
label={t("pairing.hostConfirmation.relayLabel")}
|
||||
value={pending.relayEndpoint}
|
||||
testID="host-confirmation-relay"
|
||||
showBorder
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.confirmActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
style={FLEX_1_STYLE}
|
||||
onPress={cancel}
|
||||
testID="host-confirmation-cancel"
|
||||
>
|
||||
{t("common.actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
style={FLEX_1_STYLE}
|
||||
onPress={connect}
|
||||
testID="host-confirmation-connect"
|
||||
>
|
||||
{t("pairing.hostConfirmation.connect")}
|
||||
</Button>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({
|
||||
label,
|
||||
value,
|
||||
showBorder = false,
|
||||
testID,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
showBorder?: boolean;
|
||||
testID?: string;
|
||||
}) {
|
||||
return (
|
||||
<View style={[settingsStyles.row, showBorder && settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{label}</Text>
|
||||
<Text style={settingsStyles.rowHint} selectable testID={testID}>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Same shape as the remove-host and remove-connection confirm sheets in
|
||||
// screens/settings/host-page.tsx.
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
confirmText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
confirmActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
marginTop: theme.spacing[4],
|
||||
},
|
||||
}));
|
||||
|
||||
const FLEX_1_STYLE = { flex: 1 };
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ConnectionLinkImport, LinkPairing } from "@/runtime/host-runtime";
|
||||
import { openPairScan, type PairScanModel } from "./pair-scan-model";
|
||||
|
||||
const LINK = "https://app.paseo.sh/#offer=abc";
|
||||
const PAIRING: LinkPairing = { submit: async () => ({ status: "cancelled" }) };
|
||||
|
||||
/** The host runtime's side of a scan: each import resolves with the next queued outcome. */
|
||||
function createScanner(outcomes: Array<ConnectionLinkImport | Error>) {
|
||||
const imports: string[] = [];
|
||||
const connected: string[] = [];
|
||||
const scan = openPairScan({
|
||||
importConnectionLink: async (link) => {
|
||||
imports.push(link);
|
||||
const outcome = outcomes.shift();
|
||||
if (!outcome) throw new Error("No outcome queued for this import");
|
||||
if (outcome instanceof Error) throw outcome;
|
||||
return outcome;
|
||||
},
|
||||
onConnected: (serverId) => connected.push(serverId),
|
||||
});
|
||||
return { scan, imports, connected };
|
||||
}
|
||||
|
||||
async function settled(scan: PairScanModel): Promise<void> {
|
||||
await vi.waitFor(() => expect(scan.getState().status).not.toBe("pairing"));
|
||||
}
|
||||
|
||||
describe("openPairScan", () => {
|
||||
it("stops scanning after Cancel until the user scans again", async () => {
|
||||
const { scan, imports } = createScanner([{ status: "cancelled" }, { status: "cancelled" }]);
|
||||
|
||||
scan.scan(LINK);
|
||||
await settled(scan);
|
||||
|
||||
// The code is still in front of the camera.
|
||||
scan.scan(LINK);
|
||||
scan.scan(LINK);
|
||||
await Promise.resolve();
|
||||
expect(imports).toEqual([LINK]);
|
||||
expect(scan.getState()).toEqual({ status: "stopped", error: null });
|
||||
|
||||
scan.scanAgain();
|
||||
scan.scan(LINK);
|
||||
await settled(scan);
|
||||
expect(imports).toEqual([LINK, LINK]);
|
||||
});
|
||||
|
||||
it("starts one attempt per code while pairing, and ignores codes that are not pairing links", async () => {
|
||||
const { scan, imports, connected } = createScanner([
|
||||
{ status: "connected", serverId: "srv_scan" },
|
||||
]);
|
||||
|
||||
scan.scan("https://example.com/not-a-pairing-link");
|
||||
expect(scan.getState()).toEqual({ status: "scanning" });
|
||||
|
||||
scan.scan(LINK);
|
||||
scan.scan(LINK);
|
||||
await settled(scan);
|
||||
|
||||
expect(imports).toEqual([LINK]);
|
||||
expect(connected).toEqual(["srv_scan"]);
|
||||
scan.scan(LINK);
|
||||
expect(imports).toEqual([LINK]);
|
||||
});
|
||||
|
||||
it("ends the attempt when the password prompt closes", async () => {
|
||||
const { scan, imports } = createScanner([
|
||||
{ status: "password_required", link: LINK, pairing: PAIRING },
|
||||
]);
|
||||
|
||||
scan.scan(LINK);
|
||||
await settled(scan);
|
||||
expect(scan.getState()).toEqual({
|
||||
status: "passwordRequired",
|
||||
passwordRequired: { link: LINK, pairing: PAIRING },
|
||||
});
|
||||
scan.scan(LINK);
|
||||
|
||||
scan.closePassword();
|
||||
scan.scan(LINK);
|
||||
|
||||
expect(scan.getState()).toEqual({ status: "stopped", error: null });
|
||||
expect(imports).toEqual([LINK]);
|
||||
});
|
||||
|
||||
it("stops with the error when an attempt fails", async () => {
|
||||
const { scan, imports } = createScanner([new Error("Server disconnected")]);
|
||||
|
||||
scan.scan(LINK);
|
||||
await settled(scan);
|
||||
scan.scan(LINK);
|
||||
|
||||
expect(scan.getState()).toEqual({ status: "stopped", error: "Server disconnected" });
|
||||
expect(imports).toEqual([LINK]);
|
||||
});
|
||||
|
||||
it("does not navigate for an attempt that finishes after the screen closed", async () => {
|
||||
let finish: (outcome: ConnectionLinkImport) => void = () => {};
|
||||
const connected: string[] = [];
|
||||
const scan = openPairScan({
|
||||
importConnectionLink: () =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
onConnected: (serverId) => connected.push(serverId),
|
||||
});
|
||||
|
||||
scan.scan(LINK);
|
||||
scan.close();
|
||||
finish({ status: "connected", serverId: "srv_scan" });
|
||||
await Promise.resolve();
|
||||
|
||||
expect(connected).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { ConnectionLinkImport, PasswordRequiredPairing } from "@/runtime/host-runtime";
|
||||
|
||||
/**
|
||||
* One visit to the QR scan screen. The camera reports every frame that shows
|
||||
* a code, so an attempt that ends (Cancel on the host confirmation, a closed
|
||||
* password prompt, or an error) stops scanning until the user taps scan again.
|
||||
* Without that, the code still in front of the camera starts a new attempt on
|
||||
* the next frame.
|
||||
*/
|
||||
export type PairScanState =
|
||||
| { status: "scanning" }
|
||||
| { status: "pairing" }
|
||||
| { status: "passwordRequired"; passwordRequired: PasswordRequiredPairing }
|
||||
| { status: "stopped"; error: string | null }
|
||||
| { status: "connected" };
|
||||
|
||||
export interface PairScanModel {
|
||||
getState: () => PairScanState;
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
/** Data from a QR code the camera saw. Only a scanning session starts an attempt. */
|
||||
scan: (data: string) => void;
|
||||
/** The user asked to scan again after an attempt ended. */
|
||||
scanAgain: () => void;
|
||||
/** The user closed the password prompt, which ends the attempt. */
|
||||
closePassword: () => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export interface PairScanDependencies {
|
||||
importConnectionLink: (link: string) => Promise<ConnectionLinkImport>;
|
||||
onConnected: (serverId: string) => void;
|
||||
}
|
||||
|
||||
export function openPairScan(deps: PairScanDependencies): PairScanModel {
|
||||
let state: PairScanState = { status: "scanning" };
|
||||
let closed = false;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function publish(next: PairScanState): void {
|
||||
if (closed) return;
|
||||
state = next;
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
async function pair(link: string): Promise<void> {
|
||||
publish({ status: "pairing" });
|
||||
try {
|
||||
const outcome = await deps.importConnectionLink(link);
|
||||
if (closed) return;
|
||||
if (outcome.status === "connected") {
|
||||
publish({ status: "connected" });
|
||||
deps.onConnected(outcome.serverId);
|
||||
return;
|
||||
}
|
||||
if (outcome.status === "password_required") {
|
||||
const { link: passwordLink, pairing } = outcome;
|
||||
publish({ status: "passwordRequired", passwordRequired: { link: passwordLink, pairing } });
|
||||
return;
|
||||
}
|
||||
publish({ status: "stopped", error: null });
|
||||
} catch (error) {
|
||||
publish({ status: "stopped", error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getState: () => state,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
scan(data) {
|
||||
if (state.status !== "scanning") return;
|
||||
const link = pairingLinkFromScan(data);
|
||||
if (!link) return;
|
||||
void pair(link);
|
||||
},
|
||||
scanAgain() {
|
||||
if (state.status !== "stopped") return;
|
||||
publish({ status: "scanning" });
|
||||
},
|
||||
closePassword() {
|
||||
if (state.status !== "passwordRequired") return;
|
||||
publish({ status: "stopped", error: null });
|
||||
},
|
||||
close() {
|
||||
closed = true;
|
||||
listeners.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pairingLinkFromScan(data: string): string | null {
|
||||
const raw = data.trim();
|
||||
if (raw.includes("#offer=") || raw.includes("#connect=") || raw.startsWith("relay://")) {
|
||||
return raw;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -439,7 +439,9 @@ export const ar: TranslationResources = {
|
||||
recovery: {
|
||||
archivedTitle: "مساحة العمل مؤرشفة",
|
||||
restoreDescription:
|
||||
"تمت أرشفة {{workspaceName}} وإزالة شجرة العمل الخاصة بها. استعد الفرع {{branch}} لفتحها مجددًا.",
|
||||
"استعد {{workspaceName}} للعودة إلى وكلائها. ستستخدم شجرة العمل الفرع {{branch}}.",
|
||||
restoreWithoutBranchDescription:
|
||||
"استعد {{workspaceName}} للعودة إلى وكلائها. سيبدأ فرع جديد من الفرع الأساسي المحفوظ أو الفرع الافتراضي للمستودع.",
|
||||
unarchiveDescription: "{{workspaceName}} مؤرشفة. ألغِ أرشفتها لفتحها مجددًا.",
|
||||
restoreAction: "استعادة",
|
||||
unarchiveAction: "إلغاء الأرشفة",
|
||||
@@ -1619,6 +1621,17 @@ export const ar: TranslationResources = {
|
||||
title: "كلمة المرور لـ {{host}}",
|
||||
label: "كلمة مرور المضيف",
|
||||
},
|
||||
hostConfirmation: {
|
||||
title: "الاتصال بهذا المضيف؟",
|
||||
description:
|
||||
"سيتمكن هذا المضيف من تشغيل تعليمات برمجية في هذا التطبيق والوصول إلى مضيفيك الآخرين المتصلين. اتصل فقط إذا كنت تعرفه.",
|
||||
descriptionChanged:
|
||||
"يغيّر هذا الرابط طريقة اتصالك بهذا المضيف. سيتمكن المضيف من تشغيل تعليمات برمجية في هذا التطبيق والوصول إلى مضيفيك الآخرين المتصلين. اتصل فقط إذا كنت تعرفه.",
|
||||
hostLabel: "المضيف",
|
||||
fingerprintLabel: "بصمة المفتاح",
|
||||
relayLabel: "المُرحّل",
|
||||
connect: "اتصال",
|
||||
},
|
||||
connectionMethods: {
|
||||
title: "إضافة اتصال",
|
||||
direct: {
|
||||
|
||||
@@ -436,7 +436,9 @@ export const en = {
|
||||
recovery: {
|
||||
archivedTitle: "Workspace archived",
|
||||
restoreDescription:
|
||||
"{{workspaceName}} was archived and its worktree was removed. Restore branch {{branch}} to open it again.",
|
||||
"Restore {{workspaceName}} to return to its agents. Its worktree will use branch {{branch}}.",
|
||||
restoreWithoutBranchDescription:
|
||||
"Restore {{workspaceName}} to return to its agents. A new branch will start from the saved base or the repository default.",
|
||||
unarchiveDescription: "{{workspaceName}} is archived. Unarchive it to open it again.",
|
||||
restoreAction: "Restore",
|
||||
unarchiveAction: "Unarchive",
|
||||
@@ -1644,6 +1646,17 @@ export const en = {
|
||||
title: "Password for {{host}}",
|
||||
label: "Host password",
|
||||
},
|
||||
hostConfirmation: {
|
||||
title: "Connect to this host?",
|
||||
description:
|
||||
"This host will be able to run code in this app and reach your other connected hosts. Only connect if you recognize it.",
|
||||
descriptionChanged:
|
||||
"This link changes how you connect to this host. The host will be able to run code in this app and reach your other connected hosts. Only connect if you recognize it.",
|
||||
hostLabel: "Host",
|
||||
fingerprintLabel: "Key fingerprint",
|
||||
relayLabel: "Relay",
|
||||
connect: "Connect",
|
||||
},
|
||||
connectionMethods: {
|
||||
title: "Add connection",
|
||||
direct: {
|
||||
|
||||
@@ -444,7 +444,9 @@ export const es: TranslationResources = {
|
||||
recovery: {
|
||||
archivedTitle: "Espacio de trabajo archivado",
|
||||
restoreDescription:
|
||||
"{{workspaceName}} se archivó y se eliminó su worktree. Restaura la rama {{branch}} para volver a abrirlo.",
|
||||
"Restaura {{workspaceName}} para volver a sus agentes. Su worktree usará la rama {{branch}}.",
|
||||
restoreWithoutBranchDescription:
|
||||
"Restaura {{workspaceName}} para volver a sus agentes. Se creará una rama nueva desde la base guardada o la rama predeterminada del repositorio.",
|
||||
unarchiveDescription:
|
||||
"{{workspaceName}} está archivado. Desarchívalo para volver a abrirlo.",
|
||||
restoreAction: "Restaurar",
|
||||
@@ -1665,6 +1667,17 @@ export const es: TranslationResources = {
|
||||
title: "Contraseña de {{host}}",
|
||||
label: "Contraseña del host",
|
||||
},
|
||||
hostConfirmation: {
|
||||
title: "¿Conectar con este host?",
|
||||
description:
|
||||
"Este host podrá ejecutar código en esta app y acceder a tus otros hosts conectados. Conéctate solo si lo reconoces.",
|
||||
descriptionChanged:
|
||||
"Este enlace cambia cómo te conectas a este host. El host podrá ejecutar código en esta app y acceder a tus otros hosts conectados. Conéctate solo si lo reconoces.",
|
||||
hostLabel: "Host",
|
||||
fingerprintLabel: "Huella de la clave",
|
||||
relayLabel: "Relay",
|
||||
connect: "Conectar",
|
||||
},
|
||||
connectionMethods: {
|
||||
title: "Agregar conexión",
|
||||
direct: {
|
||||
|
||||
@@ -445,7 +445,9 @@ export const fr: TranslationResources = {
|
||||
recovery: {
|
||||
archivedTitle: "Espace de travail archivé",
|
||||
restoreDescription:
|
||||
"{{workspaceName}} a été archivé et son worktree supprimé. Restaurez la branche {{branch}} pour le rouvrir.",
|
||||
"Restaurez {{workspaceName}} pour retrouver ses agents. Son worktree utilisera la branche {{branch}}.",
|
||||
restoreWithoutBranchDescription:
|
||||
"Restaurez {{workspaceName}} pour retrouver ses agents. Une nouvelle branche partira de la base enregistrée ou de la branche par défaut du dépôt.",
|
||||
unarchiveDescription: "{{workspaceName}} est archivé. Désarchivez-le pour le rouvrir.",
|
||||
restoreAction: "Restaurer",
|
||||
unarchiveAction: "Désarchiver",
|
||||
@@ -1669,6 +1671,17 @@ export const fr: TranslationResources = {
|
||||
title: "Mot de passe pour {{host}}",
|
||||
label: "Mot de passe de l’hôte",
|
||||
},
|
||||
hostConfirmation: {
|
||||
title: "Se connecter à cet hôte ?",
|
||||
description:
|
||||
"Cet hôte pourra exécuter du code dans cette application et accéder à vos autres hôtes connectés. Connectez-vous uniquement si vous le reconnaissez.",
|
||||
descriptionChanged:
|
||||
"Ce lien modifie la façon dont vous vous connectez à cet hôte. L'hôte pourra exécuter du code dans cette application et accéder à vos autres hôtes connectés. Connectez-vous uniquement si vous le reconnaissez.",
|
||||
hostLabel: "Hôte",
|
||||
fingerprintLabel: "Empreinte de la clé",
|
||||
relayLabel: "Relais",
|
||||
connect: "Se connecter",
|
||||
},
|
||||
connectionMethods: {
|
||||
title: "Ajouter une connexion",
|
||||
direct: {
|
||||
|
||||
@@ -444,7 +444,9 @@ export const ja: TranslationResources = {
|
||||
recovery: {
|
||||
archivedTitle: "ワークスペースはアーカイブ済みです",
|
||||
restoreDescription:
|
||||
"{{workspaceName}} はアーカイブされ、worktree が削除されました。ブランチ {{branch}} を復元して再度開きます。",
|
||||
"{{workspaceName}} を復元してエージェントに戻ります。worktree ではブランチ {{branch}} を使用します。",
|
||||
restoreWithoutBranchDescription:
|
||||
"{{workspaceName}} を復元してエージェントに戻ります。保存されたベース、またはリポジトリのデフォルトブランチから新しいブランチを作成します。",
|
||||
unarchiveDescription:
|
||||
"{{workspaceName}} はアーカイブされています。再度開くにはアーカイブを解除してください。",
|
||||
restoreAction: "復元",
|
||||
@@ -1636,6 +1638,17 @@ export const ja: TranslationResources = {
|
||||
title: "{{host}} のパスワード",
|
||||
label: "ホストのパスワード",
|
||||
},
|
||||
hostConfirmation: {
|
||||
title: "このホストに接続しますか?",
|
||||
description:
|
||||
"このホストはこのアプリ内でコードを実行し、接続中の他のホストにアクセスできるようになります。心当たりがある場合のみ接続してください。",
|
||||
descriptionChanged:
|
||||
"このリンクはこのホストへの接続方法を変更します。ホストはこのアプリ内でコードを実行し、接続中の他のホストにアクセスできるようになります。心当たりがある場合のみ接続してください。",
|
||||
hostLabel: "ホスト",
|
||||
fingerprintLabel: "鍵のフィンガープリント",
|
||||
relayLabel: "リレー",
|
||||
connect: "接続",
|
||||
},
|
||||
connectionMethods: {
|
||||
title: "接続を追加",
|
||||
direct: {
|
||||
|
||||
@@ -440,7 +440,9 @@ export const ko: TranslationResources = {
|
||||
recovery: {
|
||||
archivedTitle: "워크스페이스가 보관되었습니다",
|
||||
restoreDescription:
|
||||
"{{workspaceName}}가 보관되고 워크트리가 제거되었습니다. 다시 열려면 {{branch}} 브랜치를 복원하세요.",
|
||||
"{{workspaceName}}를 복원하여 에이전트로 돌아갑니다. 워크트리는 {{branch}} 브랜치를 사용합니다.",
|
||||
restoreWithoutBranchDescription:
|
||||
"{{workspaceName}}를 복원하여 에이전트로 돌아갑니다. 저장된 기반 브랜치 또는 저장소 기본 브랜치에서 새 브랜치를 만듭니다.",
|
||||
unarchiveDescription: "{{workspaceName}}가 보관되었습니다. 다시 열려면 보관을 취소하세요.",
|
||||
restoreAction: "복원",
|
||||
unarchiveAction: "보관 취소",
|
||||
@@ -1629,6 +1631,17 @@ export const ko: TranslationResources = {
|
||||
title: "{{host}}의 비밀번호",
|
||||
label: "호스트 비밀번호",
|
||||
},
|
||||
hostConfirmation: {
|
||||
title: "이 호스트에 연결할까요?",
|
||||
description:
|
||||
"이 호스트는 이 앱에서 코드를 실행하고 연결된 다른 호스트에 접근할 수 있게 됩니다. 알고 있는 호스트인 경우에만 연결하세요.",
|
||||
descriptionChanged:
|
||||
"이 링크는 이 호스트에 연결하는 방식을 변경합니다. 호스트는 이 앱에서 코드를 실행하고 연결된 다른 호스트에 접근할 수 있게 됩니다. 알고 있는 호스트인 경우에만 연결하세요.",
|
||||
hostLabel: "호스트",
|
||||
fingerprintLabel: "키 지문",
|
||||
relayLabel: "릴레이",
|
||||
connect: "연결",
|
||||
},
|
||||
connectionMethods: {
|
||||
title: "연결 추가",
|
||||
direct: {
|
||||
|
||||
@@ -443,7 +443,9 @@ export const ptBR: TranslationResources = {
|
||||
recovery: {
|
||||
archivedTitle: "Workspace arquivado",
|
||||
restoreDescription:
|
||||
"{{workspaceName}} foi arquivado e sua worktree foi removida. Restaure a branch {{branch}} para abri-lo novamente.",
|
||||
"Restaure {{workspaceName}} para voltar aos seus agentes. A worktree usará a branch {{branch}}.",
|
||||
restoreWithoutBranchDescription:
|
||||
"Restaure {{workspaceName}} para voltar aos seus agentes. Uma nova branch partirá da base salva ou da branch padrão do repositório.",
|
||||
unarchiveDescription:
|
||||
"{{workspaceName}} está arquivado. Desarquive-o para abri-lo novamente.",
|
||||
restoreAction: "Restaurar",
|
||||
@@ -1650,6 +1652,17 @@ export const ptBR: TranslationResources = {
|
||||
title: "Senha de {{host}}",
|
||||
label: "Senha do host",
|
||||
},
|
||||
hostConfirmation: {
|
||||
title: "Conectar a este host?",
|
||||
description:
|
||||
"Este host poderá executar código neste app e acessar seus outros hosts conectados. Conecte apenas se você o reconhecer.",
|
||||
descriptionChanged:
|
||||
"Este link muda como você se conecta a este host. O host poderá executar código neste app e acessar seus outros hosts conectados. Conecte apenas se você o reconhecer.",
|
||||
hostLabel: "Host",
|
||||
fingerprintLabel: "Impressão da chave",
|
||||
relayLabel: "Relay",
|
||||
connect: "Conectar",
|
||||
},
|
||||
connectionMethods: {
|
||||
title: "Adicionar conexão",
|
||||
direct: {
|
||||
|
||||
@@ -443,7 +443,9 @@ export const ru: TranslationResources = {
|
||||
recovery: {
|
||||
archivedTitle: "Рабочая область в архиве",
|
||||
restoreDescription:
|
||||
"Рабочее пространство «{{workspaceName}}» было архивировано, а его worktree удалён. Восстановите ветку {{branch}}, чтобы снова открыть рабочее пространство.",
|
||||
"Восстановите «{{workspaceName}}», чтобы вернуться к его агентам. Worktree будет использовать ветку {{branch}}.",
|
||||
restoreWithoutBranchDescription:
|
||||
"Восстановите «{{workspaceName}}», чтобы вернуться к его агентам. Новая ветка будет создана от сохранённой базовой или ветки репозитория по умолчанию.",
|
||||
unarchiveDescription:
|
||||
"Рабочее пространство «{{workspaceName}}» находится в архиве. Разархивируйте его, чтобы снова открыть.",
|
||||
restoreAction: "Восстановить",
|
||||
@@ -1648,6 +1650,17 @@ export const ru: TranslationResources = {
|
||||
title: "Пароль для {{host}}",
|
||||
label: "Пароль хоста",
|
||||
},
|
||||
hostConfirmation: {
|
||||
title: "Подключиться к этому хосту?",
|
||||
description:
|
||||
"Этот хост сможет выполнять код в приложении и обращаться к другим вашим подключённым хостам. Подключайтесь, только если узнаёте его.",
|
||||
descriptionChanged:
|
||||
"Эта ссылка меняет способ подключения к этому хосту. Хост сможет выполнять код в приложении и обращаться к другим вашим подключённым хостам. Подключайтесь, только если узнаёте его.",
|
||||
hostLabel: "Хост",
|
||||
fingerprintLabel: "Отпечаток ключа",
|
||||
relayLabel: "Реле",
|
||||
connect: "Подключить",
|
||||
},
|
||||
connectionMethods: {
|
||||
title: "Добавить подключение",
|
||||
direct: {
|
||||
|
||||
@@ -439,7 +439,9 @@ export const zhCN: TranslationResources = {
|
||||
recovery: {
|
||||
archivedTitle: "Workspace 已归档",
|
||||
restoreDescription:
|
||||
"{{workspaceName}} 已归档,其 worktree 已移除。恢复分支 {{branch}} 以重新打开。",
|
||||
"恢复 {{workspaceName}} 以返回其中的代理。worktree 将使用分支 {{branch}}。",
|
||||
restoreWithoutBranchDescription:
|
||||
"恢复 {{workspaceName}} 以返回其中的代理。新分支将从保存的基准分支或仓库默认分支创建。",
|
||||
unarchiveDescription: "{{workspaceName}} 已归档。取消归档以重新打开。",
|
||||
restoreAction: "恢复",
|
||||
unarchiveAction: "取消归档",
|
||||
@@ -1602,6 +1604,16 @@ export const zhCN: TranslationResources = {
|
||||
title: "{{host}} 的密码",
|
||||
label: "主机密码",
|
||||
},
|
||||
hostConfirmation: {
|
||||
title: "连接到此主机?",
|
||||
description: "此主机将能在此应用中运行代码,并访问你其他已连接的主机。仅在你认识它时才连接。",
|
||||
descriptionChanged:
|
||||
"此链接会改变你连接此主机的方式。该主机将能在此应用中运行代码,并访问你其他已连接的主机。仅在你认识它时才连接。",
|
||||
hostLabel: "主机",
|
||||
fingerprintLabel: "密钥指纹",
|
||||
relayLabel: "中继",
|
||||
connect: "连接",
|
||||
},
|
||||
connectionMethods: {
|
||||
title: "添加连接",
|
||||
direct: {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer";
|
||||
import {
|
||||
hostHasConnection,
|
||||
relayConnectionFromOffer,
|
||||
type HostProfile,
|
||||
} from "@/types/host-connection";
|
||||
|
||||
/**
|
||||
* The question shown before a pairing link adds a host that is not saved, or
|
||||
* changes the key, relay, or TLS setting of a saved one.
|
||||
*/
|
||||
export interface HostConfirmationRequest {
|
||||
/** Identifies this question, so an answer shown for it never answers a newer one. */
|
||||
id: number;
|
||||
serverId: string;
|
||||
/** Grouped daemon public key the user can compare with the one their daemon shows. */
|
||||
keyFingerprint: string;
|
||||
relayEndpoint: string;
|
||||
kind: "newHost" | "changedConnection";
|
||||
}
|
||||
|
||||
interface PendingConfirmation {
|
||||
request: HostConfirmationRequest;
|
||||
resolve: (approved: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the one host confirmation the app is waiting on. The host runtime asks
|
||||
* through `confirmLink`; the view reads `getPending` and replies with `answer`.
|
||||
* A question waits until it is answered, whether or not a view is showing it.
|
||||
*/
|
||||
export class HostConfirmations {
|
||||
private pending: PendingConfirmation | null = null;
|
||||
private nextRequestId = 1;
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
getPending(): HostConfirmationRequest | null {
|
||||
return this.pending?.request ?? null;
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/** Answers the request with `requestId`; an answer for any other request is ignored. */
|
||||
answer(requestId: number, approved: boolean): void {
|
||||
const current = this.pending;
|
||||
if (!current || current.request.id !== requestId) return;
|
||||
this.pending = null;
|
||||
this.emit();
|
||||
current.resolve(approved);
|
||||
}
|
||||
|
||||
/** Resolves true without asking when the link matches a saved host exactly. */
|
||||
confirmLink(offer: ConnectionOffer, savedHosts: HostProfile[]): Promise<boolean> {
|
||||
const connection = relayConnectionFromOffer(offer);
|
||||
const savedHost = savedHosts.find((host) => host.serverId === offer.serverId);
|
||||
if (savedHost && hostHasConnection(savedHost, connection)) return Promise.resolve(true);
|
||||
return this.ask({
|
||||
id: this.nextRequestId++,
|
||||
serverId: offer.serverId,
|
||||
keyFingerprint: formatDaemonKeyFingerprint(connection.daemonPublicKeyB64),
|
||||
relayEndpoint: connection.relayEndpoint,
|
||||
kind: savedHost ? "changedConnection" : "newHost",
|
||||
});
|
||||
}
|
||||
|
||||
private ask(request: HostConfirmationRequest): Promise<boolean> {
|
||||
// One question at a time: a newer one cancels the unanswered one.
|
||||
this.pending?.resolve(false);
|
||||
return new Promise<boolean>((resolve) => {
|
||||
this.pending = { request, resolve };
|
||||
this.emit();
|
||||
});
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the key material itself (not a re-hash) in groups of four. Keys longer
|
||||
* than 16 characters show only their first and last 8, so two keys that share
|
||||
* both ends show the same value.
|
||||
*/
|
||||
function formatDaemonKeyFingerprint(daemonPublicKeyB64: string): string {
|
||||
const normalized = daemonPublicKeyB64.replace(/[^A-Za-z0-9]/g, "");
|
||||
const group = (value: string): string => value.replace(/(.{4})/g, "$1 ").trim();
|
||||
if (normalized.length <= 16) return group(normalized);
|
||||
return `${group(normalized.slice(0, 8))} … ${group(normalized.slice(-8))}`;
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store";
|
||||
|
||||
import { subscriptionFixture } from "./subscription-fixture";
|
||||
import { readDesktopManagedLocalCredential } from "@/desktop/daemon/local-credential";
|
||||
import type { HostConfirmationRequest } from "./host-confirmation";
|
||||
|
||||
it("requests the managed connection credential through desktop main without a web hint", async () => {
|
||||
const requests: string[] = [];
|
||||
@@ -507,6 +508,63 @@ function makeConnectedProbeClient(latencyMs: number): FakeDaemonClient {
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers every host confirmation the store asks, the way the sheet does, and
|
||||
* records each request.
|
||||
*/
|
||||
function answerHostConfirmations(
|
||||
store: HostRuntimeStore,
|
||||
answer: boolean | ((request: HostConfirmationRequest, index: number) => boolean),
|
||||
): HostConfirmationRequest[] {
|
||||
const requests: HostConfirmationRequest[] = [];
|
||||
store.subscribeHostConfirmation(() => {
|
||||
const pending = store.getPendingHostConfirmation();
|
||||
if (!pending) return;
|
||||
requests.push(pending);
|
||||
store.answerHostConfirmation(
|
||||
pending.id,
|
||||
typeof answer === "function" ? answer(pending, requests.length - 1) : answer,
|
||||
);
|
||||
});
|
||||
return requests;
|
||||
}
|
||||
|
||||
function requirePendingConfirmation(store: HostRuntimeStore): HostConfirmationRequest {
|
||||
const pending = store.getPendingHostConfirmation();
|
||||
if (!pending) throw new Error("Expected a pending host confirmation");
|
||||
return pending;
|
||||
}
|
||||
|
||||
function createPairingStore(
|
||||
input: {
|
||||
hostname?: string;
|
||||
password?: string;
|
||||
connectAttempts?: string[];
|
||||
serverIdForHost?: (host: HostProfile) => string;
|
||||
storage?: HostRuntimeStorage;
|
||||
} = {},
|
||||
): HostRuntimeStore {
|
||||
return new HostRuntimeStore({
|
||||
storage: input.storage ?? createMemoryHostRuntimeStorage(),
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host, connection }) => {
|
||||
input.connectAttempts?.push(connection.id);
|
||||
if (input.password && host.password !== input.password) {
|
||||
throw new DaemonAuthenticationError("password_required");
|
||||
}
|
||||
return {
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: input.serverIdForHost?.(host) ?? host.serverId,
|
||||
hostname: input.hostname ?? "paired",
|
||||
};
|
||||
},
|
||||
getClientId: async () => "cid_pairing",
|
||||
readInitialConnectionHint: () => null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createMemoryHostRuntimeStorage(entries: Record<string, string> = {}): HostRuntimeStorage {
|
||||
const values = new Map(Object.entries(entries));
|
||||
return {
|
||||
@@ -3547,19 +3605,10 @@ describe("HostRuntimeStore", () => {
|
||||
});
|
||||
|
||||
it("uses the advertised hostname when adding a relay host from a pairing offer", async () => {
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host }) => ({
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label ?? null,
|
||||
}),
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
const store = createPairingStore({ hostname: "mbp" });
|
||||
answerHostConfirmations(store, true);
|
||||
|
||||
await store.upsertConnectionFromOffer(makeOffer(), "mbp");
|
||||
await store.beginLinkPairing().submit(encodeOfferUrl(makeOffer()));
|
||||
|
||||
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
|
||||
expect(pairedHost?.label).toBe("mbp");
|
||||
@@ -3568,27 +3617,14 @@ describe("HostRuntimeStore", () => {
|
||||
});
|
||||
|
||||
it("stores relay TLS from a pairing offer", async () => {
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host }) => ({
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label ?? null,
|
||||
}),
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
const store = createPairingStore();
|
||||
answerHostConfirmations(store, true);
|
||||
|
||||
await store.upsertConnectionFromOffer(
|
||||
makeOffer({
|
||||
relay: {
|
||||
endpoint: "relay.example.com:443",
|
||||
useTls: true,
|
||||
},
|
||||
}),
|
||||
"tls relay",
|
||||
);
|
||||
await store
|
||||
.beginLinkPairing()
|
||||
.submit(
|
||||
encodeOfferUrl(makeOffer({ relay: { endpoint: "relay.example.com:443", useTls: true } })),
|
||||
);
|
||||
|
||||
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
|
||||
expect(pairedHost?.connections).toEqual([
|
||||
@@ -3605,17 +3641,8 @@ describe("HostRuntimeStore", () => {
|
||||
});
|
||||
|
||||
it("uses TLS for old pairing URLs that omit relay TLS on port 443", async () => {
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host }) => ({
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label ?? null,
|
||||
}),
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
const store = createPairingStore();
|
||||
answerHostConfirmations(store, true);
|
||||
const oldPairingUrl = encodeOfferUrl({
|
||||
v: 2,
|
||||
serverId: "srv_offer",
|
||||
@@ -3623,7 +3650,7 @@ describe("HostRuntimeStore", () => {
|
||||
relay: { endpoint: "relay.paseo.sh:443" },
|
||||
});
|
||||
|
||||
await store.upsertConnectionFromOfferUrl(oldPairingUrl, "old relay");
|
||||
await store.importConnectionLink(oldPairingUrl, "openProject");
|
||||
|
||||
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
|
||||
expect(pairedHost?.connections).toEqual([
|
||||
@@ -3640,54 +3667,266 @@ describe("HostRuntimeStore", () => {
|
||||
});
|
||||
|
||||
it("probes a pairing link immediately and saves only after admission", async () => {
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host }) => {
|
||||
if (host.password !== "correct-password")
|
||||
throw new DaemonAuthenticationError("password_required");
|
||||
return {
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: "paired host",
|
||||
};
|
||||
},
|
||||
getClientId: async () => "cid_pairing",
|
||||
},
|
||||
});
|
||||
const store = createPairingStore({ password: "correct-password" });
|
||||
answerHostConfirmations(store, true);
|
||||
const offerUrl = encodeOfferUrl(makeOffer());
|
||||
await expect(store.probeAndUpsertConnectionFromOfferUrl(offerUrl)).rejects.toThrow(
|
||||
"Password required",
|
||||
);
|
||||
const pairing = store.beginLinkPairing();
|
||||
|
||||
await expect(pairing.submit(offerUrl)).rejects.toThrow("Password required");
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
const result = await store.probeAndUpsertConnectionFromOfferUrl(offerUrl, "correct-password");
|
||||
expect(result.serverId).toBe("srv_offer");
|
||||
const result = await pairing.submit(offerUrl, "correct-password");
|
||||
expect(result).toMatchObject({ status: "connected", serverId: "srv_offer" });
|
||||
expect(store.getHosts()[0]?.password).toBe("correct-password");
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("preserves the existing host label when re-pairing an existing relay host", async () => {
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host }) => ({
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label ?? null,
|
||||
}),
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
it("asks before a pairing link adds a new host, and Cancel saves and connects nothing", async () => {
|
||||
const connectAttempts: string[] = [];
|
||||
const store = createPairingStore({ connectAttempts });
|
||||
const requests = answerHostConfirmations(store, false);
|
||||
const offerUrl = encodeOfferUrl(makeOffer());
|
||||
const relayLink = "relay://relay.example:443/srv_relay?key=AAAA&ssl=true";
|
||||
|
||||
// `#offer=` in the app URL, then a QR scan, then the pair-link and add-host modals.
|
||||
await expect(store.importConnectionLink(offerUrl, "openProject")).resolves.toEqual({
|
||||
status: "cancelled",
|
||||
});
|
||||
await expect(store.importConnectionLink(offerUrl, "hostRoot")).resolves.toEqual({
|
||||
status: "cancelled",
|
||||
});
|
||||
await expect(store.beginLinkPairing().submit(offerUrl)).resolves.toEqual({
|
||||
status: "cancelled",
|
||||
});
|
||||
await expect(store.beginLinkPairing().submit(relayLink)).resolves.toEqual({
|
||||
status: "cancelled",
|
||||
});
|
||||
|
||||
expect(requests).toEqual([
|
||||
...Array.from({ length: 3 }, () => ({
|
||||
id: expect.any(Number),
|
||||
serverId: "srv_offer",
|
||||
keyFingerprint: "pkte stof fer",
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
kind: "newHost",
|
||||
})),
|
||||
{
|
||||
id: expect.any(Number),
|
||||
serverId: "srv_relay",
|
||||
keyFingerprint: "AAAA",
|
||||
relayEndpoint: "relay.example:443",
|
||||
kind: "newHost",
|
||||
},
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
});
|
||||
]);
|
||||
expect(store.getHosts()).toEqual([]);
|
||||
expect(connectAttempts).toEqual([]);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
await store.upsertRelayConnection({
|
||||
it("connects a saved host from a matching pairing link without asking", async () => {
|
||||
const store = createPairingStore();
|
||||
const requests = answerHostConfirmations(store, true);
|
||||
const offerUrl = encodeOfferUrl(makeOffer());
|
||||
await store.beginLinkPairing().submit(offerUrl);
|
||||
expect(requests).toHaveLength(1);
|
||||
|
||||
await expect(store.importConnectionLink(offerUrl, "openProject")).resolves.toEqual({
|
||||
status: "connected",
|
||||
serverId: "srv_offer",
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
daemonPublicKeyB64: "pk_test_offer",
|
||||
label: "Custom name",
|
||||
});
|
||||
await expect(store.importConnectionLink(offerUrl, "hostRoot")).resolves.toEqual({
|
||||
status: "connected",
|
||||
serverId: "srv_offer",
|
||||
});
|
||||
await expect(store.beginLinkPairing().submit(offerUrl)).resolves.toMatchObject({
|
||||
status: "connected",
|
||||
});
|
||||
|
||||
await store.upsertConnectionFromOffer(makeOffer(), "mbp");
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(store.getHosts()).toHaveLength(1);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("asks when a pairing link changes a saved host's key, relay, or TLS setting", async () => {
|
||||
const store = createPairingStore();
|
||||
const requests = answerHostConfirmations(store, (_request, index) => index === 0);
|
||||
await store.beginLinkPairing().submit(encodeOfferUrl(makeOffer()));
|
||||
const savedHosts = store.getHosts();
|
||||
|
||||
const changedLinks = [
|
||||
makeOffer({ daemonPublicKeyB64: "pk_other_key" }),
|
||||
makeOffer({ relay: { endpoint: "relay.example:443", useTls: false } }),
|
||||
makeOffer({ relay: { endpoint: "relay.paseo.sh:443", useTls: true } }),
|
||||
].map(encodeOfferUrl);
|
||||
for (const link of changedLinks) {
|
||||
await expect(store.importConnectionLink(link, "openProject")).resolves.toEqual({
|
||||
status: "cancelled",
|
||||
});
|
||||
await expect(store.beginLinkPairing().submit(link)).resolves.toEqual({
|
||||
status: "cancelled",
|
||||
});
|
||||
}
|
||||
|
||||
expect(requests.map((request) => request.kind)).toEqual([
|
||||
"newHost",
|
||||
...Array.from({ length: 6 }, () => "changedConnection"),
|
||||
]);
|
||||
expect(store.getHosts()).toEqual(savedHosts);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("asks once per pairing flow and link when the host asks for a password", async () => {
|
||||
const store = createPairingStore({ password: "correct-password" });
|
||||
const requests = answerHostConfirmations(store, (_request, index) => index < 2);
|
||||
const offerUrl = encodeOfferUrl(makeOffer());
|
||||
const pairing = store.beginLinkPairing();
|
||||
|
||||
await expect(pairing.submit(offerUrl)).rejects.toThrow("Password required");
|
||||
await expect(pairing.submit(offerUrl, "wrong-password")).rejects.toThrow();
|
||||
expect(requests).toHaveLength(1);
|
||||
|
||||
// A new pairing flow for the same link asks again.
|
||||
await expect(store.beginLinkPairing().submit(offerUrl, "wrong-password")).rejects.toThrow();
|
||||
expect(requests).toHaveLength(2);
|
||||
|
||||
await expect(pairing.submit(offerUrl, "correct-password")).resolves.toMatchObject({
|
||||
status: "connected",
|
||||
});
|
||||
expect(requests).toHaveLength(2);
|
||||
expect(store.getHosts()[0]).toMatchObject({
|
||||
serverId: "srv_offer",
|
||||
password: "correct-password",
|
||||
});
|
||||
|
||||
// A different link in the same flow asks again.
|
||||
const otherKeyUrl = encodeOfferUrl(makeOffer({ daemonPublicKeyB64: "pk_other_key" }));
|
||||
await expect(pairing.submit(otherKeyUrl, "correct-password")).resolves.toEqual({
|
||||
status: "cancelled",
|
||||
});
|
||||
expect(requests).toHaveLength(3);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("continues a scanned link's password retry without asking again", async () => {
|
||||
const store = createPairingStore({ password: "correct-password" });
|
||||
const requests = answerHostConfirmations(store, true);
|
||||
const link = "relay://relay.example:443/srv_pair?key=AAAA&ssl=true";
|
||||
|
||||
const outcome = await store.importConnectionLink(link, "hostRoot");
|
||||
expect(outcome).toMatchObject({ status: "password_required", link });
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
if (outcome.status !== "password_required") throw new Error("expected password_required");
|
||||
|
||||
await outcome.pairing.submit(outcome.link, "correct-password");
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(store.getHosts()[0]).toMatchObject({
|
||||
serverId: "srv_pair",
|
||||
password: "correct-password",
|
||||
});
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("keeps a host confirmation pending until it is answered, and a newer one cancels it", async () => {
|
||||
const store = createPairingStore();
|
||||
const first = store.importConnectionLink(encodeOfferUrl(makeOffer()), "openProject");
|
||||
await vi.waitFor(() =>
|
||||
expect(store.getPendingHostConfirmation()).toMatchObject({ serverId: "srv_offer" }),
|
||||
);
|
||||
|
||||
const second = store.importConnectionLink(
|
||||
encodeOfferUrl(makeOffer({ serverId: "srv_second" })),
|
||||
"openProject",
|
||||
);
|
||||
await expect(first).resolves.toEqual({ status: "cancelled" });
|
||||
await vi.waitFor(() =>
|
||||
expect(store.getPendingHostConfirmation()).toMatchObject({ serverId: "srv_second" }),
|
||||
);
|
||||
|
||||
store.answerHostConfirmation(requirePendingConfirmation(store).id, true);
|
||||
await expect(second).resolves.toEqual({ status: "connected", serverId: "srv_second" });
|
||||
expect(store.getPendingHostConfirmation()).toBeNull();
|
||||
expect(store.getHosts().map((host) => host.serverId)).toEqual(["srv_second"]);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("ignores an answer for a host confirmation that a newer one replaced", async () => {
|
||||
const store = createPairingStore();
|
||||
const first = store.importConnectionLink(encodeOfferUrl(makeOffer()), "openProject");
|
||||
await vi.waitFor(() =>
|
||||
expect(store.getPendingHostConfirmation()).toMatchObject({ serverId: "srv_offer" }),
|
||||
);
|
||||
const firstRequest = requirePendingConfirmation(store);
|
||||
const second = store.importConnectionLink(
|
||||
encodeOfferUrl(makeOffer({ serverId: "srv_second" })),
|
||||
"openProject",
|
||||
);
|
||||
await expect(first).resolves.toEqual({ status: "cancelled" });
|
||||
|
||||
// Connect tapped on the first sheet as the second link arrives.
|
||||
store.answerHostConfirmation(firstRequest.id, true);
|
||||
|
||||
const secondRequest = requirePendingConfirmation(store);
|
||||
expect(secondRequest.serverId).toBe("srv_second");
|
||||
store.answerHostConfirmation(secondRequest.id, false);
|
||||
await expect(second).resolves.toEqual({ status: "cancelled" });
|
||||
expect(store.getHosts()).toEqual([]);
|
||||
});
|
||||
|
||||
it("waits for saved hosts to load before deciding whether a pairing link asks", async () => {
|
||||
const savedHost = makeHost({
|
||||
serverId: "srv_offer",
|
||||
connections: [
|
||||
{
|
||||
id: "relay:relay.paseo.sh:443",
|
||||
type: "relay",
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
useTls: false,
|
||||
daemonPublicKeyB64: "pk_test_offer",
|
||||
},
|
||||
],
|
||||
});
|
||||
const store = createPairingStore({
|
||||
storage: createMemoryHostRuntimeStorage({
|
||||
"@paseo:daemon-registry": JSON.stringify([savedHost]),
|
||||
"@paseo:e2e": "1",
|
||||
}),
|
||||
});
|
||||
const requests = answerHostConfirmations(store, true);
|
||||
|
||||
await expect(
|
||||
store.importConnectionLink(encodeOfferUrl(makeOffer()), "openProject"),
|
||||
).resolves.toEqual({ status: "connected", serverId: "srv_offer" });
|
||||
|
||||
expect(requests).toEqual([]);
|
||||
expect(store.getHosts().map((host) => host.serverId)).toEqual(["srv_offer"]);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("adds hosts by address and the default localhost without asking", async () => {
|
||||
const store = createPairingStore({
|
||||
serverIdForHost: (host) =>
|
||||
host.connections[0]?.type === "directTcp" ? `srv_${host.connections[0].endpoint}` : "",
|
||||
});
|
||||
const requests = answerHostConfirmations(store, false);
|
||||
|
||||
await store.boot();
|
||||
await store.probeAndUpsertDirectConnection({ endpoint: "10.0.0.5:6767" });
|
||||
|
||||
expect(requests).toEqual([]);
|
||||
expect(store.getHosts().map((host) => host.serverId)).toEqual([
|
||||
"srv_localhost:6767",
|
||||
"srv_10.0.0.5:6767",
|
||||
]);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("preserves the existing host label when re-pairing an existing relay host", async () => {
|
||||
const store = createPairingStore({ hostname: "mbp" });
|
||||
answerHostConfirmations(store, true);
|
||||
|
||||
await store.beginLinkPairing().submit(encodeOfferUrl(makeOffer()));
|
||||
await store.renameHost("srv_offer", "Custom name");
|
||||
|
||||
await store.beginLinkPairing().submit(encodeOfferUrl(makeOffer()));
|
||||
|
||||
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
|
||||
expect(pairedHost?.label).toBe("Custom name");
|
||||
@@ -3963,35 +4202,6 @@ describe("HostRuntimeStore initial connection hint bootstrap", () => {
|
||||
expect(probeAttempts).toBe(attemptsBeforeRetry);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
it("imports a pairing link after a password retry in the add flow", async () => {
|
||||
const store = new HostRuntimeStore({
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host }) => {
|
||||
if (host.password !== "correct-password")
|
||||
throw new DaemonAuthenticationError("password_required");
|
||||
return {
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: "paired",
|
||||
};
|
||||
},
|
||||
getClientId: async () => "cid_link_import",
|
||||
},
|
||||
});
|
||||
const link = "relay://relay.example:443/srv_pair?key=AAAA&ssl=true";
|
||||
await expect(store.importConnectionLink(link, "hostRoot")).resolves.toEqual({
|
||||
status: "password_required",
|
||||
});
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
await store.probeAndUpsertConnectionFromOfferUrl(link, "correct-password");
|
||||
expect(store.getHosts()[0]).toMatchObject({
|
||||
serverId: "srv_pair",
|
||||
password: "correct-password",
|
||||
});
|
||||
store.syncHosts([]);
|
||||
});
|
||||
it("imports a deep link without waiting for admission, then leaves a rejected saved host red", async () => {
|
||||
const store = new HostRuntimeStore({
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
@@ -4003,6 +4213,7 @@ describe("HostRuntimeStore initial connection hint bootstrap", () => {
|
||||
getClientId: async () => "cid_deep_link",
|
||||
},
|
||||
});
|
||||
answerHostConfirmations(store, true);
|
||||
const link = "relay://relay.example:443/srv_deep?key=AAAA&ssl=true";
|
||||
await expect(store.importConnectionLink(link, "openProject")).resolves.toEqual({
|
||||
status: "connected",
|
||||
@@ -4017,6 +4228,38 @@ describe("HostRuntimeStore initial connection hint bootstrap", () => {
|
||||
expect(store.getSnapshot("srv_deep")?.connectionStatus).toBe("error");
|
||||
store.syncHosts([]);
|
||||
});
|
||||
it("stops connecting to a host removed while its first probe is pending", async () => {
|
||||
useHostRuntimeClock();
|
||||
const firstProbe = createDeferred<void>();
|
||||
let connectCalls = 0;
|
||||
const store = new HostRuntimeStore({
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host }) => {
|
||||
connectCalls += 1;
|
||||
if (connectCalls === 1) await firstProbe.promise;
|
||||
return {
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label ?? null,
|
||||
};
|
||||
},
|
||||
getClientId: async () => "cid_test_removed_host",
|
||||
},
|
||||
});
|
||||
await store.upsertDirectConnection({ serverId: "srv_removed", endpoint: "lan:6767" });
|
||||
await vi.waitFor(() => expect(connectCalls).toBe(1));
|
||||
|
||||
await store.removeHost("srv_removed");
|
||||
firstProbe.resolve();
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
|
||||
expect(connectCalls).toBe(1);
|
||||
expect(store.getSnapshot("srv_removed")).toBeNull();
|
||||
expect(useSessionStore.getState().sessions["srv_removed"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("saves and reconnects a rejected saved host after changing its password", async () => {
|
||||
const store = new HostRuntimeStore({
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
normalizeStoredHostProfile,
|
||||
upsertHostConnectionInProfiles,
|
||||
registryHasConnection,
|
||||
relayConnectionFromOffer,
|
||||
StoredHostRegistrySchema,
|
||||
type HostConnection,
|
||||
type HostProfile,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
} from "@/utils/daemon-endpoints";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { ConnectionOfferSchema, type ConnectionOffer } from "@getpaseo/protocol/connection-offer";
|
||||
import { HostConfirmations, type HostConfirmationRequest } from "./host-confirmation";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { connectToDaemon, getConnectionAuthFailureReason } from "@/utils/test-daemon-connection";
|
||||
@@ -89,6 +91,27 @@ export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "of
|
||||
export type PairingNavigationTarget = "openProject" | "hostRoot" | "hostSettings";
|
||||
export type HostRegistryStatus = "loading" | "ready";
|
||||
|
||||
/** One pairing flow started with `HostRuntimeStore.beginLinkPairing`. */
|
||||
export interface LinkPairing {
|
||||
/** Probes the linked host and saves it. Connection and password errors throw. */
|
||||
submit(link: string, password?: string): Promise<LinkPairingResult>;
|
||||
}
|
||||
|
||||
export type LinkPairingResult =
|
||||
| { status: "connected"; profile: HostProfile; serverId: string; hostname: string | null }
|
||||
| { status: "cancelled" };
|
||||
|
||||
/** A pairing link the user confirmed whose host then asked for a password. */
|
||||
export interface PasswordRequiredPairing {
|
||||
link: string;
|
||||
pairing: LinkPairing;
|
||||
}
|
||||
|
||||
export type ConnectionLinkImport =
|
||||
| { status: "connected"; serverId: string }
|
||||
| ({ status: "password_required" } & PasswordRequiredPairing)
|
||||
| { status: "cancelled" };
|
||||
|
||||
export type ActiveConnection =
|
||||
| { type: "directTcp"; endpoint: string; display: string }
|
||||
| { type: "directSocket"; endpoint: string; display: "socket" }
|
||||
@@ -677,8 +700,15 @@ export class HostRuntimeController {
|
||||
connectionId: options.initialConnection.connectionId,
|
||||
existingClient: options.initialConnection.existingClient,
|
||||
});
|
||||
if (!this.started) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await this.runProbeCycleNow();
|
||||
// stop() can run while startup is pending; a stopped controller never probes again.
|
||||
if (!this.started) {
|
||||
return;
|
||||
}
|
||||
if (options?.autoProbe !== false) {
|
||||
this.probeIntervalHandle = setInterval(() => {
|
||||
void this.runProbeCycleNow();
|
||||
@@ -1408,6 +1438,13 @@ function readConfiguredLocalDaemonOverride(): string | null {
|
||||
return value && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function isSameLinkedConnection(left: ConnectionOffer, right: ConnectionOffer): boolean {
|
||||
return (
|
||||
left.serverId === right.serverId &&
|
||||
equal(relayConnectionFromOffer(left), relayConnectionFromOffer(right))
|
||||
);
|
||||
}
|
||||
|
||||
function parseOfferConnectionUrl(input: string): { offer: ConnectionOffer; password?: string } {
|
||||
if (input.trim().startsWith("relay://") || input.includes("#connect=")) {
|
||||
return parseRelayConnectionUri(input);
|
||||
@@ -1469,6 +1506,8 @@ export class HostRuntimeStore {
|
||||
private timelineReplicaByServer = new Map<string, TimelineReplica>();
|
||||
private configuredOverrideBootstrapInFlight: Promise<void> | null = null;
|
||||
private bootPromise: Promise<void> | null = null;
|
||||
private registryLoad: Promise<void> | null = null;
|
||||
private readonly hostConfirmations = new HostConfirmations();
|
||||
private storage: HostRuntimeStorage;
|
||||
private replicaCache: ReplicaCache;
|
||||
private readonly revokePushNotifications: typeof revokePushNotifications;
|
||||
@@ -1519,8 +1558,7 @@ export class HostRuntimeStore {
|
||||
|
||||
private async runBoot(): Promise<void> {
|
||||
const override = readConfiguredLocalDaemonOverride();
|
||||
await this.loadFromStorage();
|
||||
this.markHostRegistryLoaded();
|
||||
await this.loadRegistry();
|
||||
|
||||
let isE2E: string | null = null;
|
||||
try {
|
||||
@@ -1553,6 +1591,13 @@ export class HostRuntimeStore {
|
||||
}
|
||||
}
|
||||
|
||||
private loadRegistry(): Promise<void> {
|
||||
if (!this.registryLoad) {
|
||||
this.registryLoad = this.loadFromStorage().then(() => this.markHostRegistryLoaded());
|
||||
}
|
||||
return this.registryLoad;
|
||||
}
|
||||
|
||||
private async loadFromStorage(): Promise<void> {
|
||||
let shouldPersistHosts = false;
|
||||
let profiles: HostProfile[] = [];
|
||||
@@ -1879,7 +1924,7 @@ export class HostRuntimeStore {
|
||||
});
|
||||
}
|
||||
|
||||
async upsertRelayConnection(input: {
|
||||
private async upsertRelayConnection(input: {
|
||||
serverId: string;
|
||||
relayEndpoint: string;
|
||||
useTls?: boolean;
|
||||
@@ -1912,51 +1957,91 @@ export class HostRuntimeStore {
|
||||
return profile;
|
||||
}
|
||||
|
||||
async upsertConnectionFromOffer(
|
||||
// A pairing link (a URL, a QR code, or a pasted link) asks the user to
|
||||
// confirm when it adds a new host or changes a saved host's key, relay, or
|
||||
// TLS setting. The answer needs the saved hosts, so it waits for them to load.
|
||||
private async confirmLink(offer: ConnectionOffer): Promise<boolean> {
|
||||
await this.loadRegistry();
|
||||
return this.hostConfirmations.confirmLink(offer, this.hosts);
|
||||
}
|
||||
|
||||
getPendingHostConfirmation(): HostConfirmationRequest | null {
|
||||
return this.hostConfirmations.getPending();
|
||||
}
|
||||
|
||||
subscribeHostConfirmation(listener: () => void): () => void {
|
||||
return this.hostConfirmations.subscribe(listener);
|
||||
}
|
||||
|
||||
answerHostConfirmation(requestId: number, approved: boolean): void {
|
||||
this.hostConfirmations.answer(requestId, approved);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts one pairing flow, such as one open pairing modal. Once the user
|
||||
* confirms a link, submitting the same link again in this flow (the
|
||||
* password retry) does not ask again. A new flow asks again.
|
||||
*/
|
||||
beginLinkPairing(): LinkPairing {
|
||||
let approvedOffer: ConnectionOffer | null = null;
|
||||
return {
|
||||
submit: async (link, password) => {
|
||||
const { offer, password: linkPassword } = parseOfferConnectionUrl(link);
|
||||
if (!approvedOffer || !isSameLinkedConnection(approvedOffer, offer)) {
|
||||
if (!(await this.confirmLink(offer))) return { status: "cancelled" };
|
||||
approvedOffer = offer;
|
||||
}
|
||||
const result = await this.probeAndUpsertOffer(offer, password ?? linkPassword);
|
||||
return { status: "connected", ...result };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async importConnectionLink(
|
||||
url: string,
|
||||
target: PairingNavigationTarget,
|
||||
): Promise<ConnectionLinkImport> {
|
||||
if (target === "openProject") {
|
||||
const { offer, password } = parseOfferConnectionUrl(url);
|
||||
if (!(await this.confirmLink(offer))) return { status: "cancelled" };
|
||||
const profile = await this.upsertConnectionFromOffer(offer, undefined, password);
|
||||
return { status: "connected", serverId: profile.serverId };
|
||||
}
|
||||
const pairing = this.beginLinkPairing();
|
||||
try {
|
||||
const result = await pairing.submit(url);
|
||||
if (result.status === "cancelled") return result;
|
||||
return { status: "connected", serverId: result.serverId };
|
||||
} catch (error) {
|
||||
if (!getConnectionAuthFailureReason(error)) throw error;
|
||||
return { status: "password_required", link: url, pairing };
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertConnectionFromOffer(
|
||||
offer: ConnectionOffer,
|
||||
label?: string,
|
||||
password?: string,
|
||||
): Promise<HostProfile> {
|
||||
// COMPAT(oldRelayOfferTls): added in v0.1.73, remove after 2026-11-10.
|
||||
const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint);
|
||||
const connection = relayConnectionFromOffer(offer);
|
||||
return this.upsertRelayConnection({
|
||||
serverId: offer.serverId,
|
||||
relayEndpoint: offer.relay.endpoint,
|
||||
useTls,
|
||||
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||
relayEndpoint: connection.relayEndpoint,
|
||||
useTls: connection.useTls,
|
||||
daemonPublicKeyB64: connection.daemonPublicKeyB64,
|
||||
label,
|
||||
password,
|
||||
});
|
||||
}
|
||||
|
||||
async upsertConnectionFromOfferUrl(
|
||||
offerUrlOrFragment: string,
|
||||
label?: string,
|
||||
password?: string,
|
||||
): Promise<HostProfile> {
|
||||
const parsed = parseOfferConnectionUrl(offerUrlOrFragment);
|
||||
return this.upsertConnectionFromOffer(parsed.offer, label, password ?? parsed.password);
|
||||
}
|
||||
|
||||
async probeAndUpsertConnectionFromOfferUrl(
|
||||
offerUrlOrFragment: string,
|
||||
password?: string,
|
||||
private async probeAndUpsertOffer(
|
||||
offer: ConnectionOffer,
|
||||
password: string | undefined,
|
||||
): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
|
||||
const parsed = parseOfferConnectionUrl(offerUrlOrFragment);
|
||||
const offer = parsed.offer;
|
||||
const credential = password ?? parsed.password;
|
||||
const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint);
|
||||
const relayEndpoint = normalizeHostPort(offer.relay.endpoint);
|
||||
const connection: HostConnection = {
|
||||
id: useTls ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`,
|
||||
type: "relay",
|
||||
relayEndpoint,
|
||||
useTls,
|
||||
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||
};
|
||||
const connection = relayConnectionFromOffer(offer);
|
||||
const probeHost: HostProfile = {
|
||||
serverId: offer.serverId,
|
||||
...(credential ? { password: credential } : {}),
|
||||
...(password ? { password } : {}),
|
||||
label: offer.serverId,
|
||||
appearance: defaultHostAppearance(),
|
||||
lifecycle: {},
|
||||
@@ -1967,27 +2052,10 @@ export class HostRuntimeStore {
|
||||
};
|
||||
const { client, hostname } = await this.deps.connectToDaemon({ host: probeHost, connection });
|
||||
await client.close().catch(() => undefined);
|
||||
const profile = await this.upsertConnectionFromOffer(offer, hostname ?? undefined, credential);
|
||||
const profile = await this.upsertConnectionFromOffer(offer, hostname ?? undefined, password);
|
||||
return { profile, serverId: offer.serverId, hostname };
|
||||
}
|
||||
|
||||
async importConnectionLink(
|
||||
url: string,
|
||||
target: PairingNavigationTarget,
|
||||
): Promise<{ status: "connected"; serverId: string } | { status: "password_required" }> {
|
||||
if (target === "openProject") {
|
||||
const profile = await this.upsertConnectionFromOfferUrl(url);
|
||||
return { status: "connected", serverId: profile.serverId };
|
||||
}
|
||||
try {
|
||||
const { serverId } = await this.probeAndUpsertConnectionFromOfferUrl(url);
|
||||
return { status: "connected", serverId };
|
||||
} catch (error) {
|
||||
if (!getConnectionAuthFailureReason(error)) throw error;
|
||||
return { status: "password_required" };
|
||||
}
|
||||
}
|
||||
|
||||
async upsertConnectionFromListen(input: {
|
||||
listenAddress: string;
|
||||
serverId: string;
|
||||
@@ -2735,6 +2803,29 @@ export function useHostRegistryLoaded(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function useHostConfirmation(): {
|
||||
pending: HostConfirmationRequest | null;
|
||||
answer: (approved: boolean) => void;
|
||||
} {
|
||||
const store = getHostRuntimeStore();
|
||||
const pending = useSyncExternalStore(
|
||||
(onStoreChange) => store.subscribeHostConfirmation(onStoreChange),
|
||||
() => store.getPendingHostConfirmation(),
|
||||
() => store.getPendingHostConfirmation(),
|
||||
);
|
||||
// The answer is bound to the request this render shows, so a tap never
|
||||
// answers a request that replaced it.
|
||||
return useMemo(
|
||||
() => ({
|
||||
pending,
|
||||
answer: (approved: boolean) => {
|
||||
if (pending) store.answerHostConfirmation(pending.id, approved);
|
||||
},
|
||||
}),
|
||||
[pending, store],
|
||||
);
|
||||
}
|
||||
|
||||
export interface HostMutations {
|
||||
upsertDirectConnection: (input: {
|
||||
serverId: string;
|
||||
@@ -2755,24 +2846,7 @@ export interface HostMutations {
|
||||
daemonPort?: number;
|
||||
label?: string;
|
||||
}) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>;
|
||||
upsertRelayConnection: (input: {
|
||||
serverId: string;
|
||||
relayEndpoint: string;
|
||||
useTls?: boolean;
|
||||
daemonPublicKeyB64: string;
|
||||
label?: string;
|
||||
password?: string;
|
||||
}) => Promise<HostProfile>;
|
||||
upsertConnectionFromOffer: (offer: ConnectionOffer, label?: string) => Promise<HostProfile>;
|
||||
upsertConnectionFromOfferUrl: (
|
||||
offerUrlOrFragment: string,
|
||||
label?: string,
|
||||
password?: string,
|
||||
) => Promise<HostProfile>;
|
||||
probeAndUpsertConnectionFromOfferUrl: (
|
||||
offerUrlOrFragment: string,
|
||||
password?: string,
|
||||
) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>;
|
||||
beginLinkPairing: () => LinkPairing;
|
||||
renameHost: (serverId: string, label: string) => Promise<void>;
|
||||
setHostColor: (serverId: string, color: HostColor) => Promise<void>;
|
||||
setHostBadgeDisplay: (serverId: string, badgeDisplay: HostBadgeDisplay) => Promise<void>;
|
||||
@@ -2787,12 +2861,7 @@ export function useHostMutations(): HostMutations {
|
||||
upsertDirectConnection: (input) => store.upsertDirectConnection(input),
|
||||
probeAndUpsertDirectConnection: (input) => store.probeAndUpsertDirectConnection(input),
|
||||
probeAndUpsertRemoteSshConnection: (input) => store.probeAndUpsertRemoteSshConnection(input),
|
||||
upsertRelayConnection: (input) => store.upsertRelayConnection(input),
|
||||
upsertConnectionFromOffer: (offer, label) => store.upsertConnectionFromOffer(offer, label),
|
||||
upsertConnectionFromOfferUrl: (url, label, password) =>
|
||||
store.upsertConnectionFromOfferUrl(url, label, password),
|
||||
probeAndUpsertConnectionFromOfferUrl: (url, password) =>
|
||||
store.probeAndUpsertConnectionFromOfferUrl(url, password),
|
||||
beginLinkPairing: () => store.beginLinkPairing(),
|
||||
renameHost: (serverId, label) => store.renameHost(serverId, label),
|
||||
setHostColor: (serverId, color) => store.setHostColor(serverId, color),
|
||||
setHostBadgeDisplay: (serverId, badgeDisplay) =>
|
||||
|
||||
@@ -126,15 +126,21 @@ function ArchivedWorkspaceRecovery({
|
||||
if (recovery.phase === "failed") {
|
||||
actionLabel = t("common.actions.retry");
|
||||
}
|
||||
const description =
|
||||
recovery.recovery.action === "restore"
|
||||
? t("workspace.route.recovery.restoreDescription", {
|
||||
workspaceName: recovery.recovery.workspaceName,
|
||||
branch: recovery.recovery.branch,
|
||||
})
|
||||
: t("workspace.route.recovery.unarchiveDescription", {
|
||||
workspaceName: recovery.recovery.workspaceName,
|
||||
});
|
||||
let description: string;
|
||||
if (recovery.recovery.action === "restore" && recovery.recovery.branch) {
|
||||
description = t("workspace.route.recovery.restoreDescription", {
|
||||
workspaceName: recovery.recovery.workspaceName,
|
||||
branch: recovery.recovery.branch,
|
||||
});
|
||||
} else if (recovery.recovery.action === "restore") {
|
||||
description = t("workspace.route.recovery.restoreWithoutBranchDescription", {
|
||||
workspaceName: recovery.recovery.workspaceName,
|
||||
});
|
||||
} else {
|
||||
description = t("workspace.route.recovery.unarchiveDescription", {
|
||||
workspaceName: recovery.recovery.workspaceName,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer";
|
||||
import {
|
||||
normalizeHostPort,
|
||||
normalizeLoopbackToLocalhost,
|
||||
shouldUseTlsForDefaultHostedRelay,
|
||||
} from "@getpaseo/protocol/daemon-endpoints";
|
||||
import {
|
||||
DirectTcpHostConnectionSchema,
|
||||
@@ -354,6 +356,19 @@ export function connectionFromListen(listen: string): HostConnection | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function relayConnectionFromOffer(offer: ConnectionOffer): RelayHostConnection {
|
||||
// COMPAT(oldRelayOfferTls): added in v0.1.73, remove after 2026-11-10.
|
||||
const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint);
|
||||
const relayEndpoint = normalizeHostPort(offer.relay.endpoint);
|
||||
return {
|
||||
id: useTls ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`,
|
||||
type: "relay",
|
||||
relayEndpoint,
|
||||
useTls,
|
||||
daemonPublicKeyB64: offer.daemonPublicKeyB64.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createRemoteSshHostConnection(input: {
|
||||
host: string;
|
||||
sshPort?: number;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Command } from "commander";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type { AgentDeepLinkTarget } from "@getpaseo/protocol/agent-deep-link";
|
||||
import { openDesktopWithAgent } from "../open.js";
|
||||
import type {
|
||||
CommandError,
|
||||
@@ -30,10 +31,21 @@ export function addOpenOptions(command: Command): Command {
|
||||
.option("--server <server-id>", "Server ID (defaults to the local daemon)");
|
||||
}
|
||||
|
||||
async function resolveServerId(options: CommandOptions): Promise<string> {
|
||||
function toOpenError(err: unknown): CommandError {
|
||||
if (err && typeof err === "object" && "code" in err) {
|
||||
return err as CommandError;
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { code: "OPEN_FAILED", message: `Failed to open agent: ${message}` };
|
||||
}
|
||||
|
||||
async function resolveAgentTarget(
|
||||
agentId: string,
|
||||
options: CommandOptions,
|
||||
): Promise<AgentDeepLinkTarget> {
|
||||
const explicitServerId = typeof options.server === "string" ? options.server.trim() : "";
|
||||
if (explicitServerId) {
|
||||
return explicitServerId;
|
||||
return { serverId: explicitServerId, agentId };
|
||||
}
|
||||
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
@@ -46,7 +58,19 @@ async function resolveServerId(options: CommandOptions): Promise<string> {
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
return serverId;
|
||||
|
||||
const fetchResult = await client.fetchAgent({ agentId });
|
||||
if (!fetchResult) {
|
||||
const error: CommandError = {
|
||||
code: "AGENT_NOT_FOUND",
|
||||
message: `Agent not found: ${agentId}`,
|
||||
details: 'Use "paseo ls" to list available agents',
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
return { serverId, agentId: fetchResult.agent.id };
|
||||
} catch (err) {
|
||||
throw toOpenError(err);
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
@@ -66,12 +90,12 @@ export async function runOpenCommand(
|
||||
throw error;
|
||||
}
|
||||
|
||||
const serverId = await resolveServerId(options);
|
||||
await openDesktopWithAgent({ serverId, agentId });
|
||||
const target = await resolveAgentTarget(agentId, options);
|
||||
await openDesktopWithAgent(target);
|
||||
|
||||
return {
|
||||
type: "single",
|
||||
data: { agentId, serverId, status: "opened" },
|
||||
data: { ...target, status: "opened" },
|
||||
schema: openAgentSchema,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import assert from "node:assert";
|
||||
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { runPaseoCli, startTestDaemon } from "./helpers/test-daemon.ts";
|
||||
|
||||
console.log("=== Agent Open Command Tests ===\n");
|
||||
|
||||
if (process.platform !== "linux") {
|
||||
console.log("Skipped: the fake Desktop app is a Linux AppImage path.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const daemon = await startTestDaemon();
|
||||
|
||||
// runPaseoCli sets HOME to the daemon's home, where the CLI looks for
|
||||
// ~/Applications/Paseo.AppImage on Linux. The fake app records its launch.
|
||||
const launchRecord = join(daemon.paseoHome, "desktop-launches.txt");
|
||||
const fakeDesktop = join(daemon.paseoHome, "Applications", "Paseo.AppImage");
|
||||
await mkdir(join(daemon.paseoHome, "Applications"), { recursive: true });
|
||||
await writeFile(fakeDesktop, `#!/bin/sh\necho "$@" >> "${launchRecord}"\n`);
|
||||
await chmod(fakeDesktop, 0o755);
|
||||
|
||||
try {
|
||||
{
|
||||
console.log("Test 1: an unknown agent ID fails without opening Desktop");
|
||||
const result = await runPaseoCli(daemon, [
|
||||
"agent",
|
||||
"open",
|
||||
"does-not-exist",
|
||||
"--host",
|
||||
`127.0.0.1:${daemon.port}`,
|
||||
"--json",
|
||||
]);
|
||||
|
||||
assert.notStrictEqual(result.exitCode, 0, "open should fail for an unknown agent");
|
||||
assert.match(JSON.parse(result.stderr).error.message, /Agent not found: does-not-exist/);
|
||||
// Give a wrongly spawned detached app time to write its record.
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
assert.ok(
|
||||
!existsSync(launchRecord),
|
||||
`Desktop should not open, but was launched with: ${existsSync(launchRecord) ? await readFile(launchRecord, "utf8") : ""}`,
|
||||
);
|
||||
console.log("✓ unknown agent ID fails without opening Desktop\n");
|
||||
}
|
||||
} finally {
|
||||
await daemon.stop();
|
||||
}
|
||||
|
||||
console.log("=== All agent open tests passed ===");
|
||||
@@ -29,7 +29,7 @@ function harness(guest: BrowserCaptureGuest | null = null) {
|
||||
describe("browser capture service", () => {
|
||||
it("validates and rounds guest-relative bounds before capture", async () => {
|
||||
const capturePage = vi.fn(async () => image());
|
||||
const { service } = harness({ isDestroyed: () => false, capturePage });
|
||||
const { service } = harness({ isDestroyed: () => false, getZoomFactor: () => 1, capturePage });
|
||||
|
||||
await expect(
|
||||
service.capture({
|
||||
@@ -41,9 +41,25 @@ describe("browser capture service", () => {
|
||||
expect(capturePage).toHaveBeenCalledWith({ x: 0, y: 9, width: 20, height: 11 });
|
||||
});
|
||||
|
||||
it("captures the selected element when the page is zoomed", async () => {
|
||||
const capturePage = vi.fn(async () => image());
|
||||
const { service } = harness({
|
||||
isDestroyed: () => false,
|
||||
getZoomFactor: () => 1.2,
|
||||
capturePage,
|
||||
});
|
||||
|
||||
await service.capture({
|
||||
browserId: "browser-1",
|
||||
hostWebContentsId: 42,
|
||||
rect: { x: 300, y: 200, width: 120, height: 60 },
|
||||
});
|
||||
expect(capturePage).toHaveBeenCalledWith({ x: 360, y: 240, width: 144, height: 72 });
|
||||
});
|
||||
|
||||
it("rejects invalid or unavailable captures without touching the guest", async () => {
|
||||
const capturePage = vi.fn(async () => image());
|
||||
const { service } = harness({ isDestroyed: () => false, capturePage });
|
||||
const { service } = harness({ isDestroyed: () => false, getZoomFactor: () => 1, capturePage });
|
||||
|
||||
await expect(
|
||||
service.capture({ browserId: "browser-1", hostWebContentsId: 42, rect: { width: 0 } }),
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface BrowserCaptureImage {
|
||||
|
||||
export interface BrowserCaptureGuest<TImage extends BrowserCaptureImage = BrowserCaptureImage> {
|
||||
isDestroyed(): boolean;
|
||||
getZoomFactor(): number;
|
||||
capturePage(rect: BrowserCaptureRect): Promise<TImage>;
|
||||
}
|
||||
|
||||
@@ -40,7 +41,9 @@ export interface BrowserCaptureService {
|
||||
copy(payload: unknown): Promise<boolean>;
|
||||
}
|
||||
|
||||
function captureRect(value: unknown): BrowserCaptureRect | null {
|
||||
// The renderer measures the element in page CSS pixels, while capturePage
|
||||
// takes device-independent pixels, so the page zoom scales the region.
|
||||
function captureRect(value: unknown, zoomFactor: number): BrowserCaptureRect | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
const coordinates = [record.x, record.y, record.width, record.height];
|
||||
@@ -51,7 +54,9 @@ function captureRect(value: unknown): BrowserCaptureRect | null {
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const [x, y, width, height] = coordinates as number[];
|
||||
const [x, y, width, height] = (coordinates as number[]).map(
|
||||
(coordinate) => coordinate * zoomFactor,
|
||||
);
|
||||
if (width <= 0 || height <= 0) return null;
|
||||
return {
|
||||
x: Math.max(0, Math.round(x)),
|
||||
@@ -80,8 +85,9 @@ export function createBrowserCaptureService<TImage extends BrowserCaptureImage>(
|
||||
async capture({ browserId, hostWebContentsId, rect }) {
|
||||
if (typeof browserId !== "string" || browserId.trim().length === 0) return null;
|
||||
const guest = dependencies.findGuest(browserId, hostWebContentsId);
|
||||
const bounds = captureRect(rect);
|
||||
if (!guest || guest.isDestroyed() || !bounds) return null;
|
||||
if (!guest || guest.isDestroyed()) return null;
|
||||
const bounds = captureRect(rect, guest.getZoomFactor());
|
||||
if (!bounds) return null;
|
||||
try {
|
||||
const image = await guest.capturePage(bounds);
|
||||
return image.isEmpty() ? null : image.toDataURL();
|
||||
|
||||
@@ -49,6 +49,22 @@ describe("agent feature schemas", () => {
|
||||
expect(parsed.value).toBe("flex");
|
||||
});
|
||||
|
||||
it.each(["icon", "label"])(
|
||||
"preserves the %s desktop trigger for any select feature",
|
||||
(desktopTrigger) => {
|
||||
const feature = {
|
||||
type: "select",
|
||||
id: "custom-feature",
|
||||
label: "Custom setting",
|
||||
icon: "gauge",
|
||||
value: "first",
|
||||
options: [{ id: "first", label: "First" }],
|
||||
desktopTrigger,
|
||||
};
|
||||
expect(AgentFeatureSchema.parse(feature)).toEqual(feature);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects invalid features", () => {
|
||||
const invalidDiscriminator = AgentFeatureSchema.safeParse({
|
||||
type: "slider",
|
||||
|
||||
@@ -145,6 +145,8 @@ export interface AgentFeatureSelect {
|
||||
description?: string;
|
||||
tooltip?: string;
|
||||
icon?: string;
|
||||
/** Desktop toolbar presentation; omitted means labeled. Mobile selectors stay labeled. */
|
||||
desktopTrigger?: "icon" | "label";
|
||||
value: string | null;
|
||||
options: AgentSelectOption[];
|
||||
}
|
||||
|
||||
@@ -301,6 +301,7 @@ export const AgentFeatureSelectSchema = z.object({
|
||||
description: z.string().optional(),
|
||||
tooltip: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
desktopTrigger: z.enum(["icon", "label"]).optional(),
|
||||
value: z.string().nullable(),
|
||||
options: z.array(AgentSelectOptionSchema),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type {
|
||||
AgentFeature,
|
||||
AgentFeatureSelect,
|
||||
AgentFeatureToggle,
|
||||
AgentProviderNotice,
|
||||
AgentTaskItem,
|
||||
JsonValue,
|
||||
@@ -8,7 +11,13 @@ import type {
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { PaseoToolCatalog } from "./tools/types.js";
|
||||
|
||||
export type { AgentProviderNotice, AgentTaskItem };
|
||||
export type {
|
||||
AgentFeature,
|
||||
AgentFeatureSelect,
|
||||
AgentFeatureToggle,
|
||||
AgentProviderNotice,
|
||||
AgentTaskItem,
|
||||
};
|
||||
|
||||
export type AgentProvider = string;
|
||||
|
||||
@@ -156,29 +165,6 @@ export interface AgentCreateConfigUnattendedInput {
|
||||
availableModes: AgentMode[];
|
||||
}
|
||||
|
||||
export interface AgentFeatureToggle {
|
||||
type: "toggle";
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
tooltip?: string;
|
||||
icon?: string;
|
||||
value: boolean;
|
||||
}
|
||||
|
||||
export interface AgentFeatureSelect {
|
||||
type: "select";
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
tooltip?: string;
|
||||
icon?: string;
|
||||
value: string | null;
|
||||
options: AgentSelectOption[];
|
||||
}
|
||||
|
||||
export type AgentFeature = AgentFeatureToggle | AgentFeatureSelect;
|
||||
|
||||
export interface AgentCapabilityFlags {
|
||||
[capability: string]: boolean | undefined;
|
||||
supportsStreaming: boolean;
|
||||
|
||||
+228
-55
@@ -19,6 +19,41 @@ interface CollaborationModeRecord {
|
||||
developer_instructions?: string | null;
|
||||
}
|
||||
|
||||
const TEST_SPEED_MODELS = [
|
||||
"gpt-6-astra",
|
||||
"gpt-6.1-sol",
|
||||
"gpt-6-sol",
|
||||
"gpt-6-luna",
|
||||
"gpt-5.6",
|
||||
"gpt-5.6-sol",
|
||||
"gpt-5.6-terra",
|
||||
"gpt-5.6-luna",
|
||||
"gpt-5.5",
|
||||
"gpt-5.4",
|
||||
].map((id) => ({
|
||||
id,
|
||||
isDefault: id === "gpt-5.4",
|
||||
defaultReasoningEffort: "medium",
|
||||
serviceTiers: [{ id: "priority", name: "Fast", description: "Faster processing" }],
|
||||
}));
|
||||
|
||||
function speedFeature(value: string) {
|
||||
return {
|
||||
type: "select",
|
||||
id: "service_tier",
|
||||
label: "Speed",
|
||||
description: "Choose processing speed. Faster tiers increase usage.",
|
||||
tooltip: "Select speed",
|
||||
icon: "zap",
|
||||
desktopTrigger: "icon",
|
||||
value,
|
||||
options: [
|
||||
{ id: "default", label: "Normal", isDefault: true },
|
||||
{ id: "priority", label: "Fast" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const TEST_COLLABORATION_MODES: CollaborationModeRecord[] = [
|
||||
{
|
||||
name: "Code",
|
||||
@@ -65,7 +100,7 @@ function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSession
|
||||
|
||||
function createSessionHarness(
|
||||
configOverrides: Partial<AgentSessionConfig> = {},
|
||||
options: { logger?: pino.Logger } = {},
|
||||
options: { logger?: pino.Logger; models?: unknown[] } = {},
|
||||
): {
|
||||
session: CodexFeaturesTestSession;
|
||||
appServer: FakeCodexAppServer;
|
||||
@@ -73,6 +108,7 @@ function createSessionHarness(
|
||||
const config = createConfig(configOverrides);
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"collaborationMode/list": () => ({ data: TEST_COLLABORATION_MODES }),
|
||||
"model/list": () => ({ data: options.models ?? TEST_SPEED_MODELS }),
|
||||
});
|
||||
const session = new CodexAppServerAgentSession(
|
||||
{ ...config, provider: CODEX_PROVIDER },
|
||||
@@ -85,7 +121,7 @@ function createSessionHarness(
|
||||
|
||||
async function createConnectedSession(
|
||||
configOverrides: Partial<AgentSessionConfig> = {},
|
||||
options: { logger?: pino.Logger } = {},
|
||||
options: { logger?: pino.Logger; models?: unknown[] } = {},
|
||||
): Promise<{
|
||||
session: CodexFeaturesTestSession;
|
||||
appServer: FakeCodexAppServer;
|
||||
@@ -97,8 +133,169 @@ async function createConnectedSession(
|
||||
}
|
||||
|
||||
describe("Codex app-server provider features", () => {
|
||||
test("GPT-6.1 Sol offers catalog speed tiers and sends Ultrafast", async () => {
|
||||
const { session, appServer } = await createConnectedSession(
|
||||
{ model: "gpt-6.1-sol" },
|
||||
{
|
||||
models: [
|
||||
{
|
||||
id: "gpt-6.1-sol",
|
||||
serviceTiers: [
|
||||
{ id: "priority", name: "Fast", description: "Faster processing" },
|
||||
{ id: "ultrafast", name: "Ultrafast", description: "Lowest latency" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
try {
|
||||
expect(session.features).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "service_tier",
|
||||
type: "select",
|
||||
value: "default",
|
||||
options: [
|
||||
{ id: "default", label: "Normal", isDefault: true },
|
||||
{ id: "priority", label: "Fast" },
|
||||
{ id: "ultrafast", label: "Ultrafast" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
await session.setFeature?.("service_tier", "ultrafast");
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
serviceTier: "ultrafast",
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("new models and service tiers work without a Paseo catalog update", async () => {
|
||||
const { session, appServer } = await createConnectedSession(
|
||||
{ model: "future-codex-model" },
|
||||
{
|
||||
models: [
|
||||
{
|
||||
id: "future-codex-model",
|
||||
serviceTiers: [
|
||||
{ id: "future-tier", name: "Future speed", description: "A newly advertised tier" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
try {
|
||||
expect(session.features).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "service_tier",
|
||||
options: [
|
||||
{ id: "default", label: "Normal", isDefault: true },
|
||||
{ id: "future-tier", label: "Future speed" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
await session.setFeature?.("service_tier", "future-tier");
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
model: "future-codex-model",
|
||||
serviceTier: "future-tier",
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.each(["priority", "ultrafast"])(
|
||||
"restores %s and Normal explicitly overrides an existing thread tier",
|
||||
async (tier) => {
|
||||
const { session, appServer } = await createConnectedSession(
|
||||
{ model: "future-model", featureValues: { service_tier: tier } },
|
||||
{
|
||||
models: [
|
||||
{
|
||||
id: "future-model",
|
||||
serviceTiers: [
|
||||
{ id: "priority", name: "Fast", description: "Fast processing" },
|
||||
{ id: "ultrafast", name: "Ultrafast", description: "Ultra processing" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
try {
|
||||
expect(session.features).toContainEqual(
|
||||
expect.objectContaining({ id: "service_tier", value: tier }),
|
||||
);
|
||||
await session.getRuntimeInfo();
|
||||
await session.setFeature?.("service_tier", "default");
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
serviceTier: "default",
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("rejects unadvertised tiers and non-string values", async () => {
|
||||
const { session } = await createConnectedSession();
|
||||
try {
|
||||
for (const value of ["ultrafast", "bogus", true, null]) {
|
||||
await expect(session.setFeature?.("service_tier", value)).rejects.toThrow(
|
||||
"is not available",
|
||||
);
|
||||
}
|
||||
expect(session.features).toContainEqual(
|
||||
expect.objectContaining({ id: "service_tier", value: "default" }),
|
||||
);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("switching to a model without the selected tier resets speed", async () => {
|
||||
const { session, appServer } = await createConnectedSession(
|
||||
{ model: "ultra-model", featureValues: { service_tier: "ultrafast" } },
|
||||
{
|
||||
models: [
|
||||
{
|
||||
id: "ultra-model",
|
||||
serviceTiers: [{ id: "ultrafast", name: "Ultrafast", description: "Ultra processing" }],
|
||||
},
|
||||
...TEST_SPEED_MODELS,
|
||||
],
|
||||
},
|
||||
);
|
||||
try {
|
||||
await session.setModel("gpt-6.1-sol");
|
||||
expect(session.features).toContainEqual(
|
||||
expect.objectContaining({ id: "service_tier", value: "default" }),
|
||||
);
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" });
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("maps legacy Fast commands to the catalog's request id", async () => {
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
try {
|
||||
await session.setFeature?.("fast_mode", true);
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
serviceTier: "priority",
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
"gpt-6-astra",
|
||||
"gpt-6.1-sol",
|
||||
"gpt-6-sol",
|
||||
"gpt-6-luna",
|
||||
"gpt-5.6",
|
||||
@@ -112,15 +309,15 @@ describe("Codex app-server provider features", () => {
|
||||
try {
|
||||
expect(session.features).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "fast_mode",
|
||||
value: false,
|
||||
id: "service_tier",
|
||||
value: "default",
|
||||
}),
|
||||
);
|
||||
await session.setFeature?.("fast_mode", true);
|
||||
await session.setFeature?.("service_tier", "priority");
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
model,
|
||||
serviceTier: "fast",
|
||||
serviceTier: "priority",
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
@@ -149,8 +346,8 @@ describe("Codex app-server provider features", () => {
|
||||
`Codex fast mode is not available for model '${model}'`,
|
||||
);
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({
|
||||
serviceTier: expect.anything(),
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
serviceTier: "default",
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
@@ -165,8 +362,8 @@ describe("Codex app-server provider features", () => {
|
||||
try {
|
||||
expect(session.features).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "fast_mode",
|
||||
value: true,
|
||||
id: "service_tier",
|
||||
value: "priority",
|
||||
}),
|
||||
);
|
||||
await session.setModel("gpt-5.6-sol");
|
||||
@@ -174,26 +371,18 @@ describe("Codex app-server provider features", () => {
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
model: "gpt-6-astra",
|
||||
serviceTier: "fast",
|
||||
serviceTier: "priority",
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("features returns fast and plan toggles when supported", async () => {
|
||||
test("features returns speed selector and plan toggle when supported", async () => {
|
||||
const { session } = await createConnectedSession();
|
||||
|
||||
expect(session.features).toEqual([
|
||||
{
|
||||
type: "toggle",
|
||||
id: "fast_mode",
|
||||
label: "Fast",
|
||||
description: "Priority inference at increased usage",
|
||||
tooltip: "Toggle fast mode",
|
||||
icon: "zap",
|
||||
value: false,
|
||||
},
|
||||
speedFeature("default"),
|
||||
{
|
||||
type: "toggle",
|
||||
id: "plan_mode",
|
||||
@@ -205,19 +394,11 @@ describe("Codex app-server provider features", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
await session.setFeature?.("fast_mode", true);
|
||||
await session.setFeature?.("service_tier", "priority");
|
||||
await session.setFeature?.("plan_mode", true);
|
||||
|
||||
expect(session.features).toEqual([
|
||||
{
|
||||
type: "toggle",
|
||||
id: "fast_mode",
|
||||
label: "Fast",
|
||||
description: "Priority inference at increased usage",
|
||||
tooltip: "Toggle fast mode",
|
||||
icon: "zap",
|
||||
value: true,
|
||||
},
|
||||
speedFeature("priority"),
|
||||
{
|
||||
type: "toggle",
|
||||
id: "plan_mode",
|
||||
@@ -265,23 +446,23 @@ describe("Codex app-server provider features", () => {
|
||||
]);
|
||||
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({
|
||||
serviceTier: expect.anything(),
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
serviceTier: "default",
|
||||
});
|
||||
});
|
||||
|
||||
test("setFeature('fast_mode', true) sets serviceTier to fast", async () => {
|
||||
test("selecting Fast sends the catalog service tier", async () => {
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
|
||||
await session.setFeature?.("fast_mode", true);
|
||||
await session.setFeature?.("service_tier", "priority");
|
||||
await session.startTurn("hello");
|
||||
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
serviceTier: "fast",
|
||||
serviceTier: "priority",
|
||||
});
|
||||
});
|
||||
|
||||
test("setFeature('fast_mode', false) clears serviceTier to null", async () => {
|
||||
test("legacy Fast off explicitly selects Normal", async () => {
|
||||
const { session, appServer } = await createConnectedSession({
|
||||
featureValues: { fast_mode: true },
|
||||
});
|
||||
@@ -289,8 +470,8 @@ describe("Codex app-server provider features", () => {
|
||||
await session.setFeature?.("fast_mode", false);
|
||||
await session.startTurn("hello");
|
||||
|
||||
await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({
|
||||
serviceTier: expect.anything(),
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
serviceTier: "default",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -330,15 +511,7 @@ describe("Codex app-server provider features", () => {
|
||||
});
|
||||
|
||||
expect(session.features).toEqual([
|
||||
{
|
||||
type: "toggle",
|
||||
id: "fast_mode",
|
||||
label: "Fast",
|
||||
description: "Priority inference at increased usage",
|
||||
tooltip: "Toggle fast mode",
|
||||
icon: "zap",
|
||||
value: true,
|
||||
},
|
||||
speedFeature("priority"),
|
||||
{
|
||||
type: "toggle",
|
||||
id: "plan_mode",
|
||||
@@ -352,7 +525,7 @@ describe("Codex app-server provider features", () => {
|
||||
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
serviceTier: "fast",
|
||||
serviceTier: "priority",
|
||||
collaborationMode: expect.objectContaining({
|
||||
mode: "plan",
|
||||
}),
|
||||
@@ -362,11 +535,11 @@ describe("Codex app-server provider features", () => {
|
||||
test("startTurn includes serviceTier when fast mode is enabled", async () => {
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
|
||||
await session.setFeature?.("fast_mode", true);
|
||||
await session.setFeature?.("service_tier", "priority");
|
||||
await session.startTurn("hello");
|
||||
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
serviceTier: "fast",
|
||||
serviceTier: "priority",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -388,7 +561,7 @@ describe("Codex app-server provider features", () => {
|
||||
msg: "Starting Codex app-server turn",
|
||||
model: "gpt-5.4",
|
||||
modeId: "auto",
|
||||
serviceTier: "fast",
|
||||
serviceTier: "priority",
|
||||
cwd: "/tmp/codex-fast-mode-test",
|
||||
});
|
||||
expect(JSON.stringify(entry)).not.toContain(prompt);
|
||||
@@ -397,7 +570,7 @@ describe("Codex app-server provider features", () => {
|
||||
test("setModel clears fast mode when switching to an unsupported model", async () => {
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
|
||||
await session.setFeature?.("fast_mode", true);
|
||||
await session.setFeature?.("service_tier", "priority");
|
||||
await session.setModel("gpt-3.5-turbo");
|
||||
|
||||
expect(session.features).toEqual([
|
||||
@@ -413,8 +586,8 @@ describe("Codex app-server provider features", () => {
|
||||
]);
|
||||
await session.startTurn("hello");
|
||||
|
||||
await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({
|
||||
serviceTier: expect.anything(),
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
serviceTier: "default",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -804,6 +804,7 @@ let buffer = "";
|
||||
function resultFor(method, params) {
|
||||
if (method === "initialize") return {};
|
||||
if (method === "collaborationMode/list") return { data: [] };
|
||||
if (method === "model/list") return { data: [] };
|
||||
if (method === "skills/list") {
|
||||
const cwds = params && params.cwds;
|
||||
const projectCwd = ${JSON.stringify(projectCwd)};
|
||||
@@ -1170,6 +1171,36 @@ describe("Codex app-server provider", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the default effort when a session without one is resumed after a restart", async () => {
|
||||
const beforeRestart = createFakeCodexAppServer();
|
||||
const firstSession = await createProviderWithFakeAppServer(beforeRestart).createSession(
|
||||
createConfig({ thinkingOptionId: undefined }),
|
||||
);
|
||||
await firstSession.startTurn("first turn");
|
||||
await expect(beforeRestart.waitForTurnStart()).resolves.toMatchObject({ effort: "medium" });
|
||||
const handle = firstSession.describePersistence()!;
|
||||
await firstSession.close();
|
||||
|
||||
const afterRestart = createFakeCodexAppServer();
|
||||
// The daemon resumes with the stored agent config, which never recorded an effort.
|
||||
const resumed = await createProviderWithFakeAppServer(afterRestart).resumeSession(handle, {
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(resumed.getRuntimeInfo()).resolves.toMatchObject({
|
||||
thinkingOptionId: "medium",
|
||||
});
|
||||
await resumed.startTurn("turn after restart");
|
||||
await expect(afterRestart.waitForTurnStart()).resolves.toMatchObject({ effort: "medium" });
|
||||
beforeRestart.assertNoErrors();
|
||||
afterRestart.assertNoErrors();
|
||||
} finally {
|
||||
await resumed.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("preapproves only granted tools on the injected Codex MCP server", async () => {
|
||||
const session = createSession({
|
||||
modeId: undefined,
|
||||
@@ -1934,6 +1965,66 @@ describe("Codex app-server provider", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("rewinds a legacy conversation with a bounded fork on Codex without thread/rollback", async () => {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
initialize: () => ({ userAgent: "paseo/0.159.0 (Ubuntu 26.4.0; x86_64) (paseo; 0)" }),
|
||||
"thread/read": () => ({
|
||||
thread: { id: "thread-1", historyMode: "legacy", turns: [] },
|
||||
}),
|
||||
"thread/rollback": () => ({
|
||||
__jsonRpcError: {
|
||||
code: -32600,
|
||||
message: "Invalid request: unknown variant `thread/rollback`",
|
||||
},
|
||||
}),
|
||||
});
|
||||
const session = new CodexAppServerAgentSession(
|
||||
createConfig({ cwd: "/workspace/project" }),
|
||||
null,
|
||||
createTestLogger(),
|
||||
async () => appServer.child,
|
||||
);
|
||||
|
||||
await session.startTurn("remember first");
|
||||
emitCodexUserMessage(appServer, {
|
||||
id: "codex-first",
|
||||
text: "remember first",
|
||||
turnId: "turn-first",
|
||||
});
|
||||
appServer.completeTurn();
|
||||
await session.startTurn("remember second");
|
||||
emitCodexUserMessage(appServer, {
|
||||
id: "codex-second",
|
||||
text: "remember second",
|
||||
turnId: "turn-second",
|
||||
});
|
||||
appServer.completeTurn();
|
||||
|
||||
await session.revertConversation({ messageId: "codex-second" });
|
||||
|
||||
const forkRequests = appServer
|
||||
.requests()
|
||||
.filter((request) => request.method === "thread/fork")
|
||||
.map((request) => request.params);
|
||||
expect(forkRequests).toEqual([
|
||||
{
|
||||
threadId: "thread-1",
|
||||
beforeTurnId: "turn-second",
|
||||
cwd: "/workspace/project",
|
||||
model: "gpt-5.4",
|
||||
serviceTier: null,
|
||||
excludeTurns: false,
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
]);
|
||||
expect(appServer.recordedRollbacks).toEqual([]);
|
||||
await expect(session.getRuntimeInfo()).resolves.toMatchObject({
|
||||
sessionId: "forked-thread",
|
||||
});
|
||||
appServer.assertNoErrors();
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("rewinds a paginated conversation through the public session capability", async () => {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"thread/read": () => ({
|
||||
@@ -2199,7 +2290,12 @@ describe("Codex app-server provider", () => {
|
||||
throw new Error(`resumeSession timed out; thread requests: ${threadRequests.join(", ")}`);
|
||||
}
|
||||
|
||||
expect(threadRequests).toEqual(["config/read", "thread/loaded/list", "thread/resume"]);
|
||||
expect(threadRequests).toEqual([
|
||||
"model/list",
|
||||
"config/read",
|
||||
"thread/loaded/list",
|
||||
"thread/resume",
|
||||
]);
|
||||
expect(outcome).toBe("rejected");
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
@@ -2373,6 +2469,71 @@ describe("Codex app-server provider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps an output field named properties as a field", () => {
|
||||
const input = {
|
||||
type: "object",
|
||||
properties: { properties: { type: "string" } },
|
||||
required: ["properties"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
expect(normalizeCodexOutputSchema(input)).toEqual(input);
|
||||
});
|
||||
|
||||
test("normalizes schemas under keywords and leaves literal values alone", () => {
|
||||
const literal = { type: "object", properties: { type: "string" } };
|
||||
const input = {
|
||||
type: "object",
|
||||
properties: {
|
||||
result: { $ref: "#/$defs/result" },
|
||||
choice: { anyOf: [{ type: "object", properties: { id: { type: "string" } } }] },
|
||||
},
|
||||
$defs: {
|
||||
properties: { type: "string" },
|
||||
result: {
|
||||
type: "object",
|
||||
properties: { value: { type: "string" } },
|
||||
default: literal,
|
||||
examples: [literal],
|
||||
enum: [literal],
|
||||
const: literal,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(normalizeCodexOutputSchema(input)).toEqual({
|
||||
type: "object",
|
||||
properties: {
|
||||
result: { $ref: "#/$defs/result" },
|
||||
choice: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: { id: { type: "string" } },
|
||||
required: ["id"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
required: ["result", "choice"],
|
||||
additionalProperties: false,
|
||||
$defs: {
|
||||
properties: { type: "string" },
|
||||
result: {
|
||||
type: "object",
|
||||
properties: { value: { type: "string" } },
|
||||
default: literal,
|
||||
examples: [literal],
|
||||
enum: [literal],
|
||||
const: literal,
|
||||
required: ["value"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("passes a normalized output schema to turn/start", async () => {
|
||||
const session = createSession();
|
||||
const request = vi.fn(async (method: string) => {
|
||||
|
||||
@@ -73,7 +73,12 @@ import {
|
||||
import { createPathEquivalenceMatcher } from "../../../utils/path.js";
|
||||
import { spawnProcess } from "../../../utils/spawn.js";
|
||||
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
|
||||
import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js";
|
||||
import {
|
||||
buildCodexFeatures,
|
||||
CodexServiceTierSchema,
|
||||
readCodexServiceTier,
|
||||
type CodexServiceTier,
|
||||
} from "./codex-feature-definitions.js";
|
||||
import {
|
||||
CodexAppServerClient,
|
||||
CodexAppServerRpcError,
|
||||
@@ -169,6 +174,9 @@ const CODEX_PLAN_IMPLEMENTATION_PROMPT_PREFIX =
|
||||
// (and the /goal slash command) when the binary is too old.
|
||||
const CODEX_GOALS_MIN_VERSION: readonly [number, number, number] = [0, 128, 0];
|
||||
const CODEX_AUTO_REVIEW_MIN_VERSION: readonly [number, number, number] = [0, 115, 0];
|
||||
// Codex removed `thread/rollback` in 0.156.0, so rewinding a legacy thread there
|
||||
// has to fork before the target turn instead.
|
||||
const CODEX_THREAD_ROLLBACK_REMOVED_VERSION: readonly [number, number, number] = [0, 156, 0];
|
||||
|
||||
function parseCodexVersion(versionOutput: string): [number, number, number] | null {
|
||||
const match = versionOutput.match(/(\d+)\.(\d+)\.(\d+)/);
|
||||
@@ -189,6 +197,15 @@ function codexVersionAtLeast(
|
||||
return true;
|
||||
}
|
||||
|
||||
// The initialize userAgent starts with `<client>/<codex version>`. An agent we
|
||||
// cannot parse is treated as an older server that still has rollback.
|
||||
function codexServerHasThreadRollback(userAgent: unknown): boolean {
|
||||
return (
|
||||
typeof userAgent !== "string" ||
|
||||
!codexVersionAtLeast(userAgent, CODEX_THREAD_ROLLBACK_REMOVED_VERSION)
|
||||
);
|
||||
}
|
||||
|
||||
type GoalSubcommand =
|
||||
| { kind: "set"; objective: string }
|
||||
| { kind: "pause" }
|
||||
@@ -365,19 +382,67 @@ function isObjectSchemaNode(schema: Record<string, unknown>): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCodexOutputSchemaNode(schema: unknown, schemaPath: string): unknown {
|
||||
// Keywords whose value maps names to schemas.
|
||||
const SCHEMA_MAP_KEYWORDS = [
|
||||
"properties",
|
||||
"patternProperties",
|
||||
"$defs",
|
||||
"definitions",
|
||||
"dependentSchemas",
|
||||
] as const;
|
||||
|
||||
// Keywords whose value is a schema or an array of schemas.
|
||||
const SUBSCHEMA_KEYWORDS = [
|
||||
"items",
|
||||
"prefixItems",
|
||||
"additionalItems",
|
||||
"contains",
|
||||
"additionalProperties",
|
||||
"unevaluatedProperties",
|
||||
"unevaluatedItems",
|
||||
"propertyNames",
|
||||
"allOf",
|
||||
"anyOf",
|
||||
"oneOf",
|
||||
"not",
|
||||
"if",
|
||||
"then",
|
||||
"else",
|
||||
] as const;
|
||||
|
||||
function normalizeCodexOutputSubschema(schema: unknown, schemaPath: string): unknown {
|
||||
if (Array.isArray(schema)) {
|
||||
return schema.map((entry, index) =>
|
||||
normalizeCodexOutputSchemaNode(entry, `${schemaPath}[${index}]`),
|
||||
);
|
||||
}
|
||||
return normalizeCodexOutputSchemaNode(schema, schemaPath);
|
||||
}
|
||||
|
||||
function normalizeCodexOutputSchemaNode(schema: unknown, schemaPath: string): unknown {
|
||||
if (!isSchemaRecord(schema)) {
|
||||
return schema;
|
||||
}
|
||||
|
||||
const normalized: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(schema)) {
|
||||
normalized[key] = normalizeCodexOutputSchemaNode(value, `${schemaPath}.${key}`);
|
||||
const normalized: Record<string, unknown> = { ...schema };
|
||||
for (const keyword of SCHEMA_MAP_KEYWORDS) {
|
||||
const schemas = schema[keyword];
|
||||
if (isSchemaRecord(schemas)) {
|
||||
normalized[keyword] = Object.fromEntries(
|
||||
Object.entries(schemas).map(([name, child]) => [
|
||||
name,
|
||||
normalizeCodexOutputSchemaNode(child, `${schemaPath}.${keyword}.${name}`),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const keyword of SUBSCHEMA_KEYWORDS) {
|
||||
if (keyword in schema) {
|
||||
normalized[keyword] = normalizeCodexOutputSubschema(
|
||||
schema[keyword],
|
||||
`${schemaPath}.${keyword}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isObjectSchemaNode(normalized)) {
|
||||
@@ -881,6 +946,7 @@ interface CodexModel {
|
||||
model?: string;
|
||||
defaultReasoningEffort?: string;
|
||||
supportedReasoningEfforts?: CodexReasoningEffortEntry[];
|
||||
serviceTiers?: CodexServiceTier[];
|
||||
}
|
||||
|
||||
const CodexModelListResponseSchema = z.object({
|
||||
@@ -888,6 +954,7 @@ const CodexModelListResponseSchema = z.object({
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
serviceTiers: z.array(CodexServiceTierSchema).optional(),
|
||||
displayName: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
@@ -3373,12 +3440,14 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
cancelRequested: boolean;
|
||||
} | null = null;
|
||||
private client: CodexAppServerClient | null = null;
|
||||
private threadRollbackAvailable = true;
|
||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||
private nextTurnOrdinal = 0;
|
||||
private activeForegroundTurnId: string | null = null;
|
||||
private activeClientMessageId: string | null = null;
|
||||
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
|
||||
private serviceTier: "fast" | null = null;
|
||||
private serviceTier: string | null = null;
|
||||
private speedModels: CodexModel[] = [];
|
||||
private planModeEnabled = false;
|
||||
private historyPending = false;
|
||||
private persistedHistory: PersistedTimelineEntry[] = [];
|
||||
@@ -3469,9 +3538,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.asyncQuestions = new CodexAsyncQuestions(resumeHandle?.metadata?.asyncQuestions);
|
||||
this.codexHome = deps.codexHome ?? resolveCodexHomeDir(process.env);
|
||||
this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
|
||||
if (this.config.featureValues?.fast_mode && codexModelSupportsFastMode(this.config.model)) {
|
||||
this.serviceTier = "fast";
|
||||
}
|
||||
this.serviceTier = readCodexServiceTier(this.config.featureValues);
|
||||
if (this.config.featureValues?.plan_mode) {
|
||||
this.planModeEnabled = true;
|
||||
}
|
||||
@@ -3499,8 +3566,8 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
get features(): AgentFeature[] {
|
||||
return buildCodexFeatures({
|
||||
modelId: this.config.model,
|
||||
fastModeEnabled: this.serviceTier === "fast",
|
||||
serviceTiers: this.currentServiceTiers(),
|
||||
serviceTier: this.serviceTier ?? "default",
|
||||
planModeEnabled: this.planModeEnabled,
|
||||
planModeAvailable: this.hasPlanCollaborationMode(),
|
||||
});
|
||||
@@ -3553,9 +3620,15 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.registerRequestHandlers();
|
||||
|
||||
try {
|
||||
await client.request("initialize", buildCodexAppServerInitializeParams());
|
||||
const initialized = toObjectRecord(
|
||||
await client.request("initialize", buildCodexAppServerInitializeParams()),
|
||||
);
|
||||
this.threadRollbackAvailable = codexServerHasThreadRollback(initialized?.userAgent);
|
||||
client.notify("initialized", {});
|
||||
|
||||
this.speedModels =
|
||||
CodexModelListResponseSchema.parse(await client.request("model/list", {})).data ?? [];
|
||||
this.reconcileServiceTier();
|
||||
await this.loadResolvedWorkspaceWrite();
|
||||
await this.loadCollaborationModes();
|
||||
await this.loadSkills();
|
||||
@@ -3563,6 +3636,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
if (this.currentThreadId) {
|
||||
await this.ensureThreadLoaded();
|
||||
await this.loadPersistedHistory(this.client);
|
||||
await this.applyDefaultModelAndThinking();
|
||||
}
|
||||
|
||||
if (this.closed) {
|
||||
@@ -3759,18 +3833,30 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.resolvedCollaborationMode = this.resolveCollaborationMode();
|
||||
}
|
||||
|
||||
private applyFeatureValue(featureId: "fast_mode" | "plan_mode", value: boolean): void {
|
||||
this.config.featureValues = {
|
||||
...this.config.featureValues,
|
||||
[featureId]: value,
|
||||
};
|
||||
private currentServiceTiers(): CodexServiceTier[] {
|
||||
const selectedModel = this.config.model
|
||||
? this.speedModels.find(
|
||||
(model) => model.id === this.config.model || model.model === this.config.model,
|
||||
)
|
||||
: (this.speedModels.find((model) => model.isDefault) ?? this.speedModels[0]);
|
||||
return selectedModel?.serviceTiers ?? [];
|
||||
}
|
||||
|
||||
if (featureId === "fast_mode") {
|
||||
this.serviceTier = value ? "fast" : null;
|
||||
this.cachedRuntimeInfo = null;
|
||||
return;
|
||||
private reconcileServiceTier(): void {
|
||||
const tiers = this.currentServiceTiers();
|
||||
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated.
|
||||
if (this.serviceTier === "fast" && !tiers.some((tier) => tier.id === "fast")) {
|
||||
this.serviceTier = tiers.find((tier) => tier.name.toLowerCase() === "fast")?.id ?? null;
|
||||
}
|
||||
if (this.serviceTier !== "default" && !tiers.some((tier) => tier.id === this.serviceTier)) {
|
||||
this.serviceTier = null;
|
||||
}
|
||||
const { fast_mode: _legacyFast, ...values } = this.config.featureValues ?? {};
|
||||
this.config.featureValues = { ...values, service_tier: this.serviceTier ?? "default" };
|
||||
}
|
||||
|
||||
private applyFeatureValue(featureId: "plan_mode", value: boolean): void {
|
||||
this.config.featureValues = { ...this.config.featureValues, [featureId]: value };
|
||||
this.planModeEnabled = value;
|
||||
this.refreshResolvedCollaborationMode();
|
||||
this.cachedRuntimeInfo = null;
|
||||
@@ -4101,9 +4187,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
if (thinkingOptionId) {
|
||||
params.effort = thinkingOptionId;
|
||||
}
|
||||
if (this.serviceTier) {
|
||||
params.serviceTier = this.serviceTier;
|
||||
}
|
||||
params.serviceTier = this.serviceTier ?? "default";
|
||||
if (this.resolvedCollaborationMode) {
|
||||
params.collaborationMode = {
|
||||
mode: this.resolvedCollaborationMode.mode,
|
||||
@@ -4521,9 +4605,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
async setModel(modelId: string | null): Promise<void> {
|
||||
this.config.model = modelId ?? undefined;
|
||||
if (!codexModelSupportsFastMode(this.config.model)) {
|
||||
this.serviceTier = null;
|
||||
}
|
||||
this.reconcileServiceTier();
|
||||
this.refreshResolvedCollaborationMode();
|
||||
this.cachedRuntimeInfo = null;
|
||||
}
|
||||
@@ -4538,13 +4620,27 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
||||
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once clients use service_tier.
|
||||
if (featureId === "fast_mode") {
|
||||
if (Boolean(value) && !codexModelSupportsFastMode(this.config.model)) {
|
||||
const fast = this.currentServiceTiers().find((tier) => tier.name.toLowerCase() === "fast");
|
||||
if (value && !fast)
|
||||
throw new Error(
|
||||
`Codex fast mode is not available for model '${this.config.model ?? "default"}'`,
|
||||
);
|
||||
return this.setFeature("service_tier", value ? fast!.id : "default");
|
||||
}
|
||||
if (featureId === "service_tier") {
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
(value !== "default" && !this.currentServiceTiers().some((tier) => tier.id === value))
|
||||
) {
|
||||
throw new Error(
|
||||
`Codex speed '${String(value)}' is not available for model '${this.config.model ?? "default"}'`,
|
||||
);
|
||||
}
|
||||
this.applyFeatureValue("fast_mode", Boolean(value));
|
||||
this.serviceTier = value;
|
||||
this.reconcileServiceTier();
|
||||
this.cachedRuntimeInfo = null;
|
||||
return;
|
||||
}
|
||||
if (featureId === "plan_mode") {
|
||||
@@ -4847,6 +4943,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
serviceTier: this.serviceTier,
|
||||
config: this.buildCodexInnerConfig(),
|
||||
userMessageTurns: this.codexUserMessageTurns(),
|
||||
threadRollbackAvailable: this.threadRollbackAvailable,
|
||||
setThreadId: async (threadId) => {
|
||||
this.currentThreadId = threadId;
|
||||
this.cachedRuntimeInfo = null;
|
||||
@@ -5160,13 +5257,21 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
return { model, thinkingOptionId };
|
||||
}
|
||||
|
||||
// A session without a model or effort runs on Codex's configured defaults, whether its
|
||||
// thread is new or resumed after a restart.
|
||||
private async applyDefaultModelAndThinking(): Promise<string> {
|
||||
const { model, thinkingOptionId } = await this.resolveModelAndThinking();
|
||||
this.config.model = model;
|
||||
this.reconcileServiceTier();
|
||||
this.config.thinkingOptionId = thinkingOptionId;
|
||||
return model;
|
||||
}
|
||||
|
||||
private async ensureThread(): Promise<void> {
|
||||
if (!this.client) return;
|
||||
if (this.currentThreadId) return;
|
||||
|
||||
const { model, thinkingOptionId } = await this.resolveModelAndThinking();
|
||||
this.config.model = model;
|
||||
this.config.thinkingOptionId = thinkingOptionId;
|
||||
const model = await this.applyDefaultModelAndThinking();
|
||||
|
||||
const { params, approvalPolicy, sandbox } = this.buildThreadStartRequest(model);
|
||||
const rawResponse = await this.client.request("thread/start", params);
|
||||
|
||||
@@ -1,27 +1,33 @@
|
||||
import type { AgentFeature, AgentFeatureToggle } from "../agent-sdk-types.js";
|
||||
|
||||
// Codex Fast is distinct from API Priority processing. Keep model support aligned with
|
||||
// https://developers.openai.com/codex/speed and https://developers.openai.com/codex/models.
|
||||
const CODEX_FAST_MODE_SUPPORTED_MODELS = new Set([
|
||||
"gpt-6-astra",
|
||||
"gpt-6-sol",
|
||||
"gpt-6-luna",
|
||||
"gpt-5.6",
|
||||
"gpt-5.6-sol",
|
||||
"gpt-5.6-terra",
|
||||
"gpt-5.6-luna",
|
||||
"gpt-5.5",
|
||||
"gpt-5.4",
|
||||
]);
|
||||
import { z } from "zod";
|
||||
|
||||
export const CODEX_FAST_MODE_FEATURE: Omit<AgentFeatureToggle, "value"> = {
|
||||
type: "toggle",
|
||||
id: "fast_mode",
|
||||
label: "Fast",
|
||||
description: "Priority inference at increased usage",
|
||||
tooltip: "Toggle fast mode",
|
||||
icon: "zap",
|
||||
};
|
||||
export const CodexServiceTierSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
});
|
||||
export type CodexServiceTier = z.infer<typeof CodexServiceTierSchema>;
|
||||
|
||||
function buildCodexSpeedFeature(tiers: CodexServiceTier[], value: string): AgentFeature[] {
|
||||
if (tiers.length === 0) return [];
|
||||
return [
|
||||
{
|
||||
type: "select",
|
||||
id: "service_tier",
|
||||
label: "Speed",
|
||||
description: "Choose processing speed. Faster tiers increase usage.",
|
||||
tooltip: "Select speed",
|
||||
icon: "zap",
|
||||
desktopTrigger: "icon",
|
||||
value,
|
||||
options: [
|
||||
{ id: "default", label: "Normal", isDefault: true },
|
||||
...tiers.map((tier) => ({ id: tier.id, label: tier.name })),
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const CODEX_PLAN_MODE_FEATURE: Omit<AgentFeatureToggle, "value"> = {
|
||||
type: "toggle",
|
||||
@@ -32,40 +38,22 @@ export const CODEX_PLAN_MODE_FEATURE: Omit<AgentFeatureToggle, "value"> = {
|
||||
icon: "list-todo",
|
||||
};
|
||||
|
||||
function normalizeCodexModelId(modelId: string | null | undefined): string | null {
|
||||
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
export function codexModelSupportsFastMode(modelId: string | null | undefined): boolean {
|
||||
const normalizedModelId = normalizeCodexModelId(modelId);
|
||||
if (!normalizedModelId) {
|
||||
return false;
|
||||
}
|
||||
return CODEX_FAST_MODE_SUPPORTED_MODELS.has(normalizedModelId);
|
||||
}
|
||||
|
||||
export function buildCodexFeatures(input: {
|
||||
modelId: string | null | undefined;
|
||||
fastModeEnabled: boolean;
|
||||
serviceTiers: CodexServiceTier[];
|
||||
serviceTier: string;
|
||||
planModeEnabled: boolean;
|
||||
planModeAvailable?: boolean;
|
||||
}): AgentFeature[] {
|
||||
const features: AgentFeature[] = [];
|
||||
|
||||
if (codexModelSupportsFastMode(input.modelId)) {
|
||||
features.push({
|
||||
...CODEX_FAST_MODE_FEATURE,
|
||||
value: input.fastModeEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
const features = buildCodexSpeedFeature(input.serviceTiers, input.serviceTier);
|
||||
if (input.planModeAvailable !== false) {
|
||||
features.push({
|
||||
...CODEX_PLAN_MODE_FEATURE,
|
||||
value: input.planModeEnabled,
|
||||
});
|
||||
features.push({ ...CODEX_PLAN_MODE_FEATURE, value: input.planModeEnabled });
|
||||
}
|
||||
|
||||
return features;
|
||||
}
|
||||
|
||||
export function readCodexServiceTier(values: Record<string, unknown> | undefined): string | null {
|
||||
const tier = values?.service_tier;
|
||||
if (typeof tier === "string") return tier;
|
||||
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated.
|
||||
return values?.fast_mode ? "fast" : null;
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ describe("Codex Rewind", () => {
|
||||
model: "gpt-5.4-mini",
|
||||
serviceTier: null,
|
||||
userMessageTurns,
|
||||
threadRollbackAvailable: true,
|
||||
setThreadId: (threadId) => {
|
||||
reboundThreadId = threadId;
|
||||
},
|
||||
@@ -131,6 +132,7 @@ describe("Codex Rewind", () => {
|
||||
threadId: "source-thread",
|
||||
messageId: "codex-second",
|
||||
userMessageTurns,
|
||||
threadRollbackAvailable: true,
|
||||
setThreadId: (threadId) => {
|
||||
reboundThreadId = threadId;
|
||||
},
|
||||
@@ -175,6 +177,7 @@ describe("Codex Rewind", () => {
|
||||
model: "gpt-5.4-mini",
|
||||
serviceTier: null,
|
||||
userMessageTurns,
|
||||
threadRollbackAvailable: true,
|
||||
setThreadId: (threadId) => {
|
||||
reboundThreadId = threadId;
|
||||
},
|
||||
@@ -218,6 +221,7 @@ describe("Codex Rewind", () => {
|
||||
model: "gpt-5.4-mini",
|
||||
serviceTier: null,
|
||||
userMessageTurns,
|
||||
threadRollbackAvailable: true,
|
||||
setThreadId: (threadId) => {
|
||||
reboundThreadId = threadId;
|
||||
},
|
||||
@@ -239,6 +243,7 @@ describe("Codex Rewind", () => {
|
||||
threadId: "source-thread",
|
||||
messageId: "missing-message",
|
||||
userMessageTurns,
|
||||
threadRollbackAvailable: true,
|
||||
setThreadId: () => undefined,
|
||||
}),
|
||||
).rejects.toThrow("Codex could not find user message missing-message");
|
||||
|
||||
@@ -69,6 +69,7 @@ export async function revertCodexConversation(input: {
|
||||
serviceTier?: string | null;
|
||||
config?: Record<string, unknown> | null;
|
||||
userMessageTurns: CodexUserMessageTurnIndex;
|
||||
threadRollbackAvailable: boolean;
|
||||
setThreadId: (threadId: string) => void | Promise<void>;
|
||||
}): Promise<void> {
|
||||
if (!input.threadId) {
|
||||
@@ -98,8 +99,10 @@ export async function revertCodexConversation(input: {
|
||||
persistExtendedHistory: true,
|
||||
};
|
||||
|
||||
const historyMode = await readCodexThreadHistoryMode(input.client, input.threadId);
|
||||
if (historyMode === "paginated") {
|
||||
if (
|
||||
!input.threadRollbackAvailable ||
|
||||
(await readCodexThreadHistoryMode(input.client, input.threadId)) === "paginated"
|
||||
) {
|
||||
if (!targetTurn.turnId) {
|
||||
throw new Error(`Codex could not find the turn containing user message ${input.messageId}`);
|
||||
}
|
||||
@@ -111,7 +114,8 @@ export async function revertCodexConversation(input: {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fork is non-destructive: the old thread file stays on disk and remains
|
||||
// Legacy threads on Codex before 0.156 fork and then roll back. Fork is
|
||||
// non-destructive: the old thread file stays on disk and remains
|
||||
// recoverable with `codex resume <old-uuid>` if the rewind target was wrong.
|
||||
const forked = await forkCodexThread(input.client, forkParams);
|
||||
const forkedThreadId = forked.thread.id;
|
||||
|
||||
@@ -656,6 +656,29 @@ describe("codex tool-call mapper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
type: "commandExecution",
|
||||
id: "codex-declined-command",
|
||||
status: "declined",
|
||||
command: "printf ok > /outside/permission.txt",
|
||||
cwd: "/tmp/repo",
|
||||
aggregatedOutput: null,
|
||||
exitCode: null,
|
||||
},
|
||||
{
|
||||
type: "fileChange",
|
||||
id: "codex-declined-patch",
|
||||
status: "declined",
|
||||
changes: [],
|
||||
},
|
||||
])("settles a declined $type as failed instead of running", (threadItem) => {
|
||||
const item = expectMapped(mapCodexToolCallFromThreadItem(threadItem));
|
||||
|
||||
expect(item.callId).toBe(threadItem.id);
|
||||
expect(item.status).toBe("failed");
|
||||
});
|
||||
|
||||
it("maps unknown tools to unknown detail with raw payloads", () => {
|
||||
const item = expectMapped(
|
||||
mapCodexToolCallEnvelope({
|
||||
|
||||
@@ -312,6 +312,10 @@ const OpencodeKnownToolDetailSchema = z.union([
|
||||
z.unknown(),
|
||||
(input) => toEditToolDetail(input, null),
|
||||
),
|
||||
// OpenCode v2 reports apply_patch as `patch`.
|
||||
toolDetailBranchByToolName("patch", OpencodeApplyPatchTextInputSchema, z.unknown(), (input) =>
|
||||
toEditToolDetail(input, null),
|
||||
),
|
||||
toolDetailBranchByToolName(
|
||||
"apply_diff",
|
||||
OpencodeEditInputSchema,
|
||||
|
||||
@@ -387,6 +387,34 @@ describe("opencode tool-call mapper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("maps OpenCode v2 patch payloads from GPT models into edit detail", () => {
|
||||
const item = expectMapped(
|
||||
mapOpencodeToolCall({
|
||||
toolName: "patch",
|
||||
callId: "opencode-v2-patch-1",
|
||||
status: "completed",
|
||||
input: {
|
||||
patchText:
|
||||
"*** Begin Patch\n*** Update File: patch.txt\n@@\n-north south\n+NORTH south\n*** End Patch",
|
||||
},
|
||||
output: "Success. Updated the following files:\nM patch.txt",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(item.detail).toEqual({
|
||||
type: "edit",
|
||||
filePath: "patch.txt",
|
||||
unifiedDiff: [
|
||||
"diff --git a/patch.txt b/patch.txt",
|
||||
"--- a/patch.txt",
|
||||
"+++ b/patch.txt",
|
||||
"@@",
|
||||
"-north south",
|
||||
"+NORTH south",
|
||||
].join("\n"),
|
||||
});
|
||||
});
|
||||
|
||||
it("maps unknown tools to unknown detail with raw payloads", () => {
|
||||
const item = expectMapped(
|
||||
mapOpencodeToolCall({
|
||||
|
||||
@@ -35,6 +35,7 @@ import { features } from "./configuration.js";
|
||||
import { commands } from "./commands.js";
|
||||
import { messages } from "./history.js";
|
||||
import { SessionPermissions } from "./permissions.js";
|
||||
import { SessionUsage } from "./usage.js";
|
||||
|
||||
export class OpenCodeV2Session implements AgentSession {
|
||||
readonly provider = "opencode";
|
||||
@@ -45,6 +46,7 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
private readonly permissions: SessionPermissions;
|
||||
private readonly turns: SessionTurns;
|
||||
private readonly children: SessionChildren;
|
||||
private readonly usage: SessionUsage;
|
||||
private stream: Promise<void> | null = null;
|
||||
private streamAbort = new AbortController();
|
||||
private exited = false;
|
||||
@@ -101,6 +103,17 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
await this.permissions.respondToPermission(request.id, { behavior: "deny" });
|
||||
},
|
||||
});
|
||||
this.usage = new SessionUsage({
|
||||
client: () => this.client,
|
||||
cwd: config.cwd,
|
||||
info: () => this.info,
|
||||
emit: (event) => this.emit(event),
|
||||
reportError: (error) =>
|
||||
this.logger.warn(
|
||||
{ error: toDiagnosticErrorMessage(error) },
|
||||
"OpenCode context usage update failed",
|
||||
),
|
||||
});
|
||||
}
|
||||
get id() {
|
||||
return this.info.id;
|
||||
@@ -465,6 +478,7 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
return;
|
||||
}
|
||||
this.permissions.observe(event);
|
||||
this.usage.observe(event);
|
||||
this.turns.observe(event);
|
||||
this.scheduleReconcile();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { ModelInfo, TokenUsageInfo } from "@opencode/client";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { OpenCodeV2AgentClient } from "./agent.js";
|
||||
import { V2Harness } from "../test-utils/v2-harness.js";
|
||||
import { createTestLogger } from "../../../../../test-utils/test-logger.js";
|
||||
import type { AgentUsage } from "../../../agent-sdk-types.js";
|
||||
|
||||
function model(context: number): ModelInfo {
|
||||
return {
|
||||
id: "model",
|
||||
modelID: "model",
|
||||
providerID: "test",
|
||||
name: "Model",
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
variants: [],
|
||||
time: { released: 1 },
|
||||
cost: [],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context, output: 10_000 },
|
||||
};
|
||||
}
|
||||
|
||||
function stepEnded(harness: V2Harness, id: string, tokens: TokenUsageInfo) {
|
||||
harness.push({
|
||||
id,
|
||||
created: 2,
|
||||
type: "session.step.ended",
|
||||
durable: { aggregateID: "session", seq: 1, version: 1 },
|
||||
data: {
|
||||
sessionID: "session",
|
||||
assistantMessageID: "answer",
|
||||
finish: "tool-calls",
|
||||
cost: 0.1,
|
||||
tokens,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("OpenCode v2 context usage", () => {
|
||||
test("reports each step's context against the model limit, in step order", async () => {
|
||||
const harness = new V2Harness();
|
||||
harness.info.model = { providerID: "test", id: "model" };
|
||||
harness.info.tokens = {
|
||||
input: 900,
|
||||
output: 90,
|
||||
reasoning: 0,
|
||||
cache: { read: 40, write: 0 },
|
||||
};
|
||||
harness.info.cost = 0.5;
|
||||
// Each lookup answers faster than the one before it, so a lookup per step would
|
||||
// deliver step 1's usage after step 2's.
|
||||
const lookupDelays = [50, 0];
|
||||
harness.api.model.list = async (input) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, lookupDelays.shift() ?? 0));
|
||||
return {
|
||||
location: { directory: input.location.directory },
|
||||
data: [model(200_000)],
|
||||
};
|
||||
};
|
||||
const client = new OpenCodeV2AgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: harness.runtime,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
});
|
||||
const usage: AgentUsage[] = [];
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "usage_updated") usage.push(event.usage);
|
||||
});
|
||||
try {
|
||||
stepEnded(harness, "step-1", {
|
||||
input: 100,
|
||||
output: 10,
|
||||
reasoning: 5,
|
||||
cache: { read: 1_000, write: 20 },
|
||||
});
|
||||
stepEnded(harness, "step-2", {
|
||||
input: 300,
|
||||
output: 10,
|
||||
reasoning: 5,
|
||||
cache: { read: 1_000, write: 20 },
|
||||
});
|
||||
await expect.poll(() => usage.length).toBe(2);
|
||||
expect(usage.map((entry) => entry.contextWindowUsedTokens)).toEqual([1_135, 1_335]);
|
||||
expect(usage[1]).toEqual({
|
||||
inputTokens: 900,
|
||||
outputTokens: 90,
|
||||
cachedInputTokens: 40,
|
||||
totalCostUsd: 0.5,
|
||||
contextWindowUsedTokens: 1_335,
|
||||
contextWindowMaxTokens: 200_000,
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { ModelRef, OpenCodeEvent, SessionInfo, TokenUsageInfo } from "@opencode/client";
|
||||
import type { AgentStreamEvent } from "../../../agent-sdk-types.js";
|
||||
import type { V2Api } from "./api.js";
|
||||
import { usageFromV2 } from "./mapping.js";
|
||||
|
||||
interface UsageOptions {
|
||||
client(): V2Api;
|
||||
cwd: string;
|
||||
info(): SessionInfo;
|
||||
emit(event: AgentStreamEvent): void;
|
||||
reportError(error: unknown): void;
|
||||
}
|
||||
|
||||
// A step is one model call, so its tokens are the context that call used.
|
||||
function contextWindowUsedTokens(tokens: TokenUsageInfo) {
|
||||
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write;
|
||||
}
|
||||
|
||||
export class SessionUsage {
|
||||
private readonly limits = new Map<string, Promise<number | undefined>>();
|
||||
private reports: Promise<void> = Promise.resolve();
|
||||
constructor(private readonly options: UsageOptions) {}
|
||||
observe(event: OpenCodeEvent) {
|
||||
if (event.type !== "session.step.ended") return;
|
||||
const { tokens } = event.data;
|
||||
// Reports stay in step order while a model's context limit is still being looked up.
|
||||
this.reports = this.reports
|
||||
.then(() => this.report(tokens))
|
||||
.catch((error: unknown) => this.options.reportError(error));
|
||||
}
|
||||
private async report(tokens: TokenUsageInfo) {
|
||||
const model = this.options.info().model;
|
||||
const contextWindowMaxTokens = model ? await this.contextLimit(model) : undefined;
|
||||
this.options.emit({
|
||||
type: "usage_updated",
|
||||
provider: "opencode",
|
||||
usage: {
|
||||
...usageFromV2(this.options.info()),
|
||||
contextWindowUsedTokens: contextWindowUsedTokens(tokens),
|
||||
...(contextWindowMaxTokens === undefined ? {} : { contextWindowMaxTokens }),
|
||||
},
|
||||
});
|
||||
}
|
||||
private contextLimit(model: ModelRef) {
|
||||
const key = `${model.providerID}/${model.id}`;
|
||||
const cached = this.limits.get(key);
|
||||
if (cached) return cached;
|
||||
const limit = this.options
|
||||
.client()
|
||||
.model.list({ location: { directory: this.options.cwd } })
|
||||
.then(
|
||||
(catalog) =>
|
||||
catalog.data.find(
|
||||
(entry) => entry.providerID === model.providerID && entry.id === model.id,
|
||||
)?.limit.context,
|
||||
);
|
||||
this.limits.set(key, limit);
|
||||
limit.catch(() => this.limits.delete(key));
|
||||
return limit;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ const FAILED_STATUS_VOCAB = new Set([
|
||||
"errored",
|
||||
"rejected",
|
||||
"denied",
|
||||
"declined",
|
||||
]);
|
||||
const CANCELED_STATUS_VOCAB = new Set(["canceled", "cancelled", "interrupted", "aborted"]);
|
||||
const COMPLETED_STATUS_VOCAB = new Set(["completed", "complete", "done", "success", "succeeded"]);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { ForgeService, PullRequestCheckoutTarget } from "../services/forge-service.js";
|
||||
import type { WorktreeSource } from "../utils/worktree.js";
|
||||
|
||||
export type WorktreeCreationIntent = Exclude<WorktreeSource, { kind: "restore" }>;
|
||||
export type WorktreeCreationIntent = Exclude<
|
||||
WorktreeSource,
|
||||
{ kind: "restore" | "restore-from-base" }
|
||||
>;
|
||||
|
||||
export interface ResolveWorktreeCreationIntentInput {
|
||||
worktreeSlug?: string;
|
||||
|
||||
+61
@@ -357,6 +357,67 @@ test("preserves ordinary checkout behavior when no separate base was recorded",
|
||||
).toMatchObject({ currentBranch: "feature", baseRef: "feature" });
|
||||
});
|
||||
|
||||
test("restores a branchless workspace from its recorded base so work can continue", async () => {
|
||||
const fixture = await createBaseRecoveryFixture("refs/heads/release");
|
||||
execFileSync("git", ["switch", "-c", "release"], { cwd: fixture.repoDir, stdio: "pipe" });
|
||||
writeFileSync(join(fixture.repoDir, "release.txt"), "release\n");
|
||||
execFileSync("git", ["add", "release.txt"], { cwd: fixture.repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "release"], { cwd: fixture.repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["switch", "main"], { cwd: fixture.repoDir, stdio: "pipe" });
|
||||
await fixture.registry.update(fixture.workspace.workspaceId, (workspace) => ({
|
||||
...workspace,
|
||||
branch: null,
|
||||
}));
|
||||
await fixture.archiveAndRemove();
|
||||
|
||||
await expect(fixture.service.inspect(fixture.workspace.workspaceId)).resolves.toMatchObject({
|
||||
kind: "recoverable",
|
||||
action: "restore",
|
||||
branch: null,
|
||||
});
|
||||
await expect(fixture.service.restore(fixture.workspace.workspaceId)).resolves.toEqual({
|
||||
workspaceId: fixture.workspace.workspaceId,
|
||||
action: "restore",
|
||||
});
|
||||
|
||||
const checkout = await getCheckoutStatus(fixture.workspace.cwd, { paseoHome: fixture.paseoHome });
|
||||
expect(checkout.currentBranch).toBeTruthy();
|
||||
expect(checkout.currentBranch).not.toBe("feature");
|
||||
expect(
|
||||
execFileSync("git", ["rev-parse", "HEAD"], {
|
||||
cwd: fixture.workspace.cwd,
|
||||
encoding: "utf8",
|
||||
}).trim(),
|
||||
).toBe(
|
||||
execFileSync("git", ["rev-parse", "release"], {
|
||||
cwd: fixture.repoDir,
|
||||
encoding: "utf8",
|
||||
}).trim(),
|
||||
);
|
||||
});
|
||||
|
||||
test("restores a branchless workspace from the repository default when its base is missing", async () => {
|
||||
const fixture = await createBaseRecoveryFixture("refs/heads/deleted-base");
|
||||
await fixture.registry.update(fixture.workspace.workspaceId, (workspace) => ({
|
||||
...workspace,
|
||||
branch: null,
|
||||
}));
|
||||
await fixture.archiveAndRemove();
|
||||
|
||||
await expect(fixture.service.restore(fixture.workspace.workspaceId)).resolves.toEqual({
|
||||
workspaceId: fixture.workspace.workspaceId,
|
||||
action: "restore",
|
||||
});
|
||||
expect(
|
||||
execFileSync("git", ["rev-parse", "HEAD"], {
|
||||
cwd: fixture.workspace.cwd,
|
||||
encoding: "utf8",
|
||||
}).trim(),
|
||||
).toBe(
|
||||
execFileSync("git", ["rev-parse", "main"], { cwd: fixture.repoDir, encoding: "utf8" }).trim(),
|
||||
);
|
||||
});
|
||||
|
||||
test("restores the branch HEAD with a base name when the exact base is missing", async () => {
|
||||
const fixture = await createBaseRecoveryFixture("refs/remotes/upstream/main");
|
||||
const headBeforeArchive = execFileSync("git", ["rev-parse", "HEAD"], {
|
||||
|
||||
+31
-17
@@ -1,5 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { basename } from "node:path";
|
||||
|
||||
import { resolveRepositoryDefaultBranch } from "../../../utils/checkout-git.js";
|
||||
import { createRealpathAwarePathMatcher } from "../../../utils/path.js";
|
||||
import { runGitCommand } from "../../../utils/run-git-command.js";
|
||||
import {
|
||||
@@ -7,6 +9,7 @@ import {
|
||||
isPaseoOwnedWorktreeCwd,
|
||||
mapWorkspaceCwdToWorktree,
|
||||
rollbackCreatedPaseoWorktree,
|
||||
type WorktreeSource,
|
||||
} from "../../../utils/worktree.js";
|
||||
import { WorktreeRequestError, toWorktreeRequestError } from "../../worktree-errors.js";
|
||||
import {
|
||||
@@ -109,15 +112,6 @@ export function createWorkspaceRecoveryService(deps: {
|
||||
message: "The archived workspace directory no longer exists and cannot be recreated.",
|
||||
};
|
||||
}
|
||||
if (!workspace.branch) {
|
||||
return {
|
||||
kind: "unavailable",
|
||||
workspaceId,
|
||||
reason: "worktree_branch_missing",
|
||||
message: "The archived worktree has no branch recorded, so it cannot be restored.",
|
||||
};
|
||||
}
|
||||
|
||||
// COMPAT(worktreeRestoreMissingMainRepoRoot): records created before v0.1.110
|
||||
// lack placement ownership; remove the project-root fallback after 2027-01-17.
|
||||
const sourceRepoRoot = workspace.mainRepoRoot ?? project.rootPath;
|
||||
@@ -157,13 +151,13 @@ export function createWorkspaceRecoveryService(deps: {
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
sourceRepoRoot: string,
|
||||
): Promise<void> {
|
||||
const branch = workspace.branch;
|
||||
if (!branch) {
|
||||
throw new WorktreeRequestError({
|
||||
code: "unknown",
|
||||
message: `Workspace ${workspace.workspaceId} has no branch to restore`,
|
||||
});
|
||||
}
|
||||
const source: WorktreeSource = workspace.branch
|
||||
? { kind: "restore", branchName: workspace.branch, baseRef: workspace.baseBranch }
|
||||
: {
|
||||
kind: "restore-from-base",
|
||||
branchName: `restored/${randomUUID().slice(0, 8)}`,
|
||||
baseRef: await resolveRecoveryBase(sourceRepoRoot, workspace.baseBranch),
|
||||
};
|
||||
|
||||
try {
|
||||
await runGitCommand(["worktree", "prune"], { cwd: sourceRepoRoot, timeout: 30_000 });
|
||||
@@ -189,7 +183,7 @@ export function createWorkspaceRecoveryService(deps: {
|
||||
const result = await createWorktree({
|
||||
cwd: sourceRepoRoot,
|
||||
worktreeSlug: basename(previousWorktreePath),
|
||||
source: { kind: "restore", branchName: branch, baseRef: workspace.baseBranch },
|
||||
source,
|
||||
runSetup: false,
|
||||
paseoHome: deps.paseoHome,
|
||||
worktreesRoot: deps.worktreesRoot,
|
||||
@@ -234,6 +228,26 @@ export function createWorkspaceRecoveryService(deps: {
|
||||
return { inspect, restore };
|
||||
}
|
||||
|
||||
async function resolveRecoveryBase(repoRoot: string, recordedBase: string | null): Promise<string> {
|
||||
if (recordedBase) {
|
||||
try {
|
||||
await runGitCommand(["rev-parse", "--verify", `${recordedBase}^{commit}`], { cwd: repoRoot });
|
||||
return recordedBase;
|
||||
} catch {
|
||||
// The saved base was removed; use the repository default for continued work.
|
||||
}
|
||||
}
|
||||
const defaultBranch = await resolveRepositoryDefaultBranch(repoRoot);
|
||||
if (!defaultBranch) {
|
||||
throw new WorktreeRequestError({
|
||||
code: "unknown",
|
||||
message:
|
||||
"The saved base is unavailable and the repository has no default branch to restore from.",
|
||||
});
|
||||
}
|
||||
return defaultBranch;
|
||||
}
|
||||
|
||||
function createRecoveryPlan(
|
||||
input:
|
||||
| { action: "unarchive"; workspace: PersistedWorkspaceRecord }
|
||||
|
||||
@@ -186,6 +186,7 @@ export type WorktreeSource =
|
||||
| { kind: "branch-off"; baseBranch: string; branchName: string }
|
||||
| { kind: "checkout-branch"; branchName: string }
|
||||
| { kind: "restore"; branchName: string; baseRef: string | null }
|
||||
| { kind: "restore-from-base"; baseRef: string; branchName: string }
|
||||
| {
|
||||
kind: "checkout-change-request";
|
||||
forge: string;
|
||||
@@ -1341,6 +1342,21 @@ async function resolveRestoredWorktreeSourcePlan(
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveRestoredWorktreeFromBasePlan(
|
||||
cwd: string,
|
||||
source: Extract<WorktreeSource, { kind: "restore-from-base" }>,
|
||||
): Promise<WorktreeSourcePlan> {
|
||||
await validateGitBranchName(cwd, source.branchName);
|
||||
const baseRef = await resolveBaseBranchForWorktree(cwd, source.baseRef);
|
||||
const branchName = await resolveUniqueLocalBranchName(cwd, source.branchName);
|
||||
return {
|
||||
branchName,
|
||||
metadataBaseRefName: normalizeRequiredBaseBranch(source.baseRef),
|
||||
metadataBaseRef: baseRef,
|
||||
addArguments: ["-b", branchName, "--no-track", baseRef],
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveBranchOffWorktreeSourcePlan(
|
||||
cwd: string,
|
||||
source: Extract<WorktreeSource, { kind: "branch-off" }>,
|
||||
@@ -1377,6 +1393,8 @@ async function resolveWorktreeSourcePlan({
|
||||
return resolveBranchOffWorktreeSourcePlan(cwd, source, desiredSlug);
|
||||
case "restore":
|
||||
return resolveRestoredWorktreeSourcePlan(cwd, source);
|
||||
case "restore-from-base":
|
||||
return resolveRestoredWorktreeFromBasePlan(cwd, source);
|
||||
case "checkout-branch": {
|
||||
await validateGitBranchName(cwd, source.branchName);
|
||||
await ensureLocalBranch(cwd, source.branchName);
|
||||
|
||||
Reference in New Issue
Block a user