Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion appcontainer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,13 @@ path, trusted runtime caller, supported mode, and a canonical database path
inside the OpenClaw state directory. Redirected or malformed staging roots are
rejected; this does not allow arbitrary Node commands outside the sandbox.
Scoped SQLite sessions validate each IPC request before the runtime handles it;
database requests must stay inside the canonical state directory. The separate
database requests must stay inside the canonical state directory. OpenClaw
2026.10.5 reads may include mode-specific `auth` or `operation` metadata carrying
the captured file identity, an optional artifact-preserving flag, and an encoded
read-only operation command. Only these known fields and types are accepted;
OpenClaw still verifies the physical file identity and admits the read operation.
Snapshot, staging, and transfer messages retain their two-field envelope. This
shared session path serves cloud providers and local models alike. The separate
state-ownership reader is restricted to its exact worker, caller, and database
argument. Worker-thread startup uses the package root captured from the desktop
when Node has not initialized the worker's entry arguments yet.
Expand Down
36 changes: 33 additions & 3 deletions appcontainer/sandbox-cp-hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -421,13 +421,18 @@ function isOpenClawInternalSqliteSessionMessage(message, runtime) {
typeof message !== "object" ||
Array.isArray(message) ||
!Number.isSafeInteger(message.id) ||
message.id < 1 ||
Object.keys(message).length !== 2
message.id < 1
) {
return false;
}
if (message.transfer) {
if (typeof message.transfer !== "object" || Array.isArray(message.transfer)) return false;
if (
Object.keys(message).length !== 2 ||
typeof message.transfer !== "object" ||
Array.isArray(message.transfer)
) {
return false;
}
return (
Object.keys(message.transfer).length === 2 &&
(message.transfer.type === "next" || message.transfer.type === "end") &&
Expand All @@ -448,6 +453,31 @@ function isOpenClawInternalSqliteSessionMessage(message, runtime) {
}
var mode = args[0];
var target = args[1];
var metadataKey = mode === "operation" ? "operation" : mode === "auth-profile-rows" ? "auth" : "";
var hasMetadata = metadataKey && Object.prototype.hasOwnProperty.call(message, metadataKey);
if (Object.keys(message).length !== (hasMetadata ? 3 : 2)) return false;
if (hasMetadata) {
var metadata = message[metadataKey];
if (
!metadata ||
typeof metadata !== "object" ||
Array.isArray(metadata) ||
typeof metadata.expectedIdentity !== "string" ||
!metadata.expectedIdentity.startsWith("file:") ||
(metadata.artifactPreserving !== undefined &&
typeof metadata.artifactPreserving !== "boolean") ||
(mode === "operation" && typeof metadata.command !== "string") ||
!Object.keys(metadata).every(function (key) {
return (
key === "expectedIdentity" ||
key === "artifactPreserving" ||
(mode === "operation" && key === "command")
);
})
) {
return false;
}
}
if (!pathMod.isAbsolute(target)) return false;
if (mode === "staging-create" || mode === "staging-create-legacy") {
return args.length === 2 && isOpenClawSqliteStagingParent(target, runtime);
Expand Down
39 changes: 35 additions & 4 deletions desktop/src/openclaw-sqlite-sandbox.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ describe.skipIf(!publishedPackage || process.platform !== "win32")(
const metadata = JSON.parse(
readFileSync(path.join(publishedPackage!, "package.json"), "utf8"),
);
expect(metadata.version).toBe("2026.10.3");
expect(metadata.version).toBe("2026.10.5");
const stateDir = path.join(root, "state");
const localAppData = path.join(root, "local");
const tempDir = path.join(root, "temp");
Expand Down Expand Up @@ -204,9 +204,18 @@ const invocation = hooks.buildInternalSqliteWorkerInvocation([], {
assert.equal(invocation.options.env.NODE_COMPILE_CACHE, process.env.NODE_COMPILE_CACHE);
assert.equal(invocation.options.env.NODE_OPTIONS, undefined);
assert.equal(sandbox.state.sandboxActive, true);
const worker = await import(pathToFileURL(path.join(packageRoot, "dist", "sqlite-readonly-worker-DPlfD0t8.mjs")).href);
const stagingRuntime = await import(pathToFileURL(path.join(packageRoot, "dist", "sqlite-snapshot-staging-DxUHqJpO.mjs")).href);
const cleanupRuntime = await import(pathToFileURL(path.join(packageRoot, "dist", "sqlite-readonly-location-cleanup-CPS8QEIp.mjs")).href);
async function importChunk(prefix, exportName) {
const dist = path.join(packageRoot, "dist");
for (const name of fs.readdirSync(dist)) {
if (!name.startsWith(prefix) || name.length !== prefix.length + 12 || !name.endsWith(".mjs")) continue;
const candidate = await import(pathToFileURL(path.join(dist, name)).href);
if (typeof candidate[exportName] === "function") return candidate;
}
throw new Error("Missing OpenClaw runtime export: " + exportName);
}
const worker = await importChunk("sqlite-readonly-worker-", "runSqliteReadOnlyWorker");
const stagingRuntime = await importChunk("sqlite-snapshot-staging-", "r");
const cleanupRuntime = await importChunk("sqlite-readonly-location-cleanup-", "c");
for (const mode of ["sync", "async", "session"]) {
const staging = stagingRuntime.r(path.join(process.env.LOCALAPPDATA, "openclaw"));
assert.match(path.basename(staging), /^openclaw-sqlite-readonly-v2-/);
Expand All @@ -222,6 +231,27 @@ for (const mode of ["sync", "async", "session"]) {
fs.readdirSync(staging);
assert.equal(cleanupRuntime.c(staging), true);
}
const identity = fs.statSync(databasePath, { bigint: true });
const expectedIdentity = "file:" + identity.dev + ":" + identity.ino;
const reads = worker.createScopedSqliteReadOnlyWorker(worker.captureSqliteReadOnlyWorkerLaunch());
try {
for (const artifactPreserving of [false, true]) {
const auth = await reads.run(databasePath, { mode: "auth-profile-rows", expectedIdentity, artifactPreserving });
assert.equal(typeof auth.store.status, "string");
assert.equal(typeof auth.state.status, "string");
const operation = await reads.run(databasePath, {
mode: "operation",
expectedIdentity,
artifactPreserving,
command: { type: "pluginCatalog.read", input: { agentId: "main", pluginIds: [] } },
});
assert.equal(operation.operation, "pluginCatalog.read");
assert.deepEqual(operation.value, []);
}
} finally {
await reads.close();
}
console.log("SQLite auth and operation metadata verified with sandbox active");
const outsideDatabasePath = path.join(process.env.TEMP, "outside.sqlite");
const outsideDatabase = new DatabaseSync(outsideDatabasePath);
outsideDatabase.exec("CREATE TABLE secret (value TEXT);");
Expand Down Expand Up @@ -268,6 +298,7 @@ console.log("SQLite sync/async snapshots and cleanup verified with sandbox activ
expect(result.stderr).toContain("spawn: OpenClaw SQLite worker -> BYPASS (internal IPC)");
expect(result.stderr).not.toMatch(/permission requested|BLOCKED \(permission\)/i);
expect(result.stdout).toContain("SQLite sync/async snapshots and cleanup verified");
expect(result.stdout).toContain("SQLite auth and operation metadata verified");
} finally {
rmSync(root, { recursive: true, force: true });
}
Expand Down
68 changes: 68 additions & 0 deletions desktop/src/sandbox-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2680,6 +2680,74 @@ describe("OpenClaw internal SQLite worker", () => {
).toBe(false);
});

it.each(["auth-profile-rows", "operation"])(
"accepts 10.5 %s metadata without weakening database scope",
(mode) => {
const metadata = {
expectedIdentity: "file:1:2",
...(mode === "operation" ? { command: "AA==" } : {}),
};
const metadataKey = mode === "operation" ? "operation" : "auth";
const message = { id: 1, args: [mode, args[3]], [metadataKey]: metadata };
for (const artifactPreserving of [undefined, false, true]) {
expect(
cpHooks.isOpenClawInternalSqliteSessionMessage(
{ ...message, [metadataKey]: { ...metadata, artifactPreserving } },
runtime,
),
).toBe(true);
}
expect(cpHooks.isOpenClawInternalSqliteSessionMessage(message, runtime)).toBe(true);
expect(
cpHooks.isOpenClawInternalSqliteSessionMessage(
{ ...message, args: [mode, path.join(localAppData, "outside.sqlite")] },
runtime,
),
).toBe(false);
expect(
cpHooks.isOpenClawInternalSqliteSessionMessage(message, {
...runtime,
realpath: (value: string) =>
value === args[3] ? path.join(localAppData, "outside.sqlite") : runtime.realpath(value),
}),
).toBe(false);
},
);

it.each([
{ args: ["sync", args[3]], auth: { expectedIdentity: "file:1:2" } },
{ args: ["staging-create", path.join(localAppData, "openclaw")], auth: {} },
{ args: ["operation", args[3]], auth: { expectedIdentity: "file:1:2" } },
{ args: ["auth-profile-rows", args[3]], operation: { expectedIdentity: "file:1:2" } },
{ args: ["auth-profile-rows", args[3]], auth: null },
{ args: ["auth-profile-rows", args[3]], auth: [] },
{ args: ["auth-profile-rows", args[3]], auth: {} },
{ args: ["auth-profile-rows", args[3]], auth: { expectedIdentity: 1 } },
{ args: ["auth-profile-rows", args[3]], auth: { expectedIdentity: "other:1:2" } },
{
args: ["auth-profile-rows", args[3]],
auth: { expectedIdentity: "file:1:2", artifactPreserving: "true" },
},
{
args: ["auth-profile-rows", args[3]],
auth: { expectedIdentity: "file:1:2", command: "AA==" },
},
{ args: ["operation", args[3]], operation: { expectedIdentity: "file:1:2" } },
{
args: ["operation", args[3]],
operation: { expectedIdentity: "file:1:2", command: {} },
},
{
args: ["operation", args[3]],
operation: { expectedIdentity: "file:1:2", command: "AA==", extra: true },
},
{ transfer: { type: "next", transferId: 1 }, auth: { expectedIdentity: "file:1:2" } },
])("rejects malformed or misplaced SQLite read metadata: %j", (request) => {
expect(cpHooks.isOpenClawInternalSqliteSessionMessage({ id: 1, ...request }, runtime)).toBe(
false,
);
});

it("accepts the trusted package directory used before Node initializes argv[1]", () => {
expect(
cpHooks.isOpenClawInternalSqliteWorkerCommand(execPath, args, stack, {
Expand Down
33 changes: 33 additions & 0 deletions desktop/src/session-model-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,39 @@ describe("removed GitHub Copilot session models", () => {
});

describe("session model reads", () => {
it.each(["qwen/qwen3.8-27b", "minimax/MiniMax-M2.5", "llama-cpp/local"])(
"uses the same session RPCs for the inherited and selected %s model",
async (modelRef) => {
const f = fixture();
const defaults = (f.config.agents as Row).defaults as Row;
defaults.model = { primary: modelRef };
await expect(f.service.get("chat")).resolves.toEqual({
sessionKey: "agent:main:chat",
modelRef,
overridden: false,
});
expect(f.request.mock.calls.map(([method]) => method)).toEqual([
"sessions.describe",
"config.get",
"sessions.list",
]);
f.sessions.set("agent:main:chat", {
key: "agent:main:chat",
agentId: "main",
modelProvider: modelRef.slice(0, modelRef.indexOf("/")),
model: modelRef.slice(modelRef.indexOf("/") + 1),
modelOverrideSource: "user",
});
await expect(f.service.get("chat")).resolves.toEqual({
sessionKey: "agent:main:chat",
modelRef,
overridden: true,
});
expect(f.sessions.size).toBe(1);
expect(f.patches()).toEqual([]);
},
);

it("uses the same canonical identity for send/terminal-event chat guards", () => {
const f = fixture();
expect(sessionModelChatKey(f.config, "main")).toBe(
Expand Down
Loading