mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d98544251 | ||
|
|
521afd9f33 | ||
|
|
14519bdb5b | ||
|
|
891e62309c | ||
|
|
6ae6308cfb | ||
|
|
ef77b823ae | ||
|
|
60f29a4350 | ||
|
|
8efb21f55c | ||
|
|
b2666a0aec |
@@ -135,6 +135,32 @@ jobs:
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Backport electron-builder#10172 to the pinned build tool only.
|
||||
# Remove this step when app-builder-lib is upgraded to a release containing it.
|
||||
- name: Repair temporary keychain password in signing tool
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'KEYCHAIN_FIX'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const manifestPath = require.resolve('app-builder-lib/package.json');
|
||||
if (JSON.parse(fs.readFileSync(manifestPath, 'utf8')).version !== '26.8.1') {
|
||||
throw new Error('Reassess/remove the signing backport for the new app-builder-lib version');
|
||||
}
|
||||
const sourcePath = path.join(path.dirname(manifestPath), 'out/codeSign/macCodeSign.js');
|
||||
let source = fs.readFileSync(sourcePath, 'utf8');
|
||||
const replacements = [
|
||||
['importCerts(keychainFile, certPaths, cscPasswords)', 'importCerts(keychainFile, certPaths, cscPasswords, keychainPassword)'],
|
||||
['importCerts(keychainFile, paths, keyPasswords)', 'importCerts(keychainFile, paths, keyPasswords, keychainPassword)'],
|
||||
['["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]', '["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", keychainPassword, keychainFile]'],
|
||||
];
|
||||
for (const [before, after] of replacements) {
|
||||
if (source.split(before).length !== 2) throw new Error('Unexpected signing tool source; refusing to patch');
|
||||
source = source.replace(before, after);
|
||||
}
|
||||
fs.writeFileSync(sourcePath, source);
|
||||
KEYCHAIN_FIX
|
||||
|
||||
- name: Set desktop package version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -120,6 +120,7 @@ import {
|
||||
} from "../provider-launch-config.js";
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
|
||||
import { resolveResumeProcessCwd } from "./resume-process-cwd.js";
|
||||
import {
|
||||
buildStringCommandShellInvocation,
|
||||
createStringCommandShellEnvOverlay,
|
||||
@@ -165,18 +166,6 @@ function rejectOnProcessFailure(child: ChildProcess, stderrChunks: string[]): Pr
|
||||
});
|
||||
}
|
||||
|
||||
async function isDirectory(target: string): Promise<boolean> {
|
||||
try {
|
||||
return (await fs.stat(target)).isDirectory();
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT" || code === "ENOTDIR") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -2883,11 +2872,8 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
// Loading an archived agent's history runs nothing in its working directory, which
|
||||
// may have been removed with its worktree. session/load still names the original
|
||||
// directory; only the process starts from the home directory.
|
||||
private async resolveProcessCwd(): Promise<string> {
|
||||
if (this.resumePurpose !== "history" || (await isDirectory(this.config.cwd))) {
|
||||
return this.config.cwd;
|
||||
}
|
||||
return homedir();
|
||||
private resolveProcessCwd(): Promise<string> {
|
||||
return resolveResumeProcessCwd(this.config.cwd, this.resumePurpose);
|
||||
}
|
||||
|
||||
private async spawnProcess(): Promise<SpawnedACPProcess> {
|
||||
|
||||
@@ -5,11 +5,13 @@ import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { setImmediate as waitForImmediate } from "node:timers/promises";
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
PiRpcAgentSession,
|
||||
transformPiModels,
|
||||
} from "./agent.js";
|
||||
import { PiCliRuntime } from "./cli-runtime.js";
|
||||
import { FakePi } from "./test-utils/fake-pi.js";
|
||||
import { createPiExtensionHost } from "./extensions/index.js";
|
||||
import { PiExtensionHost } from "./extensions/host.js";
|
||||
@@ -2719,6 +2722,7 @@ describe("PiRpcAgentClient", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "paseo-pi-import-config-"));
|
||||
const cwd = path.join(root, "workspace");
|
||||
const sessionsDir = path.join(root, "sessions");
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
const sessionFile = path.join(sessionsDir, "20260103_session.jsonl");
|
||||
writeFileSync(
|
||||
@@ -3532,6 +3536,320 @@ describe("PiRpcAgentClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PiRpcAgentClient history resume", () => {
|
||||
const BUILTIN_MCP_COMMAND = {
|
||||
name: "mcp",
|
||||
source: "extension",
|
||||
sourceInfo: { path: "builtin:mcp" },
|
||||
} as const;
|
||||
const PASEO_MCP_SERVERS = {
|
||||
paseo: { type: "http", url: "http://127.0.0.1:6767/mcp/agents" },
|
||||
} as const;
|
||||
|
||||
interface ArchivedPiSession {
|
||||
handle: {
|
||||
provider: string;
|
||||
sessionId: string;
|
||||
nativeHandle: string;
|
||||
metadata: { cwd: string; model?: string };
|
||||
};
|
||||
sessionFile: string;
|
||||
header: Record<string, unknown>;
|
||||
body: string;
|
||||
originalBytes: Buffer;
|
||||
}
|
||||
|
||||
function createDeletedFolder(): string {
|
||||
const folder = mkdtempSync(path.join(tmpdir(), "paseo-pi-deleted-cwd-"));
|
||||
rmSync(folder, { recursive: true, force: true });
|
||||
return folder;
|
||||
}
|
||||
|
||||
function createFileWhereFolderWas(): string {
|
||||
const folder = createDeletedFolder();
|
||||
writeFileSync(folder, "not a directory");
|
||||
onTestFinished(() => rmSync(folder, { force: true }));
|
||||
return folder;
|
||||
}
|
||||
|
||||
function createExistingFolder(): string {
|
||||
const folder = mkdtempSync(path.join(tmpdir(), "paseo-pi-existing-cwd-"));
|
||||
onTestFinished(() => rmSync(folder, { recursive: true, force: true }));
|
||||
return folder;
|
||||
}
|
||||
|
||||
function writeArchivedSession(cwd: string, metadata: { model?: string } = {}): ArchivedPiSession {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "paseo-pi-archived-session-"));
|
||||
onTestFinished(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const sessionFile = path.join(dir, "2026-01-01_pi-session-1.jsonl");
|
||||
const header = {
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "pi-session-1",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
cwd,
|
||||
};
|
||||
const body = [
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
id: "entry-1",
|
||||
parentId: null,
|
||||
message: { role: "user", content: "Où est le café ☕ ?" },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
id: "entry-2",
|
||||
parentId: "entry-1",
|
||||
message: { role: "assistant", content: [{ type: "text", text: "Dans la cuisine." }] },
|
||||
}),
|
||||
]
|
||||
.map((line) => `${line}\n`)
|
||||
.join("");
|
||||
writeFileSync(sessionFile, `${JSON.stringify(header)}\n${body}`);
|
||||
return {
|
||||
handle: {
|
||||
provider: "pi",
|
||||
sessionId: "pi-session-1",
|
||||
nativeHandle: sessionFile,
|
||||
metadata: { cwd, ...metadata },
|
||||
},
|
||||
sessionFile,
|
||||
header,
|
||||
body,
|
||||
originalBytes: readFileSync(sessionFile),
|
||||
};
|
||||
}
|
||||
|
||||
function readSessionCopy(sessionFile: string): { header: unknown; body: string } {
|
||||
const content = readUtf8File(sessionFile);
|
||||
const headerEnd = content.indexOf("\n");
|
||||
return {
|
||||
header: JSON.parse(content.slice(0, headerEnd)),
|
||||
body: content.slice(headerEnd + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function resumeArchived(
|
||||
client: PiRpcAgentClient,
|
||||
archived: ArchivedPiSession,
|
||||
purpose: "history" | "interactive",
|
||||
overrides: Partial<AgentSessionConfig> = {},
|
||||
): Promise<AgentSession> {
|
||||
return client.resumeSession(archived.handle, overrides, undefined, { purpose });
|
||||
}
|
||||
|
||||
function writeFakePiProcess(): {
|
||||
command: [string, ...string[]];
|
||||
readLaunches: () => Array<{ cwd: string; session: string | null }>;
|
||||
} {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "paseo-pi-process-"));
|
||||
onTestFinished(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const scriptPath = path.join(dir, "fake-pi.cjs");
|
||||
const launchesPath = path.join(dir, "launches.jsonl");
|
||||
writeFileSync(
|
||||
scriptPath,
|
||||
`
|
||||
const fs = require("node:fs");
|
||||
const readline = require("node:readline");
|
||||
const [launchesPath, ...piArgs] = process.argv.slice(2);
|
||||
const sessionFlag = piArgs.indexOf("--session");
|
||||
const sessionFile = sessionFlag === -1 ? null : piArgs[sessionFlag + 1];
|
||||
fs.appendFileSync(launchesPath, JSON.stringify({ cwd: process.cwd(), session: sessionFile }) + "\\n");
|
||||
if (sessionFile) {
|
||||
const header = JSON.parse(fs.readFileSync(sessionFile, "utf8").split("\\n", 1)[0]);
|
||||
if (header.cwd && !fs.existsSync(header.cwd)) {
|
||||
fs.writeSync(
|
||||
2,
|
||||
"Stored session working directory does not exist: " + header.cwd + "\\nSession file: " + sessionFile + "\\nCurrent working directory: " + process.cwd() + "\\n",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
const models = [
|
||||
{ provider: "openrouter", id: "a" },
|
||||
{ provider: "openrouter", id: "b" },
|
||||
];
|
||||
const state = {
|
||||
sessionId: "pi-session-1",
|
||||
sessionFile,
|
||||
model: models[0],
|
||||
thinkingLevel: "medium",
|
||||
isStreaming: false,
|
||||
isCompacting: false,
|
||||
messageCount: 0,
|
||||
pendingMessageCount: 0,
|
||||
};
|
||||
const commands = [${JSON.stringify(BUILTIN_MCP_COMMAND)}];
|
||||
function handle(request) {
|
||||
switch (request.type) {
|
||||
case "get_state":
|
||||
return state;
|
||||
case "get_commands":
|
||||
return { commands };
|
||||
case "get_available_models":
|
||||
return { models };
|
||||
case "set_model":
|
||||
fs.appendFileSync(
|
||||
sessionFile,
|
||||
JSON.stringify({ type: "model_change", id: "model-1", provider: request.provider, modelId: request.modelId }) + "\\n",
|
||||
);
|
||||
state.model = models.find((model) => model.id === request.modelId);
|
||||
return state.model;
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
readline.createInterface({ input: process.stdin }).on("line", (line) => {
|
||||
const request = JSON.parse(line);
|
||||
process.stdout.write(
|
||||
JSON.stringify({ id: request.id, type: "response", command: request.type, success: true, data: handle(request) }) + "\\n",
|
||||
);
|
||||
});
|
||||
`,
|
||||
);
|
||||
return {
|
||||
command: [process.execPath, scriptPath, launchesPath],
|
||||
readLaunches: () =>
|
||||
readUtf8File(launchesPath)
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as { cwd: string; session: string | null }),
|
||||
};
|
||||
}
|
||||
|
||||
test.each([
|
||||
["is gone", createDeletedFolder],
|
||||
["is now a file", createFileWhereFolderWas],
|
||||
])(
|
||||
"a history resume of an agent whose folder %s starts Pi from the home directory on a copy of its session",
|
||||
async (_folderState, createFolder) => {
|
||||
const archived = writeArchivedSession(createFolder());
|
||||
const pi = new FakePi();
|
||||
|
||||
const session = await resumeArchived(createClient(pi), archived, "history");
|
||||
onTestFinished(() => session.close());
|
||||
|
||||
expect(pi.recordedLaunches).toHaveLength(1);
|
||||
const launch = pi.recordedLaunches[0]!;
|
||||
expect(launch.cwd).toBe(homedir());
|
||||
expect(launch.session).not.toBe(archived.sessionFile);
|
||||
expect(readSessionCopy(launch.session!)).toEqual({
|
||||
header: { ...archived.header, cwd: homedir() },
|
||||
body: archived.body,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test("a history resume of an agent whose folder is gone removes its session copy on close and leaves the original unchanged", async () => {
|
||||
const archived = writeArchivedSession(createDeletedFolder());
|
||||
const pi = new FakePi();
|
||||
|
||||
const session = await resumeArchived(createClient(pi), archived, "history");
|
||||
const sessionCopy = pi.recordedLaunches[0]!.session!;
|
||||
expect(existsSync(sessionCopy)).toBe(true);
|
||||
await session.close();
|
||||
|
||||
expect(existsSync(path.dirname(sessionCopy))).toBe(false);
|
||||
expect(readFileSync(archived.sessionFile)).toEqual(archived.originalBytes);
|
||||
});
|
||||
|
||||
test("a history resume of an agent whose folder is gone persists the original session file while Pi reports the copy", async () => {
|
||||
const storedCwd = createDeletedFolder();
|
||||
const archived = writeArchivedSession(storedCwd);
|
||||
const pi = new FakePi();
|
||||
|
||||
const session = await resumeArchived(createClient(pi), archived, "history");
|
||||
onTestFinished(() => session.close());
|
||||
await session.getRuntimeInfo();
|
||||
|
||||
expect(pi.latestSession().state.sessionFile).toBe(pi.recordedLaunches[0]!.session);
|
||||
expect(session.describePersistence()).toMatchObject({
|
||||
nativeHandle: archived.sessionFile,
|
||||
metadata: { cwd: storedCwd },
|
||||
});
|
||||
});
|
||||
|
||||
test("a history resume of an agent whose folder is gone removes its session copy when Pi fails to start", async () => {
|
||||
const archived = writeArchivedSession(createDeletedFolder());
|
||||
const pi = new FakePi();
|
||||
pi.queueSessionSetup((session) => {
|
||||
session.getStateError = new Error("Pi RPC process exited with code 1 and signal null");
|
||||
});
|
||||
|
||||
await expect(resumeArchived(createClient(pi), archived, "history")).rejects.toThrow(
|
||||
"Pi RPC process exited with code 1",
|
||||
);
|
||||
|
||||
expect(pi.recordedLaunches).toHaveLength(1);
|
||||
expect(pi.recordedLaunches[0]!.session).not.toBe(archived.sessionFile);
|
||||
expect(existsSync(path.dirname(pi.recordedLaunches[0]!.session!))).toBe(false);
|
||||
});
|
||||
|
||||
test("a history resume of an agent whose folder is gone checks MCP support from the home directory", async () => {
|
||||
const archived = writeArchivedSession(createDeletedFolder());
|
||||
const pi = new FakePi();
|
||||
pi.queueCommands([BUILTIN_MCP_COMMAND]);
|
||||
|
||||
const session = await resumeArchived(createClient(pi), archived, "history", {
|
||||
mcpServers: PASEO_MCP_SERVERS,
|
||||
});
|
||||
onTestFinished(() => session.close());
|
||||
|
||||
expect(pi.recordedLaunches.map((launch) => launch.cwd)).toEqual([homedir(), homedir()]);
|
||||
expect(session.capabilities.supportsMcpServers).toBe(true);
|
||||
});
|
||||
|
||||
test("an interactive resume of an agent whose folder is gone still fails in its stored folder", async () => {
|
||||
const storedCwd = createDeletedFolder();
|
||||
const archived = writeArchivedSession(storedCwd);
|
||||
const pi = new FakePi();
|
||||
|
||||
await expect(resumeArchived(createClient(pi), archived, "interactive")).rejects.toThrow(
|
||||
`Stored session working directory does not exist: ${storedCwd}`,
|
||||
);
|
||||
|
||||
expect(pi.recordedLaunches).toMatchObject([{ cwd: storedCwd, session: archived.sessionFile }]);
|
||||
});
|
||||
|
||||
test("a history resume of an agent whose folder exists keeps the stored folder and session file", async () => {
|
||||
const storedCwd = createExistingFolder();
|
||||
const archived = writeArchivedSession(storedCwd);
|
||||
const pi = new FakePi();
|
||||
|
||||
const session = await resumeArchived(createClient(pi), archived, "history");
|
||||
onTestFinished(() => session.close());
|
||||
|
||||
expect(pi.recordedLaunches).toMatchObject([{ cwd: storedCwd, session: archived.sessionFile }]);
|
||||
});
|
||||
|
||||
test("a history resume of an agent whose folder is gone opens its session in a real Pi process started from the home directory", async () => {
|
||||
const archived = writeArchivedSession(createDeletedFolder(), { model: "openrouter/b" });
|
||||
const fakePiProcess = writeFakePiProcess();
|
||||
const logger = pino({ level: "silent" });
|
||||
const client = new PiRpcAgentClient({
|
||||
logger,
|
||||
runtime: new PiCliRuntime({ logger, command: fakePiProcess.command }),
|
||||
});
|
||||
|
||||
const session = await resumeArchived(client, archived, "history", {
|
||||
mcpServers: PASEO_MCP_SERVERS,
|
||||
});
|
||||
onTestFinished(() => session.close());
|
||||
|
||||
const launches = fakePiProcess.readLaunches();
|
||||
const home = realpathSync.native(homedir());
|
||||
expect(launches.map((launch) => realpathSync.native(launch.cwd))).toEqual([home, home]);
|
||||
expect(session.capabilities.supportsMcpServers).toBe(true);
|
||||
const sessionCopy = launches[1]!.session!;
|
||||
expect(readUtf8File(sessionCopy)).toContain('"type":"model_change"');
|
||||
expect(session.describePersistence()?.nativeHandle).toBe(archived.sessionFile);
|
||||
|
||||
await session.close();
|
||||
expect(existsSync(path.dirname(sessionCopy))).toBe(false);
|
||||
expect(readFileSync(archived.sessionFile)).toEqual(archived.originalBytes);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformPiModels", () => {
|
||||
test("normalizes labels that include the upstream provider prefix", () => {
|
||||
expect(
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mapCustomMessageToToolCall } from "../custom-message.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { join, resolve as resolvePath } from "node:path";
|
||||
import { basename, join, resolve as resolvePath } from "node:path";
|
||||
import type { Logger } from "pino";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import { z } from "zod";
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type AgentPersistenceHandle,
|
||||
type AgentPromptInput,
|
||||
type AgentProvider,
|
||||
type AgentResumeSessionOptions,
|
||||
type AgentRunOptions,
|
||||
type AgentRunResult,
|
||||
type AgentRuntimeInfo,
|
||||
@@ -75,8 +76,13 @@ import {
|
||||
piExtensionRuntimeBridge,
|
||||
type PiExtensionEventOutput,
|
||||
} from "./extensions/index.js";
|
||||
import { resolveResumeProcessCwd } from "../resume-process-cwd.js";
|
||||
import { revertPiConversation } from "./rewind.js";
|
||||
import { listPiImportableSessions, readPiImportSessionConfig } from "./session-descriptor.js";
|
||||
import {
|
||||
copyPiSessionFileWithCwd,
|
||||
listPiImportableSessions,
|
||||
readPiImportSessionConfig,
|
||||
} from "./session-descriptor.js";
|
||||
import type { PiRuntime, PiRuntimeSession, PiStartSessionInput } from "./runtime.js";
|
||||
import type {
|
||||
PiAgentSessionEvent,
|
||||
@@ -245,6 +251,7 @@ interface PiRpcAgentSessionOptions {
|
||||
initialState: PiSessionState;
|
||||
capabilities: AgentCapabilityFlags;
|
||||
currentModeId?: string | null;
|
||||
persistedSessionFile?: string;
|
||||
cleanup?: () => void;
|
||||
extensionTimeoutMs?: number;
|
||||
logger: Logger;
|
||||
@@ -492,13 +499,14 @@ function buildResumeConfig(
|
||||
|
||||
function buildResumeStartInput(input: {
|
||||
resumeConfig: PiResumeConfig;
|
||||
processCwd: string;
|
||||
sessionFile: string;
|
||||
launchContext: AgentLaunchContext | undefined;
|
||||
mcpConfigFile: PiTempFile | null;
|
||||
paseoExtension: PiTempFile | null;
|
||||
}): PiStartSessionInput {
|
||||
return {
|
||||
cwd: input.resumeConfig.cwd,
|
||||
cwd: input.processCwd,
|
||||
env: input.launchContext?.env,
|
||||
session: input.sessionFile,
|
||||
thinkingOptionId: normalizePiThinkingOption(input.resumeConfig.thinkingOptionId) ?? undefined,
|
||||
@@ -618,6 +626,19 @@ function createPiMcpConfigFile(
|
||||
};
|
||||
}
|
||||
|
||||
async function createPiSessionCopy(sessionFile: string, cwd: string): Promise<PiTempFile> {
|
||||
const dir = mkdtempSync(join(tmpdir(), "paseo-pi-session-"));
|
||||
const cleanup = () => rmSync(dir, { recursive: true, force: true });
|
||||
const filePath = join(dir, basename(sessionFile));
|
||||
try {
|
||||
await copyPiSessionFileWithCwd({ sourceFile: sessionFile, targetFile: filePath, cwd });
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
throw error;
|
||||
}
|
||||
return { path: filePath, cleanup };
|
||||
}
|
||||
|
||||
function createPiPaseoExtensionFile({
|
||||
systemPrompt,
|
||||
mcpServers = {},
|
||||
@@ -1232,6 +1253,7 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
this.capabilities = options.capabilities;
|
||||
this.provider = PI_PROVIDER;
|
||||
this.currentModeId = options.currentModeId ?? null;
|
||||
this.persistedSessionFile = options.persistedSessionFile;
|
||||
this.cleanup = options.cleanup;
|
||||
this.extensionTimeoutMs = options.extensionTimeoutMs ?? DEFAULT_PI_EXTENSION_RESULT_TIMEOUT_MS;
|
||||
this.logger = options.logger;
|
||||
@@ -1262,6 +1284,7 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
|
||||
private readonly runtimeSession: PiRuntimeSession;
|
||||
private readonly config: AgentSessionConfig;
|
||||
private readonly persistedSessionFile?: string;
|
||||
private readonly cleanup?: () => void;
|
||||
private readonly extensionTimeoutMs: number;
|
||||
|
||||
@@ -1496,7 +1519,7 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
return {
|
||||
provider: this.provider,
|
||||
sessionId: this.state.sessionId,
|
||||
nativeHandle: this.state.sessionFile,
|
||||
nativeHandle: this.persistedSessionFile ?? this.state.sessionFile,
|
||||
metadata: {
|
||||
cwd: this.config.cwd,
|
||||
...(this.config.model ? { model: this.config.model } : {}),
|
||||
@@ -2645,6 +2668,7 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
options?: AgentResumeSessionOptions,
|
||||
): Promise<AgentSession> {
|
||||
const sessionFile = handle.nativeHandle;
|
||||
if (!sessionFile) {
|
||||
@@ -2653,6 +2677,7 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
|
||||
const persistenceMetadata = parsePersistenceMetadata(handle.metadata);
|
||||
const resumeConfig = buildResumeConfig(persistenceMetadata, overrides, this.provider);
|
||||
const processCwd = await resolveResumeProcessCwd(resumeConfig.cwd, options?.purpose);
|
||||
|
||||
const providerOptions = PiProviderOptionsSchema.parse(
|
||||
resumeConfig.config.providerOptions ?? {},
|
||||
@@ -2663,7 +2688,7 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
...launchContext?.env,
|
||||
};
|
||||
const mcp = await this.prepareMcpInjection(
|
||||
resumeConfig.cwd,
|
||||
processCwd,
|
||||
resumeConfig.config.mcpServers,
|
||||
mcpEnv,
|
||||
runtime,
|
||||
@@ -2676,20 +2701,29 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
),
|
||||
mcpServers: mcp?.kind === "builtin" ? mcp.servers : undefined,
|
||||
});
|
||||
const tempFiles = mcpConfigFile ? [mcpConfigFile, paseoExtension] : [paseoExtension];
|
||||
const cleanupTempFiles = () => {
|
||||
for (const tempFile of tempFiles) tempFile.cleanup();
|
||||
};
|
||||
let sessionCopy: PiTempFile | null = null;
|
||||
let runtimeSession: PiRuntimeSession;
|
||||
try {
|
||||
if (processCwd !== resumeConfig.cwd) {
|
||||
sessionCopy = await createPiSessionCopy(sessionFile, processCwd);
|
||||
tempFiles.push(sessionCopy);
|
||||
}
|
||||
runtimeSession = await runtime.startSession(
|
||||
buildResumeStartInput({
|
||||
resumeConfig,
|
||||
sessionFile,
|
||||
processCwd,
|
||||
sessionFile: sessionCopy?.path ?? sessionFile,
|
||||
launchContext,
|
||||
mcpConfigFile,
|
||||
paseoExtension,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
mcpConfigFile?.cleanup();
|
||||
paseoExtension?.cleanup();
|
||||
cleanupTempFiles();
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
@@ -2702,15 +2736,15 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
},
|
||||
initialState,
|
||||
capabilities: capabilitiesForSession(mcp !== null),
|
||||
cleanup: combineCleanup([mcpConfigFile?.cleanup, paseoExtension?.cleanup]),
|
||||
persistedSessionFile: sessionCopy ? sessionFile : undefined,
|
||||
cleanup: cleanupTempFiles,
|
||||
extensionTimeoutMs: providerOptions.extensionTimeoutMs,
|
||||
logger: this.logger,
|
||||
usagePollScheduler: this.usagePollScheduler,
|
||||
});
|
||||
} catch (error) {
|
||||
await runtimeSession.close().catch(() => undefined);
|
||||
mcpConfigFile?.cleanup();
|
||||
paseoExtension?.cleanup();
|
||||
cleanupTempFiles();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { mkdtemp, mkdir, utimes, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, mkdir, readFile, rm, utimes, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
import { expect, onTestFinished, test } from "vitest";
|
||||
|
||||
import { listPiImportableSessions, readPiImportSessionConfig } from "./session-descriptor.js";
|
||||
import {
|
||||
copyPiSessionFileWithCwd,
|
||||
listPiImportableSessions,
|
||||
readPiImportSessionConfig,
|
||||
} from "./session-descriptor.js";
|
||||
|
||||
async function writeSession(root: string, lines: unknown[]): Promise<string> {
|
||||
const sessionsDir = path.join(root, "sessions", "project");
|
||||
@@ -216,3 +220,97 @@ test("Pi import config preserves thinking before a later model in large sessions
|
||||
thinkingOptionId: "low",
|
||||
});
|
||||
});
|
||||
|
||||
test("a Pi session copy names the new cwd in its header and keeps every later byte", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-"));
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }));
|
||||
const header = {
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "pi-session-1",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
cwd: path.join(root, "removed-worktree"),
|
||||
};
|
||||
const body = Buffer.from(
|
||||
Array.from(
|
||||
{ length: 2_000 },
|
||||
(_, index) =>
|
||||
`${JSON.stringify({ type: "message", id: `entry-${index}`, message: { role: "user", content: `café ☕ ${index}` } })}\n`,
|
||||
).join(""),
|
||||
);
|
||||
expect(body.length).toBeGreaterThan(64 * 1024);
|
||||
const sourceFile = path.join(root, "source.jsonl");
|
||||
const targetFile = path.join(root, "copy.jsonl");
|
||||
await writeFile(sourceFile, Buffer.concat([Buffer.from(`${JSON.stringify(header)}\n`), body]));
|
||||
|
||||
await copyPiSessionFileWithCwd({ sourceFile, targetFile, cwd: "/home/user" });
|
||||
|
||||
const copy = await readFile(targetFile);
|
||||
const headerEnd = copy.indexOf(0x0a);
|
||||
expect(JSON.parse(copy.subarray(0, headerEnd).toString("utf8"))).toEqual({
|
||||
...header,
|
||||
cwd: "/home/user",
|
||||
});
|
||||
expect(copy.subarray(headerEnd + 1).equals(body)).toBe(true);
|
||||
});
|
||||
|
||||
test("a Pi session copy refuses a file without a session header", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-"));
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }));
|
||||
const sourceFile = path.join(root, "source.jsonl");
|
||||
await writeFile(sourceFile, `${JSON.stringify({ type: "message", id: "entry-1" })}\n`);
|
||||
|
||||
await expect(
|
||||
copyPiSessionFileWithCwd({ sourceFile, targetFile: path.join(root, "copy.jsonl"), cwd: "/" }),
|
||||
).rejects.toThrow(`Pi session file has no session header: ${sourceFile}`);
|
||||
});
|
||||
|
||||
const COPY_BODY = [
|
||||
{ type: "message", id: "entry-1", message: { role: "user", content: "café ☕" } },
|
||||
{ type: "message", id: "entry-2", message: { role: "assistant", content: "Dans la cuisine." } },
|
||||
]
|
||||
.map((entry) => `${JSON.stringify(entry)}\n`)
|
||||
.join("");
|
||||
|
||||
const COPY_HEADER = {
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "pi-session-1",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
cwd: "/removed-worktree",
|
||||
};
|
||||
|
||||
async function copySessionContent(content: string): Promise<Buffer> {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-"));
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }));
|
||||
const sourceFile = path.join(root, "source.jsonl");
|
||||
const targetFile = path.join(root, "copy.jsonl");
|
||||
await writeFile(sourceFile, content);
|
||||
await copyPiSessionFileWithCwd({ sourceFile, targetFile, cwd: "/home/user" });
|
||||
return readFile(targetFile);
|
||||
}
|
||||
|
||||
test.each([
|
||||
["a header line longer than 64 KiB", "", { ...COPY_HEADER, padding: "x".repeat(70 * 1024) }],
|
||||
["a header with an empty cwd", "", { ...COPY_HEADER, cwd: "" }],
|
||||
["blank lines before the header", "\n\r\n", COPY_HEADER],
|
||||
])("a Pi session copy accepts %s", async (_headerShape, prefix, header) => {
|
||||
const copy = await copySessionContent(`${prefix}${JSON.stringify(header)}\n${COPY_BODY}`);
|
||||
|
||||
const headerEnd = copy.indexOf(0x0a);
|
||||
expect(JSON.parse(copy.subarray(0, headerEnd).toString("utf8"))).toEqual({
|
||||
...header,
|
||||
cwd: "/home/user",
|
||||
});
|
||||
expect(copy.subarray(headerEnd + 1).equals(Buffer.from(COPY_BODY))).toBe(true);
|
||||
});
|
||||
|
||||
test("a Pi session copy reports a missing session file", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-"));
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }));
|
||||
const sourceFile = path.join(root, "missing.jsonl");
|
||||
|
||||
await expect(
|
||||
copyPiSessionFileWithCwd({ sourceFile, targetFile: path.join(root, "copy.jsonl"), cwd: "/" }),
|
||||
).rejects.toThrow(`Pi session file is missing: ${sourceFile}`);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Dirent } from "node:fs";
|
||||
import { open, readdir, readFile, stat } from "node:fs/promises";
|
||||
import { createReadStream, createWriteStream, type Dirent } from "node:fs";
|
||||
import { open, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
|
||||
import type {
|
||||
ImportableProviderSession,
|
||||
@@ -18,6 +19,7 @@ const PI_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR";
|
||||
const HEAD_BYTES = 64 * 1024;
|
||||
const TAIL_BYTES = 256 * 1024;
|
||||
const FULL_SCAN_LINE_LIMIT = 2_000;
|
||||
const PI_SESSION_HEADER_SCAN_BYTES = 1024 * 1024;
|
||||
// Rank all discovered files cheaply, then parse only a bounded recent window.
|
||||
const IMPORT_CANDIDATE_OVERSCAN = 40;
|
||||
const IMPORT_CANDIDATE_MIN = 400;
|
||||
@@ -100,6 +102,59 @@ export async function listPiImportableSessions(
|
||||
);
|
||||
}
|
||||
|
||||
export async function copyPiSessionFileWithCwd(input: {
|
||||
sourceFile: string;
|
||||
targetFile: string;
|
||||
cwd: string;
|
||||
}): Promise<void> {
|
||||
const found = findSessionHeaderLine(
|
||||
await readSessionFileStart(input.sourceFile, PI_SESSION_HEADER_SCAN_BYTES),
|
||||
);
|
||||
if (!found) {
|
||||
throw new Error(`Pi session file has no session header: ${input.sourceFile}`);
|
||||
}
|
||||
await writeFile(input.targetFile, `${JSON.stringify({ ...found.header, cwd: input.cwd })}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
await pipeline(
|
||||
createReadStream(input.sourceFile, { start: found.bodyStart }),
|
||||
createWriteStream(input.targetFile, { flags: "a" }),
|
||||
);
|
||||
}
|
||||
|
||||
function findSessionHeaderLine(
|
||||
scanned: Buffer,
|
||||
): { header: Record<string, unknown>; bodyStart: number } | null {
|
||||
let lineStart = 0;
|
||||
while (lineStart < scanned.length) {
|
||||
const newline = scanned.indexOf(0x0a, lineStart);
|
||||
const bodyStart = newline === -1 ? scanned.length : newline + 1;
|
||||
const line = scanned.subarray(lineStart, bodyStart).toString("utf8").trim();
|
||||
if (line) {
|
||||
const header = parseJsonRecord(line);
|
||||
return header?.type === "session" ? { header, bodyStart } : null;
|
||||
}
|
||||
lineStart = bodyStart;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readSessionFileStart(filePath: string, maxBytes: number): Promise<Buffer> {
|
||||
const handle = await open(filePath, "r").catch((error: unknown) => {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
throw new Error(`Pi session file is missing: ${filePath}`);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
const buffer = Buffer.alloc(maxBytes);
|
||||
const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
|
||||
return buffer.subarray(0, bytesRead);
|
||||
} finally {
|
||||
await handle.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readPiImportSessionConfig(filePath: string): Promise<PiImportSessionConfig> {
|
||||
const descriptor = await readPiSessionDescriptor(filePath);
|
||||
if (!descriptor) return {};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
|
||||
import type {
|
||||
PiRuntime,
|
||||
PiRuntimeLaunch,
|
||||
@@ -75,6 +77,12 @@ export class FakePi implements PiRuntime {
|
||||
const session = new FakePiSession(launch, this.removedModels);
|
||||
session.commands = this.queuedCommands.shift() ?? [];
|
||||
this.queuedSessionSetups.shift()?.(session);
|
||||
const missingSessionCwd = launch.noSession ? null : readMissingSessionCwd(launch.session);
|
||||
if (missingSessionCwd !== null) {
|
||||
session.getStateError = new Error(
|
||||
`Pi RPC process exited with code 1 and signal null\nStored session working directory does not exist: ${missingSessionCwd}\nSession file: ${launch.session}\nCurrent working directory: ${launch.cwd}`,
|
||||
);
|
||||
}
|
||||
this.sessions.push(session);
|
||||
return session;
|
||||
}
|
||||
@@ -101,6 +109,27 @@ export class FakePi implements PiRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
function readMissingSessionCwd(sessionFile: string | undefined): string | null {
|
||||
if (!sessionFile || !existsSync(sessionFile)) {
|
||||
return null;
|
||||
}
|
||||
const [headerLine = ""] = readFileSync(sessionFile, "utf8").split("\n", 1);
|
||||
let header: unknown;
|
||||
try {
|
||||
header = JSON.parse(headerLine);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof header !== "object" || header === null || !("cwd" in header)) {
|
||||
return null;
|
||||
}
|
||||
const { type, cwd } = header as { type?: unknown; cwd?: unknown };
|
||||
if (type !== "session" || typeof cwd !== "string" || !cwd || existsSync(cwd)) {
|
||||
return null;
|
||||
}
|
||||
return cwd;
|
||||
}
|
||||
|
||||
export class FakePiSession implements PiRuntimeSession {
|
||||
readonly environment: Record<string, string>;
|
||||
readonly prompts: Array<{ message: string; imageCount: number }> = [];
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
import type { AgentResumePurpose } from "../agent-sdk-types.js";
|
||||
|
||||
export async function resolveResumeProcessCwd(
|
||||
cwd: string,
|
||||
purpose: AgentResumePurpose | undefined,
|
||||
): Promise<string> {
|
||||
if (purpose !== "history" || (await isDirectory(cwd))) {
|
||||
return cwd;
|
||||
}
|
||||
return homedir();
|
||||
}
|
||||
|
||||
async function isDirectory(target: string): Promise<boolean> {
|
||||
try {
|
||||
return (await stat(target)).isDirectory();
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT" || code === "ENOTDIR") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -670,11 +670,11 @@ describe("paseo daemon bootstrap", () => {
|
||||
|
||||
test("rolls back standalone listener without starting plugins when main listen fails", async () => {
|
||||
const mainPort = await findFreePort();
|
||||
const standalonePort = await findFreePort();
|
||||
const occupiedMain = http.createServer((_req, res) => {
|
||||
res.end("occupied-main");
|
||||
});
|
||||
await new Promise<void>((resolve) => occupiedMain.listen(mainPort, "127.0.0.1", resolve));
|
||||
const standalonePort = await findFreePort();
|
||||
|
||||
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-main-rollback-"));
|
||||
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
||||
|
||||
@@ -61,10 +61,24 @@ describe("managed Git plugin sources", () => {
|
||||
roots.push(home);
|
||||
const sources = new ManagedPluginSources(home);
|
||||
|
||||
const failure = sources.prepareInstall({
|
||||
source: "git:https://oauth2:super-secret@127.0.0.1:1/missing.git",
|
||||
// Own the failure response instead of relying on a closed port and OS connect timing.
|
||||
const server = createServer((_request, response) => {
|
||||
response.writeHead(500, { "Content-Type": "text/plain" });
|
||||
response.end("clone rejected for oauth2:super-secret\n");
|
||||
});
|
||||
await expect(failure).rejects.not.toThrow(/oauth2|super-secret/);
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address() as { port: number };
|
||||
try {
|
||||
const failure = sources.prepareInstall({
|
||||
source: `git:http://oauth2:super-secret@127.0.0.1:${address.port}/missing.git`,
|
||||
});
|
||||
await expect(failure).rejects.toThrow("500");
|
||||
await expect(failure).rejects.toThrow("clone rejected for [redacted]:[redacted]");
|
||||
await expect(failure).rejects.not.toThrow(/oauth2|super-secret/);
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it("normalizes explicit GitHub and Git identifiers to the same Git source", async () => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { type EventEmitter, once } from "node:events";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import type { Duplex } from "node:stream";
|
||||
import express from "express";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import pino from "pino";
|
||||
@@ -332,48 +334,39 @@ describe("service proxy subsystem shape", () => {
|
||||
});
|
||||
});
|
||||
|
||||
interface ForwardedFixture {
|
||||
interface ProxiedServiceFixture {
|
||||
upstreamPort: number;
|
||||
daemonPort: number;
|
||||
daemon: http.Server;
|
||||
hostname: string;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a real workspace service behind a real daemon listener. The upstream
|
||||
* echoes the headers it received so tests can assert what actually crossed the
|
||||
* proxy, not what a helper returned.
|
||||
* Runs a real workspace service behind a real daemon listener that serves the
|
||||
* service proxy for both HTTP and upgrades.
|
||||
*/
|
||||
async function startForwardedHeadersFixture(upstreamHost = "127.0.0.1"): Promise<ForwardedFixture> {
|
||||
const upstream = http.createServer((req, res) => {
|
||||
let body = "";
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
body += chunk.toString();
|
||||
async function startProxiedService(
|
||||
upstream: http.Server,
|
||||
options: { upstreamHost?: string; logger?: pino.Logger } = {},
|
||||
): Promise<ProxiedServiceFixture> {
|
||||
const { upstreamHost = "127.0.0.1", logger: proxyLogger = logger } = options;
|
||||
// Upgraded sockets keep the server alive; close() alone would hang.
|
||||
const sockets = new Set<net.Socket>();
|
||||
function trackConnections(server: http.Server) {
|
||||
server.on("connection", (socket: net.Socket) => {
|
||||
sockets.add(socket);
|
||||
socket.once("close", () => sockets.delete(socket));
|
||||
});
|
||||
req.on("end", () => {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ ...req.headers, method: req.method, url: req.url, body }));
|
||||
});
|
||||
});
|
||||
const upgradeSockets: net.Socket[] = [];
|
||||
upstream.on("upgrade", (req, socket) => {
|
||||
upgradeSockets.push(socket);
|
||||
// The client resets this connection once it has the echo, so the reset
|
||||
// reaches us as an 'error'. A raw upgrade socket has no default handler —
|
||||
// without this the event goes unhandled and takes down the test process.
|
||||
socket.on("error", () => socket.destroy());
|
||||
const payload = JSON.stringify(req.headers);
|
||||
socket.write(
|
||||
`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nX-Echo-Length: ${payload.length}\r\n\r\n${payload}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
trackConnections(upstream);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
upstream.once("error", reject);
|
||||
upstream.listen(0, upstreamHost, resolve);
|
||||
});
|
||||
const upstreamPort = (upstream.address() as net.AddressInfo).port;
|
||||
|
||||
const serviceProxy = createServiceProxySubsystem({ logger });
|
||||
const serviceProxy = createServiceProxySubsystem({ logger: proxyLogger });
|
||||
const route = serviceProxy.registerWorkspaceService({
|
||||
workspaceId: "workspace-a",
|
||||
projectSlug: "repo",
|
||||
@@ -390,24 +383,51 @@ async function startForwardedHeadersFixture(upstreamHost = "127.0.0.1"): Promise
|
||||
res.status(404).send("404 Not Found");
|
||||
});
|
||||
const daemon = http.createServer(app);
|
||||
trackConnections(daemon);
|
||||
daemon.on("upgrade", serviceProxy.upgradeHandler({ passthroughUnknown: false }));
|
||||
await new Promise<void>((resolve) => daemon.listen(daemonPort, "127.0.0.1", resolve));
|
||||
|
||||
return {
|
||||
upstreamPort,
|
||||
daemonPort,
|
||||
daemon,
|
||||
hostname: route.hostname,
|
||||
async close() {
|
||||
// Upgraded sockets keep the server alive; close() alone would hang.
|
||||
for (const socket of upgradeSockets) socket.destroy();
|
||||
daemon.closeAllConnections();
|
||||
for (const socket of sockets) socket.destroy();
|
||||
await new Promise<void>((resolve) => daemon.close(() => resolve()));
|
||||
upstream.closeAllConnections();
|
||||
await new Promise<void>((resolve) => upstream.close(() => resolve()));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The upstream echoes the headers it received so tests can assert what
|
||||
* actually crossed the proxy, not what a helper returned.
|
||||
*/
|
||||
function startForwardedHeadersFixture(upstreamHost = "127.0.0.1"): Promise<ProxiedServiceFixture> {
|
||||
const upstream = http.createServer((req, res) => {
|
||||
let body = "";
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
req.on("end", () => {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ ...req.headers, method: req.method, url: req.url, body }));
|
||||
});
|
||||
});
|
||||
upstream.on("upgrade", (req, socket) => {
|
||||
// The client resets this connection once it has the echo, so the reset
|
||||
// reaches us as an 'error'. A raw upgrade socket has no default handler —
|
||||
// without this the event goes unhandled and takes down the test process.
|
||||
socket.on("error", () => socket.destroy());
|
||||
const payload = JSON.stringify(req.headers);
|
||||
socket.write(
|
||||
`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nX-Echo-Length: ${payload.length}\r\n\r\n${payload}`,
|
||||
);
|
||||
});
|
||||
return startProxiedService(upstream, { upstreamHost });
|
||||
}
|
||||
|
||||
function upgradeThroughProxy(
|
||||
port: number,
|
||||
host: string,
|
||||
@@ -651,3 +671,198 @@ describe("service proxy forwarded headers", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function closed(emitter: EventEmitter): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
emitter.once("close", () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
async function within<T>(promise: Promise<T>, ms: number, failure: string): Promise<T> {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const timeout = new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`${failure} after ${ms}ms`)), ms);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([promise, timeout]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function requestThroughProxy(
|
||||
fixture: ProxiedServiceFixture,
|
||||
requestPath: string,
|
||||
): http.ClientRequest {
|
||||
return http.get({
|
||||
hostname: "127.0.0.1",
|
||||
port: fixture.daemonPort,
|
||||
path: requestPath,
|
||||
headers: { host: fixture.hostname },
|
||||
});
|
||||
}
|
||||
|
||||
const STRAY_WARNING_GRACE_MS = 100;
|
||||
|
||||
function createUpgradeUpstream(onUpgrade: (socket: Duplex) => void): http.Server {
|
||||
const upstream = http.createServer();
|
||||
upstream.on("upgrade", (_req, socket) => {
|
||||
socket.on("error", () => socket.destroy());
|
||||
socket.on("end", () => socket.end());
|
||||
socket.write(
|
||||
"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n",
|
||||
);
|
||||
onUpgrade(socket);
|
||||
});
|
||||
return upstream;
|
||||
}
|
||||
|
||||
function openUpgradeThroughProxy(fixture: ProxiedServiceFixture): Promise<net.Socket> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.connect({ host: "127.0.0.1", port: fixture.daemonPort }, () => {
|
||||
const lines = [
|
||||
"GET /ws HTTP/1.1",
|
||||
`Host: ${fixture.hostname}`,
|
||||
"Upgrade: websocket",
|
||||
"Connection: Upgrade",
|
||||
"",
|
||||
"",
|
||||
];
|
||||
socket.write(lines.join("\r\n"));
|
||||
});
|
||||
let raw = "";
|
||||
socket.on("data", (chunk: Buffer) => {
|
||||
raw += chunk.toString();
|
||||
if (raw.includes("\r\n\r\n")) resolve(socket);
|
||||
});
|
||||
socket.on("close", () => reject(new Error("upgrade closed before the 101 arrived")));
|
||||
socket.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
describe("service proxy connection teardown", () => {
|
||||
it("closes the upstream connection when a client leaves an event stream", async () => {
|
||||
const upstreamResponse = Promise.withResolvers<http.ServerResponse>();
|
||||
const upstream = http.createServer((_req, res) => {
|
||||
res.writeHead(200, { "content-type": "text/event-stream" });
|
||||
res.write("data: open\n\n");
|
||||
upstreamResponse.resolve(res);
|
||||
});
|
||||
const fixture = await startProxiedService(upstream);
|
||||
try {
|
||||
const request = requestThroughProxy(fixture, "/events");
|
||||
const [response] = (await once(request, "response")) as [http.IncomingMessage];
|
||||
await once(response, "data");
|
||||
const upstreamClosed = closed(await upstreamResponse.promise);
|
||||
|
||||
request.destroy();
|
||||
|
||||
await within(upstreamClosed, 1000, "the service still holds the event stream");
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
it("aborts the upstream request when a client leaves before the response starts", async () => {
|
||||
const warnings: unknown[] = [];
|
||||
const proxyLogger = pino(
|
||||
{ level: "warn" },
|
||||
{ write: (line: string) => warnings.push(JSON.parse(line)) },
|
||||
);
|
||||
const upstreamResponse = Promise.withResolvers<http.ServerResponse>();
|
||||
const upstream = http.createServer((_req, res) => {
|
||||
upstreamResponse.resolve(res);
|
||||
});
|
||||
const fixture = await startProxiedService(upstream, { logger: proxyLogger });
|
||||
try {
|
||||
const request = requestThroughProxy(fixture, "/slow");
|
||||
request.on("error", () => undefined);
|
||||
const upstreamClosed = closed(await upstreamResponse.promise);
|
||||
|
||||
request.destroy();
|
||||
|
||||
await within(upstreamClosed, 1000, "the service still holds the abandoned request");
|
||||
await new Promise((resolve) => setTimeout(resolve, STRAY_WARNING_GRACE_MS));
|
||||
expect(warnings).toEqual([]);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
it("lets a complete response finish without resetting the service", async () => {
|
||||
const upstreamResponse = Promise.withResolvers<http.ServerResponse>();
|
||||
const upstream = http.createServer((_req, res) => {
|
||||
res.writeHead(200, { "content-type": "text/plain" });
|
||||
res.write("first;");
|
||||
upstreamResponse.resolve(res);
|
||||
});
|
||||
const fixture = await startProxiedService(upstream);
|
||||
try {
|
||||
const response = httpGet(fixture.daemonPort, fixture.hostname, { path: "/" });
|
||||
const res = await upstreamResponse.promise;
|
||||
const upstreamClosed = closed(res);
|
||||
|
||||
res.end("second");
|
||||
|
||||
await expect(response).resolves.toEqual({ status: 200, body: "first;second" });
|
||||
await within(upstreamClosed, 1000, "the service response never closed");
|
||||
expect(res.writableFinished).toBe(true);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
it("closes the upstream WebSocket when the daemon drops the client socket", async () => {
|
||||
const upstreamSocket = Promise.withResolvers<Duplex>();
|
||||
const fixture = await startProxiedService(
|
||||
createUpgradeUpstream((socket) => upstreamSocket.resolve(socket)),
|
||||
);
|
||||
const daemonSide = Promise.withResolvers<Duplex>();
|
||||
fixture.daemon.on("upgrade", (_req, socket) => daemonSide.resolve(socket));
|
||||
try {
|
||||
const client = await openUpgradeThroughProxy(fixture);
|
||||
const upstreamClosed = closed(await upstreamSocket.promise);
|
||||
|
||||
(await daemonSide.promise).destroy();
|
||||
|
||||
await within(upstreamClosed, 1000, "the service still holds the WebSocket");
|
||||
client.destroy();
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
it("closes the upstream WebSocket when the client disconnects", async () => {
|
||||
const upstreamSocket = Promise.withResolvers<Duplex>();
|
||||
const fixture = await startProxiedService(
|
||||
createUpgradeUpstream((socket) => upstreamSocket.resolve(socket)),
|
||||
);
|
||||
try {
|
||||
const client = await openUpgradeThroughProxy(fixture);
|
||||
const upstreamClosed = closed(await upstreamSocket.promise);
|
||||
|
||||
client.destroy();
|
||||
|
||||
await within(upstreamClosed, 1000, "the service still holds the WebSocket");
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
it("closes the client WebSocket when the service closes it", async () => {
|
||||
const upstreamSocket = Promise.withResolvers<Duplex>();
|
||||
const fixture = await startProxiedService(
|
||||
createUpgradeUpstream((socket) => upstreamSocket.resolve(socket)),
|
||||
);
|
||||
try {
|
||||
const client = await openUpgradeThroughProxy(fixture);
|
||||
const clientClosed = closed(client);
|
||||
|
||||
(await upstreamSocket.promise).destroy();
|
||||
|
||||
await within(clientClosed, 1000, "the client WebSocket is still open");
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
@@ -342,7 +342,14 @@ function proxyHttpRequest({
|
||||
proxyRes.pipe(res, { end: true });
|
||||
},
|
||||
);
|
||||
let clientClosed = false;
|
||||
res.on("close", () => {
|
||||
if (res.writableFinished) return;
|
||||
clientClosed = true;
|
||||
proxyReq.destroy();
|
||||
});
|
||||
proxyReq.on("error", (err) => {
|
||||
if (clientClosed) return;
|
||||
logger.warn(
|
||||
{ err, hostname: route.hostname, port: route.port },
|
||||
"Service proxy: upstream unreachable",
|
||||
@@ -399,6 +406,9 @@ function proxyUpgradeRequest({
|
||||
socket.on("error", () => {
|
||||
targetSocket.destroy();
|
||||
});
|
||||
socket.on("close", () => {
|
||||
targetSocket.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
function sameRouteOwner(left: ServiceProxyRouteEntry, right: ServiceProxyRouteEntry): boolean {
|
||||
|
||||
@@ -95,7 +95,13 @@ it("accepts terminalId and token reports through the route into the tracker", as
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"-e",
|
||||
`require("node:fs").writeFileSync(${JSON.stringify(envPath)}, JSON.stringify({ terminalId: process.env.PASEO_TERMINAL_ID, token: process.env.PASEO_ACTIVITY_TOKEN, url: process.env.PASEO_TERMINAL_ACTIVITY_URL })); setInterval(() => {}, 1000);`,
|
||||
`
|
||||
const fs = require("node:fs");
|
||||
const envPath = ${JSON.stringify(envPath)};
|
||||
fs.writeFileSync(envPath + ".tmp", JSON.stringify({ terminalId: process.env.PASEO_TERMINAL_ID, token: process.env.PASEO_ACTIVITY_TOKEN, url: process.env.PASEO_TERMINAL_ACTIVITY_URL }));
|
||||
fs.renameSync(envPath + ".tmp", envPath);
|
||||
setInterval(() => {}, 1000);
|
||||
`,
|
||||
],
|
||||
});
|
||||
await waitForCondition(() => existsSync(envPath), 10000);
|
||||
|
||||
@@ -410,7 +410,12 @@ describe("createTerminal", () => {
|
||||
cwd: realpathSync(tmpdir()),
|
||||
cols: 500,
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.stdout.write(JSON.stringify(process.argv.slice(1)))", nastyPrompt],
|
||||
// Keep the PTY alive until the screen assertion; exit disposes its parser.
|
||||
args: [
|
||||
"-e",
|
||||
"process.stdout.write(JSON.stringify(process.argv.slice(1))); setInterval(() => {}, 100000)",
|
||||
nastyPrompt,
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -186,7 +186,13 @@ function insertDocByPath(nodes: DocsNavNode[], doc: Doc, stripTopDirectory = fal
|
||||
|
||||
// A section's top-level directory and its category name the same thing, so
|
||||
// only directories below it become collapsible groups.
|
||||
if (stripTopDirectory) directories.shift();
|
||||
if (
|
||||
stripTopDirectory &&
|
||||
directories[0] &&
|
||||
doc.frontmatter.category?.toLowerCase().endsWith(directories[0].toLowerCase())
|
||||
) {
|
||||
directories.shift();
|
||||
}
|
||||
|
||||
let current = nodes;
|
||||
let parent: Extract<DocsNavNode, { type: "group" }> | undefined;
|
||||
|
||||
@@ -2,6 +2,25 @@ import { describe, expect, it } from "vitest";
|
||||
import { buildDocsNavTree, getDoc, getDocs, getLegacyDocsRedirect, parseHeadings } from "./docs";
|
||||
|
||||
describe("plugin documentation", () => {
|
||||
it.each(["en", "zh-CN"] as const)(
|
||||
"nests local file copying under Git worktrees in %s",
|
||||
(locale) => {
|
||||
const workspaces = buildDocsNavTree(getDocs(locale)).find(
|
||||
(node) => node.type === "category" && node.label === "Workspaces",
|
||||
);
|
||||
expect(workspaces).toMatchObject({
|
||||
children: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: "group",
|
||||
href: "/docs/worktrees",
|
||||
children: [expect.objectContaining({ href: "/docs/worktrees/worktreeinclude" })],
|
||||
}),
|
||||
]),
|
||||
});
|
||||
expect(getDoc("worktrees/worktreeinclude", locale)).toBeDefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("groups current guides by task without version navigation", () => {
|
||||
const plugins = buildDocsNavTree(getDocs("en")).find(
|
||||
(node) => node.type === "category" && node.label === "Plugins",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { expect, it } from "vitest";
|
||||
|
||||
const docsRoot = path.resolve(import.meta.dirname, "../../../public-docs");
|
||||
|
||||
it.each(["schedules.md", "schedules-cli.md", "mcp.md", "worktrees.md"])(
|
||||
it.each(["schedules.md", "schedules-cli.md", "mcp.md", "worktrees/index.md"])(
|
||||
"%s makes schedule run retention discoverable",
|
||||
async (file) => {
|
||||
const markdown = await readFile(path.join(docsRoot, file), "utf8");
|
||||
|
||||
@@ -5,14 +5,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { discover, fetchUsage } from "./usage.js";
|
||||
import type { UsageReport } from "@getpaseo/plugin/server/usage";
|
||||
|
||||
function kimiCredentialPath(dir: string): string {
|
||||
return join(dir, "credentials", "kimi-code.json");
|
||||
// The slot the Kimi CLI writes for a login to the global (.ai) deployment.
|
||||
const GLOBAL_SLOT = "kimi-code-env-0e4f99c69cc27850";
|
||||
|
||||
function kimiCredentialPath(dir: string, slot = "kimi-code"): string {
|
||||
return join(dir, "credentials", `${slot}.json`);
|
||||
}
|
||||
|
||||
function writeKimiCredentials(dir: string, accessToken: string, overrides: object = {}): void {
|
||||
function writeKimiCredentials(
|
||||
dir: string,
|
||||
accessToken: string,
|
||||
overrides: object = {},
|
||||
slot = "kimi-code",
|
||||
): void {
|
||||
mkdirSync(join(dir, "credentials"), { recursive: true });
|
||||
writeFileSync(
|
||||
kimiCredentialPath(dir),
|
||||
kimiCredentialPath(dir, slot),
|
||||
JSON.stringify({
|
||||
access_token: accessToken,
|
||||
refresh_token: "rt_kimi",
|
||||
@@ -173,6 +181,102 @@ describe("kimi usage source", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches usage from the global deployment for a kimi.ai login", async () => {
|
||||
writeKimiCredentials(join(homeDir, ".kimi-code"), "kimi_global_token", {}, GLOBAL_SLOT);
|
||||
let requestedUrl: string | null = null;
|
||||
let authorization: string | null = null;
|
||||
fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => {
|
||||
requestedUrl = url.toString();
|
||||
authorization = (init?.headers as Record<string, string> | undefined)?.Authorization ?? null;
|
||||
return jsonResponse({ usage: { limit: "100", remaining: "60" } });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const kimi = findProvider(await service().listUsage(), "kimi");
|
||||
|
||||
expect(requestedUrl).toBe("https://api.kimi.ai/coding/v1/usages");
|
||||
expect(authorization).toBe("Bearer kimi_global_token");
|
||||
expect(kimi.status).toBe("available");
|
||||
});
|
||||
|
||||
it("prefers the login the CLI still refreshes over a stale slot", async () => {
|
||||
const kimiHome = join(homeDir, ".kimi-code");
|
||||
writeKimiCredentials(kimiHome, "kimi_stale_token", { expires_at: 1_791_426_752 });
|
||||
writeKimiCredentials(kimiHome, "kimi_live_token", { expires_at: 1_793_292_337 }, GLOBAL_SLOT);
|
||||
|
||||
expect(await discover()).toEqual([
|
||||
{
|
||||
key: "default",
|
||||
input: {
|
||||
store: "file",
|
||||
locator: kimiCredentialPath(kimiHome, GLOBAL_SLOT),
|
||||
baseUrl: "https://api.kimi.ai/coding/v1",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("fetches global usage when an expired default-deployment login remains", async () => {
|
||||
const kimiHome = join(homeDir, ".kimi-code");
|
||||
writeKimiCredentials(kimiHome, "expired_default", { expires_at: 1_700_000_000 });
|
||||
writeKimiCredentials(kimiHome, "live_global", { expires_at: 2_000_000_000 }, GLOBAL_SLOT);
|
||||
const [account] = await discover();
|
||||
if (!account) throw new Error("No configured Kimi account");
|
||||
const requests: string[] = [];
|
||||
|
||||
const report = await fetchUsage(
|
||||
account.input as Parameters<typeof fetchUsage>[0],
|
||||
async (url, init) => {
|
||||
requests.push(url.toString());
|
||||
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer live_global");
|
||||
return jsonResponse({ usage: { limit: "100", remaining: "60" } });
|
||||
},
|
||||
);
|
||||
|
||||
expect(requests).toEqual(["https://api.kimi.ai/coding/v1/usages"]);
|
||||
expect(report).toMatchObject({ status: "available", windows: [{ usedPct: 40 }] });
|
||||
});
|
||||
|
||||
it("keeps KIMI_CODE_HOME ahead of a newer legacy-home login", async () => {
|
||||
const selectedHome = join(homeDir, "selected-kimi");
|
||||
process.env.KIMI_CODE_HOME = selectedHome;
|
||||
writeKimiCredentials(
|
||||
selectedHome,
|
||||
"selected_global_token",
|
||||
{ expires_at: 2_000_000_000 },
|
||||
GLOBAL_SLOT,
|
||||
);
|
||||
writeKimiCredentials(join(homeDir, ".kimi"), "legacy_token", { expires_at: 2_000_000_100 });
|
||||
|
||||
const accounts = await discover();
|
||||
|
||||
expect(accounts).toEqual([
|
||||
{
|
||||
key: "default",
|
||||
input: {
|
||||
store: "file",
|
||||
locator: kimiCredentialPath(selectedHome, GLOBAL_SLOT),
|
||||
baseUrl: "https://api.kimi.ai/coding/v1",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("asks the user to run kimi when the CLI token has expired", async () => {
|
||||
writeKimiCredentials(join(homeDir, ".kimi-code"), "kimi_cli_token", {
|
||||
expires_at: 1_700_000_000,
|
||||
});
|
||||
|
||||
const kimi = findProvider(await service().listUsage(), "kimi");
|
||||
|
||||
expect(kimi).toEqual({
|
||||
providerId: "kimi",
|
||||
status: "unavailable",
|
||||
problem: { kind: "expired", expiresAt: "2023-11-14T22:13:20.000Z", refreshedBy: "kimi" },
|
||||
error: null,
|
||||
planLabel: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads Kimi credentials whose optional fields are null", async () => {
|
||||
writeKimiCredentials(join(homeDir, ".kimi-code"), "kimi_cli_token", {
|
||||
expires_at: null,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { UsageInput } from "../shared/input.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
@@ -18,7 +19,21 @@ const ApiOptionalStringSchema = z.preprocess(
|
||||
z.coerce.string().optional(),
|
||||
);
|
||||
|
||||
const KIMI_USAGE_URL = "https://api.kimi.com/coding/v1/usages";
|
||||
const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1";
|
||||
|
||||
// The Kimi CLI's official deployments. A login writes its token to a credential
|
||||
// slot derived from the deployment, so a global (.ai) login never touches
|
||||
// kimi-code.json. Mirrors resolveKimiCodeOAuthKey in the Kimi CLI.
|
||||
const KIMI_CODE_DEPLOYMENTS = [
|
||||
{ oauthHost: "https://auth.kimi.com", baseUrl: KIMI_CODE_BASE_URL },
|
||||
{ oauthHost: "https://auth.kimi.ai", baseUrl: "https://api.kimi.ai/coding/v1" },
|
||||
];
|
||||
|
||||
function credentialSlot(deployment: { oauthHost: string; baseUrl: string }): string {
|
||||
if (deployment.baseUrl === KIMI_CODE_BASE_URL) return "kimi-code";
|
||||
const hash = createHash("sha256").update(JSON.stringify(deployment)).digest("hex");
|
||||
return `kimi-code-env-${hash.slice(0, 16)}`;
|
||||
}
|
||||
|
||||
const KimiUsageFieldsSchema = z.object({
|
||||
limit: ApiOptionalStringSchema,
|
||||
@@ -257,7 +272,7 @@ export async function fetchUsage(
|
||||
fetchApi: typeof fetch = fetch,
|
||||
): Promise<UsageReport> {
|
||||
async function callUsageApi(token: string): Promise<Response> {
|
||||
return fetchApi(KIMI_USAGE_URL, {
|
||||
return fetchApi(`${input.baseUrl ?? KIMI_CODE_BASE_URL}/usages`, {
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
@@ -272,6 +287,7 @@ export async function fetchUsage(
|
||||
return unavailable({
|
||||
kind: "expired",
|
||||
expiresAt: new Date(credentials.expires_at * 1000).toISOString(),
|
||||
refreshedBy: "kimi",
|
||||
});
|
||||
|
||||
const res = await callUsageApi(credentials.access_token);
|
||||
@@ -306,21 +322,31 @@ async function readCredentials(input: UsageInput): Promise<KimiCredentials | und
|
||||
}
|
||||
}
|
||||
export async function discover(): Promise<UsageAccount[]> {
|
||||
const candidates: UsageInput[] = ["KIMI_TOKEN", "KIMI_API_KEY"].map((locator) => ({
|
||||
store: "env",
|
||||
locator,
|
||||
}));
|
||||
candidates.push(
|
||||
...[
|
||||
join(
|
||||
process.env.KIMI_CODE_HOME || join(homedir(), ".kimi-code"),
|
||||
"credentials",
|
||||
"kimi-code.json",
|
||||
),
|
||||
join(homedir(), ".kimi", "credentials", "kimi-code.json"),
|
||||
].map((locator) => ({ store: "file" as const, locator })),
|
||||
);
|
||||
for (const input of candidates)
|
||||
for (const locator of ["KIMI_TOKEN", "KIMI_API_KEY"]) {
|
||||
const input: UsageInput = { store: "env", locator };
|
||||
if (await readCredentials(input)) return [{ key: "default", input }];
|
||||
}
|
||||
const homes = [
|
||||
process.env.KIMI_CODE_HOME || join(homedir(), ".kimi-code"),
|
||||
join(homedir(), ".kimi"),
|
||||
];
|
||||
// A login to another deployment leaves the old slot behind with a dead token.
|
||||
// Within each home, prefer the latest expiry. Keep the configured home ahead
|
||||
// of the legacy fallback, even if that fallback holds a newer token.
|
||||
for (const home of homes) {
|
||||
let found: { input: UsageInput; expiresAt: number } | undefined;
|
||||
for (const deployment of KIMI_CODE_DEPLOYMENTS) {
|
||||
const input: UsageInput = {
|
||||
store: "file",
|
||||
locator: join(home, "credentials", `${credentialSlot(deployment)}.json`),
|
||||
baseUrl: deployment.baseUrl,
|
||||
};
|
||||
const credentials = await readCredentials(input);
|
||||
if (!credentials) continue;
|
||||
const expiresAt = credentials.expires_at ?? Number.POSITIVE_INFINITY;
|
||||
if (!found || expiresAt > found.expiresAt) found = { input, expiresAt };
|
||||
}
|
||||
if (found) return [{ key: "default", input: found.input }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ export const inputSchema = z
|
||||
.object({
|
||||
store: z.enum(["env", "file"]),
|
||||
locator: z.string().min(1),
|
||||
baseUrl: z.url().optional(),
|
||||
})
|
||||
.strict();
|
||||
export type UsageInput = z.infer<typeof inputSchema>;
|
||||
|
||||
@@ -84,16 +84,7 @@ Add `--forge <name>` when Paseo cannot infer the forge from the source checkout.
|
||||
|
||||
## Copy local files with .worktreeinclude
|
||||
|
||||
Create `.worktreeinclude` at the root of your source checkout to copy selected gitignored files into new worktrees:
|
||||
|
||||
```gitignore
|
||||
.env
|
||||
config/*.local.json
|
||||
```
|
||||
|
||||
Patterns use gitignore syntax. A file must match `.worktreeinclude` and be gitignored to be copied. Tracked files are never copied, and existing destination files are never overwritten. Without `.worktreeinclude`, no local files are copied. Symlinked directories and nested repositories inside an included path cause the copy to fail and abort worktree creation.
|
||||
|
||||
The built-in `worktree-include` plugin copies from the checkout you create the worktree from, including when that checkout is another worktree. Copying finishes before setup, terminals, and the first agent. A copy failure aborts creation and removes the new worktree. Copies use copy-on-write clones on APFS and btrfs when supported, with a regular copy elsewhere.
|
||||
Use [`.worktreeinclude`](/docs/worktrees/worktreeinclude) to copy selected gitignored files, such as `.env`, from your source checkout before setup runs.
|
||||
|
||||
## paseo.json
|
||||
|
||||
@@ -82,16 +82,7 @@ paseo workspace create \
|
||||
|
||||
## 使用 .worktreeinclude 复制本地文件 {#copy-local-files-with-worktreeinclude}
|
||||
|
||||
在源检出的根目录创建 `.worktreeinclude`,将选定的 Git 忽略文件复制到新的 worktree:
|
||||
|
||||
```gitignore
|
||||
.env
|
||||
config/*.local.json
|
||||
```
|
||||
|
||||
模式使用 gitignore 语法。只有同时匹配 `.worktreeinclude` 且被 Git 忽略的文件才会复制。已跟踪的文件不会被复制,目标中已存在的文件不会被覆盖。没有 `.worktreeinclude` 时,不会复制任何本地文件。包含路径中的符号链接目录和嵌套仓库会导致复制失败,并中止 worktree 创建。
|
||||
|
||||
内置的 `worktree-include` 插件从你创建 worktree 时所选的检出复制文件,即使源检出本身也是 worktree。复制会在 setup、终端和第一个 Agent 运行之前完成。复制失败会中止创建并删除新的 worktree。支持时会在 APFS 和 btrfs 上使用写时复制克隆,否则执行普通复制。
|
||||
使用 [`.worktreeinclude`](/docs/worktrees/worktreeinclude),在 setup 运行前将 `.env` 等选定的 Git 忽略文件从源检出复制到新 worktree。
|
||||
|
||||
## paseo.json {#paseojson}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
title: .worktreeinclude
|
||||
description: Copy gitignored local files into new Paseo worktrees.
|
||||
nav: .worktreeinclude
|
||||
order: 12
|
||||
category: Workspaces
|
||||
---
|
||||
|
||||
# .worktreeinclude
|
||||
|
||||
Add `.worktreeinclude` at the root of your source checkout to copy local configuration into new [Git worktrees](/docs/worktrees). Paseo copies matching gitignored files before setup, terminals, and the first Agent start.
|
||||
|
||||
## Copy your local configuration
|
||||
|
||||
For a project whose `.gitignore` includes `.env` and `config/*.local.json`, create this `.worktreeinclude`:
|
||||
|
||||
```gitignore
|
||||
.env
|
||||
config/*.local.json
|
||||
```
|
||||
|
||||
When you create a worktree through Paseo, an existing `.env` and matching files such as `config/dev.local.json` are copied to the same relative paths in the new worktree. Their contents stay out of Git.
|
||||
|
||||
Commit `.worktreeinclude` to share the patterns with your team. Each teammate supplies their own ignored files in the checkout they create worktrees from.
|
||||
|
||||
## Pattern syntax
|
||||
|
||||
Use gitignore syntax, with one pattern per line. Comments start with `#`. Prefix a pattern with `!` to exclude a match:
|
||||
|
||||
```gitignore
|
||||
# Local environment and configuration
|
||||
.env
|
||||
config/*.local.json
|
||||
!config/production.local.json
|
||||
```
|
||||
|
||||
A pattern only selects files that Git already ignores. Adding a path here does not ignore it, so also configure `.gitignore` when needed. Tracked files and untracked files that Git does not ignore are excluded.
|
||||
|
||||
## When files are copied
|
||||
|
||||
The built-in `worktree-include` plugin reads the file from the checkout you create the worktree from. That source can itself be another worktree. Keep the plugin enabled for copying to run.
|
||||
|
||||
- Without `.worktreeinclude`, the plugin copies nothing.
|
||||
- Matching files must exist in the source checkout.
|
||||
- Existing destination files are skipped, preserving their contents.
|
||||
- Copies happen during creation, with no ongoing synchronization between checkouts.
|
||||
- A copy error aborts creation and removes the new worktree. Avoid including symlinked directories or nested repositories, which cannot be copied as files.
|
||||
|
||||
## Run commands during setup
|
||||
|
||||
Use `.worktreeinclude` for files that already exist locally. Use a [setup hook](/docs/worktrees#setup-and-teardown) to install dependencies, generate files, or fetch secrets:
|
||||
|
||||
```json
|
||||
{
|
||||
"worktree": {
|
||||
"setup": "npm ci"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Put this configuration in `paseo.json` on the base branch used for the new worktree. Copied files are available when setup runs.
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
title: .worktreeinclude
|
||||
description: 将 Git 忽略的本地文件复制到新的 Paseo worktree。
|
||||
nav: .worktreeinclude
|
||||
order: 12
|
||||
category: Workspaces
|
||||
---
|
||||
|
||||
# .worktreeinclude
|
||||
|
||||
在源检出的根目录添加 `.worktreeinclude`,将本地配置复制到新的 [Git worktree](/docs/worktrees)。Paseo 会在 setup、终端和首个 Agent 启动之前复制匹配的 Git 忽略文件。
|
||||
|
||||
## 复制本地配置 {#copy-your-local-configuration}
|
||||
|
||||
如果项目的 `.gitignore` 包含 `.env` 和 `config/*.local.json`,创建以下 `.worktreeinclude`:
|
||||
|
||||
```gitignore
|
||||
.env
|
||||
config/*.local.json
|
||||
```
|
||||
|
||||
通过 Paseo 创建 worktree 时,已有的 `.env` 和 `config/dev.local.json` 等匹配文件会被复制到新 worktree 中的相同相对路径。文件内容仍不会纳入 Git。
|
||||
|
||||
提交 `.worktreeinclude`,与团队共享这些模式。每位团队成员在用于创建 worktree 的检出中提供自己的忽略文件。
|
||||
|
||||
## 模式语法 {#pattern-syntax}
|
||||
|
||||
使用 gitignore 语法,每行一个模式。注释以 `#` 开头。模式前加 `!` 可排除匹配项:
|
||||
|
||||
```gitignore
|
||||
# Local environment and configuration
|
||||
.env
|
||||
config/*.local.json
|
||||
!config/production.local.json
|
||||
```
|
||||
|
||||
模式只会选择 Git 已忽略的文件。在此添加路径不会使 Git 忽略该文件,需要时还应配置 `.gitignore`。已跟踪的文件和未被 Git 忽略的未跟踪文件都不会被复制。
|
||||
|
||||
## 文件复制时机 {#when-files-are-copied}
|
||||
|
||||
内置的 `worktree-include` 插件从用于创建 worktree 的检出读取该文件。源检出也可以是另一个 worktree。保持该插件启用才能执行复制。
|
||||
|
||||
- 没有 `.worktreeinclude` 时,插件不会复制任何文件。
|
||||
- 匹配的文件必须存在于源检出中。
|
||||
- 目标中已存在的文件会被跳过,保留原有内容。
|
||||
- 复制发生在创建期间,之后不会在检出之间持续同步。
|
||||
- 复制错误会中止创建并删除新 worktree。避免包含符号链接目录或嵌套仓库,因为它们无法作为文件复制。
|
||||
|
||||
## 在 setup 期间运行命令 {#run-commands-during-setup}
|
||||
|
||||
使用 `.worktreeinclude` 复制本地已有文件。使用 [setup 钩子](/docs/worktrees#setup-and-teardown) 安装依赖、生成文件或获取密钥:
|
||||
|
||||
```json
|
||||
{
|
||||
"worktree": {
|
||||
"setup": "npm ci"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
将此配置放入新 worktree 所用基准分支的 `paseo.json` 中。setup 运行时,复制的文件已可使用。
|
||||
@@ -1,5 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, readdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { readFileSync, readdirSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
@@ -679,3 +679,53 @@ test("macOS jobs use latest runners while preserving release architectures", ()
|
||||
// GitHub has no standard macos-latest-intel alias; use the newest Intel image.
|
||||
assert.match(release, /runner: macos-26-intel\n\s+electron_arch: x64/);
|
||||
});
|
||||
|
||||
test("macOS signing repair keeps certificate and keychain passwords separate", () => {
|
||||
const workflow = readFileSync(new URL(".github/workflows/desktop-release.yml", repoRoot), "utf8");
|
||||
const match = workflow.match(/<<'KEYCHAIN_FIX'\n([\s\S]*?)\n KEYCHAIN_FIX/);
|
||||
assert.ok(match, "the release workflow repairs the pinned signing tool");
|
||||
const script = match[1].replace(/^ /gm, "");
|
||||
const original = [
|
||||
"return await importCerts(keychainFile, certPaths, cscPasswords);",
|
||||
"async function importCerts(keychainFile, paths, keyPasswords) {",
|
||||
'["import", paths[i], "-P", password]',
|
||||
'["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]',
|
||||
"}",
|
||||
].join("\n");
|
||||
const directory = mkdtempSync(join(tmpdir(), "signing-repair-"));
|
||||
try {
|
||||
const packageDirectory = join(directory, "node_modules/app-builder-lib");
|
||||
mkdirSync(join(packageDirectory, "out/codeSign"), { recursive: true });
|
||||
const manifest = join(packageDirectory, "package.json");
|
||||
const sourcePath = join(packageDirectory, "out/codeSign/macCodeSign.js");
|
||||
for (const [version, source, succeeds] of [
|
||||
["26.8.1", original, true],
|
||||
["26.8.1", original.replace("cscPasswords);", "differentArguments);"), false],
|
||||
["26.18.0", original, false],
|
||||
]) {
|
||||
writeFileSync(manifest, JSON.stringify({ name: "app-builder-lib", version }));
|
||||
writeFileSync(sourcePath, source);
|
||||
const result = spawnSync(process.execPath, ["-e", script], {
|
||||
cwd: directory,
|
||||
encoding: "utf8",
|
||||
});
|
||||
const actual = readFileSync(sourcePath, "utf8");
|
||||
if (succeeds) {
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.ok(
|
||||
actual.includes("importCerts(keychainFile, certPaths, cscPasswords, keychainPassword)"),
|
||||
);
|
||||
assert.ok(
|
||||
actual.includes("importCerts(keychainFile, paths, keyPasswords, keychainPassword)"),
|
||||
);
|
||||
assert.ok(actual.includes('["import", paths[i], "-P", password]'));
|
||||
assert.ok(actual.includes('"-k", keychainPassword, keychainFile]'));
|
||||
} else {
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.equal(actual, source, "unexpected tool inputs must fail without a partial write");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user