mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 02:15:02 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a5cce8561 | ||
|
|
6016ed7059 | ||
|
|
8e858f0e35 | ||
|
|
fbc83613c0 | ||
|
|
c356394bfa | ||
|
|
e6085c1e98 | ||
|
|
1bf5312297 | ||
|
|
7fa78244fd |
@@ -54,6 +54,54 @@ describe("question form card core", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps checked options and appends the other answer for multi-select", () => {
|
||||
const questions = parseQuestionFormQuestions({
|
||||
questions: [
|
||||
{
|
||||
question: "Which fruits do you like?",
|
||||
header: "Fruits",
|
||||
options: [{ label: "Apple" }, { label: "Banana" }, { label: "Cherry" }],
|
||||
multiSelect: true,
|
||||
allowOther: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!questions) throw new Error("questions did not parse");
|
||||
expect(buildQuestionFormAnswers(questions, { 0: new Set([0, 2]) }, { 0: " durian " })).toEqual({
|
||||
Fruits: "Apple, Cherry, durian",
|
||||
});
|
||||
expect(buildQuestionFormAnswers(questions, { 0: new Set([0, 2]) }, {})).toEqual({
|
||||
Fruits: "Apple, Cherry",
|
||||
});
|
||||
expect(buildQuestionFormAnswers(questions, { 0: new Set() }, { 0: "durian" })).toEqual({
|
||||
Fruits: "durian",
|
||||
});
|
||||
expect(buildQuestionFormAnswers(questions, {}, { 0: "durian" })).toEqual({ Fruits: "durian" });
|
||||
});
|
||||
|
||||
test("replaces the selected option with the other answer for single-select", () => {
|
||||
const questions = parseQuestionFormQuestions({
|
||||
questions: [
|
||||
{
|
||||
question: "Which provider?",
|
||||
header: "Provider",
|
||||
options: [{ label: "Claude Code" }, { label: "Codex" }],
|
||||
multiSelect: false,
|
||||
allowOther: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!questions) throw new Error("questions did not parse");
|
||||
expect(buildQuestionFormAnswers(questions, { 0: new Set([1]) }, { 0: "OpenCode" })).toEqual({
|
||||
Provider: "OpenCode",
|
||||
});
|
||||
expect(buildQuestionFormAnswers(questions, { 0: new Set([1]) }, {})).toEqual({
|
||||
Provider: "Codex",
|
||||
});
|
||||
});
|
||||
|
||||
test("shows text input for explicit other questions", () => {
|
||||
const questions = parseQuestionFormQuestions({
|
||||
questions: [
|
||||
|
||||
@@ -111,10 +111,13 @@ export function buildQuestionFormAnswers(
|
||||
const q = questions[i];
|
||||
const selected = selections[i];
|
||||
const otherText = otherTexts[i]?.trim();
|
||||
const labels = selected ? Array.from(selected).map((idx) => q.options[idx].label) : [];
|
||||
|
||||
if (questionShowsTextInput(q)) {
|
||||
if (otherText && otherText.length > 0) {
|
||||
answers[q.header] = otherText;
|
||||
// Multi-select keeps the checked options and appends the custom answer, the way
|
||||
// Claude Code's own AskUserQuestion UI does. Single-select replaces the option.
|
||||
answers[q.header] = q.multiSelect ? [...labels, otherText].join(", ") : otherText;
|
||||
continue;
|
||||
}
|
||||
if (q.allowEmpty && q.options.length === 0) {
|
||||
@@ -123,8 +126,7 @@ export function buildQuestionFormAnswers(
|
||||
}
|
||||
}
|
||||
|
||||
if (selected && selected.size > 0) {
|
||||
const labels = Array.from(selected).map((idx) => q.options[idx].label);
|
||||
if (labels.length > 0) {
|
||||
answers[q.header] = labels.join(", ");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { within } from "@testing-library/dom";
|
||||
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n as testI18n } from "@/i18n/i18next";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import { QuestionFormCard } from "./question-form-card";
|
||||
|
||||
// Load translations so controls expose their real accessible names.
|
||||
void testI18n;
|
||||
|
||||
// App sources compile against the classic JSX runtime, which expects React on the global.
|
||||
beforeEach(() => vi.stubGlobal("React", React));
|
||||
|
||||
/**
|
||||
* A real browser with the real web `EditingTextInput`, because the bug under test lives in the
|
||||
* gap between that input and React state: the input owns its text and never replays state, so a
|
||||
* card that drops the Other text from state alone keeps showing it while submit ignores it.
|
||||
*/
|
||||
|
||||
interface Mounted {
|
||||
root: Root;
|
||||
container: HTMLDivElement;
|
||||
}
|
||||
|
||||
const mounted: Mounted[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const entry of mounted.splice(0)) {
|
||||
act(() => entry.root.unmount());
|
||||
entry.container.remove();
|
||||
}
|
||||
});
|
||||
|
||||
function buildPermission(question: Record<string, unknown>): PendingPermission {
|
||||
return {
|
||||
key: "perm-1",
|
||||
agentId: "agent-1",
|
||||
request: {
|
||||
id: "perm-1",
|
||||
provider: "claude",
|
||||
name: "AskUserQuestion",
|
||||
kind: "question",
|
||||
input: { questions: [question] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mountCard(question: Record<string, unknown>) {
|
||||
const onRespond = vi.fn<(response: AgentPermissionResponse) => void>();
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
act(() =>
|
||||
root.render(
|
||||
<QuestionFormCard
|
||||
permission={buildPermission(question)}
|
||||
onRespond={onRespond}
|
||||
isResponding={false}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
mounted.push({ root, container });
|
||||
|
||||
const view = within(container);
|
||||
const optionRole = question.multiSelect ? "checkbox" : "radio";
|
||||
const otherInput = () =>
|
||||
view.getByRole<HTMLInputElement>("textbox", { name: String(question.question) });
|
||||
const check = (label: string) => act(() => view.getByRole(optionRole, { name: label }).click());
|
||||
const type = (text: string) => {
|
||||
const input = otherInput();
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
if (!valueSetter) throw new Error("HTML input value setter is unavailable");
|
||||
act(() => {
|
||||
valueSetter.call(input, text);
|
||||
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: text }));
|
||||
});
|
||||
};
|
||||
const submit = () => act(() => view.getByRole("button", { name: "Submit" }).click());
|
||||
const submittedAnswers = (): Record<string, string> => {
|
||||
const response = onRespond.mock.calls[0]?.[0];
|
||||
if (!response || response.behavior !== "allow") throw new Error("card did not submit");
|
||||
return (response.updatedInput as { answers: Record<string, string> }).answers;
|
||||
};
|
||||
return { check, type, otherInput, submit, submittedAnswers };
|
||||
}
|
||||
|
||||
const multiSelectQuestion = {
|
||||
question: "Which fruits do you like?",
|
||||
header: "Fruits",
|
||||
options: [{ label: "Apple" }, { label: "Banana" }, { label: "Cherry" }],
|
||||
multiSelect: true,
|
||||
allowOther: true,
|
||||
};
|
||||
|
||||
const singleSelectQuestion = {
|
||||
question: "Which provider?",
|
||||
header: "Provider",
|
||||
options: [{ label: "Claude Code" }, { label: "Codex" }],
|
||||
multiSelect: false,
|
||||
allowOther: true,
|
||||
};
|
||||
|
||||
describe("QuestionFormCard other answers", () => {
|
||||
it("keeps checked options when the other answer is typed afterwards (multi-select)", () => {
|
||||
const card = mountCard(multiSelectQuestion);
|
||||
|
||||
card.check("Apple");
|
||||
card.check("Cherry");
|
||||
card.type("durian");
|
||||
card.submit();
|
||||
|
||||
expect(card.submittedAnswers()).toEqual({ Fruits: "Apple, Cherry, durian" });
|
||||
});
|
||||
|
||||
it("keeps the typed other answer when options are checked afterwards (multi-select)", () => {
|
||||
const card = mountCard(multiSelectQuestion);
|
||||
|
||||
card.type("durian");
|
||||
card.check("Apple");
|
||||
card.check("Banana");
|
||||
|
||||
expect(card.otherInput().value).toBe("durian");
|
||||
card.submit();
|
||||
expect(card.submittedAnswers()).toEqual({ Fruits: "Apple, Banana, durian" });
|
||||
});
|
||||
|
||||
it("replaces the selected option with the typed other answer (single-select)", () => {
|
||||
const card = mountCard(singleSelectQuestion);
|
||||
|
||||
card.check("Codex");
|
||||
card.type("OpenCode");
|
||||
card.submit();
|
||||
|
||||
expect(card.submittedAnswers()).toEqual({ Provider: "OpenCode" });
|
||||
});
|
||||
|
||||
it("clears the typed other answer on screen when an option is picked afterwards (single-select)", () => {
|
||||
const card = mountCard(singleSelectQuestion);
|
||||
|
||||
card.type("OpenCode");
|
||||
card.check("Codex");
|
||||
|
||||
expect(card.otherInput().value).toBe("");
|
||||
card.submit();
|
||||
expect(card.submittedAnswers()).toEqual({ Provider: "Codex" });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useState, useCallback, useMemo, useRef, type RefObject } from "react";
|
||||
import { View, Text, Pressable, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -9,6 +9,7 @@ import type { PendingPermission } from "@/types/shared";
|
||||
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input/types";
|
||||
import {
|
||||
areQuestionsAnswered,
|
||||
buildQuestionFormAnswers,
|
||||
@@ -258,6 +259,7 @@ function QuestionNav({
|
||||
|
||||
interface QuestionOtherInputProps {
|
||||
qIndex: number;
|
||||
inputRef: RefObject<EditingTextInputHandle | null>;
|
||||
accessibilityLabel: string;
|
||||
value: string;
|
||||
placeholder: string;
|
||||
@@ -268,6 +270,7 @@ interface QuestionOtherInputProps {
|
||||
|
||||
function QuestionOtherInput({
|
||||
qIndex,
|
||||
inputRef,
|
||||
accessibilityLabel,
|
||||
value,
|
||||
placeholder,
|
||||
@@ -303,6 +306,7 @@ function QuestionOtherInput({
|
||||
);
|
||||
return (
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={otherInputStyle}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
@@ -328,6 +332,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
|
||||
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
|
||||
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
|
||||
const otherInputRef = useRef<EditingTextInputHandle | null>(null);
|
||||
const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null);
|
||||
const [activeQuestionIndex, setActiveQuestionIndex] = useState(0);
|
||||
|
||||
@@ -349,29 +354,40 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
}
|
||||
|
||||
setSelections((prev) => ({ ...prev, [qIndex]: next }));
|
||||
setOtherTexts((prev) => {
|
||||
if (!prev[qIndex]) return prev;
|
||||
const nextTexts = { ...prev };
|
||||
delete nextTexts[qIndex];
|
||||
return nextTexts;
|
||||
});
|
||||
|
||||
// Single-select: an option and a custom answer replace each other, as in Claude Code.
|
||||
// Multi-select keeps both. The editing surface owns its text and never replays state
|
||||
// (docs/forms.md), so clearing state alone would leave stale text on screen that
|
||||
// submit ignores; clear the surface explicitly.
|
||||
if (!multiSelect && otherTexts[qIndex]) {
|
||||
setOtherTexts((prev) => {
|
||||
const nextTexts = { ...prev };
|
||||
delete nextTexts[qIndex];
|
||||
return nextTexts;
|
||||
});
|
||||
otherInputRef.current?.replaceText("");
|
||||
}
|
||||
|
||||
if (!multiSelect && next.size > 0 && qIndex === activeQuestionIndex && questions) {
|
||||
setActiveQuestionIndex(Math.min(qIndex + 1, questions.length - 1));
|
||||
}
|
||||
},
|
||||
[activeQuestionIndex, questions, selections],
|
||||
[activeQuestionIndex, otherTexts, questions, selections],
|
||||
);
|
||||
|
||||
const setOtherText = useCallback((qIndex: number, text: string) => {
|
||||
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
|
||||
if (text.length > 0) {
|
||||
setSelections((prev) => {
|
||||
if (!prev[qIndex] || prev[qIndex].size === 0) return prev;
|
||||
return { ...prev, [qIndex]: new Set<number>() };
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
const setOtherText = useCallback(
|
||||
(qIndex: number, text: string) => {
|
||||
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
|
||||
const multiSelect = questions?.[qIndex]?.multiSelect ?? false;
|
||||
if (!multiSelect && text.length > 0) {
|
||||
setSelections((prev) => {
|
||||
if (!prev[qIndex] || prev[qIndex].size === 0) return prev;
|
||||
return { ...prev, [qIndex]: new Set<number>() };
|
||||
});
|
||||
}
|
||||
},
|
||||
[questions],
|
||||
);
|
||||
|
||||
const allAnswered = areQuestionsAnswered(questions, selections, otherTexts);
|
||||
const resolvedActiveQuestionIndex = questions
|
||||
@@ -554,6 +570,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
{showTextInput ? (
|
||||
<QuestionOtherInput
|
||||
qIndex={resolvedActiveQuestionIndex}
|
||||
inputRef={otherInputRef}
|
||||
accessibilityLabel={activeQuestion.question}
|
||||
value={otherText}
|
||||
placeholder={getQuestionInputPlaceholder({
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildForgeBlobUrl, buildForgeBranchTreeUrl } from "./forge-url";
|
||||
|
||||
// Repro for https://github.com/getpaseo/paseo/issues/4641: a Gitea instance whose
|
||||
// SSH host (git.example.com) differs from the web URL configured in the tea
|
||||
// login (https://projects.example.com). The source links must use the web host.
|
||||
describe("#4641 Gitea source links with a split SSH/web host", () => {
|
||||
const remoteUrl = "ssh://git@git.example.com:7998/Acme/main.git";
|
||||
|
||||
it("links the branch tree on the tea login's web host", () => {
|
||||
expect(buildForgeBranchTreeUrl("gitea", { remoteUrl, branch: "main" })).toBe(
|
||||
"https://projects.example.com/Acme/main/src/branch/main",
|
||||
);
|
||||
});
|
||||
|
||||
it("links a file on the tea login's web host", () => {
|
||||
expect(buildForgeBlobUrl("gitea", { remoteUrl, branch: "main", path: "README.md" })).toBe(
|
||||
"https://projects.example.com/Acme/main/src/branch/main/README.md",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,7 @@ export interface PermissionResponseItem {
|
||||
export const permitResponseSchema: OutputSchema<PermissionResponseItem> = {
|
||||
idField: "requestId",
|
||||
columns: [
|
||||
{ header: "REQUEST ID", field: "requestId", width: 12 },
|
||||
{ header: "REQUEST ID", field: (item) => item.requestId.slice(0, 8), width: 12 },
|
||||
{ header: "AGENT", field: "agentShortId", width: 10 },
|
||||
{ header: "TOOL", field: "name", width: 20 },
|
||||
{
|
||||
@@ -32,6 +32,21 @@ export const permitResponseSchema: OutputSchema<PermissionResponseItem> = {
|
||||
],
|
||||
};
|
||||
|
||||
/** Transform a permission the agent answered to a response item */
|
||||
export function toPermissionResponseItem(
|
||||
agentId: string,
|
||||
permission: AgentPermissionRequest,
|
||||
result: "allowed" | "denied",
|
||||
): PermissionResponseItem {
|
||||
return {
|
||||
requestId: permission.id,
|
||||
agentId,
|
||||
agentShortId: agentId.slice(0, 7),
|
||||
name: permission.name,
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
export type PermitAllowResult = ListResult<PermissionResponseItem>;
|
||||
|
||||
export interface PermitAllowOptions extends CommandOptions {
|
||||
@@ -118,13 +133,7 @@ export async function runAllowCommand(
|
||||
behavior: "allow",
|
||||
...(updatedInput ? { updatedInput } : {}),
|
||||
});
|
||||
return {
|
||||
requestId: permission.id.slice(0, 8),
|
||||
agentId: resolvedAgentId,
|
||||
agentShortId: resolvedAgentId.slice(0, 7),
|
||||
name: permission.name,
|
||||
result: "allowed",
|
||||
};
|
||||
return toPermissionResponseItem(resolvedAgentId, permission, "allowed");
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { Command } from "commander";
|
||||
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type { CommandOptions, ListResult, CommandError } from "../../output/index.js";
|
||||
import { permitResponseSchema, type PermissionResponseItem } from "./allow.js";
|
||||
import {
|
||||
permitResponseSchema,
|
||||
toPermissionResponseItem,
|
||||
type PermissionResponseItem,
|
||||
} from "./allow.js";
|
||||
|
||||
export type PermitDenyResult = ListResult<PermissionResponseItem>;
|
||||
|
||||
@@ -83,13 +87,7 @@ export async function runDenyCommand(
|
||||
...(options.message ? { message: options.message } : {}),
|
||||
...(options.interrupt ? { interrupt: true } : {}),
|
||||
});
|
||||
return {
|
||||
requestId: permission.id.slice(0, 8),
|
||||
agentId: resolvedAgentId,
|
||||
agentShortId: resolvedAgentId.slice(0, 7),
|
||||
name: permission.name,
|
||||
result: "denied",
|
||||
};
|
||||
return toPermissionResponseItem(resolvedAgentId, permission, "denied");
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ export const permitLsSchema: OutputSchema<PermissionListItem> = {
|
||||
idField: "id",
|
||||
columns: [
|
||||
{ header: "AGENT", field: "agentShortId", width: 12 },
|
||||
{ header: "REQ_ID", field: "id", width: 12 },
|
||||
{ header: "REQ_ID", field: (item) => item.id.slice(0, 8), width: 12 },
|
||||
{ header: "TOOL", field: "name", width: 20 },
|
||||
{ header: "DESCRIPTION", field: "description", width: 50 },
|
||||
],
|
||||
@@ -30,7 +30,7 @@ function toListItem(
|
||||
permission: AgentPermissionRequest,
|
||||
): PermissionListItem {
|
||||
return {
|
||||
id: permission.id.slice(0, 8),
|
||||
id: permission.id,
|
||||
agentId: agent.id,
|
||||
agentShortId: agent.id.slice(0, 7),
|
||||
name: permission.name,
|
||||
@@ -40,6 +40,22 @@ function toListItem(
|
||||
|
||||
export type PermitLsResult = ListResult<PermissionListItem>;
|
||||
|
||||
/** Collect the pending permissions of every agent */
|
||||
export function listPendingPermissions(agents: AgentSnapshotPayload[]): PermitLsResult {
|
||||
const items: PermissionListItem[] = [];
|
||||
for (const agent of agents) {
|
||||
for (const permission of agent.pendingPermissions ?? []) {
|
||||
items.push(toListItem(agent, permission));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: "list",
|
||||
data: items,
|
||||
schema: permitLsSchema,
|
||||
};
|
||||
}
|
||||
|
||||
export interface PermitLsOptions extends CommandOptions {
|
||||
host?: string;
|
||||
}
|
||||
@@ -55,21 +71,7 @@ export async function runLsCommand(
|
||||
const agents = agentsPayload.entries.map((entry) => entry.agent);
|
||||
await client.close();
|
||||
|
||||
// Collect all pending permissions from all agents
|
||||
const items: PermissionListItem[] = [];
|
||||
for (const agent of agents) {
|
||||
if (agent.pendingPermissions && agent.pendingPermissions.length > 0) {
|
||||
for (const permission of agent.pendingPermissions) {
|
||||
items.push(toListItem(agent, permission));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: "list",
|
||||
data: items,
|
||||
schema: permitLsSchema,
|
||||
};
|
||||
return listPendingPermissions(agents);
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {});
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render } from "../../output/index.js";
|
||||
import { permitResponseSchema, toPermissionResponseItem } from "./allow.js";
|
||||
import { listPendingPermissions } from "./ls.js";
|
||||
|
||||
const agentId = "2d85f428-fdaf-4d98-906f-c583de2b805f";
|
||||
|
||||
function permissionRequest(id: string): AgentPermissionRequest {
|
||||
return { id, provider: "claude", name: "Write", kind: "tool" };
|
||||
}
|
||||
|
||||
const writeRequest = permissionRequest("permission-cc93510e-a45d-4c4e-a0bf-fc2821f82783");
|
||||
const editRequest = permissionRequest("permission-654ff862-b312-4f6e-8aac-427421ff65eb");
|
||||
|
||||
const agent = {
|
||||
id: agentId,
|
||||
pendingPermissions: [writeRequest, editRequest],
|
||||
} as AgentSnapshotPayload;
|
||||
|
||||
describe("permit ls output", () => {
|
||||
it("emits full request ids that permit allow accepts", () => {
|
||||
const result = listPendingPermissions([agent]);
|
||||
|
||||
const listed = JSON.parse(render(result, { format: "json" })) as Array<{ id: string }>;
|
||||
expect(listed.map((item) => item.id)).toEqual([writeRequest.id, editRequest.id]);
|
||||
expect(render(result, { quiet: true }).split("\n")).toEqual([writeRequest.id, editRequest.id]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("permit allow and deny output", () => {
|
||||
it("emits the full id of the request that was answered", () => {
|
||||
const result = {
|
||||
type: "list" as const,
|
||||
data: [toPermissionResponseItem(agentId, writeRequest, "allowed")],
|
||||
schema: permitResponseSchema,
|
||||
};
|
||||
|
||||
const answered = JSON.parse(render(result, { format: "json" })) as Array<{
|
||||
requestId: string;
|
||||
}>;
|
||||
expect(answered.map((item) => item.requestId)).toEqual([writeRequest.id]);
|
||||
});
|
||||
});
|
||||
@@ -52,13 +52,20 @@ export function buildDaemonConnectionCommandError(options: ConnectOptions & { er
|
||||
return {
|
||||
code,
|
||||
message: `Cannot connect to daemon at ${describeDaemonTarget(options.target)}: ${message}`,
|
||||
details:
|
||||
options.target.kind === "instance"
|
||||
? `Start with: paseo daemon start --home ${JSON.stringify(options.target.home)}`
|
||||
: "Check the selected endpoint and credentials. SSH transport does not install or start the daemon.",
|
||||
details: describeConnectionRemedy(code, options.target),
|
||||
};
|
||||
}
|
||||
|
||||
function describeConnectionRemedy(code: string, target: DaemonTarget): string {
|
||||
if (code === "AUTH_REQUIRED")
|
||||
return "The daemon requires a password. Set PASEO_PASSWORD and retry.";
|
||||
if (code === "AUTH_FAILED")
|
||||
return "The daemon rejected the password. Check PASEO_PASSWORD and retry.";
|
||||
if (target.kind === "instance")
|
||||
return `Start with: paseo daemon start --home ${JSON.stringify(target.home)}`;
|
||||
return "Check the selected endpoint and credentials. SSH transport does not install or start the daemon.";
|
||||
}
|
||||
|
||||
export function normalizeDaemonHost(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import assert from "node:assert";
|
||||
import { runLocalPaseo } from "./helpers/local-cli.ts";
|
||||
import { startTestDaemon } from "./helpers/test-daemon.ts";
|
||||
|
||||
console.log("=== Daemon Auth Command Errors ===\n");
|
||||
|
||||
const daemon = await startTestDaemon({
|
||||
env: { PASEO_PASSWORD: "shared-secret" },
|
||||
});
|
||||
|
||||
async function lsError(password: string) {
|
||||
const result = await runLocalPaseo(["ls", "--json"], {
|
||||
PASEO_HOME: daemon.paseoHome,
|
||||
PASEO_HOST: "",
|
||||
PASEO_PASSWORD: password,
|
||||
});
|
||||
assert.notStrictEqual(result.exitCode, 0, "ls should fail without a valid password");
|
||||
return JSON.parse(result.stderr).error as {
|
||||
code: string;
|
||||
message: string;
|
||||
details: string;
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
{
|
||||
console.log("Test 1: missing password asks for PASEO_PASSWORD, not a daemon start");
|
||||
const error = await lsError("");
|
||||
assert.strictEqual(error.code, "AUTH_REQUIRED");
|
||||
assert.match(error.message, /Password required/);
|
||||
assert.match(error.details, /PASEO_PASSWORD/);
|
||||
assert.doesNotMatch(error.details, /daemon start/);
|
||||
console.log("✓ missing password points at PASEO_PASSWORD\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 2: wrong password asks for PASEO_PASSWORD, not a daemon start");
|
||||
const error = await lsError("wrong-secret");
|
||||
assert.strictEqual(error.code, "AUTH_FAILED");
|
||||
assert.match(error.message, /Incorrect password/);
|
||||
assert.match(error.details, /PASEO_PASSWORD/);
|
||||
assert.doesNotMatch(error.details, /daemon start/);
|
||||
console.log("✓ wrong password points at PASEO_PASSWORD\n");
|
||||
}
|
||||
} finally {
|
||||
await daemon.stop();
|
||||
}
|
||||
|
||||
console.log("=== Daemon Auth Command Errors Tests Passed ===");
|
||||
@@ -89,6 +89,75 @@ async function createMcpClient(url: string, authToken?: string): Promise<McpClie
|
||||
return { callTool: boundCallTool, close: () => rawClient.close() };
|
||||
}
|
||||
|
||||
interface OfflineMcpDaemon {
|
||||
client: McpClient;
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
async function startOfflineMcpDaemon(): Promise<OfflineMcpDaemon> {
|
||||
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-"));
|
||||
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
||||
const removeDirectories = async () => {
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
await rm(staticDir, { recursive: true, force: true });
|
||||
};
|
||||
const port = await getAvailablePort();
|
||||
const daemon = await createPaseoDaemon(
|
||||
{
|
||||
listen: `127.0.0.1:${port}`,
|
||||
paseoHome,
|
||||
corsAllowedOrigins: [],
|
||||
hostnames: true,
|
||||
mcpEnabled: true,
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
agentClients: createTestAgentClients(),
|
||||
agentStoragePath: path.join(paseoHome, "agents"),
|
||||
},
|
||||
pino({ level: "silent" }),
|
||||
).catch(async (error: unknown) => {
|
||||
await removeDirectories();
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
await daemon.start();
|
||||
const client = await createMcpClient(`http://127.0.0.1:${port}/mcp/agents`);
|
||||
return {
|
||||
client,
|
||||
stop: async () => {
|
||||
await client.close();
|
||||
await daemon.stop();
|
||||
await removeDirectories();
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await daemon.stop();
|
||||
await removeDirectories();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
type WorkspaceCreation = { workspaceId: unknown } | { error: unknown };
|
||||
|
||||
async function createLocalWorkspace(client: McpClient, cwd: string): Promise<WorkspaceCreation> {
|
||||
const result = await client.callTool({
|
||||
name: "create_workspace",
|
||||
args: { isolation: "local", path: cwd },
|
||||
});
|
||||
if (result.isError) {
|
||||
const content = result.content?.[0];
|
||||
return { error: content && "text" in content ? content.text : undefined };
|
||||
}
|
||||
return { workspaceId: getStructuredContent(result)?.workspaceId };
|
||||
}
|
||||
|
||||
async function listWorkspaces(client: McpClient): Promise<WorkspaceCreation[]> {
|
||||
const result = await client.callTool({ name: "list_workspaces", args: {} });
|
||||
const workspaces = getStructuredContent(result)?.workspaces;
|
||||
if (!Array.isArray(workspaces)) return [];
|
||||
return workspaces.map((workspace: StructuredContent) => ({ workspaceId: workspace.workspaceId }));
|
||||
}
|
||||
|
||||
interface LaunchRecorder {
|
||||
recordedLaunches: AgentSessionConfig[];
|
||||
}
|
||||
@@ -235,6 +304,27 @@ describe("agent MCP end-to-end (offline)", () => {
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test("create_workspace with local isolation adopts only an existing directory", async () => {
|
||||
const daemon = await startOfflineMcpDaemon();
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-local-workspace-"));
|
||||
const missingPath = path.join(root, "does-not-exist");
|
||||
const filePath = path.join(root, "regular-file");
|
||||
await writeFile(filePath, "not a directory\n", "utf8");
|
||||
try {
|
||||
expect(await createLocalWorkspace(daemon.client, missingPath)).toEqual({
|
||||
error: expect.stringContaining(`Directory not found: ${missingPath}`),
|
||||
});
|
||||
expect(await createLocalWorkspace(daemon.client, filePath)).toEqual({
|
||||
error: expect.stringContaining(`Directory not found: ${filePath}`),
|
||||
});
|
||||
const created = await createLocalWorkspace(daemon.client, root);
|
||||
expect(await listWorkspaces(daemon.client)).toEqual([created]);
|
||||
} finally {
|
||||
await daemon.stop();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test("password-protected daemon authorizes the agent MCP via the capability token", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-"));
|
||||
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import { z } from "zod";
|
||||
import { ensureValidJson } from "../../json-utils.js";
|
||||
import type { Logger } from "pino";
|
||||
@@ -223,6 +224,20 @@ function assertOptionsAbsent(
|
||||
}
|
||||
}
|
||||
|
||||
async function isExistingDirectory(path: string): Promise<boolean> {
|
||||
try {
|
||||
return (await stat(path)).isDirectory();
|
||||
} catch (error) {
|
||||
if (isMissingPathError(error)) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingPathError(error: unknown): boolean {
|
||||
if (typeof error !== "object" || error === null || !("code" in error)) return false;
|
||||
return error.code === "ENOENT" || error.code === "ENOTDIR";
|
||||
}
|
||||
|
||||
function resolveWorkspaceWorktreeTarget(input: WorkspaceWorktreeOptions): WorkspaceWorktreeTarget {
|
||||
switch (input.mode ?? "branch-off") {
|
||||
case "branch-off":
|
||||
@@ -1225,7 +1240,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
||||
path: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Local directory or source checkout. Defaults to your current workspace."),
|
||||
.describe(
|
||||
"Local directory or source checkout. Defaults to your current workspace. Local isolation adopts an existing directory and never creates one.",
|
||||
),
|
||||
projectId: z.string().optional().describe("Existing project id to own the workspace."),
|
||||
title: z.string().trim().min(1).optional(),
|
||||
mode: z
|
||||
@@ -1277,6 +1294,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
||||
let workspace: PersistedWorkspaceRecord;
|
||||
if (isolation === "local") {
|
||||
const cwd = resolveScopedCwd(path, { required: true });
|
||||
if (!(await isExistingDirectory(cwd))) {
|
||||
throw new Error(`Directory not found: ${cwd}`);
|
||||
}
|
||||
assertOptionsAbsent(
|
||||
[
|
||||
["mode", mode],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { existsSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
@@ -56,6 +56,49 @@ describe("file uploads", () => {
|
||||
expect(readFileSync(path, "utf8")).toBe("hello world");
|
||||
});
|
||||
|
||||
it("keeps the original file name for non-ASCII and punctuated names", async () => {
|
||||
const uploads = new FileUploadStore({ paseoHome: makePaseoHome() });
|
||||
|
||||
for (const fileName of [
|
||||
"2026年9月绩效计划表.xlsx",
|
||||
"테스트 파일 (1).xlsx",
|
||||
"résumé [final] & notes, v2.pdf",
|
||||
"cafe\u0301 हिंदी.txt",
|
||||
]) {
|
||||
const file = await uploadNamed(uploads, fileName);
|
||||
expect(file?.fileName).toBe(fileName);
|
||||
expect(basename(file!.path)).toBe(fileName);
|
||||
expect(readFileSync(file!.path, "utf8")).toBe("hello world");
|
||||
}
|
||||
});
|
||||
|
||||
it("replaces path separators, control characters, and characters Windows rejects", async () => {
|
||||
const uploads = new FileUploadStore({ paseoHome: makePaseoHome() });
|
||||
|
||||
await expect(uploadNamed(uploads, "../../etc/passwd")).resolves.toMatchObject({
|
||||
fileName: "passwd",
|
||||
});
|
||||
const backslashed = await uploadNamed(uploads, "dir\\name.txt");
|
||||
expect(backslashed?.fileName).not.toContain("\\");
|
||||
expect(backslashed?.fileName).toMatch(/name\.txt$/);
|
||||
await expect(uploadNamed(uploads, 'a<b>:"c|?*.txt')).resolves.toMatchObject({
|
||||
fileName: "a_b___c___.txt",
|
||||
});
|
||||
await expect(uploadNamed(uploads, "line\nbreak.txt")).resolves.toMatchObject({
|
||||
fileName: "line_break.txt",
|
||||
});
|
||||
});
|
||||
|
||||
it("shortens a long non-ASCII name to the file system limit and keeps its extension", async () => {
|
||||
const uploads = new FileUploadStore({ paseoHome: makePaseoHome() });
|
||||
|
||||
const file = await uploadNamed(uploads, `${"绩".repeat(100)}.xlsx`);
|
||||
|
||||
expect(file?.fileName).toBe(`${"绩".repeat(83)}.xlsx`);
|
||||
expect(Buffer.byteLength(file!.fileName)).toBeLessThanOrEqual(255);
|
||||
expect(readFileSync(file!.path, "utf8")).toBe("hello world");
|
||||
});
|
||||
|
||||
it("rejects chunks beyond the declared size and removes the partial file", async () => {
|
||||
const paseoHome = makePaseoHome();
|
||||
const uploads = new FileUploadStore({ paseoHome });
|
||||
@@ -202,6 +245,25 @@ describe("file uploads", () => {
|
||||
});
|
||||
});
|
||||
|
||||
let uploadCount = 0;
|
||||
|
||||
async function uploadNamed(uploads: FileUploadStore, fileName: string) {
|
||||
const requestId = `req-named-${uploadCount++}`;
|
||||
uploads.beginUpload({
|
||||
type: "file.upload.request",
|
||||
fileName,
|
||||
mimeType: "text/plain",
|
||||
size: 11,
|
||||
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||
requestId,
|
||||
});
|
||||
await uploads.receiveFrame(uploadBegins(requestId));
|
||||
await uploads.receiveFrame(uploadChunk(requestId, "hello world"));
|
||||
const response = await uploads.receiveFrame(uploadEnds(requestId));
|
||||
expect(response?.payload.error).toBeNull();
|
||||
return response?.payload.file;
|
||||
}
|
||||
|
||||
function makePaseoHome(): string {
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), "file-upload-test-")));
|
||||
tempDirs.push(root);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { appendFile, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { basename, join } from "node:path";
|
||||
import { basename, extname, join } from "node:path";
|
||||
|
||||
import { FileTransferOpcode, type FileTransferFrame } from "@getpaseo/protocol/binary-frames/index";
|
||||
import { getErrorMessage } from "@getpaseo/protocol/error-utils";
|
||||
@@ -220,9 +220,26 @@ function buildUploadResponse(upload: PendingUpload, error: string | null): FileU
|
||||
};
|
||||
}
|
||||
|
||||
// Most file systems cap a single file name at 255 bytes.
|
||||
const MAX_FILE_NAME_BYTES = 255;
|
||||
|
||||
// Keeps the client's file name, replacing only what cannot appear in a single
|
||||
// file name on Linux, macOS, or Windows.
|
||||
function sanitizeFileName(value: string): string {
|
||||
const name = basename(value)
|
||||
.replace(/[^a-zA-Z0-9._ -]/g, "_")
|
||||
.replace(/[\p{Cc}\\/:*?"<>|]/gu, "_")
|
||||
.trim();
|
||||
return name.length > 0 && name !== "." && name !== ".." ? name : "upload";
|
||||
return fitFileNameLength(name.length > 0 && name !== "." && name !== ".." ? name : "upload");
|
||||
}
|
||||
|
||||
function fitFileNameLength(name: string): string {
|
||||
if (Buffer.byteLength(name) <= MAX_FILE_NAME_BYTES) return name;
|
||||
const extension = extname(name);
|
||||
const keptExtension = Buffer.byteLength(extension) < MAX_FILE_NAME_BYTES ? extension : "";
|
||||
let stem = "";
|
||||
for (const char of name.slice(0, name.length - keptExtension.length)) {
|
||||
if (Buffer.byteLength(stem + char + keptExtension) > MAX_FILE_NAME_BYTES) break;
|
||||
stem += char;
|
||||
}
|
||||
return stem + keptExtension;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
loadPersistedConfig,
|
||||
PersistedConfigSchema,
|
||||
readPersistedConfig,
|
||||
savePersistedConfig,
|
||||
} from "./persisted-config.js";
|
||||
import { PRIVATE_DIRECTORY_MODE, PRIVATE_FILE_MODE } from "./private-files.js";
|
||||
@@ -736,6 +737,34 @@ describe("loadPersistedConfig", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("config.json saved with a UTF-8 byte order mark", () => {
|
||||
// Windows Notepad writes this shape: a BOM, then CRLF line endings.
|
||||
const notepadConfig =
|
||||
'{\r\n "version": 1,\r\n "daemon": { "listen": "127.0.0.1:6767" }\r\n}\r\n';
|
||||
|
||||
test("loadPersistedConfig reads it", () => {
|
||||
const home = createTempHome();
|
||||
try {
|
||||
writeFileSync(path.join(home, "config.json"), notepadConfig);
|
||||
|
||||
expect(loadPersistedConfig(home).daemon?.listen).toBe("127.0.0.1:6767");
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("readPersistedConfig reads it", () => {
|
||||
const home = createTempHome();
|
||||
try {
|
||||
writeFileSync(path.join(home, "config.json"), notepadConfig);
|
||||
|
||||
expect(readPersistedConfig(home).daemon?.listen).toBe("127.0.0.1:6767");
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === "win32")("persisted config file permissions", () => {
|
||||
test("initializes config.json with private permissions", () => {
|
||||
const home = createTempHome();
|
||||
|
||||
@@ -438,7 +438,7 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
parsed = parseConfigText(raw);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`[Config] Invalid JSON in ${configPath}: ${message}`, {
|
||||
@@ -472,7 +472,14 @@ export function readPersistedConfig(
|
||||
return options.defaultsIfMissing ? structuredClone(DEFAULT_PERSISTED_CONFIG) : {};
|
||||
throw error;
|
||||
}
|
||||
return PersistedConfigSchema.parse(stripRemovedConfigFields(JSON.parse(raw))) as PersistedConfig;
|
||||
return PersistedConfigSchema.parse(
|
||||
stripRemovedConfigFields(parseConfigText(raw)),
|
||||
) as PersistedConfig;
|
||||
}
|
||||
|
||||
/** Editors such as Windows Notepad save UTF-8 with a byte order mark, which JSON.parse rejects. */
|
||||
function parseConfigText(raw: string): unknown {
|
||||
return JSON.parse(raw.replace(/^\uFEFF/, ""));
|
||||
}
|
||||
|
||||
function configPathParts(field: string): string[] {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, open, rm, stat, utimes, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, open, readFile, rm, stat, utimes, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
@@ -256,4 +256,38 @@ describe("pid-lock ownership", () => {
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("starts over an empty lock file left by a supervisor killed before writing it", async () => {
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-empty-"));
|
||||
const ownerPid = process.pid + 10_000;
|
||||
|
||||
try {
|
||||
await writeFile(join(paseoHome, "paseo.pid"), "");
|
||||
|
||||
await expect(getPidLockInfo(paseoHome)).resolves.toBeNull();
|
||||
await acquirePidLock(paseoHome, null, { ownerPid });
|
||||
|
||||
const lock = await getPidLockInfo(paseoHome);
|
||||
expect(lock?.pid).toBe(ownerPid);
|
||||
} finally {
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps a lock file whose contents cannot be read as a lock", async () => {
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-unparseable-"));
|
||||
const pidPath = join(paseoHome, "paseo.pid");
|
||||
|
||||
try {
|
||||
await writeFile(pidPath, JSON.stringify({ pid: "unknown" }));
|
||||
|
||||
await expect(
|
||||
acquirePidLock(paseoHome, null, { ownerPid: process.pid + 10_000 }),
|
||||
).rejects.toThrow("Cannot read daemon state");
|
||||
|
||||
await expect(readFile(pidPath, "utf-8")).resolves.toBe(JSON.stringify({ pid: "unknown" }));
|
||||
} finally {
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { open, readFile, unlink, utimes } from "node:fs/promises";
|
||||
import { open, readFile, stat, unlink, utimes } from "node:fs/promises";
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
import { ensurePrivateDirectory } from "./private-files.js";
|
||||
import { join } from "node:path";
|
||||
@@ -84,18 +84,26 @@ async function touchPidLockFile(pidPath: string): Promise<void> {
|
||||
|
||||
async function readPidLock(pidPath: string): Promise<PidLockInfo | null> {
|
||||
let lastError: unknown;
|
||||
let empty = false;
|
||||
for (let attempt = 0; attempt < PID_LOCK_READ_RETRY_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
const content = await readFile(pidPath, "utf-8");
|
||||
const lock = parsePidLockInfo(JSON.parse(content));
|
||||
if (lock) return lock;
|
||||
lastError = new Error("Invalid lock shape");
|
||||
empty = content === "";
|
||||
if (!empty) {
|
||||
const lock = parsePidLockInfo(JSON.parse(content));
|
||||
if (lock) return lock;
|
||||
lastError = new Error("Invalid lock shape");
|
||||
}
|
||||
} catch (error) {
|
||||
if (isErrnoException(error) && error.code === "ENOENT") return null;
|
||||
empty = false;
|
||||
lastError = error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, PID_LOCK_READ_RETRY_DELAY_MS));
|
||||
}
|
||||
// A supervisor writes its lock right after creating the file, so a file still empty
|
||||
// after the retries was abandoned in between and names no owner.
|
||||
if (empty) return null;
|
||||
throw Object.assign(
|
||||
new PidLockError(`Cannot read daemon state at ${pidPath}: ${String(lastError)}`),
|
||||
{ code: "DAEMON_STATE_READ_FAILED" },
|
||||
@@ -149,6 +157,14 @@ async function clearExistingPidLock(
|
||||
return "cleared";
|
||||
}
|
||||
|
||||
async function removeEmptyPidLock(pidPath: string): Promise<void> {
|
||||
try {
|
||||
if ((await stat(pidPath)).size === 0) await unlink(pidPath);
|
||||
} catch (error) {
|
||||
if (!isErrnoException(error) || error.code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeNewPidLock(pidPath: string, lockInfo: PidLockInfo): Promise<void> {
|
||||
let fd;
|
||||
try {
|
||||
@@ -191,6 +207,8 @@ export async function acquirePidLock(
|
||||
if (result === "already_owned") {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await removeEmptyPidLock(pidPath);
|
||||
}
|
||||
|
||||
// Create new lock with exclusive flag
|
||||
|
||||
Reference in New Issue
Block a user