Compare commits
5 Commits
main
...
vincentkoc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9761a71a5a | ||
|
|
bf202cd6e3 | ||
|
|
da5770df67 | ||
|
|
bbc3bd9cf2 | ||
|
|
7f9eaed281 |
@ -747,6 +747,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Onboarding/Custom providers: use Azure OpenAI-specific verification auth/payload shape (`api-key`, deployment-path chat completions payload) when probing Azure endpoints so valid Azure custom-provider setup no longer fails preflight. (#29421) Thanks @kunalk16.
|
- Onboarding/Custom providers: use Azure OpenAI-specific verification auth/payload shape (`api-key`, deployment-path chat completions payload) when probing Azure endpoints so valid Azure custom-provider setup no longer fails preflight. (#29421) Thanks @kunalk16.
|
||||||
- Feishu/Docx editing tools: add `feishu_doc` positional insert, table row/column operations, table-cell merge, and color-text updates; switch markdown write/append/insert to Descendant API insertion with large-document batching; and harden image uploads for data URI/base64/local-path inputs with strict validation and routing-safe upload metadata. (#29411) Thanks @Elarwei001.
|
- Feishu/Docx editing tools: add `feishu_doc` positional insert, table row/column operations, table-cell merge, and color-text updates; switch markdown write/append/insert to Descendant API insertion with large-document batching; and harden image uploads for data URI/base64/local-path inputs with strict validation and routing-safe upload metadata. (#29411) Thanks @Elarwei001.
|
||||||
- Commands/Owner-only tools: treat identified direct-chat senders as owners when no owner allowlist is configured, while preserving internal `operator.admin` owner sessions. (#26331) thanks @widingmarcus-cyber
|
- Commands/Owner-only tools: treat identified direct-chat senders as owners when no owner allowlist is configured, while preserving internal `operator.admin` owner sessions. (#26331) thanks @widingmarcus-cyber
|
||||||
|
- Gateway/Auth hardening: add bounded `GatewayClient` request timeouts, support optional `OPENCLAW_PASSPHRASE` sealing for OpenClaw-owned auth stores (`auth-profiles.json` and legacy `oauth.json`), and re-assert `0600` permissions after mirrored transcript writes. Thanks @alamine42 and @vincentkoc.
|
||||||
|
|
||||||
## 2026.2.26
|
## 2026.2.26
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,9 @@ import fs from "node:fs/promises";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { isSealedJsonText } from "../infra/sealed-json-file.js";
|
||||||
|
import { withEnvAsync } from "../test-utils/env.js";
|
||||||
|
import { ensureAuthProfileStore } from "./auth-profiles.js";
|
||||||
import { resolveAuthStorePath } from "./auth-profiles/paths.js";
|
import { resolveAuthStorePath } from "./auth-profiles/paths.js";
|
||||||
import { saveAuthProfileStore } from "./auth-profiles/store.js";
|
import { saveAuthProfileStore } from "./auth-profiles/store.js";
|
||||||
import type { AuthProfileStore } from "./auth-profiles/types.js";
|
import type { AuthProfileStore } from "./auth-profiles/types.js";
|
||||||
@ -61,4 +64,37 @@ describe("saveAuthProfileStore", () => {
|
|||||||
await fs.rm(agentDir, { recursive: true, force: true });
|
await fs.rm(agentDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("seals auth-profiles.json when OPENCLAW_PASSPHRASE is set", async () => {
|
||||||
|
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-save-"));
|
||||||
|
try {
|
||||||
|
const store: AuthProfileStore = {
|
||||||
|
version: 1,
|
||||||
|
profiles: {
|
||||||
|
"openai:default": {
|
||||||
|
type: "api_key",
|
||||||
|
provider: "openai",
|
||||||
|
key: "sk-secret",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await withEnvAsync({ OPENCLAW_PASSPHRASE: "test-passphrase" }, async () => {
|
||||||
|
saveAuthProfileStore(store, agentDir);
|
||||||
|
|
||||||
|
const raw = await fs.readFile(resolveAuthStorePath(agentDir), "utf8");
|
||||||
|
expect(isSealedJsonText(raw)).toBe(true);
|
||||||
|
expect(raw).not.toContain("sk-secret");
|
||||||
|
|
||||||
|
const loaded = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
|
||||||
|
expect(loaded.profiles["openai:default"]).toMatchObject({
|
||||||
|
type: "api_key",
|
||||||
|
provider: "openai",
|
||||||
|
key: "sk-secret",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await fs.rm(agentDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { saveJsonFile } from "../../infra/json-file.js";
|
import { saveSealedJsonFile } from "../../infra/sealed-json-file.js";
|
||||||
import { resolveUserPath } from "../../utils.js";
|
import { resolveUserPath } from "../../utils.js";
|
||||||
import { resolveOpenClawAgentDir } from "../agent-paths.js";
|
import { resolveOpenClawAgentDir } from "../agent-paths.js";
|
||||||
import { AUTH_PROFILE_FILENAME, AUTH_STORE_VERSION, LEGACY_AUTH_FILENAME } from "./constants.js";
|
import { AUTH_PROFILE_FILENAME, AUTH_STORE_VERSION, LEGACY_AUTH_FILENAME } from "./constants.js";
|
||||||
@ -29,5 +29,5 @@ export function ensureAuthStoreFile(pathname: string) {
|
|||||||
version: AUTH_STORE_VERSION,
|
version: AUTH_STORE_VERSION,
|
||||||
profiles: {},
|
profiles: {},
|
||||||
};
|
};
|
||||||
saveJsonFile(pathname, payload);
|
saveSealedJsonFile(pathname, payload);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,12 @@ import fs from "node:fs";
|
|||||||
import type { OAuthCredentials } from "@mariozechner/pi-ai";
|
import type { OAuthCredentials } from "@mariozechner/pi-ai";
|
||||||
import { resolveOAuthPath } from "../../config/paths.js";
|
import { resolveOAuthPath } from "../../config/paths.js";
|
||||||
import { withFileLock } from "../../infra/file-lock.js";
|
import { withFileLock } from "../../infra/file-lock.js";
|
||||||
import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js";
|
import { loadJsonFile } from "../../infra/json-file.js";
|
||||||
|
import {
|
||||||
|
SealedJsonPassphraseRequiredError,
|
||||||
|
loadSealedJsonFile,
|
||||||
|
saveSealedJsonFile,
|
||||||
|
} from "../../infra/sealed-json-file.js";
|
||||||
import { AUTH_STORE_LOCK_OPTIONS, AUTH_STORE_VERSION, log } from "./constants.js";
|
import { AUTH_STORE_LOCK_OPTIONS, AUTH_STORE_VERSION, log } from "./constants.js";
|
||||||
import { syncExternalCliCredentials } from "./external-cli-sync.js";
|
import { syncExternalCliCredentials } from "./external-cli-sync.js";
|
||||||
import { ensureAuthStoreFile, resolveAuthStorePath, resolveLegacyAuthStorePath } from "./paths.js";
|
import { ensureAuthStoreFile, resolveAuthStorePath, resolveLegacyAuthStorePath } from "./paths.js";
|
||||||
@ -24,6 +29,19 @@ function resolveRuntimeStoreKey(agentDir?: string): string {
|
|||||||
return resolveAuthStorePath(agentDir);
|
return resolveAuthStorePath(agentDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadProtectedAuthJson(pathname: string, label: string): unknown {
|
||||||
|
try {
|
||||||
|
return loadSealedJsonFile(pathname);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SealedJsonPassphraseRequiredError) {
|
||||||
|
log.warn(`${label} is encrypted but OPENCLAW_PASSPHRASE is not set`, { pathname });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
log.warn(`failed to load ${label}`, { pathname, err });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function cloneAuthProfileStore(store: AuthProfileStore): AuthProfileStore {
|
function cloneAuthProfileStore(store: AuthProfileStore): AuthProfileStore {
|
||||||
return structuredClone(store);
|
return structuredClone(store);
|
||||||
}
|
}
|
||||||
@ -278,7 +296,7 @@ function mergeAuthProfileStores(
|
|||||||
|
|
||||||
function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
|
function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
|
||||||
const oauthPath = resolveOAuthPath();
|
const oauthPath = resolveOAuthPath();
|
||||||
const oauthRaw = loadJsonFile(oauthPath);
|
const oauthRaw = loadProtectedAuthJson(oauthPath, "oauth.json");
|
||||||
if (!oauthRaw || typeof oauthRaw !== "object") {
|
if (!oauthRaw || typeof oauthRaw !== "object") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -339,7 +357,7 @@ function applyLegacyStore(store: AuthProfileStore, legacy: LegacyAuthStore): voi
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loadCoercedStore(authPath: string): AuthProfileStore | null {
|
function loadCoercedStore(authPath: string): AuthProfileStore | null {
|
||||||
const raw = loadJsonFile(authPath);
|
const raw = loadProtectedAuthJson(authPath, "auth-profiles.json");
|
||||||
return coerceAuthStore(raw);
|
return coerceAuthStore(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -350,7 +368,7 @@ export function loadAuthProfileStore(): AuthProfileStore {
|
|||||||
// Sync from external CLI tools on every load.
|
// Sync from external CLI tools on every load.
|
||||||
const synced = syncExternalCliCredentials(asStore);
|
const synced = syncExternalCliCredentials(asStore);
|
||||||
if (synced) {
|
if (synced) {
|
||||||
saveJsonFile(authPath, asStore);
|
saveSealedJsonFile(authPath, asStore);
|
||||||
}
|
}
|
||||||
return asStore;
|
return asStore;
|
||||||
}
|
}
|
||||||
@ -383,7 +401,7 @@ function loadAuthProfileStoreForAgent(
|
|||||||
// sync external CLI credentials in-memory, but never persist while readOnly.
|
// sync external CLI credentials in-memory, but never persist while readOnly.
|
||||||
const synced = syncExternalCliCredentials(asStore);
|
const synced = syncExternalCliCredentials(asStore);
|
||||||
if (synced && !readOnly) {
|
if (synced && !readOnly) {
|
||||||
saveJsonFile(authPath, asStore);
|
saveSealedJsonFile(authPath, asStore);
|
||||||
}
|
}
|
||||||
return asStore;
|
return asStore;
|
||||||
}
|
}
|
||||||
@ -395,7 +413,7 @@ function loadAuthProfileStoreForAgent(
|
|||||||
const mainStore = coerceAuthStore(mainRaw);
|
const mainStore = coerceAuthStore(mainRaw);
|
||||||
if (mainStore && Object.keys(mainStore.profiles).length > 0) {
|
if (mainStore && Object.keys(mainStore.profiles).length > 0) {
|
||||||
// Clone main store to subagent directory for auth inheritance
|
// Clone main store to subagent directory for auth inheritance
|
||||||
saveJsonFile(authPath, mainStore);
|
saveSealedJsonFile(authPath, mainStore);
|
||||||
log.info("inherited auth-profiles from main agent", { agentDir });
|
log.info("inherited auth-profiles from main agent", { agentDir });
|
||||||
return mainStore;
|
return mainStore;
|
||||||
}
|
}
|
||||||
@ -417,7 +435,7 @@ function loadAuthProfileStoreForAgent(
|
|||||||
const forceReadOnly = process.env.OPENCLAW_AUTH_STORE_READONLY === "1";
|
const forceReadOnly = process.env.OPENCLAW_AUTH_STORE_READONLY === "1";
|
||||||
const shouldWrite = !readOnly && !forceReadOnly && (legacy !== null || mergedOAuth || syncedCli);
|
const shouldWrite = !readOnly && !forceReadOnly && (legacy !== null || mergedOAuth || syncedCli);
|
||||||
if (shouldWrite) {
|
if (shouldWrite) {
|
||||||
saveJsonFile(authPath, store);
|
saveSealedJsonFile(authPath, store);
|
||||||
}
|
}
|
||||||
|
|
||||||
// PR #368: legacy auth.json could get re-migrated from other agent dirs,
|
// PR #368: legacy auth.json could get re-migrated from other agent dirs,
|
||||||
@ -505,5 +523,5 @@ export function saveAuthProfileStore(store: AuthProfileStore, agentDir?: string)
|
|||||||
lastGood: store.lastGood ?? undefined,
|
lastGood: store.lastGood ?? undefined,
|
||||||
usageStats: store.usageStats ?? undefined,
|
usageStats: store.usageStats ?? undefined,
|
||||||
} satisfies AuthProfileStore;
|
} satisfies AuthProfileStore;
|
||||||
saveJsonFile(authPath, payload);
|
saveSealedJsonFile(authPath, payload);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { Api, Model } from "@mariozechner/pi-ai";
|
import type { Api, Model } from "@mariozechner/pi-ai";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { saveSealedJsonFile } from "../infra/sealed-json-file.js";
|
||||||
import { withEnvAsync } from "../test-utils/env.js";
|
import { withEnvAsync } from "../test-utils/env.js";
|
||||||
import { ensureAuthProfileStore } from "./auth-profiles.js";
|
import { ensureAuthProfileStore } from "./auth-profiles.js";
|
||||||
import { getApiKeyForModel, resolveApiKeyForProvider, resolveEnvApiKey } from "./model-auth.js";
|
import { getApiKeyForModel, resolveApiKeyForProvider, resolveEnvApiKey } from "./model-auth.js";
|
||||||
@ -114,6 +115,41 @@ describe("getApiKeyForModel", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("migrates sealed legacy oauth.json into auth-profiles.json", async () => {
|
||||||
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const agentDir = path.join(tempDir, "agent");
|
||||||
|
await withEnvAsync(
|
||||||
|
{
|
||||||
|
OPENCLAW_PASSPHRASE: "test-passphrase",
|
||||||
|
OPENCLAW_STATE_DIR: tempDir,
|
||||||
|
OPENCLAW_AGENT_DIR: agentDir,
|
||||||
|
PI_CODING_AGENT_DIR: agentDir,
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const oauthDir = path.join(tempDir, "credentials");
|
||||||
|
await fs.mkdir(oauthDir, { recursive: true, mode: 0o700 });
|
||||||
|
saveSealedJsonFile(path.join(oauthDir, "oauth.json"), {
|
||||||
|
"openai-codex": oauthFixture,
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = ensureAuthProfileStore(process.env.OPENCLAW_AGENT_DIR, {
|
||||||
|
allowKeychainPrompt: false,
|
||||||
|
});
|
||||||
|
expect(store.profiles["openai-codex:default"]).toMatchObject({
|
||||||
|
type: "oauth",
|
||||||
|
provider: "openai-codex",
|
||||||
|
access: oauthFixture.access,
|
||||||
|
refresh: oauthFixture.refresh,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("suggests openai-codex when only Codex OAuth is configured", async () => {
|
it("suggests openai-codex when only Codex OAuth is configured", async () => {
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-"));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-"));
|
||||||
|
|
||||||
|
|||||||
@ -79,6 +79,14 @@ async function ensureSessionHeader(params: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function hardenSessionFilePermissions(sessionFile: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fs.promises.chmod(sessionFile, 0o600);
|
||||||
|
} catch {
|
||||||
|
// Best-effort on platforms without POSIX chmod support.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function appendAssistantMessageToSessionTranscript(params: {
|
export async function appendAssistantMessageToSessionTranscript(params: {
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
@ -152,6 +160,7 @@ export async function appendAssistantMessageToSessionTranscript(params: {
|
|||||||
stopReason: "stop",
|
stopReason: "stop",
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
|
await hardenSessionFilePermissions(sessionFile);
|
||||||
|
|
||||||
emitSessionTranscriptUpdate(sessionFile);
|
emitSessionTranscriptUpdate(sessionFile);
|
||||||
return { ok: true, sessionFile };
|
return { ok: true, sessionFile };
|
||||||
|
|||||||
@ -19,11 +19,14 @@ type WsEventHandlers = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
class MockWebSocket {
|
class MockWebSocket {
|
||||||
|
static readonly OPEN = 1;
|
||||||
|
static readonly CLOSED = 3;
|
||||||
private openHandlers: WsEventHandlers["open"][] = [];
|
private openHandlers: WsEventHandlers["open"][] = [];
|
||||||
private messageHandlers: WsEventHandlers["message"][] = [];
|
private messageHandlers: WsEventHandlers["message"][] = [];
|
||||||
private closeHandlers: WsEventHandlers["close"][] = [];
|
private closeHandlers: WsEventHandlers["close"][] = [];
|
||||||
private errorHandlers: WsEventHandlers["error"][] = [];
|
private errorHandlers: WsEventHandlers["error"][] = [];
|
||||||
readonly sent: string[] = [];
|
readonly sent: string[] = [];
|
||||||
|
readyState = MockWebSocket.CLOSED;
|
||||||
|
|
||||||
constructor(_url: string, _options?: unknown) {
|
constructor(_url: string, _options?: unknown) {
|
||||||
wsInstances.push(this);
|
wsInstances.push(this);
|
||||||
@ -59,6 +62,7 @@ class MockWebSocket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
emitOpen(): void {
|
emitOpen(): void {
|
||||||
|
this.readyState = MockWebSocket.OPEN;
|
||||||
for (const handler of this.openHandlers) {
|
for (const handler of this.openHandlers) {
|
||||||
handler();
|
handler();
|
||||||
}
|
}
|
||||||
@ -71,6 +75,7 @@ class MockWebSocket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
emitClose(code: number, reason: string): void {
|
emitClose(code: number, reason: string): void {
|
||||||
|
this.readyState = MockWebSocket.CLOSED;
|
||||||
for (const handler of this.closeHandlers) {
|
for (const handler of this.closeHandlers) {
|
||||||
handler(code, Buffer.from(reason));
|
handler(code, Buffer.from(reason));
|
||||||
}
|
}
|
||||||
@ -438,4 +443,30 @@ describe("GatewayClient connect auth payload", () => {
|
|||||||
});
|
});
|
||||||
client.stop();
|
client.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("times out pending requests and cleans them up", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const client = new GatewayClient({
|
||||||
|
url: "ws://127.0.0.1:18789",
|
||||||
|
requestTimeoutMs: 25,
|
||||||
|
});
|
||||||
|
|
||||||
|
client.start();
|
||||||
|
const ws = getLatestWs();
|
||||||
|
ws.emitOpen();
|
||||||
|
|
||||||
|
const pending = client.request("health.check");
|
||||||
|
const observed = pending.catch((err) => err);
|
||||||
|
await vi.advanceTimersByTimeAsync(25);
|
||||||
|
|
||||||
|
await expect(observed).resolves.toMatchObject({
|
||||||
|
message: "gateway request timeout for health.check",
|
||||||
|
});
|
||||||
|
expect((client as unknown as { pending: Map<string, unknown> }).pending.size).toBe(0);
|
||||||
|
client.stop();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -39,12 +39,14 @@ type Pending = {
|
|||||||
resolve: (value: unknown) => void;
|
resolve: (value: unknown) => void;
|
||||||
reject: (err: unknown) => void;
|
reject: (err: unknown) => void;
|
||||||
expectFinal: boolean;
|
expectFinal: boolean;
|
||||||
|
cleanup?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GatewayClientOptions = {
|
export type GatewayClientOptions = {
|
||||||
url?: string; // ws://127.0.0.1:18789
|
url?: string; // ws://127.0.0.1:18789
|
||||||
connectDelayMs?: number;
|
connectDelayMs?: number;
|
||||||
tickWatchMinIntervalMs?: number;
|
tickWatchMinIntervalMs?: number;
|
||||||
|
requestTimeoutMs?: number;
|
||||||
token?: string;
|
token?: string;
|
||||||
deviceToken?: string;
|
deviceToken?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
@ -442,6 +444,7 @@ export class GatewayClient {
|
|||||||
|
|
||||||
private flushPendingErrors(err: Error) {
|
private flushPendingErrors(err: Error) {
|
||||||
for (const [, p] of this.pending) {
|
for (const [, p] of this.pending) {
|
||||||
|
p.cleanup?.();
|
||||||
p.reject(err);
|
p.reject(err);
|
||||||
}
|
}
|
||||||
this.pending.clear();
|
this.pending.clear();
|
||||||
@ -501,7 +504,7 @@ export class GatewayClient {
|
|||||||
async request<T = Record<string, unknown>>(
|
async request<T = Record<string, unknown>>(
|
||||||
method: string,
|
method: string,
|
||||||
params?: unknown,
|
params?: unknown,
|
||||||
opts?: { expectFinal?: boolean },
|
opts?: { expectFinal?: boolean; timeoutMs?: number },
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||||
throw new Error("gateway not connected");
|
throw new Error("gateway not connected");
|
||||||
@ -514,14 +517,46 @@ export class GatewayClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const expectFinal = opts?.expectFinal === true;
|
const expectFinal = opts?.expectFinal === true;
|
||||||
|
const rawTimeoutMs = opts?.timeoutMs ?? this.opts.requestTimeoutMs;
|
||||||
|
const timeoutMs =
|
||||||
|
typeof rawTimeoutMs === "number" && Number.isFinite(rawTimeoutMs)
|
||||||
|
? Math.max(1, Math.min(300_000, rawTimeoutMs))
|
||||||
|
: 30_000;
|
||||||
const p = new Promise<T>((resolve, reject) => {
|
const p = new Promise<T>((resolve, reject) => {
|
||||||
|
let timeout: NodeJS.Timeout | null = setTimeout(() => {
|
||||||
|
timeout = null;
|
||||||
|
this.pending.delete(id);
|
||||||
|
reject(new Error(`gateway request timeout for ${method}`));
|
||||||
|
}, timeoutMs);
|
||||||
|
timeout.unref?.();
|
||||||
|
const cleanup = () => {
|
||||||
|
if (!timeout) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = null;
|
||||||
|
};
|
||||||
this.pending.set(id, {
|
this.pending.set(id, {
|
||||||
resolve: (value) => resolve(value as T),
|
resolve: (value) => {
|
||||||
reject,
|
cleanup();
|
||||||
|
resolve(value as T);
|
||||||
|
},
|
||||||
|
reject: (err) => {
|
||||||
|
cleanup();
|
||||||
|
reject(err);
|
||||||
|
},
|
||||||
expectFinal,
|
expectFinal,
|
||||||
|
cleanup,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
this.ws.send(JSON.stringify(frame));
|
try {
|
||||||
|
this.ws.send(JSON.stringify(frame));
|
||||||
|
} catch (err) {
|
||||||
|
const pending = this.pending.get(id);
|
||||||
|
pending?.cleanup?.();
|
||||||
|
this.pending.delete(id);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
119
src/infra/sealed-json-file.ts
Normal file
119
src/infra/sealed-json-file.ts
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { randomBytes, createCipheriv, createDecipheriv, scryptSync } from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const SEALED_JSON_PREFIX = "openclaw-sealed-json-v1:";
|
||||||
|
|
||||||
|
type SealedJsonEnvelope = {
|
||||||
|
v: 1;
|
||||||
|
alg: "aes-256-gcm";
|
||||||
|
salt: string;
|
||||||
|
iv: string;
|
||||||
|
tag: string;
|
||||||
|
ciphertext: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class SealedJsonPassphraseRequiredError extends Error {
|
||||||
|
constructor(pathname: string) {
|
||||||
|
super(
|
||||||
|
`Encrypted OpenClaw auth store at ${pathname} requires OPENCLAW_PASSPHRASE to be set before it can be read.`,
|
||||||
|
);
|
||||||
|
this.name = "SealedJsonPassphraseRequiredError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePassphrase(env: NodeJS.ProcessEnv = process.env): string | null {
|
||||||
|
const value = env.OPENCLAW_PASSPHRASE?.trim();
|
||||||
|
return value ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBase64(value: Buffer): string {
|
||||||
|
return value.toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromBase64(value: string): Buffer {
|
||||||
|
return Buffer.from(value, "base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveKey(passphrase: string, salt: Buffer): Buffer {
|
||||||
|
return scryptSync(passphrase, salt, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sealUtf8(plaintext: string, passphrase: string): string {
|
||||||
|
const salt = randomBytes(16);
|
||||||
|
const iv = randomBytes(12);
|
||||||
|
const key = deriveKey(passphrase, salt);
|
||||||
|
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||||
|
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||||
|
const envelope: SealedJsonEnvelope = {
|
||||||
|
v: 1,
|
||||||
|
alg: "aes-256-gcm",
|
||||||
|
salt: toBase64(salt),
|
||||||
|
iv: toBase64(iv),
|
||||||
|
tag: toBase64(cipher.getAuthTag()),
|
||||||
|
ciphertext: toBase64(ciphertext),
|
||||||
|
};
|
||||||
|
return `${SEALED_JSON_PREFIX}${JSON.stringify(envelope)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unsealUtf8(raw: string, passphrase: string): string {
|
||||||
|
if (!raw.startsWith(SEALED_JSON_PREFIX)) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
const payload = raw.slice(SEALED_JSON_PREFIX.length).trim();
|
||||||
|
const envelope = JSON.parse(payload) as Partial<SealedJsonEnvelope>;
|
||||||
|
if (
|
||||||
|
envelope.v !== 1 ||
|
||||||
|
envelope.alg !== "aes-256-gcm" ||
|
||||||
|
typeof envelope.salt !== "string" ||
|
||||||
|
typeof envelope.iv !== "string" ||
|
||||||
|
typeof envelope.tag !== "string" ||
|
||||||
|
typeof envelope.ciphertext !== "string"
|
||||||
|
) {
|
||||||
|
throw new Error("invalid sealed json envelope");
|
||||||
|
}
|
||||||
|
const key = deriveKey(passphrase, fromBase64(envelope.salt));
|
||||||
|
const decipher = createDecipheriv("aes-256-gcm", key, fromBase64(envelope.iv));
|
||||||
|
decipher.setAuthTag(fromBase64(envelope.tag));
|
||||||
|
return Buffer.concat([
|
||||||
|
decipher.update(fromBase64(envelope.ciphertext)),
|
||||||
|
decipher.final(),
|
||||||
|
]).toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadSealedJsonFile(
|
||||||
|
pathname: string,
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
): unknown {
|
||||||
|
if (!fs.existsSync(pathname)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const raw = fs.readFileSync(pathname, "utf8");
|
||||||
|
if (!raw.startsWith(SEALED_JSON_PREFIX)) {
|
||||||
|
return JSON.parse(raw) as unknown;
|
||||||
|
}
|
||||||
|
const passphrase = resolvePassphrase(env);
|
||||||
|
if (!passphrase) {
|
||||||
|
throw new SealedJsonPassphraseRequiredError(pathname);
|
||||||
|
}
|
||||||
|
return JSON.parse(unsealUtf8(raw, passphrase)) as unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSealedJsonFile(
|
||||||
|
pathname: string,
|
||||||
|
data: unknown,
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
): void {
|
||||||
|
const dir = path.dirname(pathname);
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||||
|
}
|
||||||
|
const plaintext = `${JSON.stringify(data, null, 2)}\n`;
|
||||||
|
const passphrase = resolvePassphrase(env);
|
||||||
|
fs.writeFileSync(pathname, passphrase ? sealUtf8(plaintext, passphrase) : plaintext, "utf8");
|
||||||
|
fs.chmodSync(pathname, 0o600);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSealedJsonText(raw: string): boolean {
|
||||||
|
return raw.startsWith(SEALED_JSON_PREFIX);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user