Compare commits

..
Author SHA1 Message Date
Peter Steinberger 53a40871fe fix: preserve google-antigravity tool calls (#894) (thanks @mukhtharcm) 2026-01-16 08:12:41 +00:00
Muhammed Mukhthar CM f99e2eaebb Lint: formatting and unused vars in test 2026-01-16 08:12:41 +00:00
Muhammed Mukhthar CM e41a8c8af1 Lint: fix formatting in test 2026-01-16 08:12:41 +00:00
Muhammed Mukhthar CM a50bee4383 Agent: exclude google-antigravity from history downgrade hack 2026-01-16 08:12:41 +00:00
Peter Steinberger d0cb4e092f Merge pull request #845 from MatthieuBizien/fix/issue-841-openrouter-gemini
Agents: sanitize OpenRouter Gemini thoughtSignature
2026-01-16 08:03:40 +00:00
Peter Steinberger f5a881c99d fix: port OpenRouter Gemini sanitization to split files (#845) (thanks @MatthieuBizien) 2026-01-16 08:02:56 +00:00
Peter Steinberger 66377fc030 fix: update macOS IPC tests 2026-01-16 07:58:35 +00:00
Matthieu Bizien d8d295b0b3 Changelog: thank PR #845 2026-01-16 07:51:49 +00:00
Matthieu Bizien ef36e24522 Agents: sanitize OpenRouter Gemini thoughtSignature 2026-01-16 07:51:49 +00:00
Palash OswalandPeter Steinberger d43d4fcced Gateway auth: accept local Tailscale Serve hostnames and tailnet IPs (#885)
* Gateway auth: accept local Tailscale Serve hostnames and tailnet IPs

* fix: allow local Tailscale Serve hostnames (#885) (thanks @oswalpalash)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-16 07:51:25 +00:00
Peter Steinberger d42b69df74 Merge pull request #982 from wes-davis/fix/gateway-connection-diagnostics
macOS: keep gateway connected (stop port flapping)
2026-01-16 07:36:46 +00:00
Peter Steinberger 1ec1f6dcbf fix: sync remote ssh targets 2026-01-16 07:33:15 +00:00
Peter Steinberger e96b939732 feat: add system.which bin probe 2026-01-16 07:31:41 +00:00
Peter Steinberger e479c870fd fix: handle MiniMax coding plan usage payloads 2026-01-16 07:28:48 +00:00
Wes 509215e935 macOS: stop flapping gateway port 2026-01-15 17:12:14 -08:00
Wes f726656d1e macOS: start gateway before app in restart-mac 2026-01-15 17:12:14 -08:00
Wes 30d3e1da21 CLI: fix status --all gateway auth selection 2026-01-15 17:12:14 -08:00
36 changed files with 1061 additions and 91 deletions

No files matched your search

+6 -1
View File
@@ -4,6 +4,7 @@
- **BREAKING:** Microsoft Teams is now a plugin; install `@clawdbot/msteams` via `clawdbot plugins install @clawdbot/msteams`.
- CLI: set process titles to `clawdbot-<command>` for clearer process listings.
- CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware).
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
- Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
@@ -17,8 +18,10 @@
- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.
- Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).
- Agents: avoid false positives when logging unsupported Google tool schema keywords.
- Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm.
- Status: restore usage summary line for current provider when no OAuth profiles exist.
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
- macOS: add `system.which` for prompt-free remote skill discovery (with gateway fallback to `system.run`).
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
- Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.
@@ -26,7 +29,7 @@
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
- Fix: macOS gateway stays attached across restarts (no port flapping) and status probes pick the right auth. (#982) — thanks @wes-davis.
- Fix: support MiniMax coding plan usage responses with `model_remains`/`current_interval_*` payloads.
- CLI: add `--json` output for `clawdbot daemon` lifecycle/install commands.
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
@@ -40,6 +43,7 @@
- Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.
- Fix: sanitize user-facing error text + strip `<final>` tags across reply pipelines. (#975) — thanks @ThomsenDrake.
- Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.
- Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.
## 2026.1.14-1
@@ -141,6 +145,7 @@
### Fixes
- Packaging: include `dist/memory/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/memory/index.js`).
- Agents: persist sub-agent registry across gateway restarts and resume announce flow safely. (#831) — thanks @roshanasingh4.
- Agents: strip invalid Gemini thought signatures from OpenRouter history to avoid 400s. (#841, #845) — thanks @MatthieuBizien.
## 2026.1.12-1
+49 -8
View File
@@ -341,6 +341,15 @@ final class AppState {
return host
}
private static func sanitizeSSHTarget(_ value: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.hasPrefix("ssh ") {
return trimmed.replacingOccurrences(of: "ssh ", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
return trimmed
}
private func startConfigWatcher() {
let configUrl = ClawdbotConfigFile.url()
self.configWatcher = ConfigFileWatcher(url: configUrl) { [weak self] in
@@ -406,6 +415,7 @@ final class AppState {
let connectionMode = self.connectionMode
let remoteTarget = self.remoteTarget
let remoteIdentity = self.remoteIdentity
let desiredMode: String? = switch connectionMode {
case .local:
"local"
@@ -435,15 +445,46 @@ final class AppState {
changed = true
}
if connectionMode == .remote, let host = remoteHost {
if connectionMode == .remote {
var remote = gateway["remote"] as? [String: Any] ?? [:]
let existingUrl = (remote["url"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
let port = parsedExisting?.port ?? 18789
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
if existingUrl != desiredUrl {
remote["url"] = desiredUrl
var remoteChanged = false
if let host = remoteHost {
let existingUrl = (remote["url"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
let port = parsedExisting?.port ?? 18789
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
if existingUrl != desiredUrl {
remote["url"] = desiredUrl
remoteChanged = true
}
}
let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget)
if !sanitizedTarget.isEmpty {
if (remote["sshTarget"] as? String) != sanitizedTarget {
remote["sshTarget"] = sanitizedTarget
remoteChanged = true
}
} else if remote["sshTarget"] != nil {
remote.removeValue(forKey: "sshTarget")
remoteChanged = true
}
let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedIdentity.isEmpty {
if (remote["sshIdentity"] as? String) != trimmedIdentity {
remote["sshIdentity"] = trimmedIdentity
remoteChanged = true
}
} else if remote["sshIdentity"] != nil {
remote.removeValue(forKey: "sshIdentity")
remoteChanged = true
}
if remoteChanged {
gateway["remote"] = remote
changed = true
}
@@ -188,9 +188,15 @@ extension CronJobEditor {
}
}
func applyDeleteAfterRun(to root: inout [String: Any]) {
if self.scheduleKind == .at {
root["deleteAfterRun"] = self.deleteAfterRun
func applyDeleteAfterRun(
to root: inout [String: Any],
scheduleKind: ScheduleKind? = nil,
deleteAfterRun: Bool? = nil
) {
let resolvedSchedule = scheduleKind ?? self.scheduleKind
let resolvedDelete = deleteAfterRun ?? self.deleteAfterRun
if resolvedSchedule == .at {
root["deleteAfterRun"] = resolvedDelete
} else if self.job?.deleteAfterRun != nil {
root["deleteAfterRun"] = false
}
@@ -178,7 +178,7 @@ enum GatewayEnvironment {
let port = self.gatewayPort()
if let gatewayBin {
let bind = self.preferredGatewayBind() ?? "loopback"
let cmd = [gatewayBin, "gateway", "--port", "\(port)", "--bind", bind]
let cmd = [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]
return GatewayCommandResolution(status: status, command: cmd)
}
@@ -186,7 +186,7 @@ enum GatewayEnvironment {
case let .success(resolvedRuntime) = runtime
{
let bind = self.preferredGatewayBind() ?? "loopback"
let cmd = [resolvedRuntime.path, entry, "gateway", "--port", "\(port)", "--bind", bind]
let cmd = [resolvedRuntime.path, entry, "gateway-daemon", "--port", "\(port)", "--bind", bind]
return GatewayCommandResolution(status: status, command: cmd)
}
@@ -164,6 +164,7 @@ final class MacNodeModeCoordinator {
]
if SystemRunPolicy.load() != .never {
commands.append(ClawdbotSystemCommand.which.rawValue)
commands.append(ClawdbotSystemCommand.run.rawValue)
}
@@ -55,6 +55,8 @@ actor MacNodeRuntime {
return try await self.handleScreenRecordInvoke(req)
case ClawdbotSystemCommand.run.rawValue:
return try await self.handleSystemRun(req)
case ClawdbotSystemCommand.which.rawValue:
return try await self.handleSystemWhich(req)
case ClawdbotSystemCommand.notify.rawValue:
return try await self.handleSystemNotify(req)
default:
@@ -494,6 +496,33 @@ actor MacNodeRuntime {
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
private func handleSystemWhich(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
let params = try Self.decodeParams(ClawdbotSystemWhichParams.self, from: req.paramsJSON)
let bins = params.bins
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
guard !bins.isEmpty else {
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: bins required")
}
let searchPaths = CommandResolver.preferredPaths()
var matches: [String] = []
var paths: [String: String] = [:]
for bin in bins {
if let path = CommandResolver.findExecutable(named: bin, searchPaths: searchPaths) {
matches.append(bin)
paths[bin] = path
}
}
struct WhichPayload: Encodable {
let bins: [String]
let paths: [String: String]
}
let payload = try Self.encodePayload(WhichPayload(bins: matches, paths: paths))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
let params = try Self.decodeParams(ClawdbotSystemNotifyParams.self, from: req.paramsJSON)
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -351,11 +351,8 @@ actor PortGuardian {
if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") }
return false
case .local:
// The gateway may listen as `clawdbot` or via its runtime (e.g. node).
let hasGatewayArgs =
full.contains(" gateway ") &&
full.contains(" --port \(port)")
if hasGatewayArgs { return true }
// The gateway daemon may listen as `clawdbot` or as its runtime (`node`, `bun`, etc).
if full.contains("gateway-daemon") { return true }
// If args are unavailable, treat a clawdbot listener as expected.
if cmd.contains("clawdbot"), full == cmd { return true }
return false
@@ -1,3 +1,4 @@
import ClawdbotProtocol
import SwiftUI
import Testing
@testable import Clawdbot
@@ -38,7 +38,7 @@ struct CronJobEditorSmokeTests {
thinking: "low",
timeoutSeconds: 120,
deliver: true,
provider: "whatsapp",
channel: "whatsapp",
to: "+15551234567",
bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "Cron"),
@@ -70,22 +70,16 @@ struct CronJobEditorSmokeTests {
}
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws {
var view = CronJobEditor(
let view = CronJobEditor(
job: nil,
isSaving: .constant(false),
error: .constant(nil),
onCancel: {},
onSave: { _ in })
view.name = "One-shot"
view.sessionTarget = .main
view.payloadKind = .systemEvent
view.systemEventText = "hello"
view.scheduleKind = .at
view.atDate = Date(timeIntervalSince1970: 1_700_000_000)
view.deleteAfterRun = true
let payload = try view.buildPayload()
let raw = payload["deleteAfterRun"]?.value as? Bool
var root: [String: Any] = [:]
view.applyDeleteAfterRun(to: &root, scheduleKind: .at, deleteAfterRun: true)
let raw = root["deleteAfterRun"] as? Bool
#expect(raw == true)
}
}
@@ -31,7 +31,7 @@ struct CronModelsTests {
thinking: "low",
timeoutSeconds: 15,
deliver: true,
provider: "whatsapp",
channel: "whatsapp",
to: "+15551234567",
bestEffortDeliver: false)
let data = try JSONEncoder().encode(payload)
@@ -7,7 +7,7 @@ import Testing
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
let plist: [String: Any] = [
"ProgramArguments": ["clawdbot", "gateway", "--port", "18789", "--bind", "loopback"],
"ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789", "--bind", "loopback"],
"EnvironmentVariables": [
"CLAWDBOT_GATEWAY_TOKEN": " secret ",
"CLAWDBOT_GATEWAY_PASSWORD": "pw",
@@ -28,7 +28,7 @@ import Testing
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
let plist: [String: Any] = [
"ProgramArguments": ["clawdbot", "gateway", "--port", "18789"],
"ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789"],
]
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
try data.write(to: url, options: [.atomic])
@@ -11,7 +11,8 @@ import Testing
stateversion: StateVersion(presence: 1, health: 1),
uptimems: 123,
configpath: nil,
statedir: nil)
statedir: nil,
sessiondefaults: nil)
let hello = HelloOk(
type: "hello",
@@ -21,6 +21,15 @@ struct MacNodeRuntimeTests {
#expect(response.ok == false)
}
@Test func handleInvokeRejectsEmptySystemWhich() async throws {
let runtime = MacNodeRuntime()
let params = ClawdbotSystemWhichParams(bins: [])
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(
BridgeInvokeRequest(id: "req-2b", command: ClawdbotSystemCommand.which.rawValue, paramsJSON: json))
#expect(response.ok == false)
}
@Test func handleInvokeRejectsEmptyNotification() async throws {
let runtime = MacNodeRuntime()
let params = ClawdbotSystemNotifyParams(title: "", body: "")
@@ -65,6 +74,10 @@ struct MacNodeRuntimeTests {
{
CLLocation(latitude: 0, longitude: 0)
}
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision {
.allowOnce
}
}
let services = await MainActor.run { FakeMainActorServices() }
@@ -16,13 +16,13 @@ struct OnboardingViewSmokeTests {
}
@Test func pageOrderOmitsWorkspaceAndIdentitySteps() {
let order = OnboardingView.pageOrder(for: .local, needsBootstrap: false)
let order = OnboardingView.pageOrder(for: .local, showOnboardingChat: false)
#expect(!order.contains(7))
#expect(order.contains(3))
}
@Test func pageOrderOmitsOnboardingChatWhenIdentityKnown() {
let order = OnboardingView.pageOrder(for: .local, needsBootstrap: false)
let order = OnboardingView.pageOrder(for: .local, showOnboardingChat: false)
#expect(!order.contains(8))
}
}
@@ -49,7 +49,7 @@ struct SettingsViewSmokeTests {
thinking: "low",
timeoutSeconds: 30,
deliver: true,
provider: "sms",
channel: "sms",
to: "+15551234567",
bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "[cron] "),
@@ -17,6 +17,6 @@ import Testing
#expect(opts.thinking == "low")
#expect(opts.deliver == true)
#expect(opts.to == nil)
#expect(opts.provider == .last)
#expect(opts.channel == .last)
}
}
@@ -2,6 +2,7 @@ import Foundation
public enum ClawdbotSystemCommand: String, Codable, Sendable {
case run = "system.run"
case which = "system.which"
case notify = "system.notify"
}
@@ -39,6 +40,14 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
}
}
public struct ClawdbotSystemWhichParams: Codable, Sendable, Equatable {
public var bins: [String]
public init(bins: [String]) {
self.bins = bins
}
}
public struct ClawdbotSystemNotifyParams: Codable, Sendable, Equatable {
public var title: String
public var body: String
+1 -1
View File
@@ -187,7 +187,7 @@ services:
[
"node",
"dist/index.js",
"gateway",
"gateway-daemon",
"--bind",
"${CLAWDBOT_GATEWAY_BIND}",
"--port",
+44 -5
View File
@@ -9,26 +9,65 @@ import type { EmbeddedContextFile } from "./types.js";
type ContentBlockWithSignature = {
thought_signature?: unknown;
thoughtSignature?: unknown;
[key: string]: unknown;
};
type ThoughtSignatureSanitizeOptions = {
allowBase64Only?: boolean;
includeCamelCase?: boolean;
};
function isBase64Signature(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) return false;
const compact = trimmed.replace(/\s+/g, "");
if (!/^[A-Za-z0-9+/=_-]+$/.test(compact)) return false;
const isUrl = compact.includes("-") || compact.includes("_");
try {
const buf = Buffer.from(compact, isUrl ? "base64url" : "base64");
if (buf.length === 0) return false;
const encoded = buf.toString(isUrl ? "base64url" : "base64");
const normalize = (input: string) => input.replace(/=+$/g, "");
return normalize(encoded) === normalize(compact);
} catch {
return false;
}
}
/**
* Strips Claude-style thought_signature fields from content blocks.
*
* Gemini expects thought signatures as base64-encoded bytes, but Claude stores message ids
* like "msg_abc123...". We only strip "msg_*" to preserve any provider-valid signatures.
*/
export function stripThoughtSignatures<T>(content: T): T {
export function stripThoughtSignatures<T>(
content: T,
options?: ThoughtSignatureSanitizeOptions,
): T {
if (!Array.isArray(content)) return content;
const allowBase64Only = options?.allowBase64Only ?? false;
const includeCamelCase = options?.includeCamelCase ?? false;
const shouldStripSignature = (value: unknown): boolean => {
if (!allowBase64Only) {
return typeof value === "string" && value.startsWith("msg_");
}
return typeof value !== "string" || !isBase64Signature(value);
};
return content.map((block) => {
if (!block || typeof block !== "object") return block;
const rec = block as ContentBlockWithSignature;
const signature = rec.thought_signature;
if (typeof signature !== "string" || !signature.startsWith("msg_")) {
const stripSnake = shouldStripSignature(rec.thought_signature);
const stripCamel = includeCamelCase
? shouldStripSignature(rec.thoughtSignature)
: false;
if (!stripSnake && !stripCamel) {
return block;
}
const { thought_signature: _signature, ...rest } = rec;
return rest;
const next = { ...rec };
if (stripSnake) delete next.thought_signature;
if (stripCamel) delete next.thoughtSignature;
return next;
}) as T;
}
+3 -1
View File
@@ -23,6 +23,7 @@ export { sanitizeGoogleTurnOrdering };
type GeminiToolCallBlock = {
type?: unknown;
thought_signature?: unknown;
thoughtSignature?: unknown;
id?: unknown;
toolCallId?: unknown;
name?: unknown;
@@ -118,7 +119,8 @@ export function downgradeGeminiHistory(messages: AgentMessage[]): AgentMessage[]
const blockRecord = block as GeminiToolCallBlock;
const type = blockRecord.type;
if (type === "toolCall" || type === "functionCall" || type === "toolUse") {
const hasSignature = Boolean(blockRecord.thought_signature);
const signature = blockRecord.thought_signature ?? blockRecord.thoughtSignature;
const hasSignature = Boolean(signature);
if (!hasSignature) {
const id =
typeof blockRecord.id === "string"
+5 -1
View File
@@ -34,6 +34,10 @@ export async function sanitizeSessionMessagesImages(
sanitizeToolCallIds?: boolean;
enforceToolCallLast?: boolean;
preserveSignatures?: boolean;
sanitizeThoughtSignatures?: {
allowBase64Only?: boolean;
includeCamelCase?: boolean;
};
},
): Promise<AgentMessage[]> {
// We sanitize historical session messages because Anthropic can reject a request
@@ -82,7 +86,7 @@ export async function sanitizeSessionMessagesImages(
if (Array.isArray(content)) {
const strippedContent = options?.preserveSignatures
? content // Keep signatures for Antigravity Claude
: stripThoughtSignatures(content); // Strip for Gemini
: stripThoughtSignatures(content, options?.sanitizeThoughtSignatures); // Strip for Gemini
const filteredContent = strippedContent.filter((block) => {
if (!block || typeof block !== "object") return true;
@@ -145,6 +145,65 @@ describe("sanitizeSessionHistory (google thinking)", () => {
expect(assistant.content?.[1]?.text).toBe("internal note");
});
it("strips non-base64 thought signatures for OpenRouter Gemini", async () => {
const sessionManager = SessionManager.inMemory();
const input = [
{
role: "user",
content: "hi",
},
{
role: "assistant",
content: [
{ type: "text", text: "hello", thought_signature: "msg_abc123" },
{ type: "thinking", thinking: "ok", thought_signature: "c2ln" },
{
type: "toolCall",
id: "call_1",
name: "read",
arguments: { path: "/tmp/foo" },
thoughtSignature: "{\"id\":1}",
},
{
type: "toolCall",
id: "call_2",
name: "read",
arguments: { path: "/tmp/bar" },
thoughtSignature: "c2ln",
},
],
},
] satisfies AgentMessage[];
const out = await sanitizeSessionHistory({
messages: input,
modelApi: "openrouter",
provider: "openrouter",
modelId: "google/gemini-1.5-pro",
sessionManager,
sessionId: "session:openrouter-gemini",
});
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
content?: Array<{ type?: string; thought_signature?: string; thoughtSignature?: string }>;
};
expect(assistant.content).toEqual([
{ type: "text", text: "hello" },
{ type: "text", text: "ok" },
{
type: "text",
text: "[Tool Call: read (ID: call_1)]\nArguments: {\n \"path\": \"/tmp/foo\"\n}",
},
{
type: "toolCall",
id: "call_2",
name: "read",
arguments: { path: "/tmp/bar" },
thoughtSignature: "c2ln",
},
]);
});
it("downgrades only unsigned thinking blocks when mixed with signed ones", async () => {
const sessionManager = SessionManager.inMemory();
const input = [
@@ -0,0 +1,109 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { SessionManager } from "@mariozechner/pi-coding-agent";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as helpers from "./pi-embedded-helpers.js";
import { sanitizeSessionHistory } from "./pi-embedded-runner/google.js";
// Mock dependencies
vi.mock("./pi-embedded-helpers.js", async () => {
const actual = await vi.importActual("./pi-embedded-helpers.js");
return {
...actual,
isGoogleModelApi: vi.fn(),
downgradeGeminiHistory: vi.fn(),
sanitizeSessionMessagesImages: vi
.fn()
.mockImplementation(async (msgs) => msgs),
};
});
// We don't mock session-transcript-repair.js as it is a pure function and complicates mocking.
// We rely on the real implementation which should pass through our simple messages.
describe("sanitizeSessionHistory", () => {
const mockSessionManager = {
getEntries: vi.fn().mockReturnValue([]),
appendCustomEntry: vi.fn(),
} as unknown as SessionManager;
const mockMessages: AgentMessage[] = [{ role: "user", content: "hello" }];
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(helpers.sanitizeSessionMessagesImages).mockImplementation(
async (msgs) => msgs,
);
// Default mock implementation
vi.mocked(helpers.downgradeGeminiHistory).mockImplementation((msgs) => {
if (!msgs) return [];
return [...msgs, { role: "system", content: "downgraded" }];
});
});
it("should downgrade history for Google models if provider is not google-antigravity", async () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(true);
const result = await sanitizeSessionHistory({
messages: mockMessages,
modelApi: "google-gemini",
provider: "google-vertex",
sessionManager: mockSessionManager,
sessionId: "test-session",
});
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("google-gemini");
expect(helpers.downgradeGeminiHistory).toHaveBeenCalled();
// Check if the result contains the downgraded message
expect(result).toContainEqual({ role: "system", content: "downgraded" });
});
it("should NOT downgrade history for google-antigravity provider", async () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(true);
const result = await sanitizeSessionHistory({
messages: mockMessages,
modelApi: "google-gemini",
provider: "google-antigravity",
sessionManager: mockSessionManager,
sessionId: "test-session",
});
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("google-gemini");
expect(helpers.downgradeGeminiHistory).not.toHaveBeenCalled();
// Result should not contain the downgraded message
expect(result).not.toContainEqual({
role: "system",
content: "downgraded",
});
});
it("should NOT downgrade history for non-Google models", async () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
const _result = await sanitizeSessionHistory({
messages: mockMessages,
modelApi: "anthropic-messages",
provider: "anthropic",
sessionManager: mockSessionManager,
sessionId: "test-session",
});
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("anthropic-messages");
expect(helpers.downgradeGeminiHistory).not.toHaveBeenCalled();
});
it("should downgrade history if provider is undefined but model is Google", async () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(true);
const _result = await sanitizeSessionHistory({
messages: mockMessages,
modelApi: "google-gemini",
provider: undefined,
sessionManager: mockSessionManager,
sessionId: "test-session",
});
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("google-gemini");
expect(helpers.downgradeGeminiHistory).toHaveBeenCalled();
});
});
+1
View File
@@ -308,6 +308,7 @@ export async function compactEmbeddedPiSession(params: {
messages: session.messages,
modelApi: model.api,
modelId,
provider,
sessionManager,
sessionId: params.sessionId,
});
+15 -3
View File
@@ -185,22 +185,34 @@ export async function sanitizeSessionHistory(params: {
messages: AgentMessage[];
modelApi?: string | null;
modelId?: string;
provider?: string;
sessionManager: SessionManager;
sessionId: string;
}): Promise<AgentMessage[]> {
const isAntigravityClaudeModel = isAntigravityClaude(params.modelApi, params.modelId);
const provider = (params.provider ?? "").toLowerCase();
const modelId = (params.modelId ?? "").toLowerCase();
const isOpenRouterGemini =
(provider === "openrouter" || provider === "opencode") && modelId.includes("gemini");
const isGeminiLike = isGoogleModelApi(params.modelApi) || isOpenRouterGemini;
const sanitizedImages = await sanitizeSessionMessagesImages(params.messages, "session:history", {
sanitizeToolCallIds: shouldSanitizeToolCallIds(params.modelApi),
enforceToolCallLast: params.modelApi === "anthropic-messages",
preserveSignatures: params.modelApi === "google-antigravity" && isAntigravityClaudeModel,
sanitizeThoughtSignatures: isOpenRouterGemini
? { allowBase64Only: true, includeCamelCase: true }
: undefined,
});
const repairedTools = sanitizeToolUseResultPairing(sanitizedImages);
const shouldDowngradeGemini = isGoogleModelApi(params.modelApi) && !isAntigravityClaudeModel;
const isAntigravityProvider =
provider === "google-antigravity" || params.modelApi === "google-antigravity";
const shouldDowngradeThinking = isGeminiLike && !isAntigravityClaudeModel;
// Gemini rejects unsigned thinking blocks; downgrade them before send to avoid INVALID_ARGUMENT.
const downgradedThinking = shouldDowngradeGemini
const downgradedThinking = shouldDowngradeThinking
? downgradeGeminiThinkingBlocks(repairedTools)
: repairedTools;
const downgraded = shouldDowngradeGemini
const shouldDowngradeHistory = shouldDowngradeThinking && !isAntigravityProvider;
const downgraded = shouldDowngradeHistory
? downgradeGeminiHistory(downgradedThinking)
: downgradedThinking;
@@ -323,6 +323,7 @@ export async function runEmbeddedAttempt(
messages: activeSession.messages,
modelApi: params.model.api,
modelId: params.modelId,
provider: params.provider,
sessionManager,
sessionId: params.sessionId,
});
+7
View File
@@ -53,6 +53,13 @@ export function registerGatewayCli(program: Command) {
),
);
// Back-compat: legacy launchd plists used gateway-daemon; keep hidden alias.
addGatewayRunCommand(
program
.command("gateway-daemon", { hidden: true })
.description("Run the WebSocket Gateway as a long-lived daemon"),
);
gatewayCallOpts(
gateway
.command("call")
+129 -2
View File
@@ -11,6 +11,7 @@ const resolveGatewayPort = vi.fn(() => 18789);
const discoverGatewayBeacons = vi.fn(async () => []);
const pickPrimaryTailnetIPv4 = vi.fn(() => "100.64.0.10");
const sshStop = vi.fn(async () => {});
const resolveSshConfig = vi.fn(async () => null);
const startSshPortForward = vi.fn(async () => ({
parsedTarget: { user: "me", host: "studio", port: 22 },
localPort: 18789,
@@ -92,8 +93,16 @@ vi.mock("../infra/tailnet.js", () => ({
pickPrimaryTailnetIPv4: () => pickPrimaryTailnetIPv4(),
}));
vi.mock("../infra/ssh-tunnel.js", () => ({
startSshPortForward: (opts: unknown) => startSshPortForward(opts),
vi.mock("../infra/ssh-tunnel.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../infra/ssh-tunnel.js")>();
return {
...actual,
startSshPortForward: (opts: unknown) => startSshPortForward(opts),
};
});
vi.mock("../infra/ssh-config.js", () => ({
resolveSshConfig: (opts: unknown) => resolveSshConfig(opts),
}));
vi.mock("../gateway/probe.js", () => ({
@@ -179,4 +188,122 @@ describe("gateway-status command", () => {
const targets = parsed.targets as Array<Record<string, unknown>>;
expect(targets.some((t) => t.kind === "sshTunnel")).toBe(true);
});
it("infers SSH target from gateway.remote.url and ssh config", async () => {
const runtimeLogs: string[] = [];
const runtime = {
log: (msg: string) => runtimeLogs.push(msg),
error: (_msg: string) => {},
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
};
const originalUser = process.env.USER;
try {
process.env.USER = "steipete";
loadConfig.mockReturnValueOnce({
gateway: {
mode: "remote",
remote: { url: "ws://peters-mac-studio-1.sheep-coho.ts.net:18789", token: "rtok" },
},
});
resolveSshConfig.mockResolvedValueOnce({
user: "steipete",
host: "peters-mac-studio-1.sheep-coho.ts.net",
port: 2222,
identityFiles: ["/tmp/id_ed25519"],
});
startSshPortForward.mockClear();
const { gatewayStatusCommand } = await import("./gateway-status.js");
await gatewayStatusCommand(
{ timeout: "1000", json: true },
runtime as unknown as import("../runtime.js").RuntimeEnv,
);
expect(startSshPortForward).toHaveBeenCalledTimes(1);
const call = startSshPortForward.mock.calls[0]?.[0] as {
target: string;
identity?: string;
};
expect(call.target).toBe("steipete@peters-mac-studio-1.sheep-coho.ts.net:2222");
expect(call.identity).toBe("/tmp/id_ed25519");
} finally {
process.env.USER = originalUser;
}
});
it("falls back to host-only when USER is missing and ssh config is unavailable", async () => {
const runtimeLogs: string[] = [];
const runtime = {
log: (msg: string) => runtimeLogs.push(msg),
error: (_msg: string) => {},
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
};
const originalUser = process.env.USER;
try {
process.env.USER = "";
loadConfig.mockReturnValueOnce({
gateway: {
mode: "remote",
remote: { url: "ws://studio.example:18789", token: "rtok" },
},
});
resolveSshConfig.mockResolvedValueOnce(null);
startSshPortForward.mockClear();
const { gatewayStatusCommand } = await import("./gateway-status.js");
await gatewayStatusCommand(
{ timeout: "1000", json: true },
runtime as unknown as import("../runtime.js").RuntimeEnv,
);
const call = startSshPortForward.mock.calls[0]?.[0] as {
target: string;
};
expect(call.target).toBe("studio.example");
} finally {
process.env.USER = originalUser;
}
});
it("keeps explicit SSH identity even when ssh config provides one", async () => {
const runtimeLogs: string[] = [];
const runtime = {
log: (msg: string) => runtimeLogs.push(msg),
error: (_msg: string) => {},
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
};
loadConfig.mockReturnValueOnce({
gateway: {
mode: "remote",
remote: { url: "ws://studio.example:18789", token: "rtok" },
},
});
resolveSshConfig.mockResolvedValueOnce({
user: "me",
host: "studio.example",
port: 22,
identityFiles: ["/tmp/id_from_config"],
});
startSshPortForward.mockClear();
const { gatewayStatusCommand } = await import("./gateway-status.js");
await gatewayStatusCommand(
{ timeout: "1000", json: true, sshIdentity: "/tmp/explicit_id" },
runtime as unknown as import("../runtime.js").RuntimeEnv,
);
const call = startSshPortForward.mock.calls[0]?.[0] as {
identity?: string;
};
expect(call.identity).toBe("/tmp/explicit_id");
});
});
+65 -2
View File
@@ -2,7 +2,8 @@ import { withProgress } from "../cli/progress.js";
import { loadConfig, resolveGatewayPort } from "../config/config.js";
import { probeGateway } from "../gateway/probe.js";
import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js";
import { startSshPortForward } from "../infra/ssh-tunnel.js";
import { resolveSshConfig } from "../infra/ssh-config.js";
import { parseSshTarget, startSshPortForward } from "../infra/ssh-tunnel.js";
import type { RuntimeEnv } from "../runtime.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
@@ -47,13 +48,25 @@ export async function gatewayStatusCommand(
});
let sshTarget = sanitizeSshTarget(opts.ssh) ?? sanitizeSshTarget(cfg.gateway?.remote?.sshTarget);
const sshIdentity =
let sshIdentity =
sanitizeSshTarget(opts.sshIdentity) ?? sanitizeSshTarget(cfg.gateway?.remote?.sshIdentity);
const remotePort = resolveGatewayPort(cfg);
let sshTunnelError: string | null = null;
let sshTunnelStarted = false;
if (!sshTarget) {
sshTarget = inferSshTargetFromRemoteUrl(cfg.gateway?.remote?.url);
}
if (sshTarget) {
const resolved = await resolveSshTarget(sshTarget, sshIdentity, overallTimeoutMs);
if (resolved) {
sshTarget = resolved.target;
if (!sshIdentity && resolved.identity) sshIdentity = resolved.identity;
}
}
const { discovery, probed } = await withProgress(
{
label: "Inspecting gateways…",
@@ -314,3 +327,53 @@ export async function gatewayStatusCommand(
if (!ok) runtime.exit(1);
}
function inferSshTargetFromRemoteUrl(rawUrl?: string | null): string | null {
if (typeof rawUrl !== "string") return null;
const trimmed = rawUrl.trim();
if (!trimmed) return null;
let host: string | null = null;
try {
host = new URL(trimmed).hostname || null;
} catch {
return null;
}
if (!host) return null;
const user = process.env.USER?.trim() || "";
return user ? `${user}@${host}` : host;
}
function buildSshTarget(input: { user?: string; host?: string; port?: number }): string | null {
const host = input.host?.trim() ?? "";
if (!host) return null;
const user = input.user?.trim() ?? "";
const base = user ? `${user}@${host}` : host;
const port = input.port ?? 22;
if (port && port !== 22) return `${base}:${port}`;
return base;
}
async function resolveSshTarget(
rawTarget: string,
identity: string | null,
overallTimeoutMs: number,
): Promise<{ target: string; identity?: string } | null> {
const parsed = parseSshTarget(rawTarget);
if (!parsed) return null;
const config = await resolveSshConfig(parsed, {
identity: identity ?? undefined,
timeoutMs: Math.min(800, overallTimeoutMs),
});
if (!config) return { target: rawTarget, identity: identity ?? undefined };
const target = buildSshTarget({
user: config.user ?? parsed.user,
host: config.host ?? parsed.host,
port: config.port ?? parsed.port,
});
if (!target) return { target: rawTarget, identity: identity ?? undefined };
const identityFile =
identity ??
config.identityFiles.find((entry) => entry.trim().length > 0)?.trim() ??
undefined;
return { target, identity: identityFile };
}
+28
View File
@@ -93,6 +93,34 @@ describe("gateway auth", () => {
expect(missingProxy.reason).toBe("tailscale_proxy_missing");
});
it("treats local tailscale serve hostnames as direct", async () => {
const res = await authorizeGatewayConnect({
auth: { mode: "none", allowTailscale: true },
connectAuth: null,
req: {
socket: { remoteAddress: "127.0.0.1" },
headers: { host: "gateway.tailnet-1234.ts.net:443" },
} as never,
});
expect(res.ok).toBe(true);
expect(res.method).toBe("none");
});
it("does not treat tailscale clients as direct", async () => {
const res = await authorizeGatewayConnect({
auth: { mode: "none", allowTailscale: true },
connectAuth: null,
req: {
socket: { remoteAddress: "100.64.0.42" },
headers: { host: "gateway.tailnet-1234.ts.net" },
} as never,
});
expect(res.ok).toBe(false);
expect(res.reason).toBe("tailscale_user_missing");
});
it("allows tailscale identity to satisfy token mode auth", async () => {
const res = await authorizeGatewayConnect({
auth: { mode: "token", token: "secret", allowTailscale: true },
+43 -13
View File
@@ -1,6 +1,9 @@
import { timingSafeEqual } from "node:crypto";
import type { IncomingMessage } from "node:http";
import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js";
import type {
GatewayAuthConfig,
GatewayTailscaleMode,
} from "../config/config.js";
export type ResolvedGatewayAuthMode = "none" | "token" | "password";
export type ResolvedGatewayAuth = {
@@ -42,20 +45,34 @@ function isLoopbackAddress(ip: string | undefined): boolean {
return false;
}
function getHostName(hostHeader?: string): string {
const host = (hostHeader ?? "").trim().toLowerCase();
if (!host) return "";
if (host.startsWith("[")) {
const end = host.indexOf("]");
if (end !== -1) return host.slice(1, end);
}
const [name] = host.split(":");
return name ?? "";
}
function isLocalDirectRequest(req?: IncomingMessage): boolean {
if (!req) return false;
const clientIp = req.socket?.remoteAddress ?? "";
if (!isLoopbackAddress(clientIp)) return false;
const host = (req.headers.host ?? "").toLowerCase();
const host = getHostName(req.headers?.host);
const hostIsLocal =
host.startsWith("localhost") || host.startsWith("127.0.0.1") || host.startsWith("[::1]");
host === "localhost" || host === "127.0.0.1" || host === "::1";
const hostIsTailscaleServe = host.endsWith(".ts.net");
const hasForwarded = Boolean(
req.headers["x-forwarded-for"] || req.headers["x-real-ip"] || req.headers["x-forwarded-host"],
req.headers?.["x-forwarded-for"] ||
req.headers?.["x-real-ip"] ||
req.headers?.["x-forwarded-host"],
);
return hostIsLocal && !hasForwarded;
return (hostIsLocal || hostIsTailscaleServe) && !hasForwarded;
}
function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null {
@@ -64,11 +81,17 @@ function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null {
if (typeof login !== "string" || !login.trim()) return null;
const nameRaw = req.headers["tailscale-user-name"];
const profilePic = req.headers["tailscale-user-profile-pic"];
const name = typeof nameRaw === "string" && nameRaw.trim() ? nameRaw.trim() : login.trim();
const name =
typeof nameRaw === "string" && nameRaw.trim()
? nameRaw.trim()
: login.trim();
return {
login: login.trim(),
name,
profilePic: typeof profilePic === "string" && profilePic.trim() ? profilePic.trim() : undefined,
profilePic:
typeof profilePic === "string" && profilePic.trim()
? profilePic.trim()
: undefined,
};
}
@@ -76,14 +99,17 @@ function hasTailscaleProxyHeaders(req?: IncomingMessage): boolean {
if (!req) return false;
return Boolean(
req.headers["x-forwarded-for"] &&
req.headers["x-forwarded-proto"] &&
req.headers["x-forwarded-host"],
req.headers["x-forwarded-proto"] &&
req.headers["x-forwarded-host"],
);
}
function isTailscaleProxyRequest(req?: IncomingMessage): boolean {
if (!req) return false;
return isLoopbackAddress(req.socket?.remoteAddress) && hasTailscaleProxyHeaders(req);
return (
isLoopbackAddress(req.socket?.remoteAddress) &&
hasTailscaleProxyHeaders(req)
);
}
export function resolveGatewayAuth(params: {
@@ -94,11 +120,13 @@ export function resolveGatewayAuth(params: {
const authConfig = params.authConfig ?? {};
const env = params.env ?? process.env;
const token = authConfig.token ?? env.CLAWDBOT_GATEWAY_TOKEN ?? undefined;
const password = authConfig.password ?? env.CLAWDBOT_GATEWAY_PASSWORD ?? undefined;
const password =
authConfig.password ?? env.CLAWDBOT_GATEWAY_PASSWORD ?? undefined;
const mode: ResolvedGatewayAuth["mode"] =
authConfig.mode ?? (password ? "password" : token ? "token" : "none");
const allowTailscale =
authConfig.allowTailscale ?? (params.tailscaleMode === "serve" && mode !== "password");
authConfig.allowTailscale ??
(params.tailscaleMode === "serve" && mode !== "password");
return {
mode,
token,
@@ -114,7 +142,9 @@ export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void {
);
}
if (auth.mode === "password" && !auth.password) {
throw new Error("gateway auth mode is password, but no password was configured");
throw new Error(
"gateway auth mode is password, but no password was configured",
);
}
}
+116 -4
View File
@@ -18,6 +18,10 @@ const RESET_KEYS = [
"resetAt",
"reset_time",
"resetTime",
"next_reset_at",
"nextResetAt",
"next_reset_time",
"nextResetTime",
"expires_at",
"expiresAt",
"expire_at",
@@ -52,6 +56,14 @@ const USED_KEYS = [
"usedQuota",
"used_times",
"usedTimes",
"prompt_used",
"promptUsed",
"used_prompt",
"usedPrompt",
"prompts_used",
"promptsUsed",
"current_interval_usage_count",
"currentIntervalUsageCount",
"consumed",
] as const;
@@ -65,6 +77,20 @@ const TOTAL_KEYS = [
"totalQuota",
"total_times",
"totalTimes",
"prompt_total",
"promptTotal",
"total_prompt",
"totalPrompt",
"prompt_limit",
"promptLimit",
"limit_prompt",
"limitPrompt",
"prompts_total",
"promptsTotal",
"total_prompts",
"totalPrompts",
"current_interval_total_count",
"currentIntervalTotalCount",
"limit",
"quota",
"quota_limit",
@@ -87,6 +113,20 @@ const REMAINING_KEYS = [
"remain_times",
"remainingTimes",
"remaining_times",
"prompt_remain",
"promptRemain",
"remain_prompt",
"remainPrompt",
"prompt_remaining",
"promptRemaining",
"remaining_prompt",
"remainingPrompt",
"prompts_remaining",
"promptsRemaining",
"prompt_left",
"promptLeft",
"prompts_left",
"promptsLeft",
"left",
] as const;
@@ -144,6 +184,62 @@ function parseEpoch(value: unknown): number | undefined {
return undefined;
}
function hasAny(record: Record<string, unknown>, keys: readonly string[]): boolean {
return keys.some((key) => key in record);
}
function scoreUsageRecord(record: Record<string, unknown>): number {
let score = 0;
if (hasAny(record, PERCENT_KEYS)) score += 4;
if (hasAny(record, TOTAL_KEYS)) score += 3;
if (hasAny(record, USED_KEYS) || hasAny(record, REMAINING_KEYS)) score += 2;
if (hasAny(record, RESET_KEYS)) score += 1;
if (hasAny(record, PLAN_KEYS)) score += 1;
return score;
}
function collectUsageCandidates(root: Record<string, unknown>): Record<string, unknown>[] {
const MAX_SCAN_DEPTH = 4;
const MAX_SCAN_NODES = 60;
const queue: Array<{ value: unknown; depth: number }> = [{ value: root, depth: 0 }];
const seen = new Set<object>();
const candidates: Array<{ record: Record<string, unknown>; score: number; depth: number }> = [];
let scanned = 0;
while (queue.length && scanned < MAX_SCAN_NODES) {
const next = queue.shift();
if (!next) break;
scanned += 1;
const { value, depth } = next;
if (isRecord(value)) {
if (seen.has(value)) continue;
seen.add(value);
const score = scoreUsageRecord(value);
if (score > 0) candidates.push({ record: value, score, depth });
if (depth < MAX_SCAN_DEPTH) {
for (const nested of Object.values(value)) {
if (isRecord(nested) || Array.isArray(nested)) {
queue.push({ value: nested, depth: depth + 1 });
}
}
}
continue;
}
if (Array.isArray(value) && depth < MAX_SCAN_DEPTH) {
for (const nested of value) {
if (isRecord(nested) || Array.isArray(nested)) {
queue.push({ value: nested, depth: depth + 1 });
}
}
}
}
candidates.sort((a, b) => b.score - a.score || a.depth - b.depth);
return candidates.map((candidate) => candidate.record);
}
function deriveWindowLabel(payload: Record<string, unknown>): string {
const hours = pickNumber(payload, WINDOW_HOUR_KEYS);
if (hours && Number.isFinite(hours)) return `${hours}h`;
@@ -219,7 +315,20 @@ export async function fetchMinimaxUsage(
}
const payload = isRecord(data.data) ? data.data : data;
const usedPercent = deriveUsedPercent(payload);
const candidates = collectUsageCandidates(payload);
let usageRecord: Record<string, unknown> = payload;
let usedPercent: number | null = null;
for (const candidate of candidates) {
const candidatePercent = deriveUsedPercent(candidate);
if (candidatePercent !== null) {
usageRecord = candidate;
usedPercent = candidatePercent;
break;
}
}
if (usedPercent === null) {
usedPercent = deriveUsedPercent(payload);
}
if (usedPercent === null) {
return {
provider: "minimax",
@@ -230,10 +339,13 @@ export async function fetchMinimaxUsage(
}
const resetAt =
parseEpoch(pickString(payload, RESET_KEYS)) ?? parseEpoch(pickNumber(payload, RESET_KEYS));
parseEpoch(pickString(usageRecord, RESET_KEYS)) ??
parseEpoch(pickNumber(usageRecord, RESET_KEYS)) ??
parseEpoch(pickString(payload, RESET_KEYS)) ??
parseEpoch(pickNumber(payload, RESET_KEYS));
const windows: UsageWindow[] = [
{
label: deriveWindowLabel(payload),
label: deriveWindowLabel(usageRecord),
usedPercent,
resetAt,
},
@@ -243,6 +355,6 @@ export async function fetchMinimaxUsage(
provider: "minimax",
displayName: PROVIDER_LABELS.minimax,
windows,
plan: pickString(payload, PLAN_KEYS),
plan: pickString(usageRecord, PLAN_KEYS) ?? pickString(payload, PLAN_KEYS),
};
}
+77
View File
@@ -137,6 +137,83 @@ describe("provider usage loading", () => {
expect(mockFetch).toHaveBeenCalled();
});
it("handles nested MiniMax usage payloads", async () => {
const makeResponse = (status: number, body: unknown): Response => {
const payload = typeof body === "string" ? body : JSON.stringify(body);
const headers = typeof body === "string" ? undefined : { "Content-Type": "application/json" };
return new Response(payload, { status, headers });
};
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("api.minimax.io/v1/coding_plan/remains")) {
return makeResponse(200, {
base_resp: { status_code: 0, status_msg: "ok" },
data: {
plan_name: "Coding Plan",
usage: {
prompt_limit: 200,
prompt_remain: 50,
next_reset_time: "2026-01-07T05:00:00Z",
},
},
});
}
return makeResponse(404, "not found");
});
const summary = await loadProviderUsageSummary({
now: Date.UTC(2026, 0, 7, 0, 0, 0),
auth: [{ provider: "minimax", token: "token-1b" }],
fetch: mockFetch,
});
const minimax = summary.providers.find((p) => p.provider === "minimax");
expect(minimax?.windows[0]?.usedPercent).toBe(75);
expect(minimax?.plan).toBe("Coding Plan");
expect(mockFetch).toHaveBeenCalled();
});
it("handles MiniMax model_remains usage payloads", async () => {
const makeResponse = (status: number, body: unknown): Response => {
const payload = typeof body === "string" ? body : JSON.stringify(body);
const headers = typeof body === "string" ? undefined : { "Content-Type": "application/json" };
return new Response(payload, { status, headers });
};
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("api.minimax.io/v1/coding_plan/remains")) {
return makeResponse(200, {
base_resp: { status_code: 0, status_msg: "ok" },
model_remains: [
{
start_time: 1736217600,
end_time: 1736235600,
remains_time: 600,
current_interval_total_count: 120,
current_interval_usage_count: 30,
model_name: "MiniMax-M2.1",
},
],
});
}
return makeResponse(404, "not found");
});
const summary = await loadProviderUsageSummary({
now: Date.UTC(2026, 0, 7, 0, 0, 0),
auth: [{ provider: "minimax", token: "token-1b" }],
fetch: mockFetch,
});
const minimax = summary.providers.find((p) => p.provider === "minimax");
expect(minimax?.windows[0]?.usedPercent).toBe(25);
expect(mockFetch).toHaveBeenCalled();
});
it("discovers Claude usage from token auth profiles", async () => {
await withTempHome(
async (tempHome) => {
+47 -21
View File
@@ -37,6 +37,10 @@ function supportsSystemRun(commands?: string[]): boolean {
return Array.isArray(commands) && commands.includes("system.run");
}
function supportsSystemWhich(commands?: string[]): boolean {
return Array.isArray(commands) && commands.includes("system.which");
}
function upsertNode(record: {
nodeId: string;
displayName?: string;
@@ -136,6 +140,27 @@ function buildBinProbeScript(bins: string[]): string {
return `for b in ${escaped}; do if command -v "$b" >/dev/null 2>&1; then echo "$b"; fi; done`;
}
function parseBinProbePayload(payloadJSON: string | null | undefined): string[] {
if (!payloadJSON) return [];
try {
const parsed = JSON.parse(payloadJSON) as { stdout?: unknown; bins?: unknown };
if (Array.isArray(parsed.bins)) {
return parsed.bins
.map((bin) => String(bin).trim())
.filter(Boolean);
}
if (typeof parsed.stdout === "string") {
return parsed.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}
} catch {
return [];
}
return [];
}
export async function refreshRemoteNodeBins(params: {
nodeId: string;
platform?: string;
@@ -146,7 +171,9 @@ export async function refreshRemoteNodeBins(params: {
}) {
if (!remoteBridge) return;
if (!isMacPlatform(params.platform, params.deviceFamily)) return;
if (!supportsSystemRun(params.commands)) return;
const canWhich = supportsSystemWhich(params.commands);
const canRun = supportsSystemRun(params.commands);
if (!canWhich && !canRun) return;
const workspaceDirs = listWorkspaceDirs(params.cfg);
const requiredBins = new Set<string>();
@@ -158,31 +185,30 @@ export async function refreshRemoteNodeBins(params: {
}
if (requiredBins.size === 0) return;
const script = buildBinProbeScript([...requiredBins]);
const payload = {
command: ["/bin/sh", "-lc", script],
};
try {
const res = await remoteBridge.invoke({
nodeId: params.nodeId,
command: "system.run",
paramsJSON: JSON.stringify(payload),
timeoutMs: params.timeoutMs ?? 15_000,
});
const binsList = [...requiredBins];
const res = await remoteBridge.invoke(
canWhich
? {
nodeId: params.nodeId,
command: "system.which",
paramsJSON: JSON.stringify({ bins: binsList }),
timeoutMs: params.timeoutMs ?? 15_000,
}
: {
nodeId: params.nodeId,
command: "system.run",
paramsJSON: JSON.stringify({
command: ["/bin/sh", "-lc", buildBinProbeScript(binsList)],
}),
timeoutMs: params.timeoutMs ?? 15_000,
},
);
if (!res.ok) {
log.warn(`remote bin probe failed (${params.nodeId}): ${res.error?.message ?? "unknown"}`);
return;
}
const raw = typeof res.payloadJSON === "string" ? res.payloadJSON : "";
const parsed =
raw && raw.trim().length > 0
? (JSON.parse(raw) as { stdout?: string })
: ({ stdout: "" } as { stdout?: string });
const stdout = typeof parsed.stdout === "string" ? parsed.stdout : "";
const bins = stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const bins = parseBinProbePayload(res.payloadJSON);
recordRemoteNodeBins(params.nodeId, bins);
await updatePairedNodeMetadata(params.nodeId, { bins });
bumpSkillsSnapshotVersion({ reason: "remote-node" });
+81
View File
@@ -0,0 +1,81 @@
import { spawn } from "node:child_process";
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
vi.mock("node:child_process", () => {
const spawn = vi.fn(() => {
const child = new EventEmitter() as EventEmitter & {
stdout?: EventEmitter & { setEncoding?: (enc: string) => void };
kill?: (signal?: string) => void;
};
const stdout = new EventEmitter() as EventEmitter & {
setEncoding?: (enc: string) => void;
};
stdout.setEncoding = vi.fn();
child.stdout = stdout;
child.kill = vi.fn();
process.nextTick(() => {
stdout.emit(
"data",
[
"user steipete",
"hostname peters-mac-studio-1.sheep-coho.ts.net",
"port 2222",
"identityfile none",
"identityfile /tmp/id_ed25519",
"",
].join("\n"),
);
child.emit("exit", 0);
});
return child;
});
return { spawn };
});
const spawnMock = vi.mocked(spawn);
describe("ssh-config", () => {
it("parses ssh -G output", async () => {
const { parseSshConfigOutput } = await import("./ssh-config.js");
const parsed = parseSshConfigOutput(
"user bob\nhostname example.com\nport 2222\nidentityfile none\nidentityfile /tmp/id\n",
);
expect(parsed.user).toBe("bob");
expect(parsed.host).toBe("example.com");
expect(parsed.port).toBe(2222);
expect(parsed.identityFiles).toEqual(["/tmp/id"]);
});
it("resolves ssh config via ssh -G", async () => {
const { resolveSshConfig } = await import("./ssh-config.js");
const config = await resolveSshConfig({ user: "me", host: "alias", port: 22 });
expect(config?.user).toBe("steipete");
expect(config?.host).toBe("peters-mac-studio-1.sheep-coho.ts.net");
expect(config?.port).toBe(2222);
expect(config?.identityFiles).toEqual(["/tmp/id_ed25519"]);
});
it("returns null when ssh -G fails", async () => {
spawnMock.mockImplementationOnce(() => {
const child = new EventEmitter() as EventEmitter & {
stdout?: EventEmitter & { setEncoding?: (enc: string) => void };
kill?: (signal?: string) => void;
};
const stdout = new EventEmitter() as EventEmitter & {
setEncoding?: (enc: string) => void;
};
stdout.setEncoding = vi.fn();
child.stdout = stdout;
child.kill = vi.fn();
process.nextTick(() => {
child.emit("exit", 1);
});
return child;
});
const { resolveSshConfig } = await import("./ssh-config.js");
const config = await resolveSshConfig({ user: "me", host: "bad-host", port: 22 });
expect(config).toBeNull();
});
});
+95
View File
@@ -0,0 +1,95 @@
import { spawn } from "node:child_process";
import type { SshParsedTarget } from "./ssh-tunnel.js";
export type SshResolvedConfig = {
user?: string;
host?: string;
port?: number;
identityFiles: string[];
};
function parsePort(value: string | undefined): number | undefined {
if (!value) return undefined;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
return parsed;
}
export function parseSshConfigOutput(output: string): SshResolvedConfig {
const result: SshResolvedConfig = { identityFiles: [] };
const lines = output.split("\n");
for (const raw of lines) {
const line = raw.trim();
if (!line) continue;
const [key, ...rest] = line.split(/\s+/);
const value = rest.join(" ").trim();
if (!key || !value) continue;
switch (key) {
case "user":
result.user = value;
break;
case "hostname":
result.host = value;
break;
case "port":
result.port = parsePort(value);
break;
case "identityfile":
if (value !== "none") result.identityFiles.push(value);
break;
default:
break;
}
}
return result;
}
export async function resolveSshConfig(
target: SshParsedTarget,
opts: { identity?: string; timeoutMs?: number } = {},
): Promise<SshResolvedConfig | null> {
const sshPath = "/usr/bin/ssh";
const args = ["-G"];
if (target.port > 0 && target.port !== 22) {
args.push("-p", String(target.port));
}
if (opts.identity?.trim()) {
args.push("-i", opts.identity.trim());
}
const userHost = target.user ? `${target.user}@${target.host}` : target.host;
args.push(userHost);
return await new Promise<SshResolvedConfig | null>((resolve) => {
const child = spawn(sshPath, args, {
stdio: ["ignore", "pipe", "ignore"],
});
let stdout = "";
child.stdout?.setEncoding("utf8");
child.stdout?.on("data", (chunk) => {
stdout += String(chunk);
});
const timeoutMs = Math.max(200, opts.timeoutMs ?? 800);
const timer = setTimeout(() => {
try {
child.kill("SIGKILL");
} finally {
resolve(null);
}
}, timeoutMs);
child.once("error", () => {
clearTimeout(timer);
resolve(null);
});
child.once("exit", (code) => {
clearTimeout(timer);
if (code !== 0 || !stdout.trim()) {
resolve(null);
return;
}
resolve(parseSshConfigOutput(stdout));
});
});
}