Merge 902e3f56d1 into 4583f88626
This commit is contained in:
commit
cabe10e8dc
@ -40,6 +40,7 @@ Status: beta.
|
|||||||
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
|
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
- Agents: initialize ExtensionRunner in embedded mode so compaction hooks produce real summaries instead of "Summary unavailable...". (#2027)
|
||||||
- Security: harden SSH tunnel target parsing to prevent option injection/DoS. (#4001) Thanks @YLChen-007.
|
- Security: harden SSH tunnel target parsing to prevent option injection/DoS. (#4001) Thanks @YLChen-007.
|
||||||
- Security: prevent PATH injection in exec sandbox; harden file serving; pin DNS in URL fetches; verify Twilio webhooks; fix LINE webhook timing-attack edge case; validate Tailscale Serve identity; flag loopback Control UI with auth disabled as critical. (#1616, #1795)
|
- Security: prevent PATH injection in exec sandbox; harden file serving; pin DNS in URL fetches; verify Twilio webhooks; fix LINE webhook timing-attack edge case; validate Tailscale Serve identity; flag loopback Control UI with auth disabled as critical. (#1616, #1795)
|
||||||
- Gateway: prevent crashes on transient network errors, suppress AbortError/unhandled rejections, sanitize error responses, clean session locks on exit, and harden reverse proxy handling for unauthenticated proxied connects. (#2980, #2451, #2483, #1795)
|
- Gateway: prevent crashes on transient network errors, suppress AbortError/unhandled rejections, sanitize error responses, clean session locks on exit, and harden reverse proxy handling for unauthenticated proxied connects. (#2980, #2451, #2483, #1795)
|
||||||
|
|||||||
@ -4,7 +4,12 @@ import os from "node:os";
|
|||||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||||
import type { AssistantMessage, ImageContent } from "@mariozechner/pi-ai";
|
import type { AssistantMessage, ImageContent } from "@mariozechner/pi-ai";
|
||||||
import { streamSimple } from "@mariozechner/pi-ai";
|
import { streamSimple } from "@mariozechner/pi-ai";
|
||||||
import { createAgentSession, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent";
|
import {
|
||||||
|
createAgentSession,
|
||||||
|
SessionManager,
|
||||||
|
SettingsManager,
|
||||||
|
type CompactOptions,
|
||||||
|
} from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js";
|
import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js";
|
||||||
import {
|
import {
|
||||||
@ -492,6 +497,74 @@ export async function runEmbeddedAttempt(
|
|||||||
// Force a stable streamFn reference so vitest can reliably mock @mariozechner/pi-ai.
|
// Force a stable streamFn reference so vitest can reliably mock @mariozechner/pi-ai.
|
||||||
activeSession.agent.streamFn = streamSimple;
|
activeSession.agent.streamFn = streamSimple;
|
||||||
|
|
||||||
|
// IMPORTANT (embedded mode): initialize the pi extension runner.
|
||||||
|
//
|
||||||
|
// In pi-coding-agent, ExtensionRunner defaults `ctx.model` to `undefined` until
|
||||||
|
// `extensionRunner.initialize(...)` is called. Moltbot uses the SDK in embedded
|
||||||
|
// mode (no interactive UI), so without this initialization compaction hooks that
|
||||||
|
// rely on `ctx.model` (e.g. `compaction-safeguard`) will always fall back to
|
||||||
|
// "Summary unavailable..." and history will be truncated without a summary.
|
||||||
|
const extensionRunner = activeSession.extensionRunner;
|
||||||
|
if (extensionRunner) {
|
||||||
|
extensionRunner.initialize(
|
||||||
|
{
|
||||||
|
// Embedded runs don't have a UI; keep UI-dependent actions as safe no-ops.
|
||||||
|
sendMessage: async () => {},
|
||||||
|
sendUserMessage: async () => {},
|
||||||
|
appendEntry: () => {},
|
||||||
|
setSessionName: () => {},
|
||||||
|
getSessionName: () => undefined,
|
||||||
|
setLabel: () => {},
|
||||||
|
getActiveTools: () =>
|
||||||
|
(activeSession.agent.state.tools ?? [])
|
||||||
|
.map((tool) => {
|
||||||
|
if (!tool) return undefined;
|
||||||
|
if (typeof tool === "string") return tool;
|
||||||
|
if (typeof tool === "object" && "name" in tool) {
|
||||||
|
const name = (tool as { name?: unknown }).name;
|
||||||
|
return typeof name === "string" ? name : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
})
|
||||||
|
.filter((name): name is string => typeof name === "string"),
|
||||||
|
getAllTools: () => [],
|
||||||
|
setActiveTools: () => {},
|
||||||
|
setModel: async (model) => {
|
||||||
|
const key = await activeSession.modelRegistry.getApiKey(model);
|
||||||
|
if (!key) return false;
|
||||||
|
await activeSession.setModel(model);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
getThinkingLevel: () => activeSession.thinkingLevel,
|
||||||
|
setThinkingLevel: (level) => {
|
||||||
|
activeSession.setThinkingLevel(level);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Provide live model + context access for extensions.
|
||||||
|
getModel: () => activeSession.agent.state.model,
|
||||||
|
isIdle: () => true,
|
||||||
|
abort: () => {
|
||||||
|
void activeSession.abort();
|
||||||
|
},
|
||||||
|
hasPendingMessages: () => false,
|
||||||
|
shutdown: () => {},
|
||||||
|
getContextUsage: () => activeSession.getContextUsage(),
|
||||||
|
compact: (options?: CompactOptions) => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const result = await activeSession.compact(options?.customInstructions);
|
||||||
|
options?.onComplete?.(result);
|
||||||
|
} catch (error) {
|
||||||
|
const err = error instanceof Error ? error : new Error(String(error));
|
||||||
|
options?.onError?.(err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
applyExtraParamsToAgent(
|
applyExtraParamsToAgent(
|
||||||
activeSession.agent,
|
activeSession.agent,
|
||||||
params.config,
|
params.config,
|
||||||
|
|||||||
@ -112,6 +112,9 @@ describe("runGatewayUpdate", () => {
|
|||||||
"pnpm install": { stdout: "" },
|
"pnpm install": { stdout: "" },
|
||||||
"pnpm build": { stdout: "" },
|
"pnpm build": { stdout: "" },
|
||||||
"pnpm ui:build": { stdout: "" },
|
"pnpm ui:build": { stdout: "" },
|
||||||
|
[`git -C ${tempDir} ls-files -- dist/control-ui/`]: {
|
||||||
|
stdout: "dist/control-ui/index.html\n",
|
||||||
|
},
|
||||||
[`git -C ${tempDir} checkout -- dist/control-ui/`]: { stdout: "" },
|
[`git -C ${tempDir} checkout -- dist/control-ui/`]: { stdout: "" },
|
||||||
"pnpm moltbot doctor --non-interactive": { stdout: "" },
|
"pnpm moltbot doctor --non-interactive": { stdout: "" },
|
||||||
});
|
});
|
||||||
@ -128,6 +131,39 @@ describe("runGatewayUpdate", () => {
|
|||||||
expect(calls).not.toContain(`git -C ${tempDir} checkout --detach ${betaTag}`);
|
expect(calls).not.toContain(`git -C ${tempDir} checkout --detach ${betaTag}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("skips restoring control-ui when dist/control-ui is not tracked", async () => {
|
||||||
|
await fs.mkdir(path.join(tempDir, ".git"));
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(tempDir, "package.json"),
|
||||||
|
JSON.stringify({ name: "moltbot", version: "1.0.0", packageManager: "pnpm@8.0.0" }),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
const stableTag = "v1.0.1-1";
|
||||||
|
const { runner, calls } = createRunner({
|
||||||
|
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
|
||||||
|
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
|
||||||
|
[`git -C ${tempDir} status --porcelain -- :!dist/control-ui/`]: { stdout: "" },
|
||||||
|
[`git -C ${tempDir} fetch --all --prune --tags`]: { stdout: "" },
|
||||||
|
[`git -C ${tempDir} tag --list v* --sort=-v:refname`]: { stdout: `${stableTag}\n` },
|
||||||
|
[`git -C ${tempDir} checkout --detach ${stableTag}`]: { stdout: "" },
|
||||||
|
"pnpm install": { stdout: "" },
|
||||||
|
"pnpm build": { stdout: "" },
|
||||||
|
"pnpm ui:build": { stdout: "" },
|
||||||
|
[`git -C ${tempDir} ls-files -- dist/control-ui/`]: { stdout: "" },
|
||||||
|
"pnpm moltbot doctor --non-interactive": { stdout: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runGatewayUpdate({
|
||||||
|
cwd: tempDir,
|
||||||
|
runCommand: async (argv, _options) => runner(argv),
|
||||||
|
timeoutMs: 5000,
|
||||||
|
channel: "beta",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe("ok");
|
||||||
|
expect(calls).not.toContain(`git -C ${tempDir} checkout -- dist/control-ui/`);
|
||||||
|
});
|
||||||
|
|
||||||
it("skips update when no git root", async () => {
|
it("skips update when no git root", async () => {
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(tempDir, "package.json"),
|
path.join(tempDir, "package.json"),
|
||||||
|
|||||||
@ -275,6 +275,41 @@ async function runStep(opts: RunStepOptions): Promise<UpdateStepResult> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function skipStep(
|
||||||
|
opts: Pick<RunStepOptions, "name" | "argv" | "cwd" | "progress" | "stepIndex" | "totalSteps"> & {
|
||||||
|
stdoutTail?: string | null;
|
||||||
|
stderrTail?: string | null;
|
||||||
|
},
|
||||||
|
): UpdateStepResult {
|
||||||
|
const { name, argv, cwd, progress, stepIndex, totalSteps, stdoutTail, stderrTail } = opts;
|
||||||
|
const command = argv.join(" ");
|
||||||
|
|
||||||
|
const stepInfo: UpdateStepInfo = {
|
||||||
|
name,
|
||||||
|
command,
|
||||||
|
index: stepIndex,
|
||||||
|
total: totalSteps,
|
||||||
|
};
|
||||||
|
|
||||||
|
progress?.onStepStart?.(stepInfo);
|
||||||
|
progress?.onStepComplete?.({
|
||||||
|
...stepInfo,
|
||||||
|
durationMs: 0,
|
||||||
|
exitCode: 0,
|
||||||
|
stderrTail: stderrTail ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
command,
|
||||||
|
cwd,
|
||||||
|
durationMs: 0,
|
||||||
|
exitCode: 0,
|
||||||
|
stdoutTail: stdoutTail ?? null,
|
||||||
|
stderrTail: stderrTail ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function managerScriptArgs(manager: "pnpm" | "bun" | "npm", script: string, args: string[] = []) {
|
function managerScriptArgs(manager: "pnpm" | "bun" | "npm", script: string, args: string[] = []) {
|
||||||
if (manager === "pnpm") return ["pnpm", script, ...args];
|
if (manager === "pnpm") return ["pnpm", script, ...args];
|
||||||
if (manager === "bun") return ["bun", "run", script, ...args];
|
if (manager === "bun") return ["bun", "run", script, ...args];
|
||||||
@ -678,13 +713,26 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
|
|||||||
|
|
||||||
// Restore dist/control-ui/ to committed state to prevent dirty repo after update
|
// Restore dist/control-ui/ to committed state to prevent dirty repo after update
|
||||||
// (ui:build regenerates assets with new hashes, which would block future updates)
|
// (ui:build regenerates assets with new hashes, which would block future updates)
|
||||||
const restoreUiStep = await runStep(
|
const restoreUiOpts = step(
|
||||||
step(
|
"restore control-ui",
|
||||||
"restore control-ui",
|
["git", "-C", gitRoot, "checkout", "--", "dist/control-ui/"],
|
||||||
["git", "-C", gitRoot, "checkout", "--", "dist/control-ui/"],
|
gitRoot,
|
||||||
gitRoot,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
const lsFilesResult = await runCommand(
|
||||||
|
["git", "-C", gitRoot, "ls-files", "--", "dist/control-ui/"],
|
||||||
|
{
|
||||||
|
cwd: gitRoot,
|
||||||
|
timeoutMs,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const hasTrackedControlUiFiles =
|
||||||
|
lsFilesResult.code === 0 && lsFilesResult.stdout.trim().length > 0;
|
||||||
|
const restoreUiStep = hasTrackedControlUiFiles
|
||||||
|
? await runStep(restoreUiOpts)
|
||||||
|
: skipStep({
|
||||||
|
...restoreUiOpts,
|
||||||
|
stdoutTail: "Skipped: dist/control-ui/ is not tracked in git.",
|
||||||
|
});
|
||||||
steps.push(restoreUiStep);
|
steps.push(restoreUiStep);
|
||||||
|
|
||||||
const doctorStep = await runStep(
|
const doctorStep = await runStep(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user