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.
|
||||
- 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
|
||||
- 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
|
||||
|
||||
|
||||
@ -2,6 +2,9 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
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 { saveAuthProfileStore } from "./auth-profiles/store.js";
|
||||
import type { AuthProfileStore } from "./auth-profiles/types.js";
|
||||
@ -61,4 +64,37 @@ describe("saveAuthProfileStore", () => {
|
||||
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 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 { resolveOpenClawAgentDir } from "../agent-paths.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,
|
||||
profiles: {},
|
||||
};
|
||||
saveJsonFile(pathname, payload);
|
||||
saveSealedJsonFile(pathname, payload);
|
||||
}
|
||||
|
||||
@ -2,7 +2,12 @@ import fs from "node:fs";
|
||||
import type { OAuthCredentials } from "@mariozechner/pi-ai";
|
||||
import { resolveOAuthPath } from "../../config/paths.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 { syncExternalCliCredentials } from "./external-cli-sync.js";
|
||||
import { ensureAuthStoreFile, resolveAuthStorePath, resolveLegacyAuthStorePath } from "./paths.js";
|
||||
@ -24,6 +29,19 @@ function resolveRuntimeStoreKey(agentDir?: string): string {
|
||||
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 {
|
||||
return structuredClone(store);
|
||||
}
|
||||
@ -278,7 +296,7 @@ function mergeAuthProfileStores(
|
||||
|
||||
function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
|
||||
const oauthPath = resolveOAuthPath();
|
||||
const oauthRaw = loadJsonFile(oauthPath);
|
||||
const oauthRaw = loadProtectedAuthJson(oauthPath, "oauth.json");
|
||||
if (!oauthRaw || typeof oauthRaw !== "object") {
|
||||
return false;
|
||||
}
|
||||
@ -339,7 +357,7 @@ function applyLegacyStore(store: AuthProfileStore, legacy: LegacyAuthStore): voi
|
||||
}
|
||||
|
||||
function loadCoercedStore(authPath: string): AuthProfileStore | null {
|
||||
const raw = loadJsonFile(authPath);
|
||||
const raw = loadProtectedAuthJson(authPath, "auth-profiles.json");
|
||||
return coerceAuthStore(raw);
|
||||
}
|
||||
|
||||
@ -350,7 +368,7 @@ export function loadAuthProfileStore(): AuthProfileStore {
|
||||
// Sync from external CLI tools on every load.
|
||||
const synced = syncExternalCliCredentials(asStore);
|
||||
if (synced) {
|
||||
saveJsonFile(authPath, asStore);
|
||||
saveSealedJsonFile(authPath, asStore);
|
||||
}
|
||||
return asStore;
|
||||
}
|
||||
@ -383,7 +401,7 @@ function loadAuthProfileStoreForAgent(
|
||||
// sync external CLI credentials in-memory, but never persist while readOnly.
|
||||
const synced = syncExternalCliCredentials(asStore);
|
||||
if (synced && !readOnly) {
|
||||
saveJsonFile(authPath, asStore);
|
||||
saveSealedJsonFile(authPath, asStore);
|
||||
}
|
||||
return asStore;
|
||||
}
|
||||
@ -395,7 +413,7 @@ function loadAuthProfileStoreForAgent(
|
||||
const mainStore = coerceAuthStore(mainRaw);
|
||||
if (mainStore && Object.keys(mainStore.profiles).length > 0) {
|
||||
// Clone main store to subagent directory for auth inheritance
|
||||
saveJsonFile(authPath, mainStore);
|
||||
saveSealedJsonFile(authPath, mainStore);
|
||||
log.info("inherited auth-profiles from main agent", { agentDir });
|
||||
return mainStore;
|
||||
}
|
||||
@ -417,7 +435,7 @@ function loadAuthProfileStoreForAgent(
|
||||
const forceReadOnly = process.env.OPENCLAW_AUTH_STORE_READONLY === "1";
|
||||
const shouldWrite = !readOnly && !forceReadOnly && (legacy !== null || mergedOAuth || syncedCli);
|
||||
if (shouldWrite) {
|
||||
saveJsonFile(authPath, store);
|
||||
saveSealedJsonFile(authPath, store);
|
||||
}
|
||||
|
||||
// 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,
|
||||
usageStats: store.usageStats ?? undefined,
|
||||
} satisfies AuthProfileStore;
|
||||
saveJsonFile(authPath, payload);
|
||||
saveSealedJsonFile(authPath, payload);
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Api, Model } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { saveSealedJsonFile } from "../infra/sealed-json-file.js";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
import { ensureAuthProfileStore } from "./auth-profiles.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 () => {
|
||||
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: {
|
||||
agentId?: string;
|
||||
sessionKey: string;
|
||||
@ -152,6 +160,7 @@ export async function appendAssistantMessageToSessionTranscript(params: {
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
await hardenSessionFilePermissions(sessionFile);
|
||||
|
||||
emitSessionTranscriptUpdate(sessionFile);
|
||||
return { ok: true, sessionFile };
|
||||
|
||||
@ -19,11 +19,14 @@ type WsEventHandlers = {
|
||||
};
|
||||
|
||||
class MockWebSocket {
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSED = 3;
|
||||
private openHandlers: WsEventHandlers["open"][] = [];
|
||||
private messageHandlers: WsEventHandlers["message"][] = [];
|
||||
private closeHandlers: WsEventHandlers["close"][] = [];
|
||||
private errorHandlers: WsEventHandlers["error"][] = [];
|
||||
readonly sent: string[] = [];
|
||||
readyState = MockWebSocket.CLOSED;
|
||||
|
||||
constructor(_url: string, _options?: unknown) {
|
||||
wsInstances.push(this);
|
||||
@ -59,6 +62,7 @@ class MockWebSocket {
|
||||
}
|
||||
|
||||
emitOpen(): void {
|
||||
this.readyState = MockWebSocket.OPEN;
|
||||
for (const handler of this.openHandlers) {
|
||||
handler();
|
||||
}
|
||||
@ -71,6 +75,7 @@ class MockWebSocket {
|
||||
}
|
||||
|
||||
emitClose(code: number, reason: string): void {
|
||||
this.readyState = MockWebSocket.CLOSED;
|
||||
for (const handler of this.closeHandlers) {
|
||||
handler(code, Buffer.from(reason));
|
||||
}
|
||||
@ -438,4 +443,30 @@ describe("GatewayClient connect auth payload", () => {
|
||||
});
|
||||
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;
|
||||
reject: (err: unknown) => void;
|
||||
expectFinal: boolean;
|
||||
cleanup?: () => void;
|
||||
};
|
||||
|
||||
export type GatewayClientOptions = {
|
||||
url?: string; // ws://127.0.0.1:18789
|
||||
connectDelayMs?: number;
|
||||
tickWatchMinIntervalMs?: number;
|
||||
requestTimeoutMs?: number;
|
||||
token?: string;
|
||||
deviceToken?: string;
|
||||
password?: string;
|
||||
@ -442,6 +444,7 @@ export class GatewayClient {
|
||||
|
||||
private flushPendingErrors(err: Error) {
|
||||
for (const [, p] of this.pending) {
|
||||
p.cleanup?.();
|
||||
p.reject(err);
|
||||
}
|
||||
this.pending.clear();
|
||||
@ -501,7 +504,7 @@ export class GatewayClient {
|
||||
async request<T = Record<string, unknown>>(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
opts?: { expectFinal?: boolean },
|
||||
opts?: { expectFinal?: boolean; timeoutMs?: number },
|
||||
): Promise<T> {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
throw new Error("gateway not connected");
|
||||
@ -514,14 +517,46 @@ export class GatewayClient {
|
||||
);
|
||||
}
|
||||
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) => {
|
||||
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, {
|
||||
resolve: (value) => resolve(value as T),
|
||||
reject,
|
||||
resolve: (value) => {
|
||||
cleanup();
|
||||
resolve(value as T);
|
||||
},
|
||||
reject: (err) => {
|
||||
cleanup();
|
||||
reject(err);
|
||||
},
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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