mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
feat(memory): confine filesystem and egress
This commit is contained in:
+14
-12
@@ -28,14 +28,14 @@ Related:
|
||||
|
||||
Doctor has six postures:
|
||||
|
||||
| Posture | Command | Behavior |
|
||||
| ------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| Inspect | `openclaw doctor` / `openclaw doctor --json` | Advisory checks in human or machine-readable form. |
|
||||
| Repair | `openclaw doctor --fix` | Applies supported repairs, using prompts unless non-interactive repair is safe. |
|
||||
| Lint | `openclaw doctor --lint [--json]` | Read-only findings with threshold-based exit codes for CI gates. |
|
||||
| Shared SQLite maintenance | `openclaw doctor --state-sqlite compact` | Explicitly checkpoints, compacts, and verifies the canonical shared state DB. |
|
||||
| Session SQLite migration | `openclaw doctor --session-sqlite <mode>` | Inspects, imports, validates, compacts, recovers, or restores session state. |
|
||||
| Memory isolation pilot | `openclaw doctor --memory-isolation <mode>` | Reads or changes the durable P1C single-subject shadow-read-only posture. |
|
||||
| Posture | Command | Behavior |
|
||||
| ------------------------- | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| Inspect | `openclaw doctor` | Human-oriented checks and guided prompts. |
|
||||
| Repair | `openclaw doctor --fix` | Applies supported repairs, using prompts unless non-interactive repair is safe. |
|
||||
| Lint | `openclaw doctor --lint` | Read-only structured findings for CI, preflight, and review gates. |
|
||||
| Shared SQLite maintenance | `openclaw doctor --state-sqlite compact` | Explicitly checkpoints, compacts, and verifies the canonical shared state DB. |
|
||||
| Session SQLite migration | `openclaw doctor --session-sqlite <mode>` | Inspects, imports, validates, compacts, recovers, or restores session state. |
|
||||
| Memory isolation pilot | `openclaw doctor --memory-isolation <mode>` | Reads or changes the durable read-only posture, including the constrained P1D execution profile when available. |
|
||||
|
||||
Use `openclaw doctor --json` when an operator or script wants the advisory Doctor report as JSON. It exits successfully after producing a report; inspect `ok` and `findings` for health state. Use explicit `openclaw doctor --lint --json` when CI should exit nonzero for findings at the selected severity threshold. Prefer `--fix` when a human operator wants Doctor to edit config or state.
|
||||
|
||||
@@ -92,7 +92,7 @@ openclaw channels status --probe
|
||||
| `--deep` | Scan system services for extra gateway installs; report recent Gateway supervisor restart handoffs. |
|
||||
| `--lint` | Run modernized health checks in read-only mode and emit diagnostic findings. |
|
||||
| `--post-upgrade` | Run post-upgrade plugin compatibility probes; findings go to stdout; exit code 1 if any error-level finding is present. |
|
||||
| `--memory-isolation <mode>` | Read or change the P1C memory-isolation posture: `status`, `shadow-read-only`, or `legacy`. |
|
||||
| `--memory-isolation <mode>` | Read or change the read-only memory-isolation posture: `status`, `shadow-read-only`, or `legacy`. |
|
||||
| `--memory-isolation-agent <id>` | With `--memory-isolation`: select one configured agent. |
|
||||
| `--state-sqlite <mode>` | Run explicit shared state SQLite maintenance. The only mode is `compact`. |
|
||||
| `--session-sqlite <mode>` | Run the targeted session SQLite migration mode: `inspect`, `dry-run`, `import`, `validate`, `compact`, `recover`, or `restore`. |
|
||||
@@ -110,7 +110,7 @@ openclaw channels status --probe
|
||||
|
||||
## Memory isolation pilot
|
||||
|
||||
`openclaw doctor --memory-isolation shadow-read-only` enables the durable Phase 1C pilot posture for one configured agent. Doctor records a verified, reversible marker in that agent's existing memory-migration store. Gateway processes read this posture once at startup, so restart the Gateway after enabling or returning to `legacy`.
|
||||
`openclaw doctor --memory-isolation shadow-read-only` enables the durable read-only memory-isolation posture for one configured agent. Doctor records a verified, reversible marker in that agent's existing memory-migration store. Gateway processes read this posture once at startup, so restart the Gateway after enabling or returning to `legacy`.
|
||||
|
||||
```bash
|
||||
openclaw doctor --memory-isolation status --memory-isolation-agent main --json
|
||||
@@ -118,9 +118,11 @@ openclaw doctor --memory-isolation shadow-read-only --memory-isolation-agent mai
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
In this posture, legacy content-bearing memory reads and ordinary durable-memory writes are unavailable for the selected agent. It is a verified single-subject, read-only pilot, not a general production cutover.
|
||||
In this posture, legacy content-bearing memory reads and ordinary durable-memory writes are unavailable for the selected agent. An admitted Phase 1D virtual view further constrains model tools to `memory_search`, `memory_get`, and brokered `read`. It omits raw host paths, `write`, `edit`, `apply_patch`, `exec`, `process`, channel/message, browser, network, MCP, and other plugin tools. Codex native app-server runs are unavailable for an isolated agent because they cannot consume that brokered view.
|
||||
|
||||
The `legacy` mode removes only the reversible P1C marker. It cannot undo a final Phase 6 cutover marker. Shadow mode provides no authorized or virtual raw-path capability and no write path. Generic model-facing filesystem raw-read and exec confinement remain unimplemented P1D work; this command does not claim them. Two-subject sharing also remains unavailable until its later phase is complete.
|
||||
The current constrained egress profile permits only the automatic final reply. It rechecks the current recipient binding, route, delivery audience, exposure revision, and registry revision both when queueing and immediately before platform delivery. A changed recipient, route, sink, later exposure, or registry revision suppresses delivery. This is deliberately not a general egress registry and does not authorize model-initiated side effects.
|
||||
|
||||
It remains a verified single-subject, read-only posture, not a general production cutover. Do not begin a two-subject pilot until every Phase 1D filesystem, sandbox, exec, egress, and required remote/cross-platform proof is complete. The `legacy` mode removes only the reversible P1C marker. It cannot undo a final Phase 6 cutover marker.
|
||||
|
||||
## Lint mode
|
||||
|
||||
|
||||
@@ -51,32 +51,16 @@ import {
|
||||
import { getLeasedSharedCodexAppServerClient } from "./shared-client.js";
|
||||
import { rotateOversizedCodexAppServerStartupBinding } from "./startup-binding.js";
|
||||
|
||||
const CODEX_PROJECT_DOCUMENTS_DISABLED_OVERRIDE = "project_doc_max_bytes=0";
|
||||
|
||||
function fenceCodexProjectDocumentsForMemoryIsolation(params: {
|
||||
agentId: string;
|
||||
appServer: ReturnType<typeof resolveCodexBindingAppServerConnection>["appServer"];
|
||||
}) {
|
||||
if (!isLegacyMemorySurfaceDisabled(params.agentId)) {
|
||||
return params.appServer;
|
||||
function assertCodexMemoryIsolationSupported(agentId: string): void {
|
||||
if (!isLegacyMemorySurfaceDisabled(agentId)) {
|
||||
return;
|
||||
}
|
||||
if (params.appServer.start.transport !== "stdio") {
|
||||
// Codex reads project documents while a process initializes. A pre-existing
|
||||
// endpoint cannot receive the startup override, so continuing would reopen
|
||||
// workspace MEMORY.md through project_doc_fallback_filenames.
|
||||
throw new Error(
|
||||
"Codex memory-isolation runs require a local stdio app-server so project documents can be disabled at startup",
|
||||
);
|
||||
}
|
||||
return {
|
||||
...params.appServer,
|
||||
start: {
|
||||
...params.appServer.start,
|
||||
// Upstream applies repeated -c values in order; append after operator args
|
||||
// so their local config.toml is never rewritten or allowed to re-enable docs.
|
||||
args: [...params.appServer.start.args, "-c", CODEX_PROJECT_DOCUMENTS_DISABLED_OVERRIDE],
|
||||
},
|
||||
};
|
||||
// Codex retains native filesystem and shell tools in its app-server protocol.
|
||||
// Project-document suppression is not a filesystem boundary, so fail closed
|
||||
// until this runtime can consume the brokered, read-only virtual projection.
|
||||
throw new Error(
|
||||
"Codex is unavailable for this memory-isolated agent: use a brokered OpenClaw coding runtime until Codex supports authorized virtual memory views.",
|
||||
);
|
||||
}
|
||||
|
||||
export async function prepareCodexAttemptConnection({ params, options }: CodexRunAttemptInput) {
|
||||
@@ -365,6 +349,7 @@ export async function prepareCodexAttemptConnection({ params, options }: CodexRu
|
||||
configured: typeof configuredAppServer,
|
||||
selection: { modelProvider?: string; model?: string },
|
||||
) => {
|
||||
assertCodexMemoryIsolationSupported(sessionAgentId);
|
||||
const session = applySessionPermissionPolicy(configured, selection);
|
||||
const trusted = resolveCodexAppServerForModelProvider({
|
||||
appServer: session,
|
||||
|
||||
@@ -6,13 +6,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/sqlite-runtime-testing";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { prepareCodexAttemptConnection } from "./run-attempt-connection.js";
|
||||
import {
|
||||
createParams,
|
||||
createStartedThreadHarness,
|
||||
runCodexAppServerAttempt,
|
||||
setupRunAttemptTestHooks,
|
||||
tempDir,
|
||||
} from "./run-attempt-test-harness.js";
|
||||
import { createParams, setupRunAttemptTestHooks, tempDir } from "./run-attempt-test-harness.js";
|
||||
import { testCodexAppServerBindingStore } from "./session-binding.test-helpers.js";
|
||||
|
||||
setupRunAttemptTestHooks();
|
||||
@@ -33,8 +27,8 @@ function markAgentMemoryCutOver(agentId: string): void {
|
||||
.run();
|
||||
}
|
||||
|
||||
describe("Codex memory-isolation project-document fence", () => {
|
||||
it("disables native project documents without rewriting local config.toml or sending legacy memory", async () => {
|
||||
describe("Codex memory-isolation boundary", () => {
|
||||
it("fails closed before the app-server can receive an unconstrained workspace", async () => {
|
||||
const agentId = "codex-project-document-fence";
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(tempDir, "state"));
|
||||
markAgentMemoryCutOver(agentId);
|
||||
@@ -56,28 +50,16 @@ describe("Codex memory-isolation project-document fence", () => {
|
||||
params.agentId = agentId;
|
||||
params.agentDir = agentDir;
|
||||
|
||||
const connection = await prepareCodexAttemptConnection({
|
||||
params,
|
||||
options: { bindingStore: testCodexAppServerBindingStore },
|
||||
});
|
||||
expect(connection.appServer.start.args).toEqual(
|
||||
expect.arrayContaining(["-c", "project_doc_max_bytes=0"]),
|
||||
await expect(
|
||||
prepareCodexAttemptConnection({
|
||||
params,
|
||||
options: { bindingStore: testCodexAppServerBindingStore },
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Codex is unavailable for this memory-isolated agent: use a brokered OpenClaw coding runtime until Codex supports authorized virtual memory views.",
|
||||
);
|
||||
expect(await fs.readFile(configTomlPath, "utf8")).toBe(configToml);
|
||||
|
||||
const harness = createStartedThreadHarness();
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
|
||||
const modelVisibleTurnPayload = JSON.stringify(
|
||||
harness.requests.filter(
|
||||
(request) => request.method === "thread/start" || request.method === "turn/start",
|
||||
),
|
||||
);
|
||||
expect(modelVisibleTurnPayload).not.toContain(legacyMemory);
|
||||
expect(modelVisibleTurnPayload).not.toContain(legacyMemoryPath);
|
||||
expect(await fs.readFile(legacyMemoryPath, "utf8")).toBe(legacyMemory);
|
||||
});
|
||||
|
||||
it("refuses a pre-existing app-server that cannot receive the startup fence", async () => {
|
||||
@@ -101,6 +83,6 @@ describe("Codex memory-isolation project-document fence", () => {
|
||||
pluginConfig: { appServer: { transport: "websocket", url: "ws://127.0.0.1:39175" } },
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("require a local stdio app-server");
|
||||
).rejects.toThrow("Codex is unavailable for this memory-isolated agent");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -739,7 +739,7 @@ describe("feishu_doc image fetch hardening", () => {
|
||||
|
||||
const feishuDocTool = resolveFeishuDocTool({
|
||||
workspaceDir: WORKSPACE_ROOT,
|
||||
fsPolicy: { workspaceOnly: true },
|
||||
fsPolicy: { kind: "workspace", workspaceOnly: true },
|
||||
});
|
||||
|
||||
await executeFeishuDocTool(feishuDocTool, {
|
||||
@@ -766,7 +766,7 @@ describe("feishu_doc image fetch hardening", () => {
|
||||
});
|
||||
|
||||
const feishuDocTool = resolveFeishuDocTool({
|
||||
fsPolicy: { workspaceOnly: true },
|
||||
fsPolicy: { kind: "workspace", workspaceOnly: true },
|
||||
});
|
||||
|
||||
await executeFeishuDocTool(feishuDocTool, {
|
||||
@@ -787,7 +787,7 @@ describe("feishu_doc image fetch hardening", () => {
|
||||
|
||||
const feishuDocTool = resolveFeishuDocTool({
|
||||
workspaceDir: WORKSPACE_ROOT,
|
||||
fsPolicy: { workspaceOnly: true },
|
||||
fsPolicy: { kind: "workspace", workspaceOnly: true },
|
||||
});
|
||||
|
||||
await executeFeishuDocTool(feishuDocTool, {
|
||||
@@ -918,7 +918,7 @@ describe("feishu_doc image fetch hardening", () => {
|
||||
|
||||
const feishuDocTool = resolveFeishuDocTool({
|
||||
workspaceDir: WORKSPACE_ROOT,
|
||||
fsPolicy: { workspaceOnly: true },
|
||||
fsPolicy: { kind: "workspace", workspaceOnly: true },
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { resolve } from "node:path";
|
||||
import type * as Lark from "@larksuiteoapi/node-sdk";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { normalizeOptionalString, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { Type } from "typebox";
|
||||
import type { OpenClawPluginApi } from "../runtime-api.js";
|
||||
@@ -36,7 +37,7 @@ import { feishuExternalToolResult as json } from "./tool-result.js";
|
||||
|
||||
function resolveDocToolLocalRoots(ctx: {
|
||||
workspaceDir?: string;
|
||||
fsPolicy?: { workspaceOnly: boolean };
|
||||
fsPolicy?: OpenClawPluginToolContext["fsPolicy"];
|
||||
}): string[] | undefined {
|
||||
if (ctx.fsPolicy?.workspaceOnly !== true) {
|
||||
return undefined;
|
||||
|
||||
@@ -22,7 +22,10 @@ import { buildMemoryFlushPlan } from "./src/flush-plan.js";
|
||||
import type { MemoryCoreAcquireLocalService } from "./src/memory/embedding-local-service.js";
|
||||
import type { MemoryCoreRuntimeHost } from "./src/memory/runtime-host.js";
|
||||
import { builtinScopedMemoryConformanceAdapter } from "./src/memory/scoped-memory-policy.js";
|
||||
import { builtinScopedMemoryAuthorizedRuntime } from "./src/memory/scoped-memory-runtime.js";
|
||||
import {
|
||||
builtinScopedMemoryAuthorizedRuntime,
|
||||
builtinScopedMemoryVirtualView,
|
||||
} from "./src/memory/scoped-memory-runtime.js";
|
||||
import { buildPromptSection } from "./src/prompt-section.js";
|
||||
import { registerSessionBackfillGatewayMethods } from "./src/session-backfill-gateway.js";
|
||||
|
||||
@@ -307,6 +310,7 @@ export default definePluginEntry({
|
||||
authorization: MEMORY_CORE_AUTHORIZATION_CAPABILITIES,
|
||||
// Phase 1B publishes the tested policy adapter; Phase 1C owns admitting it for reads.
|
||||
authorizationConformance: builtinScopedMemoryConformanceAdapter,
|
||||
virtualView: builtinScopedMemoryVirtualView,
|
||||
promptBuilder: buildPromptSection,
|
||||
flushPlanResolver: buildMemoryFlushPlan,
|
||||
runtime: memoryRuntime,
|
||||
|
||||
@@ -4,13 +4,20 @@ import path from "node:path";
|
||||
import type { MemoryContentAccessContext } from "openclaw/plugin-sdk/memory-authorization";
|
||||
import { closeOpenClawAgentDatabasesForTest } from "openclaw/plugin-sdk/sqlite-runtime-testing";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createAuthorizedMemoryReadHost } from "../../../../src/agents/memory-authorized-read-host.js";
|
||||
import {
|
||||
createAuthorizedMemoryReadHost,
|
||||
resolveAuthorizedMemoryVirtualFileBroker,
|
||||
} from "../../../../src/agents/memory-authorized-read-host.js";
|
||||
import { prepareMemoryEgressAuthorization } from "../../../../src/agents/memory-egress-admission.js";
|
||||
import { createReplyDispatcher } from "../../../../src/auto-reply/reply/reply-dispatcher.js";
|
||||
import { buildTestCtx } from "../../../../src/auto-reply/reply/test-ctx.js";
|
||||
import {
|
||||
consumeAdmittedChannelMemoryIdentityFromContext,
|
||||
createChannelMemoryIdentityAdmission,
|
||||
} from "../../../../src/channels/message-access/memory-identity-admission.js";
|
||||
import { admitMemoryAuthorizationReadRuntime } from "../../../../src/plugins/memory-authorization-runtime.js";
|
||||
import { resetMemoryIsolationCutoverForTest } from "../../../../src/plugins/memory-cutover.js";
|
||||
import { readLatestDurableMemoryRunExposure } from "../../../../src/plugins/memory-run-exposure-ledger.js";
|
||||
import { createEmptyPluginRegistry } from "../../../../src/plugins/registry-empty.js";
|
||||
import {
|
||||
resetPluginRuntimeStateForTest,
|
||||
@@ -19,8 +26,12 @@ import {
|
||||
import {
|
||||
adminLinkAdmittedMemoryIdentity,
|
||||
ensureMemoryOperationalPrincipal,
|
||||
revokeMemoryIdentityBinding,
|
||||
} from "../../../../src/state/memory-identity.js";
|
||||
import { admitInboundMemorySessionContext } from "../../../../src/state/memory-session-subject.js";
|
||||
import {
|
||||
admitInboundMemorySessionContext,
|
||||
createCurrentMemorySessionContext,
|
||||
} from "../../../../src/state/memory-session-subject.js";
|
||||
import { openOpenClawAgentDatabase } from "../../../../src/state/openclaw-agent-db.js";
|
||||
import { ensureProfileForEmail } from "../../../../src/state/user-profiles.js";
|
||||
import { MEMORY_CORE_AUTHORIZATION_CAPABILITIES } from "../authorization.js";
|
||||
@@ -28,10 +39,19 @@ import { builtinScopedMemoryConformanceAdapter } from "./scoped-memory-policy.js
|
||||
import { createBuiltinScopedMemoryResource } from "./scoped-memory-resources.js";
|
||||
import {
|
||||
builtinScopedMemoryAuthorizedRuntime,
|
||||
builtinScopedMemoryVirtualView,
|
||||
resetBuiltinScopedMemoryAuthorizedRuntimeForTest,
|
||||
} from "./scoped-memory-runtime.js";
|
||||
import { createBuiltinScopedMemoryStore } from "./scoped-memory-store.js";
|
||||
|
||||
const dispatchReplyFromConfig = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../../../src/auto-reply/reply/dispatch-from-config.js", () => ({
|
||||
dispatchReplyFromConfig,
|
||||
}));
|
||||
|
||||
const { dispatchInboundMessage } = await import("../../../../src/auto-reply/dispatch.js");
|
||||
|
||||
describe("builtin scoped authorized runtime", () => {
|
||||
let stateDir = "";
|
||||
|
||||
@@ -41,6 +61,7 @@ describe("builtin scoped authorized runtime", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dispatchReplyFromConfig.mockReset();
|
||||
resetBuiltinScopedMemoryAuthorizedRuntimeForTest();
|
||||
resetMemoryIsolationCutoverForTest();
|
||||
resetPluginRuntimeStateForTest();
|
||||
@@ -70,6 +91,7 @@ describe("builtin scoped authorized runtime", () => {
|
||||
capability: {
|
||||
authorization: MEMORY_CORE_AUTHORIZATION_CAPABILITIES,
|
||||
authorizationConformance: builtinScopedMemoryConformanceAdapter,
|
||||
virtualView: builtinScopedMemoryVirtualView,
|
||||
runtime: builtinScopedMemoryAuthorizedRuntime,
|
||||
},
|
||||
});
|
||||
@@ -327,8 +349,16 @@ describe("builtin scoped authorized runtime", () => {
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
|
||||
const aliceHost = createAuthorizedMemoryReadHost({ agentId: "main", ...aliceSession });
|
||||
const bobHost = createAuthorizedMemoryReadHost({ agentId: "main", ...bobSession });
|
||||
const aliceHost = createAuthorizedMemoryReadHost({
|
||||
agentId: "main",
|
||||
...aliceSession,
|
||||
deliveryContext: { channel: "telegram", accountId: "default", to: "alice" },
|
||||
});
|
||||
const bobHost = createAuthorizedMemoryReadHost({
|
||||
agentId: "main",
|
||||
...bobSession,
|
||||
deliveryContext: { channel: "telegram", accountId: "default", to: "bob" },
|
||||
});
|
||||
if (!aliceHost || !bobHost) {
|
||||
throw new Error("fixture failed to build an authorized memory host");
|
||||
}
|
||||
@@ -357,6 +387,157 @@ describe("builtin scoped authorized runtime", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("invalidates materialized virtual views after binding revocation and plan expiry", async () => {
|
||||
const session = { sessionKey: "agent:main:direct:alice", sessionId: "alice-session" };
|
||||
const alicePrincipalId = createVerifiedDirectSession({ name: "alice", ...session });
|
||||
createPrivateResource(alicePrincipalId, "ALICE_VIRTUAL_VIEW_CONTENT");
|
||||
markCutOver();
|
||||
installBuiltinSelectedRuntime();
|
||||
|
||||
const host = createAuthorizedMemoryReadHost({
|
||||
agentId: "main",
|
||||
...session,
|
||||
deliveryContext: { channel: "telegram", accountId: "default", to: "alice" },
|
||||
});
|
||||
if (!host) {
|
||||
throw new Error("fixture failed to build an authorized memory host");
|
||||
}
|
||||
const broker = await resolveAuthorizedMemoryVirtualFileBroker(host);
|
||||
const virtualPath = broker?.view.files[0]?.virtualPath;
|
||||
if (!broker || !virtualPath) {
|
||||
throw new Error("fixture failed to materialize an authorized virtual view");
|
||||
}
|
||||
await expect(broker.readFile(virtualPath)).resolves.toBe("ALICE_VIRTUAL_VIEW_CONTENT");
|
||||
|
||||
const sessionContext = createCurrentMemorySessionContext({
|
||||
...session,
|
||||
options: { agentId: "main" },
|
||||
});
|
||||
if (sessionContext.kind !== "current" || !sessionContext.context.bindingId) {
|
||||
throw new Error("fixture failed to retain the direct identity binding");
|
||||
}
|
||||
expect(revokeMemoryIdentityBinding({ bindingId: sessionContext.context.bindingId })).toBe(true);
|
||||
await expect(broker.readFile(virtualPath)).resolves.toBeUndefined();
|
||||
|
||||
// A fresh view starts with a live binding, then must become unusable once
|
||||
// its plan lease expires even though the broker object is still retained.
|
||||
const freshSession = { sessionKey: "agent:main:direct:bob", sessionId: "bob-session" };
|
||||
const bobPrincipalId = createVerifiedDirectSession({ name: "bob", ...freshSession });
|
||||
createPrivateResource(bobPrincipalId, "BOB_EXPIRED_VIRTUAL_VIEW_CONTENT");
|
||||
const freshHost = createAuthorizedMemoryReadHost({
|
||||
agentId: "main",
|
||||
...freshSession,
|
||||
deliveryContext: { channel: "telegram", accountId: "default", to: "bob" },
|
||||
});
|
||||
const freshBroker = freshHost && (await resolveAuthorizedMemoryVirtualFileBroker(freshHost));
|
||||
const freshPath = freshBroker?.view.files[0]?.virtualPath;
|
||||
if (!freshBroker || !freshPath) {
|
||||
throw new Error("fixture failed to materialize an expiring authorized virtual view");
|
||||
}
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.advanceTimersByTime(60_001);
|
||||
await expect(freshBroker.readFile(freshPath)).resolves.toBeUndefined();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("delivers an exposed direct-memory result only through the attested final-reply gate", async () => {
|
||||
const session = { sessionKey: "agent:main:direct:alice", sessionId: "alice-session" };
|
||||
const runId = "authorized-memory-final-reply";
|
||||
const alicePrincipalId = createVerifiedDirectSession({ name: "alice", ...session });
|
||||
createPrivateResource(alicePrincipalId, "ALICE_FINAL_REPLY_AUTHORIZED_CONTENT");
|
||||
markCutOver();
|
||||
installBuiltinSelectedRuntime();
|
||||
|
||||
const host = createAuthorizedMemoryReadHost({
|
||||
agentId: "main",
|
||||
...session,
|
||||
runId,
|
||||
deliveryContext: { channel: "telegram", accountId: "default", to: "alice" },
|
||||
});
|
||||
if (!host) {
|
||||
throw new Error("fixture failed to build an authorized memory host");
|
||||
}
|
||||
const read = await host.search({ query: "ALICE_FINAL_REPLY_AUTHORIZED_CONTENT", limit: 1 });
|
||||
if (!("results" in read) || !read.results[0]) {
|
||||
throw new Error("fixture failed to expose the authorized direct-memory result");
|
||||
}
|
||||
expect(
|
||||
readLatestDurableMemoryRunExposure({ agentId: "main", sessionId: session.sessionId, runId }),
|
||||
).toMatchObject({
|
||||
kind: "current",
|
||||
snapshot: {
|
||||
exposedResourceRevisions: [expect.any(String)],
|
||||
egressReceiptIds: [expect.any(String)],
|
||||
deliveryAudiences: [{ kind: "user", id: alicePrincipalId }],
|
||||
},
|
||||
});
|
||||
expect(
|
||||
prepareMemoryEgressAuthorization({
|
||||
capabilityId: "reply.final",
|
||||
agentId: "main",
|
||||
...session,
|
||||
runId,
|
||||
deliveryContext: { channel: "telegram", accountId: "default", to: "alice" },
|
||||
}),
|
||||
).toMatchObject({ allowed: true, authorization: { exposure: expect.any(Object) } });
|
||||
|
||||
const delivered = vi.fn(async () => undefined);
|
||||
const dispatcher = createReplyDispatcher({ deliver: delivered });
|
||||
dispatchReplyFromConfig.mockImplementation(async ({ dispatcher: activeDispatcher }) => {
|
||||
expect(activeDispatcher.sendFinalReply({ text: read.results[0]!.snippet })).toBe(true);
|
||||
return { queuedFinal: true, counts: { tool: 0, block: 0, final: 1 } };
|
||||
});
|
||||
await dispatchInboundMessage({
|
||||
ctx: buildTestCtx({
|
||||
AgentId: "main",
|
||||
SessionId: session.sessionId,
|
||||
SessionKey: session.sessionKey,
|
||||
Surface: "telegram",
|
||||
OriginatingChannel: "telegram",
|
||||
OriginatingTo: "alice",
|
||||
AccountId: "default",
|
||||
}),
|
||||
cfg: {} as never,
|
||||
dispatcher,
|
||||
replyOptions: { runId },
|
||||
outboundHooks: "disabled",
|
||||
});
|
||||
await dispatcher.waitForIdle();
|
||||
expect(delivered).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: "ALICE_FINAL_REPLY_AUTHORIZED_CONTENT" }),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
const reboundDelivered = vi.fn(async () => undefined);
|
||||
const reboundDispatcher = createReplyDispatcher({ deliver: reboundDelivered });
|
||||
dispatchReplyFromConfig.mockImplementation(async ({ ctx, dispatcher: activeDispatcher }) => {
|
||||
activeDispatcher.sendFinalReply({ text: read.results[0]!.snippet });
|
||||
ctx.OriginatingTo = "mallory";
|
||||
return { queuedFinal: true, counts: { tool: 0, block: 0, final: 1 } };
|
||||
});
|
||||
await dispatchInboundMessage({
|
||||
ctx: buildTestCtx({
|
||||
AgentId: "main",
|
||||
SessionId: session.sessionId,
|
||||
SessionKey: session.sessionKey,
|
||||
Surface: "telegram",
|
||||
OriginatingChannel: "telegram",
|
||||
OriginatingTo: "alice",
|
||||
AccountId: "default",
|
||||
}),
|
||||
cfg: {} as never,
|
||||
dispatcher: reboundDispatcher,
|
||||
replyOptions: { runId },
|
||||
outboundHooks: "disabled",
|
||||
});
|
||||
await reboundDispatcher.waitForIdle();
|
||||
expect(reboundDelivered).not.toHaveBeenCalled();
|
||||
expect(reboundDispatcher.getCancelledCounts?.().final).toBe(1);
|
||||
});
|
||||
|
||||
it("mounts only channel and explicitly addressed copies for a group actor", async () => {
|
||||
const session = { sessionKey: "agent:main:telegram:group:1", sessionId: "group-session" };
|
||||
const conversationPrincipalId = createConversationSession({
|
||||
@@ -439,7 +620,11 @@ describe("builtin scoped authorized runtime", () => {
|
||||
markCutOver();
|
||||
installBuiltinSelectedRuntime();
|
||||
|
||||
const host = createAuthorizedMemoryReadHost({ agentId: "main", ...session });
|
||||
const host = createAuthorizedMemoryReadHost({
|
||||
agentId: "main",
|
||||
...session,
|
||||
deliveryContext: { channel: "telegram", accountId: "default", to: "group-target" },
|
||||
});
|
||||
if (!host) {
|
||||
throw new Error("fixture failed to build a group memory host");
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
AuthorizedMemorySearchParams,
|
||||
AuthorizedMemorySearchResult,
|
||||
AuthorizedResourceHandle,
|
||||
AuthorizedMemoryVirtualView,
|
||||
MemoryAccessContext,
|
||||
MemoryContentAccessContext,
|
||||
} from "openclaw/plugin-sdk/memory-authorization";
|
||||
@@ -40,6 +41,23 @@ type PlanState = Readonly<{
|
||||
}>;
|
||||
|
||||
const plans = new Map<string, PlanState>();
|
||||
type VirtualViewAllocation = Readonly<{
|
||||
planId: string;
|
||||
revision: string;
|
||||
expiresAtMs: number;
|
||||
/** View paths bind the exact revision selected at materialization time. */
|
||||
revisionByVirtualPath: ReadonlyMap<string, string>;
|
||||
}>;
|
||||
|
||||
const virtualViews = new Map<string, VirtualViewAllocation>();
|
||||
|
||||
function pruneExpiredVirtualViews(nowMs = Date.now()): void {
|
||||
for (const [viewId, allocation] of virtualViews) {
|
||||
if (allocation.expiresAtMs <= nowMs) {
|
||||
virtualViews.delete(viewId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hash(parts: readonly string[]): string {
|
||||
return createHash("sha256").update(parts.join("\0")).digest("base64url");
|
||||
@@ -248,6 +266,128 @@ function readPlan(params: {
|
||||
return state;
|
||||
}
|
||||
|
||||
function materializeAuthorizedVirtualView(params: {
|
||||
context: MemoryContentAccessContext<"read">;
|
||||
plan: AuthorizedMemoryPlan & Readonly<{ operation: "read" }>;
|
||||
}): AuthorizedMemoryVirtualView | undefined {
|
||||
pruneExpiredVirtualViews();
|
||||
const state = readPlan(params);
|
||||
if (!state || state.stores.length !== state.plan.mounts.length) {
|
||||
return undefined;
|
||||
}
|
||||
const revision = `mviewr1_${hash(state.stores.map((store) => store.policyRevisionId))}`;
|
||||
const roots = state.stores.map((_, index) =>
|
||||
Object.freeze({
|
||||
version: 1 as const,
|
||||
mountHandle: state.plan.mounts[index]!.mountHandle,
|
||||
virtualRoot: `projections-${index + 1}`,
|
||||
access: "read" as const,
|
||||
}),
|
||||
);
|
||||
const revisionByVirtualPath = new Map<string, string>();
|
||||
const files = state.stores.flatMap((store, index) => {
|
||||
const root = roots[index]!;
|
||||
const rows = withScopedMemoryDatabase(
|
||||
params.context.agentId,
|
||||
(database) =>
|
||||
database
|
||||
.prepare(
|
||||
`SELECT revision.revision_id
|
||||
FROM memory_resources AS resource
|
||||
JOIN memory_resource_revisions AS revision
|
||||
ON revision.resource_id = resource.resource_id
|
||||
WHERE resource.agent_id = ?
|
||||
AND resource.store_id = ?
|
||||
AND revision.lifecycle_state = 'active'
|
||||
AND (revision.expires_at IS NULL OR revision.expires_at > ?)
|
||||
ORDER BY resource.resource_id`,
|
||||
)
|
||||
.all(params.context.agentId, store.storeId, Date.now()) as Array<{
|
||||
revision_id: string;
|
||||
}>,
|
||||
);
|
||||
return rows.flatMap((row, ordinal) => {
|
||||
const virtualPath = `${root.virtualRoot}/${ordinal + 1}.md`;
|
||||
revisionByVirtualPath.set(virtualPath, row.revision_id);
|
||||
return [
|
||||
Object.freeze({
|
||||
version: 1 as const,
|
||||
mountHandle: root.mountHandle,
|
||||
virtualPath,
|
||||
}),
|
||||
];
|
||||
});
|
||||
});
|
||||
const view = Object.freeze({
|
||||
version: 1 as const,
|
||||
viewId: `mview1_${randomUUID()}`,
|
||||
planId: state.plan.planId,
|
||||
contextFingerprint: state.contextFingerprint,
|
||||
revision,
|
||||
roots: Object.freeze(roots),
|
||||
files: Object.freeze(files),
|
||||
expiresAt: state.plan.expiresAt,
|
||||
});
|
||||
virtualViews.set(
|
||||
view.viewId,
|
||||
Object.freeze({
|
||||
planId: view.planId,
|
||||
revision: view.revision,
|
||||
expiresAtMs: state.expiresAtMs,
|
||||
revisionByVirtualPath,
|
||||
}),
|
||||
);
|
||||
return view;
|
||||
}
|
||||
|
||||
function readAuthorizedVirtualFile(params: {
|
||||
context: MemoryContentAccessContext<"read">;
|
||||
plan: AuthorizedMemoryPlan & Readonly<{ operation: "read" }>;
|
||||
view: AuthorizedMemoryVirtualView;
|
||||
virtualPath: string;
|
||||
}): AuthorizedMemoryResultEnvelope<MemoryReadResult> {
|
||||
pruneExpiredVirtualViews();
|
||||
const state = readPlan(params);
|
||||
const allocation = virtualViews.get(params.view.viewId);
|
||||
const normalized = params.virtualPath.normalize("NFC");
|
||||
const parts = normalized.split("/");
|
||||
const revisionId = allocation?.revisionByVirtualPath.get(normalized);
|
||||
if (
|
||||
!state ||
|
||||
!allocation ||
|
||||
allocation.expiresAtMs <= Date.now() ||
|
||||
allocation.planId !== params.plan.planId ||
|
||||
allocation.revision !== params.view.revision ||
|
||||
params.view.planId !== params.plan.planId ||
|
||||
params.view.contextFingerprint !== params.context.contextFingerprint ||
|
||||
normalized !== params.virtualPath ||
|
||||
parts.length !== 2 ||
|
||||
!revisionId
|
||||
) {
|
||||
throw new Error("authorized memory virtual view is unavailable");
|
||||
}
|
||||
const snapshot = readBuiltinScopedMemoryRevisionSnapshot({
|
||||
agentId: params.context.agentId,
|
||||
storeIds: state.stores.map((store) => store.storeId),
|
||||
revisionId,
|
||||
});
|
||||
if (!snapshot) {
|
||||
throw new Error("authorized memory virtual view is unavailable");
|
||||
}
|
||||
return createEnvelope({
|
||||
state,
|
||||
context: params.context,
|
||||
value: Object.freeze({
|
||||
text: snapshot.content,
|
||||
path: `memory/${snapshot.logicalLocator}`,
|
||||
from: 1,
|
||||
lines: snapshot.content.split("\n").length,
|
||||
}),
|
||||
revisions: [snapshot.revisionId],
|
||||
sourcePolicySetIds: [`mps1_${snapshot.policyRevisionId}`],
|
||||
});
|
||||
}
|
||||
|
||||
function createHandle(params: {
|
||||
plan: PlanState;
|
||||
revisionId: string;
|
||||
@@ -457,6 +597,24 @@ export const builtinScopedMemoryAuthorizedRuntime = Object.freeze(
|
||||
builtinScopedMemoryReadRuntime,
|
||||
) as unknown as Pick<AuthorizedMemoryRuntime, "authorize" | "searchAuthorized" | "readAuthorized">;
|
||||
|
||||
export const builtinScopedMemoryVirtualView = Object.freeze({
|
||||
async materializeAuthorizedVirtualView(params: {
|
||||
context: MemoryContentAccessContext<"read">;
|
||||
plan: AuthorizedMemoryPlan & Readonly<{ operation: "read" }>;
|
||||
}): Promise<AuthorizedMemoryVirtualView | undefined> {
|
||||
return materializeAuthorizedVirtualView(params);
|
||||
},
|
||||
async readAuthorizedVirtualFile(params: {
|
||||
context: MemoryContentAccessContext<"read">;
|
||||
plan: AuthorizedMemoryPlan & Readonly<{ operation: "read" }>;
|
||||
view: AuthorizedMemoryVirtualView;
|
||||
virtualPath: string;
|
||||
}): Promise<AuthorizedMemoryResultEnvelope<MemoryReadResult>> {
|
||||
return readAuthorizedVirtualFile(params);
|
||||
},
|
||||
});
|
||||
|
||||
export function resetBuiltinScopedMemoryAuthorizedRuntimeForTest(): void {
|
||||
plans.clear();
|
||||
virtualViews.clear();
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ describe("workboard tools", () => {
|
||||
const restrictedContext = {
|
||||
agentId: "main",
|
||||
workspaceDir: "/workspace",
|
||||
fsPolicy: { workspaceOnly: true },
|
||||
fsPolicy: { kind: "workspace", workspaceOnly: true },
|
||||
} as const;
|
||||
const restricted = new Map(
|
||||
guardWorkboardToolsForWorkspaceAccess(
|
||||
@@ -63,7 +63,7 @@ describe("workboard tools", () => {
|
||||
const unrestrictedContext = {
|
||||
agentId: "main",
|
||||
workspaceDir: "/workspace",
|
||||
fsPolicy: { workspaceOnly: false },
|
||||
fsPolicy: { kind: "workspace", workspaceOnly: false },
|
||||
} as const;
|
||||
const unrestricted = new Map(
|
||||
guardWorkboardToolsForWorkspaceAccess(
|
||||
@@ -92,7 +92,7 @@ describe("workboard tools", () => {
|
||||
const sandboxContext = {
|
||||
agentId: "main",
|
||||
workspaceDir: "/workspace",
|
||||
fsPolicy: { workspaceOnly: false },
|
||||
fsPolicy: { kind: "workspace", workspaceOnly: false },
|
||||
sandboxed: true,
|
||||
} as const;
|
||||
const sandboxed = new Map(
|
||||
|
||||
@@ -282,6 +282,39 @@ export type AuthorizedMemoryMount = DeepReadonly<{
|
||||
audienceRevision: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Additive Phase 1D projection contract. A view is an opaque broker handle;
|
||||
* neither artifact locations nor host filesystem roots cross this boundary.
|
||||
*/
|
||||
export type AuthorizedMemoryVirtualRoot = DeepReadonly<{
|
||||
version: 1;
|
||||
mountHandle: string;
|
||||
virtualRoot: string;
|
||||
access: "read";
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Opaque, logical file inventory for one authorized virtual view. The paths
|
||||
* are relative to the virtual filesystem only; they never name a plugin,
|
||||
* artifact, store, or host filesystem location.
|
||||
*/
|
||||
export type AuthorizedMemoryVirtualFile = DeepReadonly<{
|
||||
version: 1;
|
||||
mountHandle: string;
|
||||
virtualPath: string;
|
||||
}>;
|
||||
|
||||
export type AuthorizedMemoryVirtualView = DeepReadonly<{
|
||||
version: 1;
|
||||
viewId: string;
|
||||
planId: string;
|
||||
contextFingerprint: string;
|
||||
revision: string;
|
||||
roots: readonly AuthorizedMemoryVirtualRoot[];
|
||||
files: readonly AuthorizedMemoryVirtualFile[];
|
||||
expiresAt: string;
|
||||
}>;
|
||||
|
||||
/** Plugin-issued, revision-bound reference. It is not a bearer grant or a raw path. */
|
||||
export type AuthorizedResourceHandle = DeepReadonly<{
|
||||
version: 1;
|
||||
|
||||
@@ -1039,7 +1039,10 @@ describe("createOpenClawCodingTools", () => {
|
||||
config: { tools: { fs: { workspaceOnly: true } } },
|
||||
});
|
||||
|
||||
expect(latestCreateOpenClawToolsOptions().fsPolicy).toEqual({ workspaceOnly: true });
|
||||
expect(latestCreateOpenClawToolsOptions().fsPolicy).toEqual({
|
||||
kind: "workspace",
|
||||
workspaceOnly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the canonical spawn workspace for follow-up task suggestions", () => {
|
||||
@@ -2733,6 +2736,33 @@ function extractToolText(result: unknown): string {
|
||||
}
|
||||
|
||||
describe("createOpenClawCodingTools read behavior", () => {
|
||||
it("exposes only selected-memory reads and broker-bound read for an admitted view", () => {
|
||||
const tools = createOpenClawCodingTools({
|
||||
fsPolicy: {
|
||||
kind: "authorized-memory-view",
|
||||
workspaceOnly: true,
|
||||
viewId: "opaque-view",
|
||||
revision: "revision-1",
|
||||
virtualRoots: ["selected"],
|
||||
},
|
||||
authorizedMemoryVirtualRead: {
|
||||
viewId: "opaque-view",
|
||||
virtualRoots: ["selected"],
|
||||
virtualPaths: ["selected/MEMORY.md"],
|
||||
readFile: async () => "memory",
|
||||
},
|
||||
});
|
||||
const names = toolNameList(tools);
|
||||
|
||||
expect(names).toContain("read");
|
||||
expect(names.every((name) => ["memory_search", "memory_get", "read"].includes(name))).toBe(
|
||||
true,
|
||||
);
|
||||
for (const denied of ["exec", "process", "write", "edit", "apply_patch", "message"]) {
|
||||
expect(names).not.toContain(denied);
|
||||
}
|
||||
});
|
||||
|
||||
it("reads exact node skill locators without sending them to the filesystem backend", async () => {
|
||||
const locator = "node://node-1/skills/pond/SKILL.md";
|
||||
const execute = vi.fn(async () => {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
wrapReadToolWithAuthorizedMemoryView,
|
||||
type AuthorizedMemoryVirtualRead,
|
||||
} from "./agent-tools.read.js";
|
||||
import type { AnyAgentTool } from "./agent-tools.types.js";
|
||||
import { createCoreCodingTools } from "./core-coding-tools.js";
|
||||
|
||||
const view: AuthorizedMemoryVirtualRead = {
|
||||
viewId: "opaque-view",
|
||||
virtualRoots: ["selected"],
|
||||
virtualPaths: ["selected/MEMORY.md", "selected/café.md"],
|
||||
readFile: async (virtualPath) => `broker:${virtualPath}`,
|
||||
};
|
||||
|
||||
function createHarness(params?: { readFile?: AuthorizedMemoryVirtualRead["readFile"] }) {
|
||||
const genericExecute = vi.fn(async () => ({
|
||||
content: [{ type: "text", text: "generic filesystem result" }],
|
||||
}));
|
||||
const readFile = vi.fn(params?.readFile ?? view.readFile);
|
||||
const tool = wrapReadToolWithAuthorizedMemoryView(
|
||||
{
|
||||
name: "read",
|
||||
label: "read",
|
||||
description: "read a file",
|
||||
parameters: {},
|
||||
execute: genericExecute,
|
||||
} as unknown as AnyAgentTool,
|
||||
{ ...view, readFile },
|
||||
);
|
||||
return { genericExecute, readFile, tool };
|
||||
}
|
||||
|
||||
function resultText(result: unknown): string {
|
||||
const content = (result as { content?: Array<{ type?: unknown; text?: unknown }> }).content ?? [];
|
||||
return content
|
||||
.filter((block) => block.type === "text" && typeof block.text === "string")
|
||||
.map((block) => block.text)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
describe("authorized memory view read boundary", () => {
|
||||
it("omits mutation, patch, exec, and process tools from the admitted core surface", () => {
|
||||
const tools = createCoreCodingTools({
|
||||
codingRoot: "/tmp/authorized-memory-view",
|
||||
includeBaseCodingTools: true,
|
||||
includeShellTools: true,
|
||||
fsPolicy: {
|
||||
kind: "authorized-memory-view",
|
||||
workspaceOnly: true,
|
||||
viewId: view.viewId,
|
||||
revision: "revision-1",
|
||||
virtualRoots: view.virtualRoots,
|
||||
},
|
||||
authorizedMemoryVirtualRead: view,
|
||||
baseToolNames: ["read", "edit", "write"],
|
||||
applyPatchEnabled: true,
|
||||
applyPatchWorkspaceOnly: true,
|
||||
execDefaults: {} as never,
|
||||
processDefaults: {} as never,
|
||||
});
|
||||
|
||||
expect(tools.map((tool) => tool.name)).toEqual(["read"]);
|
||||
});
|
||||
|
||||
it("reads one exact opaque manifest URI through the broker", async () => {
|
||||
const { genericExecute, readFile, tool } = createHarness();
|
||||
|
||||
const result = await tool.execute("read-1", {
|
||||
path: "memory://opaque-view/selected/MEMORY.md",
|
||||
});
|
||||
|
||||
expect(resultText(result)).toContain("broker:selected/MEMORY.md");
|
||||
expect(readFile).toHaveBeenCalledTimes(1);
|
||||
expect(readFile).toHaveBeenCalledWith("selected/MEMORY.md");
|
||||
expect(genericExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects raw, aliased, and host paths before the generic reader or broker", async () => {
|
||||
const { genericExecute, readFile, tool } = createHarness();
|
||||
const rejectedPaths = [
|
||||
"memory/MEMORY.md",
|
||||
"/memory/selected/MEMORY.md",
|
||||
"/selected-store/MEMORY.md",
|
||||
"../memory/MEMORY.md",
|
||||
"/host/selected/MEMORY.md",
|
||||
"file:///host/selected/MEMORY.md",
|
||||
"memory://opaque-view/selected/../MEMORY.md",
|
||||
"memory://opaque-view/selected\\MEMORY.md",
|
||||
"memory://opaque-view/Selected/MEMORY.md",
|
||||
"memory://opaque-view/selected/memory.md",
|
||||
"memory://opaque-view/selected/cafe\u0301.md",
|
||||
"memory://opaque-view/selected/MEMORY.md?host=/selected-store",
|
||||
"memory:/selected/MEMORY.md",
|
||||
];
|
||||
|
||||
for (const path of rejectedPaths) {
|
||||
await expect(tool.execute(`reject:${path}`, { path })).rejects.toThrow(
|
||||
"authorized memory view path is unavailable",
|
||||
);
|
||||
}
|
||||
|
||||
expect(readFile).not.toHaveBeenCalled();
|
||||
expect(genericExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects undeclared virtual paths without probing the broker", async () => {
|
||||
const { genericExecute, readFile, tool } = createHarness();
|
||||
|
||||
await expect(
|
||||
tool.execute("missing", { path: "memory://opaque-view/selected/undeclared.md" }),
|
||||
).rejects.toThrow("authorized memory view path is unavailable");
|
||||
|
||||
expect(readFile).not.toHaveBeenCalled();
|
||||
expect(genericExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,14 @@ type OpenClawReadToolOptions = {
|
||||
imageSanitization?: ImageSanitizationLimits;
|
||||
};
|
||||
|
||||
export type AuthorizedMemoryVirtualRead = Readonly<{
|
||||
viewId: string;
|
||||
virtualRoots: readonly string[];
|
||||
/** Exact manifest members admitted with this view, never a root-wide grant. */
|
||||
virtualPaths: readonly string[];
|
||||
readFile: (virtualPath: string) => Promise<string | undefined>;
|
||||
}>;
|
||||
|
||||
type SkillReadContent = {
|
||||
filePath: string;
|
||||
readContent?: string;
|
||||
@@ -956,6 +964,7 @@ export function createHostWorkspaceWriteTool(
|
||||
options?: {
|
||||
containmentRoot?: string;
|
||||
workspaceOnly?: boolean;
|
||||
memoryFileMutationGuard?: MemoryFileMutationGuard;
|
||||
memoryWriteProvenance?: MemoryWriteProvenanceObserver;
|
||||
createTool?: typeof createWriteTool;
|
||||
},
|
||||
@@ -974,6 +983,7 @@ export function createHostWorkspaceEditTool(
|
||||
options?: {
|
||||
containmentRoot?: string;
|
||||
workspaceOnly?: boolean;
|
||||
memoryFileMutationGuard?: MemoryFileMutationGuard;
|
||||
memoryWriteProvenance?: MemoryWriteProvenanceObserver;
|
||||
createTool?: typeof createEditTool;
|
||||
},
|
||||
@@ -1072,6 +1082,115 @@ export function wrapReadToolWithSkillContent(
|
||||
};
|
||||
}
|
||||
|
||||
function authorizedMemoryViewPathUnavailable(): Error {
|
||||
// This deliberately names neither a host nor a selected-store path: both are
|
||||
// outside the opaque model-facing view and can disclose controlled state.
|
||||
return new Error("authorized memory view path is unavailable");
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a model path at the authorized-memory boundary before any generic
|
||||
* filesystem resolver sees it. An admitted view has no host-path fallback:
|
||||
* only one exact, NFC manifest URI can reach the broker.
|
||||
*/
|
||||
function parseAuthorizedMemoryViewPath(params: {
|
||||
rawPath: string;
|
||||
view: AuthorizedMemoryVirtualRead;
|
||||
}): string {
|
||||
const { rawPath, view } = params;
|
||||
if (!rawPath.startsWith("memory://")) {
|
||||
throw authorizedMemoryViewPathUnavailable();
|
||||
}
|
||||
const suffix = rawPath.slice("memory://".length);
|
||||
const separator = suffix.indexOf("/");
|
||||
const viewId = separator > 0 ? suffix.slice(0, separator) : "";
|
||||
const virtualPath = separator > 0 ? suffix.slice(separator + 1) : "";
|
||||
const parts = virtualPath.split("/");
|
||||
if (
|
||||
viewId !== view.viewId ||
|
||||
!virtualPath ||
|
||||
virtualPath.normalize("NFC") !== virtualPath ||
|
||||
parts.length !== 2 ||
|
||||
!view.virtualRoots.includes(parts[0]!) ||
|
||||
!view.virtualPaths.includes(virtualPath) ||
|
||||
parts.some(
|
||||
(part) =>
|
||||
!part ||
|
||||
part === "." ||
|
||||
part === ".." ||
|
||||
part.includes("\\") ||
|
||||
part.normalize("NFC") !== part,
|
||||
)
|
||||
) {
|
||||
throw authorizedMemoryViewPathUnavailable();
|
||||
}
|
||||
return virtualPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve only the admission-prepared memory URI through its broker. This runs
|
||||
* before workspace guards, so controlled aliases and host paths cannot fall
|
||||
* through to generic filesystem resolution.
|
||||
*/
|
||||
export function wrapReadToolWithAuthorizedMemoryView(
|
||||
tool: AnyAgentTool,
|
||||
view: AuthorizedMemoryVirtualRead,
|
||||
options?: OpenClawReadToolOptions,
|
||||
): AnyAgentTool {
|
||||
const contentByVirtualPath = new Map<string, Promise<string>>();
|
||||
const readContent = async (rawPath: string): Promise<string> => {
|
||||
const virtualPath = parseAuthorizedMemoryViewPath({ rawPath, view });
|
||||
let content = contentByVirtualPath.get(virtualPath);
|
||||
if (!content) {
|
||||
content = view.readFile(virtualPath).then((value) => {
|
||||
if (value === undefined) {
|
||||
throw createFsAccessError("ENOENT", rawPath);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
contentByVirtualPath.set(virtualPath, content);
|
||||
}
|
||||
return content;
|
||||
};
|
||||
const virtualBase = createReadTool("/", {
|
||||
operations: {
|
||||
resolvePath: (filePath) => filePath,
|
||||
access: async (filePath) => void (await readContent(filePath)),
|
||||
readFile: async (filePath) => Buffer.from(await readContent(filePath), "utf8"),
|
||||
},
|
||||
}) as unknown as AnyAgentTool;
|
||||
const virtualRead = createOpenClawReadTool(virtualBase, options);
|
||||
return {
|
||||
...tool,
|
||||
execute: async (toolCallId, args, signal, onUpdate) => {
|
||||
const record = getToolParamsRecord(args);
|
||||
const rawPath = record?.path;
|
||||
if (typeof rawPath !== "string") {
|
||||
throw authorizedMemoryViewPathUnavailable();
|
||||
}
|
||||
// Parse before virtualRead normalizes or a wrapped generic reader resolves
|
||||
// paths. This rejects raw selected-store roots, traversal, host aliases,
|
||||
// case aliases, and decomposed-Unicode aliases without probing them.
|
||||
parseAuthorizedMemoryViewPath({ rawPath, view });
|
||||
return virtualRead.execute(toolCallId, args, signal, onUpdate);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Phase 1D views are strictly read-only until the authorized-write lifecycle exists. */
|
||||
export function wrapToolAuthorizedMemoryViewMutationDeny(tool: AnyAgentTool): AnyAgentTool {
|
||||
return {
|
||||
...tool,
|
||||
execute: async (toolCallId, args, signal, onUpdate) => {
|
||||
const pathValue = getToolParamsRecord(args)?.path;
|
||||
if (typeof pathValue === "string" && pathValue.startsWith("memory:")) {
|
||||
throw new Error("authorized memory views are read-only");
|
||||
}
|
||||
return tool.execute(toolCallId, args, signal, onUpdate);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createSandboxReadOperations(params: SandboxToolParams) {
|
||||
return {
|
||||
resolvePath: (filePath: string) => {
|
||||
@@ -1088,6 +1207,13 @@ function createSandboxReadOperations(params: SandboxToolParams) {
|
||||
readFile: (absolutePath: string) =>
|
||||
params.bridge.readFile({ filePath: absolutePath, cwd: params.root }),
|
||||
access: (absolutePath: string) => assertSandboxFileExists(params, absolutePath),
|
||||
resolveFileIdentity: async (absolutePath: string) => {
|
||||
const hostPath = params.bridge.resolvePath({
|
||||
filePath: absolutePath,
|
||||
cwd: params.root,
|
||||
}).hostPath;
|
||||
return hostPath ? await fs.realpath(hostPath) : undefined;
|
||||
},
|
||||
detectImageMimeType: async (absolutePath: string, buffer: Buffer) => {
|
||||
const mime = await detectMime({ buffer, filePath: absolutePath });
|
||||
return mime?.startsWith("image/") ? mime : undefined;
|
||||
@@ -1363,19 +1489,20 @@ function withMemoryFileMutationGuard<T extends FileMutationOperations>(params: {
|
||||
operations: T;
|
||||
guard: MemoryFileMutationGuard | undefined;
|
||||
}): T {
|
||||
if (!params.guard) {
|
||||
const guard = params.guard;
|
||||
if (!guard) {
|
||||
return params.operations;
|
||||
}
|
||||
return {
|
||||
...params.operations,
|
||||
writeFile: async (absolutePath, content) => {
|
||||
await params.guard.assertCanMutate(absolutePath);
|
||||
await guard.assertCanMutate(absolutePath);
|
||||
await params.operations.writeFile(absolutePath, content);
|
||||
},
|
||||
...(params.operations.mkdir
|
||||
? {
|
||||
mkdir: async (dir: string) => {
|
||||
await params.guard.assertCanMutate(dir);
|
||||
await guard.assertCanMutate(dir);
|
||||
await params.operations.mkdir?.(dir);
|
||||
},
|
||||
}
|
||||
|
||||
+47
-18
@@ -26,6 +26,7 @@ import type {
|
||||
import { isMemoryIsolationCutoverAgent } from "../plugins/memory-cutover.js";
|
||||
import { resolveMemoryFlushPlan } from "../plugins/memory-state.js";
|
||||
import { appendRuntimePluginToolGrant } from "../plugins/tool-grant-allowlist.js";
|
||||
import type { AuthorizedMemoryReadHost } from "../plugins/tool-types.js";
|
||||
import { getPluginToolMeta } from "../plugins/tools.js";
|
||||
import { getActiveSecretsRuntimeConfigSnapshot } from "../secrets/runtime-state.js";
|
||||
import { GATEWAY_OWNER_ONLY_CORE_TOOLS } from "../security/dangerous-tools.js";
|
||||
@@ -38,6 +39,7 @@ import type { ToolOutcomeObserver } from "./agent-tools.before-tool-call.js";
|
||||
import { finalizeAgentTools } from "./agent-tools.finalize.js";
|
||||
import { filterToolsByMessageProvider } from "./agent-tools.message-provider-policy.js";
|
||||
import { wrapToolMemoryFlushAppendOnlyWrite } from "./agent-tools.read.js";
|
||||
import type { AuthorizedMemoryVirtualRead } from "./agent-tools.read.js";
|
||||
import {
|
||||
getActiveAgentRingZeroTools,
|
||||
mergeAgentRingZeroTools,
|
||||
@@ -88,8 +90,8 @@ import {
|
||||
createWriteTool,
|
||||
} from "./sessions/index.js";
|
||||
import type { TrustedSubagentCompletionHandoff } from "./subagents/announce/subagent-announce-handoff.js";
|
||||
import { resolveToolFsConfig } from "./tool-fs-policy.js";
|
||||
import type { PreparedSessionPermissionPolicy } from "./tool-fs-policy.js";
|
||||
import { createToolFsPolicy, resolveToolFsConfig } from "./tool-fs-policy.js";
|
||||
import type { PreparedSessionPermissionPolicy, ToolFsPolicy } from "./tool-fs-policy.js";
|
||||
import { resolveToolLoopDetectionConfig } from "./tool-loop-detection-config.js";
|
||||
import { buildDeclaredToolAllowlistContext } from "./tool-policy-declared-context.js";
|
||||
import { applyToolPolicyPipeline } from "./tool-policy-pipeline.js";
|
||||
@@ -120,6 +122,7 @@ import { wrapToolWithGatewayCallerIdentity } from "./tools/gateway-caller-contex
|
||||
|
||||
const MEMORY_FLUSH_ALLOWED_TOOL_NAMES = new Set(["read", "write"]);
|
||||
const MEMORY_ISOLATION_READ_TOOL_NAMES = new Set(["memory_search", "memory_get"]);
|
||||
const AUTHORIZED_MEMORY_VIEW_TOOL_NAMES = new Set(["memory_search", "memory_get", "read"]);
|
||||
|
||||
function applyModelProviderToolPolicy(
|
||||
toolsInput: AnyAgentTool[],
|
||||
@@ -208,6 +211,15 @@ type OpenClawCodingToolsOptions = {
|
||||
runId?: string;
|
||||
/** Exact admitted run instance for lifecycle-bound subprocess capabilities. */
|
||||
operationalRunInstance?: OperationalRunInstanceRef;
|
||||
/**
|
||||
* Prepared once at run admission. Plugin tools and prompt preparation must
|
||||
* share this exact host rather than minting authority from routing strings.
|
||||
*/
|
||||
authorizedMemoryRead?: AuthorizedMemoryReadHost;
|
||||
/** Admission-prepared closed filesystem policy; never derived from tool input. */
|
||||
fsPolicy?: ToolFsPolicy;
|
||||
/** Core-private opaque broker for the admitted memory view. */
|
||||
authorizedMemoryVirtualRead?: AuthorizedMemoryVirtualRead;
|
||||
/** Device-scoped operator session allowed to review approvals initiated by this run. */
|
||||
approvalReviewerDeviceId?: string;
|
||||
/** Diagnostic trace context for hook/log correlation during this run. */
|
||||
@@ -509,6 +521,12 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
|
||||
const sessionCoreToolPolicy = sessionPermissionPolicy
|
||||
? resolveSessionPermissionCoreToolPolicy(sessionPermissionPolicy)
|
||||
: undefined;
|
||||
const fsPolicy =
|
||||
options?.fsPolicy ??
|
||||
createToolFsPolicy({
|
||||
workspaceOnly:
|
||||
isMemoryFlushRun || (sessionCoreToolPolicy?.workspaceOnly ?? fsConfig.workspaceOnly),
|
||||
});
|
||||
const sandboxRoot = sandbox?.workspaceDir;
|
||||
const sandboxFsBridge = sandbox?.fsBridge;
|
||||
const allowWorkspaceWrites = sandbox?.workspaceAccess !== "ro";
|
||||
@@ -518,6 +536,10 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
|
||||
const containmentRoot = sandboxRoot ?? sessionPermissionPolicy?.root ?? codingRoot;
|
||||
const memoryFlushWriteRoot = sandboxRoot ?? workspaceRoot;
|
||||
const memoryIsolationCutover = Boolean(agentId && isMemoryIsolationCutoverAgent(agentId));
|
||||
if (fsPolicy.kind === "authorized-memory-view" && !options?.authorizedMemoryVirtualRead) {
|
||||
throw new Error("authorized memory view broker is unavailable");
|
||||
}
|
||||
const authorizedMemoryView = fsPolicy.kind === "authorized-memory-view";
|
||||
// Flush exposes one append-only target; its fallback records inherited taint after success.
|
||||
const memoryWriteProvenance = isMemoryFlushRun
|
||||
? undefined
|
||||
@@ -542,16 +564,14 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
|
||||
// P1C's selected-memory pilot is read-only. Hiding both the shell and its process controller
|
||||
// closes the generic durable-write bypass without pretending this is P1D virtual-FS confinement.
|
||||
const includeShellTools =
|
||||
includeCoreTools && toolConstructionPlan.includeShellTools && !memoryIsolationCutover;
|
||||
includeCoreTools &&
|
||||
toolConstructionPlan.includeShellTools &&
|
||||
!memoryIsolationCutover &&
|
||||
!authorizedMemoryView;
|
||||
const includeOpenClawTools = includeCoreTools && toolConstructionPlan.includeOpenClawTools;
|
||||
const includeChannelTools = toolConstructionPlan.includeChannelTools;
|
||||
const includePluginTools = toolConstructionPlan.includePluginTools;
|
||||
const workspaceOnly =
|
||||
isMemoryFlushRun || (sessionCoreToolPolicy?.workspaceOnly ?? fsConfig.workspaceOnly === true);
|
||||
const fsPolicy = {
|
||||
workspaceOnly,
|
||||
...(sessionPermissionPolicy ? { root: sessionPermissionPolicy.root } : {}),
|
||||
};
|
||||
const workspaceOnly = fsPolicy.workspaceOnly;
|
||||
const readOnly = sessionCoreToolPolicy?.readOnly ?? false;
|
||||
const applyPatchConfig = execConfig.applyPatch;
|
||||
// Secure by default: apply_patch is workspace-contained unless explicitly disabled.
|
||||
@@ -579,7 +599,8 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
|
||||
containmentRoot,
|
||||
includeBaseCodingTools,
|
||||
includeShellTools,
|
||||
workspaceOnly,
|
||||
fsPolicy,
|
||||
authorizedMemoryVirtualRead: options?.authorizedMemoryVirtualRead,
|
||||
readOnly,
|
||||
sandbox,
|
||||
skillsSnapshot: options?.skillsSnapshot,
|
||||
@@ -587,14 +608,18 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
|
||||
imageSanitization,
|
||||
memoryWriteProvenance,
|
||||
...(includeBaseCodingTools
|
||||
? { baseToolNames: createCodingTools(codingRoot).map((tool) => tool.name) }
|
||||
? {
|
||||
baseToolNames: authorizedMemoryView
|
||||
? ["read"]
|
||||
: createCodingTools(codingRoot).map((tool) => tool.name),
|
||||
}
|
||||
: {}),
|
||||
baseToolFactories: {
|
||||
createEditTool,
|
||||
createReadTool,
|
||||
createWriteTool,
|
||||
},
|
||||
applyPatchEnabled,
|
||||
applyPatchEnabled: authorizedMemoryView ? false : applyPatchEnabled,
|
||||
applyPatchWorkspaceOnly,
|
||||
execDefaults: {
|
||||
...execDefaults,
|
||||
@@ -740,6 +765,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
|
||||
requireExplicitMessageTarget: options?.requireExplicitMessageTarget,
|
||||
disableMessageTool: options?.disableMessageTool || options?.swarmCollector,
|
||||
requesterAgentIdOverride: agentId,
|
||||
authorizedMemoryRead: options?.authorizedMemoryRead,
|
||||
allowGatewaySubagentBinding: options?.allowGatewaySubagentBinding,
|
||||
clientCaps: options?.clientCaps,
|
||||
toolBindings: options?.toolBindings,
|
||||
@@ -952,15 +978,18 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
|
||||
!options?.swarmCollector ||
|
||||
(tool.name !== "ask_user" && tool.name !== "sessions_send" && tool.name !== "sessions_yield"),
|
||||
);
|
||||
// P1C admits only selected-plugin reads. Generic filesystem reads would let a model bypass the
|
||||
// broker's subject and receipt checks, while every other contributor could reopen an egress or
|
||||
// mutation path if this final surface gate moved earlier.
|
||||
const surfaceTools = memoryIsolationCutover
|
||||
? authorizedTools.filter((tool) => MEMORY_ISOLATION_READ_TOOL_NAMES.has(tool.name))
|
||||
: authorizedTools;
|
||||
// P1C's no-view cutover retains its selected-plugin reads. An admitted P1D
|
||||
// view adds only the broker-bound read tool; every other core/plugin/channel
|
||||
// tool could reopen mutation, process, or egress paths around that broker.
|
||||
const surfaceTools = authorizedMemoryView
|
||||
? authorizedTools.filter((tool) => AUTHORIZED_MEMORY_VIEW_TOOL_NAMES.has(tool.name))
|
||||
: memoryIsolationCutover
|
||||
? authorizedTools.filter((tool) => MEMORY_ISOLATION_READ_TOOL_NAMES.has(tool.name))
|
||||
: authorizedTools;
|
||||
if (
|
||||
swarmStructuredOutputTool &&
|
||||
!memoryIsolationCutover &&
|
||||
!authorizedMemoryView &&
|
||||
!surfaceTools.some((tool) => tool.name === swarmStructuredOutputTool.name)
|
||||
) {
|
||||
// Collector output is a run contract, not an operator-configurable capability.
|
||||
|
||||
@@ -202,25 +202,26 @@ function withPatchMemoryFileMutationGuard<T extends PatchMutationOperations>(par
|
||||
operations: T;
|
||||
guard: MemoryFileMutationGuard | undefined;
|
||||
}): T {
|
||||
if (!params.guard) {
|
||||
const guard = params.guard;
|
||||
if (!guard) {
|
||||
return params.operations;
|
||||
}
|
||||
return {
|
||||
...params.operations,
|
||||
writeFile: async (filePath, content) => {
|
||||
await params.guard.assertCanMutate(filePath);
|
||||
await guard.assertCanMutate(filePath);
|
||||
await params.operations.writeFile(filePath, content);
|
||||
},
|
||||
createFileExclusive: async (filePath, content) => {
|
||||
await params.guard.assertCanMutate(filePath);
|
||||
await guard.assertCanMutate(filePath);
|
||||
return await params.operations.createFileExclusive(filePath, content);
|
||||
},
|
||||
remove: async (filePath) => {
|
||||
await params.guard.assertCanMutate(filePath);
|
||||
await guard.assertCanMutate(filePath);
|
||||
await params.operations.remove(filePath);
|
||||
},
|
||||
mkdirp: async (dir) => {
|
||||
await params.guard.assertCanMutate(dir);
|
||||
await guard.assertCanMutate(dir);
|
||||
await params.operations.mkdirp(dir);
|
||||
},
|
||||
} as T;
|
||||
|
||||
@@ -119,6 +119,8 @@ export function createApplyPatchTool(
|
||||
root?: string;
|
||||
sandbox?: SandboxApplyPatchConfig;
|
||||
workspaceOnly?: boolean;
|
||||
/** Opaque Phase 1D view identity; its files are never mutable here. */
|
||||
memoryViewId?: string;
|
||||
memoryFileMutationGuard?: MemoryFileMutationGuard;
|
||||
memoryWriteProvenance?: MemoryWriteProvenanceObserver;
|
||||
} = {},
|
||||
@@ -143,6 +145,9 @@ export function createApplyPatchTool(
|
||||
if (signal?.aborted) {
|
||||
throw createAbortError("Aborted");
|
||||
}
|
||||
if (options.memoryViewId && /\*\*\* (?:Add|Delete|Update) File: memory:/u.test(input)) {
|
||||
throw new Error("authorized memory views are read-only");
|
||||
}
|
||||
|
||||
const result = await applyPatch(input, {
|
||||
cwd,
|
||||
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
createSandboxedEditTool,
|
||||
createSandboxedReadTool,
|
||||
createSandboxedWriteTool,
|
||||
wrapReadToolWithAuthorizedMemoryView,
|
||||
wrapReadToolWithSkillContent,
|
||||
wrapToolWorkspaceRootGuard,
|
||||
wrapToolWorkspaceRootGuardWithOptions,
|
||||
} from "./agent-tools.read.js";
|
||||
import type { AuthorizedMemoryVirtualRead } from "./agent-tools.read.js";
|
||||
import type { AnyAgentTool } from "./agent-tools.types.js";
|
||||
import { createApplyPatchTool } from "./apply-patch.js";
|
||||
import type { ExecToolDefaults } from "./bash-tools.exec-types.js";
|
||||
@@ -32,6 +34,7 @@ import type {
|
||||
createWriteTool,
|
||||
} from "./sessions/tools/index.js";
|
||||
import { createReadTool } from "./sessions/tools/read.js";
|
||||
import type { ToolFsPolicy } from "./tool-fs-policy.types.js";
|
||||
|
||||
function readOnlySandboxReadMounts(
|
||||
sandbox: SandboxContext,
|
||||
@@ -85,7 +88,8 @@ type CoreCodingToolsOptions = {
|
||||
containmentRoot: string;
|
||||
includeBaseCodingTools: boolean;
|
||||
includeShellTools: boolean;
|
||||
workspaceOnly: boolean;
|
||||
fsPolicy: ToolFsPolicy;
|
||||
authorizedMemoryVirtualRead?: AuthorizedMemoryVirtualRead;
|
||||
readOnly: boolean;
|
||||
sandbox?: SandboxContext;
|
||||
skillsSnapshot?: SkillSnapshot;
|
||||
@@ -112,13 +116,21 @@ export function createCoreCodingTools(options: CoreCodingToolsOptions): AnyAgent
|
||||
const sandboxRoot = sandbox?.workspaceDir;
|
||||
const sandboxFsBridge = sandbox?.fsBridge;
|
||||
const allowWorkspaceWrites = sandbox?.workspaceAccess !== "ro";
|
||||
const workspaceOnly = options.fsPolicy.workspaceOnly;
|
||||
if (options.fsPolicy.kind === "authorized-memory-view" && !options.authorizedMemoryVirtualRead) {
|
||||
throw new Error("authorized memory view broker is unavailable");
|
||||
}
|
||||
const authorizedMemoryView =
|
||||
options.fsPolicy.kind === "authorized-memory-view" && options.authorizedMemoryVirtualRead
|
||||
? options.authorizedMemoryVirtualRead
|
||||
: undefined;
|
||||
if (sandboxRoot && !sandboxFsBridge) {
|
||||
throw new Error("Sandbox filesystem bridge is unavailable.");
|
||||
}
|
||||
|
||||
const skillReadRoots = sandboxRoot ? undefined : resolveSkillReadRoots(options.skillsSnapshot);
|
||||
const needsReadOnlyWorkspaceSkillMounts =
|
||||
options.includeShellTools || (options.includeBaseCodingTools && options.workspaceOnly);
|
||||
options.includeShellTools || (options.includeBaseCodingTools && workspaceOnly);
|
||||
const readOnlyWorkspaceSkillMounts =
|
||||
sandbox && needsReadOnlyWorkspaceSkillMounts
|
||||
? resolveReadOnlyWorkspaceSkillMounts({
|
||||
@@ -150,7 +162,7 @@ export function createCoreCodingTools(options: CoreCodingToolsOptions): AnyAgent
|
||||
imageSanitization: options.imageSanitization,
|
||||
},
|
||||
);
|
||||
const guarded = options.workspaceOnly
|
||||
const guarded = workspaceOnly
|
||||
? wrapToolWorkspaceRootGuardWithOptions(
|
||||
wrapped,
|
||||
sandboxRoot ?? options.containmentRoot,
|
||||
@@ -165,36 +177,48 @@ export function createCoreCodingTools(options: CoreCodingToolsOptions): AnyAgent
|
||||
: { additionalRoots: skillReadRoots, resolutionCwd: options.codingRoot },
|
||||
)
|
||||
: wrapped;
|
||||
const memoryBound = authorizedMemoryView
|
||||
? wrapReadToolWithAuthorizedMemoryView(guarded, authorizedMemoryView, {
|
||||
modelContextWindowTokens: options.modelContextWindowTokens,
|
||||
imageSanitization: options.imageSanitization,
|
||||
})
|
||||
: guarded;
|
||||
base.push(
|
||||
wrapReadToolWithSkillContent(guarded, options.skillsSnapshot?.resolvedSkills, {
|
||||
wrapReadToolWithSkillContent(memoryBound, options.skillsSnapshot?.resolvedSkills, {
|
||||
modelContextWindowTokens: options.modelContextWindowTokens,
|
||||
imageSanitization: options.imageSanitization,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (!options.readOnly && !sandboxRoot && baseToolNames.has("edit")) {
|
||||
if (!options.readOnly && !authorizedMemoryView && !sandboxRoot && baseToolNames.has("edit")) {
|
||||
const edit = createHostWorkspaceEditTool(options.codingRoot, {
|
||||
containmentRoot: options.containmentRoot,
|
||||
workspaceOnly: options.workspaceOnly,
|
||||
workspaceOnly,
|
||||
memoryFileMutationGuard: options.memoryFileMutationGuard,
|
||||
memoryWriteProvenance: options.memoryWriteProvenance,
|
||||
createTool: options.baseToolFactories?.createEditTool,
|
||||
});
|
||||
base.push(options.workspaceOnly ? guardHostWorkspaceTool(edit, options) : edit);
|
||||
base.push(workspaceOnly ? guardHostWorkspaceTool(edit, options) : edit);
|
||||
}
|
||||
if (!options.readOnly && !sandboxRoot && baseToolNames.has("write")) {
|
||||
if (!options.readOnly && !authorizedMemoryView && !sandboxRoot && baseToolNames.has("write")) {
|
||||
const write = createHostWorkspaceWriteTool(options.codingRoot, {
|
||||
containmentRoot: options.containmentRoot,
|
||||
workspaceOnly: options.workspaceOnly,
|
||||
workspaceOnly,
|
||||
memoryFileMutationGuard: options.memoryFileMutationGuard,
|
||||
memoryWriteProvenance: options.memoryWriteProvenance,
|
||||
createTool: options.baseToolFactories?.createWriteTool,
|
||||
});
|
||||
base.push(options.workspaceOnly ? guardHostWorkspaceTool(write, options) : write);
|
||||
base.push(workspaceOnly ? guardHostWorkspaceTool(write, options) : write);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.includeBaseCodingTools && !options.readOnly && sandboxRoot && allowWorkspaceWrites) {
|
||||
if (
|
||||
options.includeBaseCodingTools &&
|
||||
!options.readOnly &&
|
||||
!authorizedMemoryView &&
|
||||
sandboxRoot &&
|
||||
allowWorkspaceWrites
|
||||
) {
|
||||
const toolOptions = {
|
||||
root: sandboxRoot,
|
||||
bridge: sandboxFsBridge!,
|
||||
@@ -209,24 +233,27 @@ export function createCoreCodingTools(options: CoreCodingToolsOptions): AnyAgent
|
||||
...toolOptions,
|
||||
createTool: options.baseToolFactories?.createWriteTool,
|
||||
});
|
||||
base.push(
|
||||
options.workspaceOnly
|
||||
? wrapToolWorkspaceRootGuardWithOptions(edit, sandboxRoot, {
|
||||
containerWorkdir: sandbox.containerWorkdir,
|
||||
})
|
||||
: edit,
|
||||
options.workspaceOnly
|
||||
? wrapToolWorkspaceRootGuardWithOptions(write, sandboxRoot, {
|
||||
containerWorkdir: sandbox.containerWorkdir,
|
||||
})
|
||||
: write,
|
||||
);
|
||||
const guardedEdit = workspaceOnly
|
||||
? wrapToolWorkspaceRootGuardWithOptions(edit, sandboxRoot, {
|
||||
containerWorkdir: sandbox.containerWorkdir,
|
||||
})
|
||||
: edit;
|
||||
const guardedWrite = workspaceOnly
|
||||
? wrapToolWorkspaceRootGuardWithOptions(write, sandboxRoot, {
|
||||
containerWorkdir: sandbox.containerWorkdir,
|
||||
})
|
||||
: write;
|
||||
base.push(guardedEdit, guardedWrite);
|
||||
}
|
||||
options.recordToolPrepStage?.("base-coding-tools");
|
||||
|
||||
const shell: AnyAgentTool[] = [];
|
||||
if (options.includeShellTools) {
|
||||
if (options.applyPatchEnabled && (!sandboxRoot || allowWorkspaceWrites)) {
|
||||
if (
|
||||
!authorizedMemoryView &&
|
||||
options.applyPatchEnabled &&
|
||||
(!sandboxRoot || allowWorkspaceWrites)
|
||||
) {
|
||||
shell.push(
|
||||
createApplyPatchTool({
|
||||
cwd: options.codingRoot,
|
||||
@@ -241,30 +268,32 @@ export function createCoreCodingTools(options: CoreCodingToolsOptions): AnyAgent
|
||||
}),
|
||||
);
|
||||
}
|
||||
shell.push(
|
||||
createLazyExecTool({
|
||||
...options.execDefaults,
|
||||
cwd: options.codingRoot,
|
||||
sandbox: sandbox
|
||||
? {
|
||||
containerName: sandbox.containerName,
|
||||
workspaceDir: sandbox.workspaceDir,
|
||||
containerWorkdir: sandbox.containerWorkdir,
|
||||
workdirValidation: sandbox.backend?.workdirValidation,
|
||||
validateWorkdir: sandbox.backend?.validateWorkdir?.bind(sandbox.backend),
|
||||
discardPreparedWorkdir: sandbox.backend?.discardPreparedWorkdir?.bind(
|
||||
sandbox.backend,
|
||||
),
|
||||
workdirRoots: sandbox.backend?.workdirRoots,
|
||||
readOnlyWorkspaceSkillMounts,
|
||||
env: sandbox.backend?.env ?? sandbox.docker.env,
|
||||
buildExecSpec: sandbox.backend?.buildExecSpec.bind(sandbox.backend),
|
||||
finalizeExec: sandbox.backend?.finalizeExec?.bind(sandbox.backend),
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
createLazyProcessTool(options.processDefaults),
|
||||
);
|
||||
if (!authorizedMemoryView) {
|
||||
shell.push(
|
||||
createLazyExecTool({
|
||||
...options.execDefaults,
|
||||
cwd: options.codingRoot,
|
||||
sandbox: sandbox
|
||||
? {
|
||||
containerName: sandbox.containerName,
|
||||
workspaceDir: sandbox.workspaceDir,
|
||||
containerWorkdir: sandbox.containerWorkdir,
|
||||
workdirValidation: sandbox.backend?.workdirValidation,
|
||||
validateWorkdir: sandbox.backend?.validateWorkdir?.bind(sandbox.backend),
|
||||
discardPreparedWorkdir: sandbox.backend?.discardPreparedWorkdir?.bind(
|
||||
sandbox.backend,
|
||||
),
|
||||
workdirRoots: sandbox.backend?.workdirRoots,
|
||||
readOnlyWorkspaceSkillMounts,
|
||||
env: sandbox.backend?.env ?? sandbox.docker.env,
|
||||
buildExecSpec: sandbox.backend?.buildExecSpec.bind(sandbox.backend),
|
||||
finalizeExec: sandbox.backend?.finalizeExec?.bind(sandbox.backend),
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
createLazyProcessTool(options.processDefaults),
|
||||
);
|
||||
}
|
||||
}
|
||||
options.recordToolPrepStage?.("shell-tools");
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ import { resolveConversationCapabilityProfile } from "../conversation-capability
|
||||
import { formatDateStamp, resolveUserTimezone } from "../date-time.js";
|
||||
import { resolveOpenClawReferencePaths } from "../docs-path.js";
|
||||
import { resolveHeartbeatPromptForSystemPrompt } from "../heartbeat-system-prompt.js";
|
||||
import { createAuthorizedMemoryReadHost } from "../memory-authorized-read-host.js";
|
||||
import { prepareAgentMemoryPrompt } from "../memory-prompt-prepare.js";
|
||||
import {
|
||||
applyAuthHeaderOverride,
|
||||
@@ -270,6 +271,21 @@ export async function buildPreparedCompactionRuntime(prepared: DirectCompactionP
|
||||
sandbox,
|
||||
resolvedWorkspace,
|
||||
});
|
||||
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
|
||||
sessionKey: params.sessionKey,
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
// Compaction shares the live run's admission discipline: prepare one host
|
||||
// before tools, then reuse it for the prompt snapshot.
|
||||
const authorizedMemoryRead = createAuthorizedMemoryReadHost({
|
||||
agentId: sessionAgentId,
|
||||
sessionKey: sandboxSessionKey,
|
||||
sessionId: params.sessionId,
|
||||
runId: params.runId,
|
||||
messageChannel: resolvedMessageProvider,
|
||||
agentAccountId: params.agentAccountId,
|
||||
});
|
||||
const runtimeCapabilityProfile = resolveConversationCapabilityProfile({
|
||||
config: params.config,
|
||||
sessionKey: sandboxSessionKey,
|
||||
@@ -326,6 +342,7 @@ export async function buildPreparedCompactionRuntime(prepared: DirectCompactionP
|
||||
: undefined,
|
||||
sessionId: params.sessionId,
|
||||
runId: params.runId,
|
||||
authorizedMemoryRead,
|
||||
oneShotCliRun: params.oneShotCliRun,
|
||||
groupId: params.groupId,
|
||||
groupChannel: params.groupChannel,
|
||||
@@ -451,11 +468,6 @@ export async function buildPreparedCompactionRuntime(prepared: DirectCompactionP
|
||||
accountId: params.agentAccountId,
|
||||
})
|
||||
: undefined;
|
||||
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
|
||||
sessionKey: params.sessionKey,
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
// Resolve channel-specific message actions for system prompt
|
||||
const channelActions = runtimeChannel
|
||||
? listChannelSupportedActions(
|
||||
@@ -560,6 +572,7 @@ export async function buildPreparedCompactionRuntime(prepared: DirectCompactionP
|
||||
agentId: runtimeInfo.agentId,
|
||||
agentSessionKey: runtimeInfo.sessionKey,
|
||||
sandboxed: sandboxInfo?.enabled === true,
|
||||
authorizedMemoryRead,
|
||||
});
|
||||
// Compaction must build byte-identical prompt sections to live turns, or
|
||||
// the compaction run misses the transcript's cached prompt prefix. The
|
||||
|
||||
@@ -23,7 +23,7 @@ import type { prepareEmbeddedAttemptToolBase } from "./attempt-tool-prepare.js";
|
||||
import type { EmbeddedRunAttemptParams } from "./types.js";
|
||||
|
||||
type AttemptSetup = Awaited<ReturnType<typeof prepareEmbeddedAttemptSetup>>;
|
||||
type PreparedToolBase = ReturnType<typeof prepareEmbeddedAttemptToolBase>;
|
||||
type PreparedToolBase = Awaited<ReturnType<typeof prepareEmbeddedAttemptToolBase>>;
|
||||
|
||||
export async function prepareEmbeddedAttemptBundleTools(params: {
|
||||
agentDir: string;
|
||||
|
||||
@@ -48,7 +48,7 @@ export type EmbeddedAttemptExecutionPhaseInput = {
|
||||
bundleTools: Prepared<typeof prepareEmbeddedAttemptBundleTools>;
|
||||
sessionRuntime: Prepared<typeof prepareEmbeddedAttemptSessionRuntime>;
|
||||
systemPrompt: Prepared<typeof prepareEmbeddedAttemptSystemPrompt>;
|
||||
toolBase: ReturnType<typeof prepareEmbeddedAttemptToolBase>;
|
||||
toolBase: Awaited<ReturnType<typeof prepareEmbeddedAttemptToolBase>>;
|
||||
toolCatalog: ReturnType<typeof prepareEmbeddedAttemptToolCatalog>;
|
||||
};
|
||||
sessionLock: Pick<
|
||||
|
||||
@@ -9,6 +9,9 @@ import type { EmbeddedRunAttemptParams } from "./types.js";
|
||||
|
||||
const resolveProviderRuntimePluginHandle = vi.hoisted(() => vi.fn());
|
||||
const resolveSandboxContext = vi.hoisted(() => vi.fn(async () => null));
|
||||
const createAuthorizedMemoryReadHost = vi.hoisted(() => vi.fn());
|
||||
const resolveAuthorizedMemoryVirtualFileBroker = vi.hoisted(() => vi.fn());
|
||||
const stageAuthorizedVirtualProjectionMountPlan = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../../plugins/provider-hook-runtime.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../../plugins/provider-hook-runtime.js")>()),
|
||||
@@ -17,6 +20,15 @@ vi.mock("../../../plugins/provider-hook-runtime.js", async (importOriginal) => (
|
||||
|
||||
vi.mock("../../sandbox.js", () => ({ resolveSandboxContext }));
|
||||
|
||||
vi.mock("../../memory-authorized-read-host.js", () => ({
|
||||
createAuthorizedMemoryReadHost,
|
||||
resolveAuthorizedMemoryVirtualFileBroker,
|
||||
}));
|
||||
|
||||
vi.mock("../../sandbox/authorized-virtual-projection-staging.js", () => ({
|
||||
stageAuthorizedVirtualProjectionMountPlan,
|
||||
}));
|
||||
|
||||
import {
|
||||
installEmbeddedAttemptContextGuards,
|
||||
prepareEmbeddedAttemptSkills,
|
||||
@@ -31,6 +43,11 @@ describe("prepareEmbeddedAttemptSetup", () => {
|
||||
beforeEach(() => {
|
||||
resolveProviderRuntimePluginHandle.mockReset();
|
||||
resolveSandboxContext.mockClear();
|
||||
createAuthorizedMemoryReadHost.mockReset();
|
||||
createAuthorizedMemoryReadHost.mockReturnValue(undefined);
|
||||
resolveAuthorizedMemoryVirtualFileBroker.mockReset();
|
||||
resolveAuthorizedMemoryVirtualFileBroker.mockResolvedValue(undefined);
|
||||
stageAuthorizedVirtualProjectionMountPlan.mockReset();
|
||||
});
|
||||
|
||||
it("prepares the default and session agent identities together", async () => {
|
||||
@@ -268,4 +285,82 @@ describe("prepareEmbeddedAttemptSkills", () => {
|
||||
await fs.rm(executionWorkspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses one admission-bound host and broker when a sandbox stages a projection", async () => {
|
||||
const sandboxRoot = path.join(os.tmpdir(), "openclaw-attempt-setup-projection-sandbox");
|
||||
const host = {} as never;
|
||||
const broker = {
|
||||
view: { viewId: "view-1", roots: [], files: [], revision: "revision-1" },
|
||||
readFile: vi.fn(),
|
||||
} as never;
|
||||
const dispose = vi.fn(async () => {});
|
||||
const staged = {
|
||||
plan: { version: 1, viewId: "view-1", revision: "revision-1", mounts: [] },
|
||||
dispose,
|
||||
};
|
||||
createAuthorizedMemoryReadHost.mockReturnValue(host);
|
||||
resolveAuthorizedMemoryVirtualFileBroker.mockResolvedValue(broker);
|
||||
stageAuthorizedVirtualProjectionMountPlan.mockResolvedValue(staged);
|
||||
resolveSandboxContext.mockImplementation(async (input) => {
|
||||
await input.prepareAuthorizedVirtualProjectionMountPlan?.({
|
||||
agentWorkspaceDir: path.join(sandboxRoot, "agent"),
|
||||
});
|
||||
return {
|
||||
enabled: true,
|
||||
workspaceAccess: "ro",
|
||||
workspaceDir: path.join(sandboxRoot, "workspace"),
|
||||
disposeAuthorizedVirtualProjectionMountPlan: dispose,
|
||||
};
|
||||
});
|
||||
|
||||
const setup = await resolveAttemptWorkspaceSandbox({
|
||||
agentId: "main",
|
||||
config: {},
|
||||
messageChannel: "telegram",
|
||||
messageTo: "dm:alice",
|
||||
runId: "run-1",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:telegram:direct:alice",
|
||||
workspaceDir: path.join(os.tmpdir(), "openclaw-attempt-setup-projection"),
|
||||
});
|
||||
|
||||
expect(createAuthorizedMemoryReadHost).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: "main",
|
||||
runId: "run-1",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:telegram:direct:alice",
|
||||
}),
|
||||
);
|
||||
expect(resolveAuthorizedMemoryVirtualFileBroker).toHaveBeenCalledWith(host);
|
||||
expect(stageAuthorizedVirtualProjectionMountPlan).toHaveBeenCalledWith({
|
||||
agentWorkspaceDir: path.join(sandboxRoot, "agent"),
|
||||
broker,
|
||||
});
|
||||
expect(setup.authorizedMemoryRead).toBe(host);
|
||||
expect(setup.authorizedMemoryVirtualBroker).toBe(broker);
|
||||
expect(dispose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disposes post-provisioning native projection staging when setup later rejects", async () => {
|
||||
const dispose = vi.fn(async () => {});
|
||||
resolveSandboxContext.mockResolvedValue({
|
||||
enabled: true,
|
||||
workspaceAccess: "ro",
|
||||
workspaceDir: "/sandbox/workspace",
|
||||
disposeAuthorizedVirtualProjectionMountPlan: dispose,
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveAttemptWorkspaceSandbox({
|
||||
agentId: "main",
|
||||
config: {},
|
||||
cwd: path.join(os.tmpdir(), "other-cwd"),
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:telegram:direct:alice",
|
||||
workspaceDir: path.join(os.tmpdir(), "openclaw-attempt-setup-native-dispose"),
|
||||
}),
|
||||
).rejects.toThrow(/cwd override is not supported/);
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,13 +35,19 @@ import {
|
||||
applySkillEnvOverridesFromSnapshot,
|
||||
} from "../../../skills/runtime/env-overrides.js";
|
||||
import { resolveUserPath } from "../../../utils.js";
|
||||
import { normalizeDeliveryContext } from "../../../utils/delivery-context.shared.js";
|
||||
import { resolveSessionAgentIds } from "../../agent-scope.js";
|
||||
import { isHeartbeatLifecycleRunKind } from "../../bootstrap-mode.js";
|
||||
import { resolveCodeModeSkills, type CodeModeSkillReader } from "../../code-mode-skills.js";
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "../../defaults.js";
|
||||
import type { EmbeddedContextFile } from "../../embedded-agent-helpers.js";
|
||||
import { resolveImageSanitizationLimits } from "../../image-sanitization.js";
|
||||
import {
|
||||
createAuthorizedMemoryReadHost,
|
||||
resolveAuthorizedMemoryVirtualFileBroker,
|
||||
} from "../../memory-authorized-read-host.js";
|
||||
import { resolveSandboxContext } from "../../sandbox.js";
|
||||
import { stageAuthorizedVirtualProjectionMountPlan } from "../../sandbox/authorized-virtual-projection-staging.js";
|
||||
import type { SandboxContext } from "../../sandbox/types.js";
|
||||
import type { guardSessionManager } from "../../session-tool-result-guard-wrapper.js";
|
||||
import { sanitizeToolUseResultPairingForModel } from "../../session-transcript-repair.js";
|
||||
@@ -104,7 +110,21 @@ type AttemptWorkspaceParams = Pick<
|
||||
| "skillWorkshopCollectionReconcile"
|
||||
| "skillsSnapshot"
|
||||
| "workspaceDir"
|
||||
>;
|
||||
> &
|
||||
Partial<
|
||||
Pick<
|
||||
EmbeddedRunAttemptParams,
|
||||
| "agentAccountId"
|
||||
| "messageChannel"
|
||||
| "messageProvider"
|
||||
| "messageThreadId"
|
||||
| "messageTo"
|
||||
| "currentChannelId"
|
||||
| "currentMessagingTarget"
|
||||
| "currentThreadTs"
|
||||
| "runId"
|
||||
>
|
||||
>;
|
||||
|
||||
/** Resolves the shared workspace and sandbox policy used by native and plugin harnesses. */
|
||||
export async function resolveAttemptWorkspaceSandbox(params: AttemptWorkspaceParams) {
|
||||
@@ -112,6 +132,34 @@ export async function resolveAttemptWorkspaceSandbox(params: AttemptWorkspacePar
|
||||
await fs.mkdir(resolvedWorkspace, { recursive: true });
|
||||
const sandboxSessionKey =
|
||||
params.sandboxSessionKey?.trim() || params.sessionKey?.trim() || params.sessionId;
|
||||
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
|
||||
sessionKey: params.sessionKey,
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
// Admission owns one host capability for prompt, tools, and sandbox staging.
|
||||
// The staging callback receives only its opaque view and broker reads, never
|
||||
// a plugin artifact root or a routing-derived memory subject.
|
||||
const authorizedMemoryRead = createAuthorizedMemoryReadHost({
|
||||
agentId: sessionAgentId,
|
||||
sessionKey: sandboxSessionKey,
|
||||
sessionId: params.sessionId,
|
||||
runId: params.runId,
|
||||
deliveryContext: normalizeDeliveryContext({
|
||||
channel: params.messageChannel ?? params.messageProvider,
|
||||
to: params.messageTo ?? params.currentMessagingTarget ?? params.currentChannelId,
|
||||
accountId: params.agentAccountId,
|
||||
threadId: params.messageThreadId ?? params.currentThreadTs,
|
||||
}),
|
||||
messageChannel: params.messageChannel ?? params.messageProvider,
|
||||
agentAccountId: params.agentAccountId,
|
||||
});
|
||||
let authorizedMemoryVirtualBroker:
|
||||
| Awaited<ReturnType<typeof resolveAuthorizedMemoryVirtualFileBroker>>
|
||||
| undefined;
|
||||
const resolveVirtualBroker = async () =>
|
||||
(authorizedMemoryVirtualBroker ??=
|
||||
await resolveAuthorizedMemoryVirtualFileBroker(authorizedMemoryRead));
|
||||
// Collection review is a host-owned maintenance run with one restricted tool.
|
||||
// Sandboxing would hide that tool or redirect it to a disposable workspace.
|
||||
const sandbox = params.skillWorkshopCollectionReconcile
|
||||
@@ -122,42 +170,60 @@ export async function resolveAttemptWorkspaceSandbox(params: AttemptWorkspacePar
|
||||
sessionKey: sandboxSessionKey,
|
||||
skillsSnapshot: params.skillsSnapshot,
|
||||
workspaceDir: resolvedWorkspace,
|
||||
...(authorizedMemoryRead
|
||||
? {
|
||||
prepareAuthorizedVirtualProjectionMountPlan: async ({ agentWorkspaceDir }) => {
|
||||
const broker = await resolveVirtualBroker();
|
||||
return broker
|
||||
? await stageAuthorizedVirtualProjectionMountPlan({
|
||||
agentWorkspaceDir,
|
||||
broker,
|
||||
})
|
||||
: undefined;
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
const effectiveWorkspace =
|
||||
sandbox?.enabled && sandbox.workspaceAccess !== "rw" ? sandbox.workspaceDir : resolvedWorkspace;
|
||||
const requestedCwd = params.cwd ? resolveUserPath(params.cwd) : undefined;
|
||||
if (params.permissionMode && !params.sessionRoot) {
|
||||
throw new Error("session permission mode requires a recorded session root");
|
||||
}
|
||||
const sessionPermissionPolicy =
|
||||
params.permissionMode && params.sessionRoot
|
||||
? { root: params.sessionRoot, mode: params.permissionMode }
|
||||
: undefined;
|
||||
if (sandbox?.enabled && requestedCwd && requestedCwd !== resolvedWorkspace) {
|
||||
throw new Error(
|
||||
"cwd override is not supported for sandboxed embedded agent runs; omit cwd or use the agent workspace as cwd",
|
||||
);
|
||||
}
|
||||
await fs.mkdir(effectiveWorkspace, { recursive: true });
|
||||
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
|
||||
sessionKey: params.sessionKey,
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
return {
|
||||
defaultAgentId,
|
||||
effectiveCwd: sandbox?.enabled ? effectiveWorkspace : (requestedCwd ?? effectiveWorkspace),
|
||||
effectiveFsWorkspaceOnly: resolveAttemptFsWorkspaceOnly({
|
||||
config: params.config,
|
||||
try {
|
||||
await resolveVirtualBroker();
|
||||
const effectiveWorkspace =
|
||||
sandbox?.enabled && sandbox.workspaceAccess !== "rw"
|
||||
? sandbox.workspaceDir
|
||||
: resolvedWorkspace;
|
||||
const requestedCwd = params.cwd ? resolveUserPath(params.cwd) : undefined;
|
||||
if (params.permissionMode && !params.sessionRoot) {
|
||||
throw new Error("session permission mode requires a recorded session root");
|
||||
}
|
||||
const sessionPermissionPolicy =
|
||||
params.permissionMode && params.sessionRoot
|
||||
? { root: params.sessionRoot, mode: params.permissionMode }
|
||||
: undefined;
|
||||
if (sandbox?.enabled && requestedCwd && requestedCwd !== resolvedWorkspace) {
|
||||
throw new Error(
|
||||
"cwd override is not supported for sandboxed embedded agent runs; omit cwd or use the agent workspace as cwd",
|
||||
);
|
||||
}
|
||||
await fs.mkdir(effectiveWorkspace, { recursive: true });
|
||||
return {
|
||||
authorizedMemoryRead,
|
||||
authorizedMemoryVirtualBroker,
|
||||
defaultAgentId,
|
||||
effectiveCwd: sandbox?.enabled ? effectiveWorkspace : (requestedCwd ?? effectiveWorkspace),
|
||||
effectiveFsWorkspaceOnly: resolveAttemptFsWorkspaceOnly({
|
||||
config: params.config,
|
||||
sessionAgentId,
|
||||
}),
|
||||
effectiveWorkspace,
|
||||
resolvedWorkspace,
|
||||
sessionPermissionPolicy,
|
||||
sandbox,
|
||||
sandboxSessionKey,
|
||||
sessionAgentId,
|
||||
}),
|
||||
effectiveWorkspace,
|
||||
resolvedWorkspace,
|
||||
sessionPermissionPolicy,
|
||||
sandbox,
|
||||
sandboxSessionKey,
|
||||
sessionAgentId,
|
||||
};
|
||||
};
|
||||
} catch (error) {
|
||||
await sandbox?.disposeAuthorizedVirtualProjectionMountPlan?.();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareEmbeddedAttemptSetup(params: EmbeddedRunAttemptParams) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
resolveProviderSystemPromptContribution,
|
||||
transformProviderSystemPrompt,
|
||||
} from "../../../plugins/provider-runtime.js";
|
||||
import { normalizeDeliveryContext } from "../../../utils/delivery-context.shared.js";
|
||||
import type { AuthorizedMemoryReadHost } from "../../../plugins/tool-types.js";
|
||||
import { normalizeMessageChannel } from "../../../utils/message-channel.js";
|
||||
import { isReasoningTagProvider } from "../../../utils/provider-utils.js";
|
||||
import { listActiveProcessSessionReferences } from "../../bash-process-references.js";
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
} from "../../channel-tools.js";
|
||||
import { resolveOpenClawReferencePaths } from "../../docs-path.js";
|
||||
import { resolveHeartbeatPromptForSystemPrompt } from "../../heartbeat-system-prompt.js";
|
||||
import { createAuthorizedMemoryReadHost } from "../../memory-authorized-read-host.js";
|
||||
import { prepareAgentMemoryPrompt } from "../../memory-prompt-prepare.js";
|
||||
import { resolveDefaultModelForAgent } from "../../model-selection.js";
|
||||
import { buildModelToolsUnavailablePrompt } from "../../model-tool-support.js";
|
||||
@@ -59,6 +58,7 @@ type PromptTools = Parameters<typeof buildEmbeddedSystemPrompt>[0]["tools"];
|
||||
|
||||
export async function prepareEmbeddedAttemptSystemPrompt(params: {
|
||||
activeContextEngine: EmbeddedRunAttemptParams["contextEngine"];
|
||||
authorizedMemoryRead?: AuthorizedMemoryReadHost;
|
||||
attempt: EmbeddedRunAttemptParams;
|
||||
bootstrap: PreparedBootstrap;
|
||||
capabilityToolNames: Set<string>;
|
||||
@@ -250,20 +250,6 @@ export async function prepareEmbeddedAttemptSystemPrompt(params: {
|
||||
});
|
||||
const includeMemorySection =
|
||||
!params.activeContextEngine || params.activeContextEngine.info.id === "legacy";
|
||||
const authorizedMemoryRead = createAuthorizedMemoryReadHost({
|
||||
agentId: params.sessionAgentId,
|
||||
sessionKey: runtimeInfo.sessionKey,
|
||||
sessionId: attempt.sessionId,
|
||||
runId: attempt.runId,
|
||||
deliveryContext: normalizeDeliveryContext({
|
||||
channel: attempt.messageChannel ?? attempt.messageProvider,
|
||||
to: attempt.messageTo ?? attempt.currentMessagingTarget ?? attempt.currentChannelId,
|
||||
accountId: attempt.agentAccountId,
|
||||
threadId: attempt.messageThreadId ?? attempt.currentThreadTs,
|
||||
}),
|
||||
messageChannel: attempt.messageChannel ?? attempt.messageProvider,
|
||||
agentAccountId: attempt.agentAccountId,
|
||||
});
|
||||
const preparedMemoryPrompt = await prepareAgentMemoryPrompt({
|
||||
enabled: effectivePromptMode === "full" && includeMemorySection,
|
||||
toolNames: params.effectiveTools.map((tool) => tool.name),
|
||||
@@ -272,7 +258,7 @@ export async function prepareEmbeddedAttemptSystemPrompt(params: {
|
||||
agentId: runtimeInfo.agentId,
|
||||
agentSessionKey: runtimeInfo.sessionKey,
|
||||
sandboxed: sandboxInfo?.enabled === true,
|
||||
authorizedMemoryRead,
|
||||
authorizedMemoryRead: params.authorizedMemoryRead,
|
||||
});
|
||||
const preparedWatchedSessions = prepareWatchedSessionsPrompt({
|
||||
enabled: effectivePromptMode === "full",
|
||||
|
||||
@@ -32,7 +32,7 @@ import { buildToolSearchRunPlan } from "./attempt-tool-search-run-plan.js";
|
||||
import { wrapEmbeddedAttemptToolWithActivity } from "./tool-activity-heartbeat.js";
|
||||
import type { EmbeddedRunAttemptParams } from "./types.js";
|
||||
|
||||
type PreparedToolBase = ReturnType<typeof prepareEmbeddedAttemptToolBase>;
|
||||
type PreparedToolBase = Awaited<ReturnType<typeof prepareEmbeddedAttemptToolBase>>;
|
||||
type PreparedBundleTools = Awaited<ReturnType<typeof prepareEmbeddedAttemptBundleTools>>;
|
||||
type ProviderRuntimeHandle = Parameters<typeof logAgentRuntimeToolDiagnostics>[0]["runtimeHandle"];
|
||||
|
||||
|
||||
@@ -9,8 +9,10 @@ import {
|
||||
logCodeModeDiagnostic,
|
||||
} from "../../../logging/code-mode-diagnostic.js";
|
||||
import { extractModelCompat } from "../../../plugins/provider-model-compat.js";
|
||||
import type { AuthorizedMemoryReadHost } from "../../../plugins/tool-types.js";
|
||||
import { getPluginToolMeta } from "../../../plugins/tools.js";
|
||||
import { isSubagentSessionKey } from "../../../routing/session-key.js";
|
||||
import { normalizeDeliveryContext } from "../../../utils/delivery-context.shared.js";
|
||||
import { createOpenClawCodingTools } from "../../agent-tools.js";
|
||||
import { getChannelAgentToolMeta } from "../../channel-tools.js";
|
||||
import type { CodeModeSkill } from "../../code-mode-skills.js";
|
||||
@@ -19,6 +21,11 @@ import {
|
||||
isLocalModelLeanEnabled,
|
||||
resolveLocalModelLeanPreserveToolNames,
|
||||
} from "../../local-model-lean.js";
|
||||
import {
|
||||
createAuthorizedMemoryReadHost,
|
||||
resolveAuthorizedMemoryVirtualFileBroker,
|
||||
type AuthorizedMemoryVirtualFileBroker,
|
||||
} from "../../memory-authorized-read-host.js";
|
||||
import { resolveModelAuthMode } from "../../model-auth.js";
|
||||
import { supportsModelTools } from "../../model-tool-support.js";
|
||||
import type { SandboxContext } from "../../sandbox/types.js";
|
||||
@@ -26,6 +33,7 @@ import {
|
||||
resolveSessionPermissionExecMode,
|
||||
type PreparedSessionPermissionPolicy,
|
||||
} from "../../tool-fs-policy.js";
|
||||
import type { ToolFsPolicy } from "../../tool-fs-policy.types.js";
|
||||
import { toolPolicyRestrictsTools } from "../../tool-policy.js";
|
||||
import { isAgentToolRestartSafe } from "../../tool-replay-safety.js";
|
||||
import {
|
||||
@@ -54,8 +62,10 @@ import type { EmbeddedRunAttemptParams } from "./types.js";
|
||||
type OpenClawCodingToolsOptions = NonNullable<Parameters<typeof createOpenClawCodingTools>[0]>;
|
||||
type SkillUsagePaths = OpenClawCodingToolsOptions["skillUsagePaths"];
|
||||
|
||||
export function prepareEmbeddedAttemptToolBase(params: {
|
||||
export async function prepareEmbeddedAttemptToolBase(params: {
|
||||
agentDir: string;
|
||||
authorizedMemoryRead?: AuthorizedMemoryReadHost;
|
||||
authorizedMemoryVirtualBroker?: AuthorizedMemoryVirtualFileBroker;
|
||||
attempt: EmbeddedRunAttemptParams;
|
||||
effectiveCwd: string;
|
||||
effectiveWorkspace: string;
|
||||
@@ -74,6 +84,40 @@ export function prepareEmbeddedAttemptToolBase(params: {
|
||||
toolSearchCatalogExecutor: ToolSearchCatalogToolExecutor;
|
||||
}) {
|
||||
const { attempt } = params;
|
||||
// Admission owns this host. Every model-facing memory path receives the
|
||||
// same already-bound capability; no downstream tool or prompt may mint one.
|
||||
const authorizedMemoryRead =
|
||||
params.authorizedMemoryRead ??
|
||||
createAuthorizedMemoryReadHost({
|
||||
agentId: params.sessionAgentId,
|
||||
sessionKey: params.sandboxSessionKey,
|
||||
sessionId: attempt.sessionId,
|
||||
runId: attempt.runId,
|
||||
deliveryContext: normalizeDeliveryContext({
|
||||
channel: attempt.messageChannel ?? attempt.messageProvider,
|
||||
to: attempt.messageTo ?? attempt.currentMessagingTarget ?? attempt.currentChannelId,
|
||||
accountId: attempt.agentAccountId,
|
||||
threadId: attempt.messageThreadId ?? attempt.currentThreadTs,
|
||||
}),
|
||||
messageChannel: attempt.messageChannel ?? attempt.messageProvider,
|
||||
agentAccountId: attempt.agentAccountId,
|
||||
});
|
||||
const authorizedMemoryVirtualBroker =
|
||||
params.authorizedMemoryVirtualBroker ??
|
||||
(await resolveAuthorizedMemoryVirtualFileBroker(authorizedMemoryRead));
|
||||
const fsPolicy: ToolFsPolicy | undefined = authorizedMemoryRead
|
||||
? authorizedMemoryVirtualBroker
|
||||
? Object.freeze({
|
||||
kind: "authorized-memory-view" as const,
|
||||
workspaceOnly: true,
|
||||
viewId: authorizedMemoryVirtualBroker.view.viewId,
|
||||
revision: authorizedMemoryVirtualBroker.view.revision,
|
||||
virtualRoots: Object.freeze(
|
||||
authorizedMemoryVirtualBroker.view.roots.map((root) => root.virtualRoot),
|
||||
),
|
||||
})
|
||||
: Object.freeze({ kind: "memory-unavailable" as const, workspaceOnly: true })
|
||||
: undefined;
|
||||
const forceDirectMessageTool = messageToolOwnsVisibleReply(attempt);
|
||||
const toolsAllowWithForcedRuntimeTools = mergeForcedEmbeddedAttemptToolsAllow(
|
||||
attempt.toolsAllow,
|
||||
@@ -281,6 +325,22 @@ export function prepareEmbeddedAttemptToolBase(params: {
|
||||
sessionId: attempt.sessionId,
|
||||
runId: attempt.runId,
|
||||
operationalRunInstance: attempt.admittedRunContext.operationalRunInstance,
|
||||
authorizedMemoryRead,
|
||||
...(fsPolicy ? { fsPolicy } : {}),
|
||||
...(authorizedMemoryVirtualBroker
|
||||
? {
|
||||
authorizedMemoryVirtualRead: {
|
||||
viewId: authorizedMemoryVirtualBroker.view.viewId,
|
||||
virtualRoots: authorizedMemoryVirtualBroker.view.roots.map(
|
||||
(root) => root.virtualRoot,
|
||||
),
|
||||
virtualPaths: authorizedMemoryVirtualBroker.view.files.map(
|
||||
(file) => file.virtualPath,
|
||||
),
|
||||
readFile: authorizedMemoryVirtualBroker.readFile,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
conversationRecall: attempt.conversationRecall,
|
||||
approvalReviewerDeviceId: attempt.approvalReviewerDeviceId,
|
||||
oneShotCliRun: attempt.oneShotCliRun,
|
||||
@@ -401,5 +461,8 @@ export function prepareEmbeddedAttemptToolBase(params: {
|
||||
toolSearchTargetTranscriptProjections,
|
||||
toolsEnabled,
|
||||
toolsRaw,
|
||||
authorizedMemoryRead,
|
||||
authorizedMemoryVirtualBroker,
|
||||
fsPolicy,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,6 +60,8 @@ export async function runEmbeddedAttempt(
|
||||
const runAbortController = new AbortController();
|
||||
const {
|
||||
agentCoreThinkingLevel,
|
||||
authorizedMemoryRead,
|
||||
authorizedMemoryVirtualBroker,
|
||||
defaultAgentId,
|
||||
effectiveCwd,
|
||||
effectiveFsWorkspaceOnly,
|
||||
@@ -190,11 +192,13 @@ export async function runEmbeddedAttempt(
|
||||
emitDiagnosticRunCompleted = emitCompleted;
|
||||
const corePluginToolStages = createEmbeddedRunStageTracker();
|
||||
let toolSearchCatalogExecutor: ToolSearchCatalogToolExecutor | undefined;
|
||||
const preparedToolBase = measureEmbeddedAgentPreparationSync(
|
||||
const preparedToolBase = await measureEmbeddedAgentPreparation(
|
||||
"attempt.tool-base",
|
||||
() =>
|
||||
prepareEmbeddedAttemptToolBase({
|
||||
agentDir,
|
||||
authorizedMemoryRead,
|
||||
authorizedMemoryVirtualBroker,
|
||||
attempt: params,
|
||||
effectiveCwd,
|
||||
effectiveWorkspace,
|
||||
@@ -323,6 +327,7 @@ export async function runEmbeddedAttempt(
|
||||
() =>
|
||||
prepareEmbeddedAttemptSystemPrompt({
|
||||
activeContextEngine,
|
||||
authorizedMemoryRead: preparedToolBase.authorizedMemoryRead,
|
||||
attempt: params,
|
||||
bootstrap: preparedBootstrap,
|
||||
capabilityToolNames: toolSearchRunPlan.capabilityToolNames,
|
||||
@@ -571,6 +576,13 @@ export async function runEmbeddedAttempt(
|
||||
`failed to clean up embedded prep resources after early attempt exit: runId=${params.runId} ${String(cleanupErr)}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
await sandbox?.disposeAuthorizedVirtualProjectionMountPlan?.();
|
||||
} catch (cleanupErr) {
|
||||
log.warn(
|
||||
`failed to remove authorized memory projection staging: runId=${params.runId} ${String(cleanupErr)}`,
|
||||
);
|
||||
}
|
||||
const terminal = projectAgentRunAttemptTerminal(executionState.terminal);
|
||||
emitDiagnosticRunCompleted?.(
|
||||
terminal.aborted ? "aborted" : "error",
|
||||
|
||||
@@ -8,9 +8,7 @@ import { applyAuthHeaderOverride, applyLocalNoAuthHeaderOverride } from "../../m
|
||||
import { appendProgressCardSystemPrompt } from "../../progress-card-system-prompt.js";
|
||||
import type { AgentRunSessionTarget } from "../../run-session-target.js";
|
||||
import type { AgentRuntimePlan } from "../../runtime-plan/types.js";
|
||||
import { resolveSandboxContext } from "../../sandbox/context.js";
|
||||
import { resolveSessionPermissionExecMode } from "../../session-permission-exec-mode.js";
|
||||
import { resolveSessionPlacementSandbox } from "../../session-placement-admission.js";
|
||||
import { createToolTerminalObserver } from "../../tool-terminal-outcome.js";
|
||||
import {
|
||||
createAdmittedGatewayToolCallerIdentity,
|
||||
@@ -197,27 +195,38 @@ export async function dispatchEmbeddedRunAttempt(input: {
|
||||
modelMaxTokens: runtime.model.maxTokens,
|
||||
userTurnTranscriptRecorder: params.userTurnTranscriptRecorder,
|
||||
});
|
||||
const promptMedia = control.pluginHarnessOwnsTransport
|
||||
? await (async () => {
|
||||
const workspace = await resolveAttemptWorkspaceSandbox({
|
||||
let pluginWorkspace: Awaited<ReturnType<typeof resolveAttemptWorkspaceSandbox>> | undefined;
|
||||
let promptMedia: {
|
||||
images: typeof params.images;
|
||||
imageOrder: typeof params.imageOrder;
|
||||
media: typeof params.media;
|
||||
};
|
||||
try {
|
||||
pluginWorkspace = control.pluginHarnessOwnsTransport
|
||||
? await resolveAttemptWorkspaceSandbox({
|
||||
...params,
|
||||
cwd: undefined,
|
||||
sessionId: runtime.sessionId,
|
||||
sessionKey: runtime.sessionKey,
|
||||
workspaceDir: runtime.workspaceDir,
|
||||
});
|
||||
return await prepareEmbeddedAttemptPromptExecution({
|
||||
})
|
||||
: undefined;
|
||||
promptMedia = pluginWorkspace
|
||||
? await prepareEmbeddedAttemptPromptExecution({
|
||||
attempt: { ...params, model: runtime.model },
|
||||
mediaOwnerAgentId: workspace.sessionAgentId,
|
||||
effectiveFsWorkspaceOnly: workspace.effectiveFsWorkspaceOnly,
|
||||
effectiveWorkspace: workspace.effectiveWorkspace,
|
||||
mediaOwnerAgentId: pluginWorkspace.sessionAgentId,
|
||||
effectiveFsWorkspaceOnly: pluginWorkspace.effectiveFsWorkspaceOnly,
|
||||
effectiveWorkspace: pluginWorkspace.effectiveWorkspace,
|
||||
prompt: "",
|
||||
sandbox: workspace.sandbox,
|
||||
sandbox: pluginWorkspace.sandbox,
|
||||
skipPromptSubmission: false,
|
||||
pluginHarness: true,
|
||||
});
|
||||
})()
|
||||
: { images: params.images, imageOrder: params.imageOrder, media: params.media };
|
||||
})
|
||||
: { images: params.images, imageOrder: params.imageOrder, media: params.media };
|
||||
} catch (error) {
|
||||
await pluginWorkspace?.sandbox?.disposeAuthorizedVirtualProjectionMountPlan?.();
|
||||
throw error;
|
||||
}
|
||||
// Plugin harnesses own their tool materialization, so the host cannot attest
|
||||
// a message tool. Finalize conservatively instead of leaking phantom guidance.
|
||||
const pluginHarnessPrompt =
|
||||
@@ -228,20 +237,7 @@ export async function dispatchEmbeddedRunAttempt(input: {
|
||||
finalize: params.finalizePromptForResolvedTools,
|
||||
})
|
||||
: undefined;
|
||||
const pluginSandbox = control.pluginHarnessOwnsTransport
|
||||
? ((await resolveSessionPlacementSandbox({
|
||||
agentId: runtime.agentId,
|
||||
config: params.config,
|
||||
sessionId: runtime.sessionId,
|
||||
sessionKey: runtime.sessionKey,
|
||||
workspaceDir: runtime.workspaceDir,
|
||||
})) ??
|
||||
(await resolveSandboxContext({
|
||||
config: params.config,
|
||||
sessionKey: params.sandboxSessionKey ?? runtime.sessionKey ?? runtime.sessionId,
|
||||
workspaceDir: runtime.workspaceDir,
|
||||
})))
|
||||
: undefined;
|
||||
const pluginSandbox = pluginWorkspace?.sandbox;
|
||||
if (!params.admittedRunContext) {
|
||||
throw new Error("embedded attempt reached dispatch without an admitted run context");
|
||||
}
|
||||
@@ -528,11 +524,12 @@ export async function dispatchEmbeddedRunAttempt(input: {
|
||||
.catch((err: unknown): never => {
|
||||
throw control.getPostCompactionAbortError() ?? err;
|
||||
})
|
||||
.finally(() => {
|
||||
.finally(async () => {
|
||||
clearAttemptTimeoutRelease();
|
||||
stopLaneProgressHeartbeat();
|
||||
parentAbortSignal?.removeEventListener?.("abort", relayParentAbort);
|
||||
control.clearPostCompactionAbortController(attemptAbortController);
|
||||
await pluginSandbox?.disposeAuthorizedVirtualProjectionMountPlan?.();
|
||||
});
|
||||
|
||||
const postCompactionAbortError = control.getPostCompactionAbortError();
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import type { MemoryActorEvidence } from "../memory-host-sdk/host/authorization.js";
|
||||
import type {
|
||||
AuthorizedMemoryVirtualView,
|
||||
MemoryActorEvidence,
|
||||
} from "../memory-host-sdk/host/authorization.js";
|
||||
import { isMemoryIsolationCutoverAgent } from "../plugins/memory-cutover.js";
|
||||
import {
|
||||
MEMORY_INVOCATION_UNAVAILABLE,
|
||||
createAuthorizedMemoryReadInvocation,
|
||||
materializeAuthorizedMemoryVirtualView,
|
||||
readAuthorizedMemoryVirtualFile,
|
||||
readAuthorizedMemoryForInvocation,
|
||||
searchAuthorizedMemoryForInvocation,
|
||||
type AuthorizedMemoryReadInvocation,
|
||||
@@ -19,6 +24,31 @@ import {
|
||||
type CurrentMemorySessionContext,
|
||||
} from "../state/memory-session-subject.js";
|
||||
import type { DeliveryContext } from "../utils/delivery-context.types.js";
|
||||
import { resolveMemoryEgressDeliveryFacts } from "./memory-egress-admission.js";
|
||||
|
||||
const authorizedMemoryVirtualBroker: unique symbol = Symbol(
|
||||
"openclaw.authorized-memory-virtual-broker",
|
||||
);
|
||||
|
||||
/** Core-private bridge for generic filesystem tools; it is absent from plugin contexts. */
|
||||
export type AuthorizedMemoryVirtualFileBroker = Readonly<{
|
||||
view: AuthorizedMemoryVirtualView;
|
||||
readFile: (virtualPath: string) => Promise<string | undefined>;
|
||||
}>;
|
||||
|
||||
type AuthorizedMemoryReadHostWithVirtualBroker = AuthorizedMemoryReadHost &
|
||||
Readonly<{
|
||||
[authorizedMemoryVirtualBroker]: () => Promise<AuthorizedMemoryVirtualFileBroker | undefined>;
|
||||
}>;
|
||||
|
||||
export async function resolveAuthorizedMemoryVirtualFileBroker(
|
||||
host: AuthorizedMemoryReadHost | undefined,
|
||||
): Promise<AuthorizedMemoryVirtualFileBroker | undefined> {
|
||||
if (!host || !(authorizedMemoryVirtualBroker in host)) {
|
||||
return undefined;
|
||||
}
|
||||
return (host as AuthorizedMemoryReadHostWithVirtualBroker)[authorizedMemoryVirtualBroker]();
|
||||
}
|
||||
|
||||
function hash(value: unknown): string {
|
||||
return createHash("sha256").update(JSON.stringify(value)).digest("base64url");
|
||||
@@ -30,48 +60,23 @@ function deliveryFacts(params: {
|
||||
messageChannel?: string;
|
||||
agentAccountId?: string;
|
||||
}) {
|
||||
const { context } = params;
|
||||
const route = {
|
||||
channel: params.deliveryContext?.channel ?? params.messageChannel ?? null,
|
||||
accountId: params.deliveryContext?.accountId ?? params.agentAccountId ?? null,
|
||||
to: params.deliveryContext?.to ?? null,
|
||||
threadId: params.deliveryContext?.threadId ?? null,
|
||||
};
|
||||
if (context.subject.kind === "user") {
|
||||
return {
|
||||
sink: "private" as const,
|
||||
audiences: [{ kind: "user" as const, id: context.subject.principalId }],
|
||||
routeRevision: `mdr1_${hash(route)}`,
|
||||
const facts = resolveMemoryEgressDeliveryFacts({
|
||||
agentId: params.context.agentId,
|
||||
sessionKey: params.context.sessionKey,
|
||||
sessionId: params.context.sessionId,
|
||||
deliveryContext: params.deliveryContext,
|
||||
messageChannel: params.messageChannel,
|
||||
agentAccountId: params.agentAccountId,
|
||||
});
|
||||
return (
|
||||
facts && {
|
||||
sink: facts.sink,
|
||||
audiences: facts.audiences,
|
||||
routeRevision: facts.deliveryRevision,
|
||||
egressCapabilityIds: ["reply.final"],
|
||||
egressRegistryRevision: "mer1_reply-final",
|
||||
};
|
||||
}
|
||||
if (context.subject.kind === "conversation") {
|
||||
// The persisted transport identity, not a latest sender, is the only
|
||||
// channel audience accepted by the scoped runtime.
|
||||
if (!context.conversation) {
|
||||
return undefined;
|
||||
egressRegistryRevision: facts.egressRegistryRevision,
|
||||
}
|
||||
return {
|
||||
sink: "channel" as const,
|
||||
audiences: [
|
||||
// Scoped stores are addressed to the canonical conversation principal. The transport
|
||||
// conversation id remains routing evidence; using it here would make every channel
|
||||
// store fail the subject-bound view check.
|
||||
{ kind: "conversation" as const, id: context.principalId },
|
||||
],
|
||||
routeRevision: `mdr1_${hash(route)}`,
|
||||
egressCapabilityIds: ["reply.final"],
|
||||
egressRegistryRevision: "mer1_reply-final",
|
||||
};
|
||||
}
|
||||
return {
|
||||
sink: "internal" as const,
|
||||
audiences: [{ kind: "agent" as const, id: context.agentId }],
|
||||
routeRevision: `mdr1_${hash(route)}`,
|
||||
egressCapabilityIds: ["reply.final"],
|
||||
egressRegistryRevision: "mer1_reply-final",
|
||||
};
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,6 +209,29 @@ export function createAuthorizedMemoryReadHost(params: {
|
||||
| undefined;
|
||||
const getInvocation = () =>
|
||||
(invocation ??= createAuthorizedMemoryReadInvocation({ context: trusted.context }));
|
||||
let virtualBroker: Promise<AuthorizedMemoryVirtualFileBroker | undefined> | undefined;
|
||||
const getVirtualBroker = () =>
|
||||
(virtualBroker ??= (async () => {
|
||||
const active = await getInvocation();
|
||||
if ("unavailable" in active) {
|
||||
return undefined;
|
||||
}
|
||||
const view = await materializeAuthorizedMemoryVirtualView({ invocation: active });
|
||||
if ("unavailable" in view) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.freeze({
|
||||
view,
|
||||
async readFile(virtualPath) {
|
||||
const result = await readAuthorizedMemoryVirtualFile({
|
||||
invocation: active,
|
||||
view,
|
||||
virtualPath,
|
||||
});
|
||||
return "unavailable" in result ? undefined : result.text;
|
||||
},
|
||||
});
|
||||
})());
|
||||
return Object.freeze({
|
||||
async search(search) {
|
||||
const active = await getInvocation();
|
||||
@@ -221,5 +249,6 @@ export function createAuthorizedMemoryReadHost(params: {
|
||||
const result = await readAuthorizedMemoryForInvocation({ invocation: active, ...read });
|
||||
return "unavailable" in result ? MEMORY_INVOCATION_UNAVAILABLE : result;
|
||||
},
|
||||
});
|
||||
[authorizedMemoryVirtualBroker]: getVirtualBroker,
|
||||
}) as AuthorizedMemoryReadHost;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
cutover: true,
|
||||
lookup: { kind: "absent" } as Record<string, unknown>,
|
||||
subject: "alice",
|
||||
subjectKind: "user" as "user" | "agent" | "conversation",
|
||||
recipientMatches: true,
|
||||
conversationTarget: "chat-1",
|
||||
}));
|
||||
|
||||
vi.mock("../infra/agent-run-registry.js", () => ({
|
||||
getAgentRunContext: () => ({
|
||||
agentId: "memory-agent",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:memory-agent:direct:alice",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/memory-cutover.js", () => ({
|
||||
isMemoryIsolationCutoverAgent: () => mocks.cutover,
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/memory-run-exposure-ledger.js", () => ({
|
||||
readLatestDurableMemoryRunExposure: () => mocks.lookup,
|
||||
}));
|
||||
|
||||
vi.mock("../state/memory-identity.js", () => ({
|
||||
recheckMemoryIdentityBindingRecipient: () =>
|
||||
mocks.recipientMatches ? { kind: "current" } : { kind: "unbound" },
|
||||
}));
|
||||
|
||||
vi.mock("../state/memory-session-subject.js", () => ({
|
||||
createCurrentMemorySessionContext: () => ({
|
||||
kind: "current",
|
||||
context: {
|
||||
agentId: "memory-agent",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:memory-agent:direct:alice",
|
||||
principalId: mocks.subject,
|
||||
...(mocks.subjectKind === "user" ? { bindingId: "binding-alice" } : {}),
|
||||
...(mocks.subjectKind === "conversation"
|
||||
? {
|
||||
conversation: {
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
primaryConversationId: "conversation-1",
|
||||
deliveryTarget: mocks.conversationTarget,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
subject: { kind: mocks.subjectKind, principalId: mocks.subject },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const {
|
||||
admitMemoryEgressAtDelivery,
|
||||
prepareMemoryEgressAuthorization,
|
||||
MEMORY_EGRESS_CAPABILITY_REPLY_FINAL,
|
||||
} = await import("./memory-egress-admission.js");
|
||||
|
||||
function currentExposure(revisionNumber: number, overrides: Record<string, unknown> = {}) {
|
||||
const previousLookup = mocks.lookup;
|
||||
mocks.lookup = { kind: "absent" };
|
||||
const prepared = prepareFinal();
|
||||
mocks.lookup = previousLookup;
|
||||
if (!prepared.allowed) {
|
||||
throw new Error("test setup could not prepare final authorization");
|
||||
}
|
||||
return {
|
||||
kind: "current",
|
||||
snapshot: {
|
||||
agentId: "memory-agent",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:memory-agent:direct:alice",
|
||||
runId: "run-1",
|
||||
exposureSetId: `exposure-${revisionNumber}`,
|
||||
revisionNumber,
|
||||
egressReceiptIds: ["receipt-1"],
|
||||
deliveryAudiences: [{ kind: "user", id: "alice" }],
|
||||
deliveryRevision: prepared.authorization.deliveryRevision,
|
||||
egressRegistryRevision: prepared.authorization.egressRegistryRevision,
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const deliveryContext = {
|
||||
channel: "telegram",
|
||||
to: "chat-1",
|
||||
accountId: "default",
|
||||
threadId: "thread-1",
|
||||
};
|
||||
|
||||
function prepareFinal() {
|
||||
return prepareMemoryEgressAuthorization({
|
||||
capabilityId: MEMORY_EGRESS_CAPABILITY_REPLY_FINAL,
|
||||
runId: "run-1",
|
||||
deliveryContext,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.cutover = true;
|
||||
mocks.lookup = { kind: "absent" };
|
||||
mocks.subject = "alice";
|
||||
mocks.subjectKind = "user";
|
||||
mocks.recipientMatches = true;
|
||||
mocks.conversationTarget = "chat-1";
|
||||
});
|
||||
|
||||
describe("memory egress admission", () => {
|
||||
it("permits only the registered final-reply capability for a cutover run", () => {
|
||||
expect(prepareFinal()).toMatchObject({ allowed: true });
|
||||
expect(
|
||||
prepareMemoryEgressAuthorization({
|
||||
capabilityId: "reply.block",
|
||||
runId: "run-1",
|
||||
deliveryContext,
|
||||
}),
|
||||
).toEqual({ allowed: false, reason: "unregistered" });
|
||||
});
|
||||
|
||||
it("fails closed when a known cutover agent has no registered run identity", () => {
|
||||
expect(
|
||||
prepareMemoryEgressAuthorization({
|
||||
capabilityId: MEMORY_EGRESS_CAPABILITY_REPLY_FINAL,
|
||||
agentId: "memory-agent",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:memory-agent:direct:alice",
|
||||
deliveryContext,
|
||||
}),
|
||||
).toEqual({ allowed: false, reason: "unavailable" });
|
||||
});
|
||||
|
||||
it("fails closed when the delivery owner cannot provide a current platform route", () => {
|
||||
expect(
|
||||
prepareMemoryEgressAuthorization({
|
||||
capabilityId: MEMORY_EGRESS_CAPABILITY_REPLY_FINAL,
|
||||
runId: "run-1",
|
||||
resolveDeliveryFacts: () => undefined,
|
||||
}),
|
||||
).toEqual({ allowed: false, reason: "unavailable" });
|
||||
});
|
||||
|
||||
it("rejects an initially unproven direct-recipient target", () => {
|
||||
mocks.recipientMatches = false;
|
||||
|
||||
expect(prepareFinal()).toEqual({ allowed: false, reason: "unavailable" });
|
||||
});
|
||||
|
||||
it("admits only the persisted group conversation target", () => {
|
||||
mocks.subjectKind = "conversation";
|
||||
mocks.subject = "conversation-principal";
|
||||
expect(prepareFinal()).toMatchObject({
|
||||
allowed: true,
|
||||
authorization: { audiences: [{ kind: "conversation", id: "conversation-principal" }] },
|
||||
});
|
||||
|
||||
mocks.conversationTarget = "other-group";
|
||||
expect(prepareFinal()).toEqual({ allowed: false, reason: "unavailable" });
|
||||
});
|
||||
|
||||
it("invalidates a queued final after a later durable memory read", () => {
|
||||
mocks.lookup = currentExposure(1);
|
||||
const prepared = prepareFinal();
|
||||
expect(prepared.allowed).toBe(true);
|
||||
if (!prepared.allowed) {
|
||||
return;
|
||||
}
|
||||
mocks.lookup = currentExposure(2);
|
||||
|
||||
expect(
|
||||
admitMemoryEgressAtDelivery({
|
||||
authorization: prepared.authorization,
|
||||
deliveryContext,
|
||||
}),
|
||||
).toEqual({ allowed: false, reason: "stale" });
|
||||
});
|
||||
|
||||
it("invalidates an unexposed queued final when memory is exposed before delivery", () => {
|
||||
const prepared = prepareFinal();
|
||||
expect(prepared.allowed).toBe(true);
|
||||
if (!prepared.allowed) {
|
||||
return;
|
||||
}
|
||||
mocks.lookup = currentExposure(1);
|
||||
|
||||
expect(
|
||||
admitMemoryEgressAtDelivery({
|
||||
authorization: prepared.authorization,
|
||||
deliveryContext,
|
||||
}),
|
||||
).toEqual({ allowed: false, reason: "stale" });
|
||||
});
|
||||
|
||||
it("samples the actual delivery route again before platform I/O", () => {
|
||||
let actualDelivery = { ...deliveryContext };
|
||||
const resolveDeliveryFacts = () => ({ deliveryContext: actualDelivery });
|
||||
const prepared = prepareMemoryEgressAuthorization({
|
||||
capabilityId: MEMORY_EGRESS_CAPABILITY_REPLY_FINAL,
|
||||
runId: "run-1",
|
||||
resolveDeliveryFacts,
|
||||
});
|
||||
expect(prepared.allowed).toBe(true);
|
||||
if (!prepared.allowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
actualDelivery = { ...deliveryContext, to: "chat-rebound" };
|
||||
expect(
|
||||
admitMemoryEgressAtDelivery({
|
||||
authorization: prepared.authorization,
|
||||
resolveDeliveryFacts,
|
||||
}),
|
||||
).toEqual({ allowed: false, reason: "stale" });
|
||||
});
|
||||
|
||||
it("rejects a direct recipient whose binding no longer matches at delivery", () => {
|
||||
const prepared = prepareFinal();
|
||||
expect(prepared.allowed).toBe(true);
|
||||
if (!prepared.allowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
mocks.recipientMatches = false;
|
||||
expect(
|
||||
admitMemoryEgressAtDelivery({
|
||||
authorization: prepared.authorization,
|
||||
deliveryContext,
|
||||
}),
|
||||
).toEqual({ allowed: false, reason: "unavailable" });
|
||||
});
|
||||
|
||||
it("invalidates a queued final when the current sink changes", () => {
|
||||
const prepared = prepareFinal();
|
||||
expect(prepared.allowed).toBe(true);
|
||||
if (!prepared.allowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
mocks.subjectKind = "agent";
|
||||
mocks.subject = "memory-agent";
|
||||
expect(
|
||||
admitMemoryEgressAtDelivery({
|
||||
authorization: prepared.authorization,
|
||||
deliveryContext,
|
||||
}),
|
||||
).toEqual({ allowed: false, reason: "unavailable" });
|
||||
});
|
||||
|
||||
it("rejects changed audience and unavailable registry-backed exposure state", () => {
|
||||
mocks.lookup = currentExposure(1);
|
||||
const prepared = prepareFinal();
|
||||
expect(prepared.allowed).toBe(true);
|
||||
if (!prepared.allowed) {
|
||||
return;
|
||||
}
|
||||
mocks.subject = "bob";
|
||||
expect(
|
||||
admitMemoryEgressAtDelivery({
|
||||
authorization: prepared.authorization,
|
||||
deliveryContext,
|
||||
}),
|
||||
).toEqual({ allowed: false, reason: "stale" });
|
||||
|
||||
mocks.subject = "alice";
|
||||
mocks.lookup = { kind: "unavailable" };
|
||||
expect(
|
||||
admitMemoryEgressAtDelivery({
|
||||
authorization: prepared.authorization,
|
||||
deliveryContext,
|
||||
}),
|
||||
).toEqual({ allowed: false, reason: "unavailable" });
|
||||
});
|
||||
|
||||
it("rejects changed delivery route and registry revisions", () => {
|
||||
mocks.lookup = currentExposure(1);
|
||||
const prepared = prepareFinal();
|
||||
expect(prepared.allowed).toBe(true);
|
||||
if (!prepared.allowed) {
|
||||
return;
|
||||
}
|
||||
mocks.lookup = currentExposure(1, { deliveryRevision: "different-route" });
|
||||
expect(
|
||||
admitMemoryEgressAtDelivery({
|
||||
authorization: prepared.authorization,
|
||||
deliveryContext,
|
||||
}),
|
||||
).toEqual({ allowed: false, reason: "stale" });
|
||||
|
||||
mocks.lookup = currentExposure(1, { egressRegistryRevision: "different-registry" });
|
||||
expect(
|
||||
admitMemoryEgressAtDelivery({
|
||||
authorization: prepared.authorization,
|
||||
deliveryContext,
|
||||
}),
|
||||
).toEqual({ allowed: false, reason: "stale" });
|
||||
});
|
||||
|
||||
it("leaves non-cutover runs unchanged", () => {
|
||||
mocks.cutover = false;
|
||||
expect(
|
||||
prepareMemoryEgressAuthorization({
|
||||
capabilityId: "reply.block",
|
||||
runId: "run-1",
|
||||
deliveryContext,
|
||||
}),
|
||||
).toMatchObject({ allowed: true, authorization: { capabilityId: "reply.block" } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,351 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { getAgentRunContext } from "../infra/agent-run-registry.js";
|
||||
import type { AudienceRef } from "../memory-host-sdk/host/authorization.js";
|
||||
import { isMemoryIsolationCutoverAgent } from "../plugins/memory-cutover.js";
|
||||
import {
|
||||
readLatestDurableMemoryRunExposure,
|
||||
type DurableMemoryRunExposureLookup,
|
||||
} from "../plugins/memory-run-exposure-ledger.js";
|
||||
import { normalizeAccountId } from "../routing/account-id.js";
|
||||
import { recheckMemoryIdentityBindingRecipient } from "../state/memory-identity.js";
|
||||
import { createCurrentMemorySessionContext } from "../state/memory-session-subject.js";
|
||||
import type { DeliveryContext } from "../utils/delivery-context.types.js";
|
||||
|
||||
/** The constrained Phase 1D pilot exposes only automatic final replies. */
|
||||
export const MEMORY_EGRESS_CAPABILITY_REPLY_FINAL = "reply.final";
|
||||
export const MEMORY_EGRESS_REGISTRY_REVISION = "mer1_reply-final";
|
||||
|
||||
type MemoryEgressDeliveryFacts = Readonly<{
|
||||
sink: "private" | "channel" | "internal";
|
||||
audiences: readonly AudienceRef[];
|
||||
deliveryRevision: string;
|
||||
egressRegistryRevision: typeof MEMORY_EGRESS_REGISTRY_REVISION;
|
||||
}>;
|
||||
|
||||
export type MemoryEgressAuthorization = Readonly<{
|
||||
version: 1;
|
||||
capabilityId: string;
|
||||
agentId: string;
|
||||
sessionId: string;
|
||||
runId: string;
|
||||
deliveryRevision: string;
|
||||
egressRegistryRevision: string;
|
||||
audiences: readonly AudienceRef[];
|
||||
exposure?: Readonly<{ exposureSetId: string; revisionNumber: number }>;
|
||||
}>;
|
||||
|
||||
/** Internal queue marker; it never crosses a channel or plugin payload boundary. */
|
||||
export type MemoryEgressPayloadAuthorization =
|
||||
| Readonly<{ kind: "authorized"; authorization: MemoryEgressAuthorization }>
|
||||
| Readonly<{ kind: "denied"; reason: "unregistered" | "unavailable" | "stale" }>;
|
||||
|
||||
export type MemoryEgressAdmission =
|
||||
| Readonly<{ allowed: true; authorization: MemoryEgressAuthorization }>
|
||||
| Readonly<{ allowed: false; reason: "unregistered" | "unavailable" | "stale" }>;
|
||||
|
||||
/**
|
||||
* Core-owned route input sampled at the queue and platform-I/O boundaries.
|
||||
* A captured dispatch context is not sufficient: the actual delivery owner
|
||||
* must provide current routing facts again before it sends anything visible.
|
||||
*/
|
||||
export type TrustedMemoryEgressDeliveryFacts = Readonly<{
|
||||
deliveryContext?: DeliveryContext;
|
||||
messageChannel?: string;
|
||||
agentAccountId?: string;
|
||||
}>;
|
||||
|
||||
export type TrustedMemoryEgressDeliveryFactsSource = () =>
|
||||
| TrustedMemoryEgressDeliveryFacts
|
||||
| undefined;
|
||||
|
||||
export function memoryEgressPayloadAuthorization(
|
||||
admission: MemoryEgressAdmission,
|
||||
): MemoryEgressPayloadAuthorization {
|
||||
return admission.allowed
|
||||
? Object.freeze({ kind: "authorized", authorization: admission.authorization })
|
||||
: Object.freeze({ kind: "denied", reason: admission.reason });
|
||||
}
|
||||
|
||||
function hash(value: unknown): string {
|
||||
return createHash("sha256").update(JSON.stringify(value)).digest("base64url");
|
||||
}
|
||||
|
||||
function sortedAudiences(audiences: readonly AudienceRef[]): readonly AudienceRef[] {
|
||||
return Object.freeze(
|
||||
[...audiences]
|
||||
.map((audience) => Object.freeze({ kind: audience.kind, id: audience.id }))
|
||||
.toSorted((left, right) =>
|
||||
`${left.kind}\u0000${left.id}`.localeCompare(`${right.kind}\u0000${right.id}`),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function sameAudiences(left: readonly AudienceRef[], right: readonly AudienceRef[]): boolean {
|
||||
const keys = (audiences: readonly AudienceRef[]) =>
|
||||
audiences.map((audience) => `${audience.kind}\u0000${audience.id}`).toSorted();
|
||||
const leftKeys = keys(left);
|
||||
const rightKeys = keys(right);
|
||||
return (
|
||||
leftKeys.length === rightKeys.length &&
|
||||
leftKeys.every((value, index) => value === rightKeys[index])
|
||||
);
|
||||
}
|
||||
|
||||
/** Recomputes route, sink, and audience facts from the current session owner. */
|
||||
export function resolveMemoryEgressDeliveryFacts(params: {
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
sessionId: string;
|
||||
deliveryContext?: DeliveryContext;
|
||||
messageChannel?: string;
|
||||
agentAccountId?: string;
|
||||
}): MemoryEgressDeliveryFacts | undefined {
|
||||
const session = createCurrentMemorySessionContext({
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: params.sessionId,
|
||||
options: { agentId: params.agentId },
|
||||
});
|
||||
if (session.kind !== "current") {
|
||||
return undefined;
|
||||
}
|
||||
const route = {
|
||||
channel: params.deliveryContext?.channel ?? params.messageChannel ?? null,
|
||||
accountId: params.deliveryContext?.accountId ?? params.agentAccountId ?? null,
|
||||
to: params.deliveryContext?.to ?? null,
|
||||
threadId: params.deliveryContext?.threadId ?? null,
|
||||
};
|
||||
if (!route.channel?.trim() || !route.accountId?.trim() || !route.to?.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
const channel = route.channel.trim().toLowerCase();
|
||||
const accountId = normalizeAccountId(route.accountId.trim());
|
||||
const to = route.to.trim();
|
||||
const sinkAndAudiences =
|
||||
session.context.subject.kind === "user"
|
||||
? session.context.bindingId &&
|
||||
recheckMemoryIdentityBindingRecipient({
|
||||
bindingId: session.context.bindingId,
|
||||
channel,
|
||||
accountId,
|
||||
recipientId: to,
|
||||
options: { agentId: params.agentId },
|
||||
}).kind === "current"
|
||||
? {
|
||||
sink: "private" as const,
|
||||
audiences: [{ kind: "user" as const, id: session.context.subject.principalId }],
|
||||
}
|
||||
: undefined
|
||||
: session.context.subject.kind === "conversation"
|
||||
? session.context.conversation &&
|
||||
session.context.conversation.channel === channel &&
|
||||
session.context.conversation.accountId === accountId &&
|
||||
session.context.conversation.deliveryTarget === to
|
||||
? {
|
||||
sink: "channel" as const,
|
||||
audiences: [{ kind: "conversation" as const, id: session.context.principalId }],
|
||||
}
|
||||
: undefined
|
||||
: undefined;
|
||||
if (!sinkAndAudiences) {
|
||||
return undefined;
|
||||
}
|
||||
const audiences = sortedAudiences(sinkAndAudiences.audiences);
|
||||
return Object.freeze({
|
||||
sink: sinkAndAudiences.sink,
|
||||
audiences,
|
||||
// Sink and audience are deliberately inside this revision: a route that keeps the same
|
||||
// transport target must still lose authority when its recipient classification changes.
|
||||
deliveryRevision: `mdr1_${hash({ route: { ...route, channel, accountId, to }, sink: sinkAndAudiences.sink, audiences })}`,
|
||||
egressRegistryRevision: MEMORY_EGRESS_REGISTRY_REVISION,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveRunIdentity(params: {
|
||||
runId?: string;
|
||||
agentId?: string;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
}) {
|
||||
const runId = params.runId?.trim();
|
||||
const registered = runId ? getAgentRunContext(runId) : undefined;
|
||||
const agentId = params.agentId?.trim() || registered?.agentId?.trim();
|
||||
const sessionId = params.sessionId?.trim() || registered?.sessionId?.trim();
|
||||
const sessionKey = params.sessionKey?.trim() || registered?.sessionKey?.trim();
|
||||
return runId && agentId && sessionId && sessionKey
|
||||
? { runId, agentId, sessionId, sessionKey }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function resolveTrustedDeliveryFacts(params: {
|
||||
deliveryContext?: DeliveryContext;
|
||||
messageChannel?: string;
|
||||
agentAccountId?: string;
|
||||
resolveDeliveryFacts?: TrustedMemoryEgressDeliveryFactsSource;
|
||||
}): TrustedMemoryEgressDeliveryFacts | undefined {
|
||||
if (!params.resolveDeliveryFacts) {
|
||||
return {
|
||||
deliveryContext: params.deliveryContext,
|
||||
messageChannel: params.messageChannel,
|
||||
agentAccountId: params.agentAccountId,
|
||||
};
|
||||
}
|
||||
try {
|
||||
return params.resolveDeliveryFacts();
|
||||
} catch {
|
||||
// A delivery owner that cannot state its current route cannot safely send
|
||||
// selected memory content to a recipient.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function authorizationFromLookup(params: {
|
||||
capabilityId: string;
|
||||
identity: NonNullable<ReturnType<typeof resolveRunIdentity>>;
|
||||
facts: MemoryEgressDeliveryFacts;
|
||||
lookup: DurableMemoryRunExposureLookup;
|
||||
}): MemoryEgressAdmission {
|
||||
const { capabilityId, identity, facts, lookup } = params;
|
||||
if (capabilityId !== MEMORY_EGRESS_CAPABILITY_REPLY_FINAL) {
|
||||
return Object.freeze({ allowed: false, reason: "unregistered" });
|
||||
}
|
||||
if (lookup.kind === "unavailable") {
|
||||
return Object.freeze({ allowed: false, reason: "unavailable" });
|
||||
}
|
||||
if (lookup.kind === "absent") {
|
||||
return Object.freeze({
|
||||
allowed: true,
|
||||
authorization: Object.freeze({
|
||||
version: 1,
|
||||
capabilityId,
|
||||
...identity,
|
||||
deliveryRevision: facts.deliveryRevision,
|
||||
egressRegistryRevision: facts.egressRegistryRevision,
|
||||
audiences: facts.audiences,
|
||||
}),
|
||||
});
|
||||
}
|
||||
const snapshot = lookup.snapshot;
|
||||
if (
|
||||
snapshot.agentId !== identity.agentId ||
|
||||
snapshot.sessionId !== identity.sessionId ||
|
||||
snapshot.sessionKey !== identity.sessionKey ||
|
||||
snapshot.runId !== identity.runId ||
|
||||
snapshot.deliveryRevision !== facts.deliveryRevision ||
|
||||
snapshot.egressRegistryRevision !== facts.egressRegistryRevision ||
|
||||
snapshot.egressReceiptIds.length === 0 ||
|
||||
!sameAudiences(snapshot.deliveryAudiences, facts.audiences)
|
||||
) {
|
||||
return Object.freeze({ allowed: false, reason: "stale" });
|
||||
}
|
||||
return Object.freeze({
|
||||
allowed: true,
|
||||
authorization: Object.freeze({
|
||||
version: 1,
|
||||
capabilityId,
|
||||
...identity,
|
||||
deliveryRevision: facts.deliveryRevision,
|
||||
egressRegistryRevision: facts.egressRegistryRevision,
|
||||
audiences: facts.audiences,
|
||||
exposure: Object.freeze({
|
||||
exposureSetId: snapshot.exposureSetId,
|
||||
revisionNumber: snapshot.revisionNumber,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Issues a queue-time authorization bound to the latest durable exposure, never process state. */
|
||||
export function prepareMemoryEgressAuthorization(params: {
|
||||
capabilityId: string;
|
||||
runId?: string;
|
||||
agentId?: string;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
deliveryContext?: DeliveryContext;
|
||||
messageChannel?: string;
|
||||
agentAccountId?: string;
|
||||
resolveDeliveryFacts?: TrustedMemoryEgressDeliveryFactsSource;
|
||||
}): MemoryEgressAdmission {
|
||||
const identity = resolveRunIdentity(params);
|
||||
const requestedAgentId = params.agentId?.trim();
|
||||
if (!identity && requestedAgentId && isMemoryIsolationCutoverAgent(requestedAgentId)) {
|
||||
return Object.freeze({ allowed: false, reason: "unavailable" });
|
||||
}
|
||||
if (!identity || !isMemoryIsolationCutoverAgent(identity.agentId)) {
|
||||
return Object.freeze({
|
||||
allowed: true,
|
||||
authorization: Object.freeze({
|
||||
version: 1,
|
||||
capabilityId: params.capabilityId,
|
||||
agentId: identity?.agentId ?? "",
|
||||
sessionId: identity?.sessionId ?? "",
|
||||
runId: identity?.runId ?? "",
|
||||
deliveryRevision: "",
|
||||
egressRegistryRevision: "",
|
||||
audiences: Object.freeze([]),
|
||||
}),
|
||||
});
|
||||
}
|
||||
const delivery = resolveTrustedDeliveryFacts(params);
|
||||
if (!delivery) {
|
||||
return Object.freeze({ allowed: false, reason: "unavailable" });
|
||||
}
|
||||
const facts = resolveMemoryEgressDeliveryFacts({ ...identity, ...delivery });
|
||||
if (!facts) {
|
||||
return Object.freeze({ allowed: false, reason: "unavailable" });
|
||||
}
|
||||
return authorizationFromLookup({
|
||||
capabilityId: params.capabilityId,
|
||||
identity,
|
||||
facts,
|
||||
lookup: readLatestDurableMemoryRunExposure(identity),
|
||||
});
|
||||
}
|
||||
|
||||
/** Rechecks a queue-time authorization immediately before recipient-visible platform I/O. */
|
||||
export function admitMemoryEgressAtDelivery(params: {
|
||||
authorization: MemoryEgressAuthorization;
|
||||
deliveryContext?: DeliveryContext;
|
||||
messageChannel?: string;
|
||||
agentAccountId?: string;
|
||||
resolveDeliveryFacts?: TrustedMemoryEgressDeliveryFactsSource;
|
||||
}): MemoryEgressAdmission {
|
||||
const { authorization } = params;
|
||||
if (!authorization.agentId || !isMemoryIsolationCutoverAgent(authorization.agentId)) {
|
||||
return Object.freeze({ allowed: true, authorization });
|
||||
}
|
||||
const identity = resolveRunIdentity(authorization);
|
||||
if (!identity) {
|
||||
return Object.freeze({ allowed: false, reason: "unavailable" });
|
||||
}
|
||||
const delivery = resolveTrustedDeliveryFacts(params);
|
||||
if (!delivery) {
|
||||
return Object.freeze({ allowed: false, reason: "unavailable" });
|
||||
}
|
||||
const facts = resolveMemoryEgressDeliveryFacts({ ...identity, ...delivery });
|
||||
if (!facts) {
|
||||
return Object.freeze({ allowed: false, reason: "unavailable" });
|
||||
}
|
||||
const current = authorizationFromLookup({
|
||||
capabilityId: authorization.capabilityId,
|
||||
identity,
|
||||
facts,
|
||||
lookup: readLatestDurableMemoryRunExposure(identity),
|
||||
});
|
||||
if (!current.allowed) {
|
||||
return current;
|
||||
}
|
||||
const expectedExposure = authorization.exposure;
|
||||
const actualExposure = current.authorization.exposure;
|
||||
if (
|
||||
authorization.capabilityId !== current.authorization.capabilityId ||
|
||||
authorization.deliveryRevision !== current.authorization.deliveryRevision ||
|
||||
authorization.egressRegistryRevision !== current.authorization.egressRegistryRevision ||
|
||||
!sameAudiences(authorization.audiences, current.authorization.audiences) ||
|
||||
expectedExposure?.exposureSetId !== actualExposure?.exposureSetId ||
|
||||
expectedExposure?.revisionNumber !== actualExposure?.revisionNumber
|
||||
) {
|
||||
return Object.freeze({ allowed: false, reason: "stale" });
|
||||
}
|
||||
return current;
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
getPreparedPluginRuntimeLoadContext,
|
||||
prepareOwnedPluginLoadContext,
|
||||
} from "./prepared-model-runtime.plugin-context.js";
|
||||
import type { ToolFsPolicy } from "./tool-fs-policy.types.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
resolvePluginTools: vi.fn(),
|
||||
@@ -102,9 +103,9 @@ describe("createOpenClawTools browser plugin integration", () => {
|
||||
});
|
||||
|
||||
it("forwards fsPolicy into plugin tool context", async () => {
|
||||
let capturedContext: { fsPolicy?: { workspaceOnly: boolean } } | undefined;
|
||||
let capturedContext: { fsPolicy?: ToolFsPolicy } | undefined;
|
||||
hoisted.resolvePluginTools.mockImplementation((params: unknown) => {
|
||||
const resolvedParams = params as { context?: { fsPolicy?: { workspaceOnly: boolean } } };
|
||||
const resolvedParams = params as { context?: { fsPolicy?: ToolFsPolicy } };
|
||||
capturedContext = resolvedParams.context;
|
||||
return [
|
||||
{
|
||||
@@ -131,7 +132,7 @@ describe("createOpenClawTools browser plugin integration", () => {
|
||||
allow: ["browser"],
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
fsPolicy: { workspaceOnly: true },
|
||||
fsPolicy: { kind: "workspace", workspaceOnly: true },
|
||||
},
|
||||
resolvedConfig: {
|
||||
plugins: {
|
||||
|
||||
@@ -70,7 +70,7 @@ describe("applyNodesToolWorkspaceGuard", () => {
|
||||
...harness,
|
||||
guardedTool: applyNodesToolWorkspaceGuard(harness.tool, {
|
||||
workspaceDir: WORKSPACE_ROOT,
|
||||
fsPolicy: { workspaceOnly },
|
||||
fsPolicy: { kind: "workspace", workspaceOnly },
|
||||
sandboxRoot: options?.sandboxRoot,
|
||||
sandboxContainerWorkdir: options?.sandboxContainerWorkdir,
|
||||
}),
|
||||
|
||||
@@ -72,11 +72,11 @@ describe("openclaw plugin tool context", () => {
|
||||
const result = resolveOpenClawPluginToolInputs({
|
||||
options: {
|
||||
config: {} as never,
|
||||
fsPolicy: { workspaceOnly: true },
|
||||
fsPolicy: { kind: "workspace", workspaceOnly: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.context.fsPolicy).toStrictEqual({ workspaceOnly: true });
|
||||
expect(result.context.fsPolicy).toStrictEqual({ kind: "workspace", workspaceOnly: true });
|
||||
});
|
||||
|
||||
it("forwards ephemeral sessionId", () => {
|
||||
|
||||
@@ -37,6 +37,8 @@ export type OpenClawPluginToolOptions = {
|
||||
modelId?: string;
|
||||
/** Stable run identifier used to bind host-owned memory reads to this invocation. */
|
||||
runId?: string;
|
||||
/** Prepared at run admission; never re-derived from a plugin tool call. */
|
||||
authorizedMemoryRead?: import("../plugins/tool-types.js").AuthorizedMemoryReadHost;
|
||||
requesterSenderId?: string | null;
|
||||
senderIsOwner?: boolean;
|
||||
conversationReadOrigin?: ConversationReadInvocationOrigin;
|
||||
@@ -94,7 +96,8 @@ export function resolveOpenClawPluginToolInputs(params: {
|
||||
});
|
||||
const memoryReadEnforced = sessionAgentId ? isMemoryIsolationCutoverAgent(sessionAgentId) : false;
|
||||
const authorizedMemoryRead = memoryReadEnforced
|
||||
? createAuthorizedMemoryReadHost({
|
||||
? (options?.authorizedMemoryRead ??
|
||||
createAuthorizedMemoryReadHost({
|
||||
agentId: sessionAgentId,
|
||||
sessionKey: options?.agentSessionKey,
|
||||
sessionId: options?.sessionId,
|
||||
@@ -102,7 +105,7 @@ export function resolveOpenClawPluginToolInputs(params: {
|
||||
deliveryContext,
|
||||
messageChannel: options?.agentChannel,
|
||||
agentAccountId: options?.agentAccountId,
|
||||
})
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { captureEnv, setTestEnvValue } from "../../test-utils/env.js";
|
||||
|
||||
const dockerAvailable =
|
||||
spawnSync("docker", ["info"], { stdio: "ignore", timeout: 3_000 }).status === 0;
|
||||
|
||||
function createConfig(params: {
|
||||
image: string;
|
||||
prefix: string;
|
||||
workspaceRoot: string;
|
||||
}): OpenClawConfig {
|
||||
return {
|
||||
agents: {
|
||||
defaults: {
|
||||
skipBootstrap: true,
|
||||
sandbox: {
|
||||
mode: "all",
|
||||
backend: "docker",
|
||||
scope: "session",
|
||||
workspaceAccess: "none",
|
||||
workspaceRoot: params.workspaceRoot,
|
||||
docker: {
|
||||
image: params.image,
|
||||
containerPrefix: params.prefix,
|
||||
},
|
||||
browser: { enabled: false },
|
||||
prune: { idleHours: 0, maxAgeDays: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test.runIf(dockerAvailable)(
|
||||
"Docker mounts only the authorized memory projection and enforces it read-only",
|
||||
async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-memory-projection-e2e-"));
|
||||
const stateDir = path.join(root, "state");
|
||||
const workspaceDir = path.join(root, "workspace");
|
||||
const artifactRoot = path.join(root, "controlled-artifacts");
|
||||
const image = process.env.OPENCLAW_SANDBOX_TEST_IMAGE ?? "openclaw-sandbox:bookworm-slim";
|
||||
const env = captureEnv(["OPENCLAW_STATE_DIR"]);
|
||||
let disposeProjection: (() => Promise<void>) | undefined;
|
||||
let runtimeId: string | undefined;
|
||||
let stagedSourcePath: string | undefined;
|
||||
|
||||
await fs.mkdir(artifactRoot, { recursive: true });
|
||||
await fs.writeFile(path.join(artifactRoot, "never-mounted.txt"), "host-only");
|
||||
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
|
||||
|
||||
try {
|
||||
const [{ resolveSandboxContext }, { stageAuthorizedVirtualProjectionMountPlan }] =
|
||||
await Promise.all([
|
||||
import("./context.js"),
|
||||
import("./authorized-virtual-projection-staging.js"),
|
||||
]);
|
||||
const sessionId = randomUUID();
|
||||
const sandbox = await resolveSandboxContext({
|
||||
agentId: "memory-projection",
|
||||
config: createConfig({
|
||||
image,
|
||||
prefix: `oc-qa-memory-projection-${process.pid}-`,
|
||||
workspaceRoot: path.join(root, "sandboxes"),
|
||||
}),
|
||||
sessionKey: `agent:memory-projection:qa:${sessionId}`,
|
||||
workspaceDir,
|
||||
prepareAuthorizedVirtualProjectionMountPlan: async ({ agentWorkspaceDir }) => {
|
||||
const staged = await stageAuthorizedVirtualProjectionMountPlan({
|
||||
agentWorkspaceDir,
|
||||
broker: {
|
||||
view: {
|
||||
version: 1,
|
||||
viewId: "view-alice",
|
||||
planId: "plan-alice",
|
||||
contextFingerprint: "context-alice",
|
||||
revision: "revision-alice",
|
||||
roots: [
|
||||
{
|
||||
version: 1,
|
||||
mountHandle: "mount-alice-private",
|
||||
virtualRoot: "private",
|
||||
access: "read",
|
||||
},
|
||||
],
|
||||
files: [
|
||||
{
|
||||
version: 1,
|
||||
mountHandle: "mount-alice-private",
|
||||
virtualPath: "private/allowed.txt",
|
||||
},
|
||||
],
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
},
|
||||
readFile: async (virtualPath) =>
|
||||
virtualPath === "private/allowed.txt" ? "alice-only" : undefined,
|
||||
},
|
||||
});
|
||||
stagedSourcePath = staged.plan.mounts[0]?.sourcePath;
|
||||
return staged;
|
||||
},
|
||||
});
|
||||
expect(sandbox).not.toBeNull();
|
||||
if (!sandbox?.backend) {
|
||||
throw new Error("expected a provisioned Docker sandbox backend");
|
||||
}
|
||||
runtimeId = sandbox.runtimeId;
|
||||
disposeProjection = sandbox.disposeAuthorizedVirtualProjectionMountPlan;
|
||||
expect(stagedSourcePath).toBeDefined();
|
||||
await expect(fs.readFile(path.join(stagedSourcePath!, "allowed.txt"), "utf8")).resolves.toBe(
|
||||
"alice-only",
|
||||
);
|
||||
const canonicalStagedSourcePath = await fs.realpath(stagedSourcePath!);
|
||||
|
||||
const { execDocker } = await import("./docker.js");
|
||||
const inspected = await execDocker(["inspect", runtimeId]);
|
||||
const mounts = JSON.parse(inspected.stdout.toString("utf8")) as Array<{
|
||||
Mounts?: Array<{ Destination?: string; Source?: string; RW?: boolean }>;
|
||||
}>;
|
||||
expect(mounts[0]?.Mounts).toContainEqual(
|
||||
expect.objectContaining({
|
||||
Destination: "/memory/private",
|
||||
Source: canonicalStagedSourcePath,
|
||||
RW: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await sandbox.backend.runShellCommand({
|
||||
script: [
|
||||
'test "$(cat /memory/private/allowed.txt)" = alice-only',
|
||||
"test ! -e /memory/channel",
|
||||
"test ! -e /memory/shared",
|
||||
"test ! -e /memory/projections",
|
||||
'test ! -e "$1"',
|
||||
"test ! -e /workspace/allowed.txt",
|
||||
"! printf denied > /memory/private/write-attempt.txt",
|
||||
].join(" && "),
|
||||
args: [artifactRoot],
|
||||
});
|
||||
expect(result.code, result.stderr.toString()).toBe(0);
|
||||
await expect(
|
||||
fs.access(path.join(artifactRoot, "never-mounted.txt")),
|
||||
).resolves.toBeUndefined();
|
||||
} finally {
|
||||
if (runtimeId) {
|
||||
const [{ removeSandboxContainer }, { execDocker }] = await Promise.all([
|
||||
import("./manage.js"),
|
||||
import("./docker.js"),
|
||||
]);
|
||||
await removeSandboxContainer(runtimeId);
|
||||
await execDocker(["rm", "-f", runtimeId], { allowFailure: true });
|
||||
}
|
||||
await disposeProjection?.();
|
||||
env.restore();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
@@ -0,0 +1,201 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
appendAuthorizedVirtualProjectionMountArgs,
|
||||
assertNoBindsCollideWithAuthorizedVirtualProjectionMounts,
|
||||
formatAuthorizedVirtualProjectionMountHashState,
|
||||
resolveAuthorizedVirtualProjectionMountPlan,
|
||||
resolveAuthorizedVirtualProjectionSourcePath,
|
||||
resolveAuthorizedVirtualProjectionRoot,
|
||||
type AuthorizedVirtualProjectionMountPlan,
|
||||
} from "./authorized-virtual-projection-mounts.js";
|
||||
|
||||
const tmpDirs: string[] = [];
|
||||
|
||||
function makeTempDir(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-authorized-projections-"));
|
||||
tmpDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function preparePlan(params?: {
|
||||
agentWorkspaceDir?: string;
|
||||
revision?: string;
|
||||
roots?: readonly string[];
|
||||
}): { agentWorkspaceDir: string; plan: AuthorizedVirtualProjectionMountPlan } {
|
||||
const agentWorkspaceDir = params?.agentWorkspaceDir ?? makeTempDir();
|
||||
const roots = params?.roots ?? ["private", "projections-1"];
|
||||
fs.mkdirSync(resolveAuthorizedVirtualProjectionRoot(agentWorkspaceDir), { recursive: true });
|
||||
const mounts = roots.map((virtualRoot, index) => {
|
||||
const mountHandle = `opaque-mount-${index + 1}`;
|
||||
const sourcePath = resolveAuthorizedVirtualProjectionSourcePath({
|
||||
agentWorkspaceDir,
|
||||
viewId: "opaque-view",
|
||||
revision: params?.revision ?? "revision-1",
|
||||
stagingId: "stage-1",
|
||||
mountHandle,
|
||||
});
|
||||
fs.mkdirSync(sourcePath, { recursive: true });
|
||||
return { mountHandle, virtualRoot, sourcePath, access: "read" as const };
|
||||
});
|
||||
return {
|
||||
agentWorkspaceDir,
|
||||
plan: {
|
||||
version: 1,
|
||||
viewId: "opaque-view",
|
||||
revision: params?.revision ?? "revision-1",
|
||||
stagingId: "stage-1",
|
||||
mounts,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tmpDirs.splice(0)) {
|
||||
fs.rmSync(resolveAuthorizedVirtualProjectionRoot(dir), { recursive: true, force: true });
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("authorized virtual projection mounts", () => {
|
||||
it("sorts opaque roots and emits physical read-only Docker mounts", () => {
|
||||
const { agentWorkspaceDir, plan } = preparePlan({ roots: ["projections-1", "private"] });
|
||||
const mounts = resolveAuthorizedVirtualProjectionMountPlan({ agentWorkspaceDir, plan });
|
||||
|
||||
expect(mounts.map((mount) => mount.containerPath)).toEqual([
|
||||
"/memory/private",
|
||||
"/memory/projections-1",
|
||||
]);
|
||||
const args: string[] = [];
|
||||
appendAuthorizedVirtualProjectionMountArgs({ args, mounts });
|
||||
expect(args).toEqual([
|
||||
"-v",
|
||||
`${mounts[0]!.sourcePath}:/memory/private:ro,z`,
|
||||
"-v",
|
||||
`${mounts[1]!.sourcePath}:/memory/projections-1:ro,z`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses raw artifact paths instead of treating them as core projections", () => {
|
||||
const { agentWorkspaceDir, plan } = preparePlan();
|
||||
const artifactDir = makeTempDir();
|
||||
expect(() =>
|
||||
resolveAuthorizedVirtualProjectionMountPlan({
|
||||
agentWorkspaceDir,
|
||||
plan: {
|
||||
...plan,
|
||||
mounts: [{ ...plan.mounts[0]!, sourcePath: artifactDir }],
|
||||
},
|
||||
}),
|
||||
).toThrow(/not the core-issued projection path/);
|
||||
});
|
||||
|
||||
it("fails closed when the exact core-issued projection source is absent", () => {
|
||||
const { agentWorkspaceDir, plan } = preparePlan({ roots: ["private"] });
|
||||
fs.rmSync(plan.mounts[0]!.sourcePath, { recursive: true });
|
||||
expect(() => resolveAuthorizedVirtualProjectionMountPlan({ agentWorkspaceDir, plan })).toThrow(
|
||||
/source is unavailable/,
|
||||
);
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"refuses core projection sources that escape through symlinks",
|
||||
() => {
|
||||
const { agentWorkspaceDir, plan } = preparePlan({ roots: ["private"] });
|
||||
const outside = makeTempDir();
|
||||
const sourcePath = plan.mounts[0]!.sourcePath;
|
||||
fs.rmSync(sourcePath, { recursive: true });
|
||||
fs.symlinkSync(outside, sourcePath, "dir");
|
||||
|
||||
expect(() =>
|
||||
resolveAuthorizedVirtualProjectionMountPlan({ agentWorkspaceDir, plan }),
|
||||
).toThrow(/must be a real directory/);
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"refuses a core projection file hard-linked to host content",
|
||||
() => {
|
||||
const { agentWorkspaceDir, plan } = preparePlan({ roots: ["private"] });
|
||||
const hostContent = path.join(makeTempDir(), "host-secret.txt");
|
||||
fs.writeFileSync(hostContent, "host-only");
|
||||
fs.linkSync(hostContent, path.join(plan.mounts[0]!.sourcePath, "linked.txt"));
|
||||
|
||||
expect(() =>
|
||||
resolveAuthorizedVirtualProjectionMountPlan({ agentWorkspaceDir, plan }),
|
||||
).toThrow(/contains an unsafe file/);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects dangerous, noncanonical, and colliding virtual targets", () => {
|
||||
const { agentWorkspaceDir, plan } = preparePlan({ roots: ["private"] });
|
||||
const sourcePath = plan.mounts[0]!.sourcePath;
|
||||
for (const virtualRoot of [
|
||||
".",
|
||||
"../private",
|
||||
"private/child",
|
||||
"PRIVATE",
|
||||
"privaté",
|
||||
"private\u0301",
|
||||
] as const) {
|
||||
expect(() =>
|
||||
resolveAuthorizedVirtualProjectionMountPlan({
|
||||
agentWorkspaceDir,
|
||||
plan: { ...plan, mounts: [{ ...plan.mounts[0]!, virtualRoot }] },
|
||||
}),
|
||||
).toThrow(/virtual root is invalid/);
|
||||
}
|
||||
expect(() =>
|
||||
resolveAuthorizedVirtualProjectionMountPlan({
|
||||
agentWorkspaceDir,
|
||||
plan: {
|
||||
...plan,
|
||||
mounts: [
|
||||
{ ...plan.mounts[0]! },
|
||||
{ mountHandle: "opaque-mount-2", virtualRoot: "private", sourcePath, access: "read" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toThrow(/must not collide/);
|
||||
});
|
||||
|
||||
it("rejects custom binds that shadow an authorized root or its parent", () => {
|
||||
const { agentWorkspaceDir, plan } = preparePlan({ roots: ["private"] });
|
||||
const mounts = resolveAuthorizedVirtualProjectionMountPlan({ agentWorkspaceDir, plan });
|
||||
expect(() =>
|
||||
assertNoBindsCollideWithAuthorizedVirtualProjectionMounts({
|
||||
binds: ["/tmp/override:/memory:ro"],
|
||||
mounts,
|
||||
}),
|
||||
).toThrow(/conflicts with an authorized virtual projection target/);
|
||||
expect(() =>
|
||||
assertNoBindsCollideWithAuthorizedVirtualProjectionMounts({
|
||||
binds: ["/tmp/override:/memory/private/nested:ro"],
|
||||
mounts,
|
||||
}),
|
||||
).toThrow(/conflicts with an authorized virtual projection target/);
|
||||
});
|
||||
|
||||
it("makes sorted view, revision, target, and access identity hashable", () => {
|
||||
const { agentWorkspaceDir, plan } = preparePlan({ roots: ["projections-1", "private"] });
|
||||
const mounts = resolveAuthorizedVirtualProjectionMountPlan({ agentWorkspaceDir, plan });
|
||||
const reversed = resolveAuthorizedVirtualProjectionMountPlan({
|
||||
agentWorkspaceDir,
|
||||
plan: { ...plan, mounts: [...plan.mounts].reverse() },
|
||||
});
|
||||
expect(formatAuthorizedVirtualProjectionMountHashState(plan, mounts)).toEqual(
|
||||
formatAuthorizedVirtualProjectionMountHashState(plan, reversed),
|
||||
);
|
||||
expect(
|
||||
formatAuthorizedVirtualProjectionMountHashState({ ...plan, revision: "revision-2" }, mounts),
|
||||
).not.toEqual(formatAuthorizedVirtualProjectionMountHashState(plan, mounts));
|
||||
expect(
|
||||
formatAuthorizedVirtualProjectionMountHashState(plan, [
|
||||
{ ...mounts[0]!, mountHandle: "opaque-mount-rebound" },
|
||||
...mounts.slice(1),
|
||||
]),
|
||||
).not.toEqual(formatAuthorizedVirtualProjectionMountHashState(plan, mounts));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Authorized virtual projection mounts for sandboxed runs.
|
||||
*
|
||||
* The selected memory plugin names opaque handles and virtual roots only. Core
|
||||
* stages projection bytes in private sandbox state before this plan reaches a
|
||||
* container, so neither artifact storage nor a broadly mounted workspace can
|
||||
* become a projection source.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { isPathInside } from "../../infra/path-guards.js";
|
||||
import { splitSandboxBindSpec } from "./bind-spec.js";
|
||||
import { SANDBOX_STATE_DIR } from "./constants.js";
|
||||
import { resolveSandboxHostPathViaExistingAncestor } from "./host-paths.js";
|
||||
import { normalizeContainerPathCore } from "./path-utils.js";
|
||||
|
||||
const AUTHORIZED_PROJECTION_DIRECTORY = ["authorized-memory-projections"] as const;
|
||||
const AUTHORIZED_PROJECTION_CONTAINER_ROOT = "/memory";
|
||||
const VIRTUAL_ROOT_PATTERN = /^[a-z][a-z0-9-]{0,63}$/u;
|
||||
|
||||
/** A core-staged, plugin-authorized read-only source for one virtual root. */
|
||||
export type AuthorizedVirtualProjectionMount = Readonly<{
|
||||
mountHandle: string;
|
||||
virtualRoot: string;
|
||||
sourcePath: string;
|
||||
access: "read";
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Per-run authorization supplied by the memory broker, never persisted in the
|
||||
* sandbox config. `sourcePath` must be a core projection directory, not a
|
||||
* plugin artifact path.
|
||||
*/
|
||||
export type AuthorizedVirtualProjectionMountPlan = Readonly<{
|
||||
version: 1;
|
||||
viewId: string;
|
||||
revision: string;
|
||||
/** Core-issued per-staging lease; forces hot bind recreation after disposal. */
|
||||
stagingId: string;
|
||||
mounts: readonly AuthorizedVirtualProjectionMount[];
|
||||
}>;
|
||||
|
||||
export type ResolvedAuthorizedVirtualProjectionMount = Readonly<
|
||||
AuthorizedVirtualProjectionMount & {
|
||||
containerPath: string;
|
||||
}
|
||||
>;
|
||||
|
||||
function requireOpaqueId(value: string, label: string): string {
|
||||
if (!value.trim() || value !== value.trim() || value.length > 512) {
|
||||
throw new Error(`Sandbox authorized projection ${label} is invalid.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeContainerPath(value: string): string {
|
||||
return normalizeContainerPathCore(value).replace(/\/+$/, "") || "/";
|
||||
}
|
||||
|
||||
function pathsCollide(left: string, right: string): boolean {
|
||||
const leftKey = normalizeContainerPath(left).toLocaleLowerCase("en-US");
|
||||
const rightKey = normalizeContainerPath(right).toLocaleLowerCase("en-US");
|
||||
return (
|
||||
leftKey === rightKey || leftKey.startsWith(`${rightKey}/`) || rightKey.startsWith(`${leftKey}/`)
|
||||
);
|
||||
}
|
||||
|
||||
function canonicalCoreProjectionRoot(agentWorkspaceDir: string): {
|
||||
lexical: string;
|
||||
canonical: string;
|
||||
} {
|
||||
const lexical = resolveAuthorizedVirtualProjectionRoot(agentWorkspaceDir);
|
||||
try {
|
||||
// A core projection directory must be a real directory. A symlink here
|
||||
// could turn an opaque projection mount into a plugin artifact mount.
|
||||
if (fs.lstatSync(lexical).isSymbolicLink()) {
|
||||
throw new Error("Sandbox authorized projection root must not be a symlink.");
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("must not be a symlink")) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error("Sandbox authorized projection root is unavailable.");
|
||||
}
|
||||
return { lexical, canonical: resolveSandboxHostPathViaExistingAncestor(lexical) };
|
||||
}
|
||||
|
||||
function assertCoreProjectionSource(params: {
|
||||
agentWorkspaceDir: string;
|
||||
sourcePath: string;
|
||||
}): string {
|
||||
const root = canonicalCoreProjectionRoot(params.agentWorkspaceDir);
|
||||
const lexicalSource = path.resolve(params.sourcePath);
|
||||
if (!isPathInside(root.lexical, lexicalSource) || lexicalSource === root.lexical) {
|
||||
throw new Error("Sandbox authorized projection source is outside the core projection root.");
|
||||
}
|
||||
let sourceStat: fs.Stats;
|
||||
try {
|
||||
sourceStat = fs.lstatSync(lexicalSource);
|
||||
} catch {
|
||||
throw new Error("Sandbox authorized projection source is unavailable.");
|
||||
}
|
||||
if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink()) {
|
||||
throw new Error("Sandbox authorized projection source must be a real directory.");
|
||||
}
|
||||
const canonicalSource = resolveSandboxHostPathViaExistingAncestor(lexicalSource);
|
||||
if (!isPathInside(root.canonical, canonicalSource) || canonicalSource === root.canonical) {
|
||||
throw new Error("Sandbox authorized projection source escapes the core projection root.");
|
||||
}
|
||||
return canonicalSource;
|
||||
}
|
||||
|
||||
function assertCoreProjectionFiles(sourcePath: string): void {
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(sourcePath, { withFileTypes: true });
|
||||
} catch {
|
||||
throw new Error("Sandbox authorized projection source is unavailable.");
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const filePath = path.join(sourcePath, entry.name);
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(filePath);
|
||||
} catch {
|
||||
throw new Error("Sandbox authorized projection source is unavailable.");
|
||||
}
|
||||
// Core creates a flat set of new files for every view. A link or nested
|
||||
// entry could substitute host bytes between staging and the container bind.
|
||||
if (!entry.isFile() || entry.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1) {
|
||||
throw new Error("Sandbox authorized projection source contains an unsafe file.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Core-owned root where a broker may materialize projection bytes for one agent.
|
||||
*
|
||||
* Do not put these bytes below `agentWorkspaceDir`: rw and ro sandboxes bind
|
||||
* that directory and would expose every staged projection outside `/memory`.
|
||||
*/
|
||||
export function resolveAuthorizedVirtualProjectionRoot(agentWorkspaceDir: string): string {
|
||||
const agentWorkspaceKey = createHash("sha256")
|
||||
.update(path.resolve(agentWorkspaceDir))
|
||||
.digest("hex");
|
||||
return path.join(SANDBOX_STATE_DIR, ...AUTHORIZED_PROJECTION_DIRECTORY, agentWorkspaceKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable private directory name for one broker-staged projection revision.
|
||||
* Opaque IDs are hashed before becoming a host path, so neither plugin data
|
||||
* nor a view id can influence traversal or a model-visible location. Revision
|
||||
* identity is part of the path: whole-directory mounts must not inherit files
|
||||
* from an older manifest that reused the same view and mount handles.
|
||||
*/
|
||||
export function resolveAuthorizedVirtualProjectionSourcePath(params: {
|
||||
agentWorkspaceDir: string;
|
||||
viewId: string;
|
||||
revision: string;
|
||||
stagingId: string;
|
||||
mountHandle: string;
|
||||
}): string {
|
||||
const digest = createHash("sha256")
|
||||
.update(
|
||||
`${requireOpaqueId(params.viewId, "view id")}\0${requireOpaqueId(params.revision, "revision")}\0${requireOpaqueId(params.stagingId, "staging id")}\0${requireOpaqueId(params.mountHandle, "mount handle")}`,
|
||||
)
|
||||
.digest("hex");
|
||||
return path.join(
|
||||
resolveAuthorizedVirtualProjectionRoot(params.agentWorkspaceDir),
|
||||
`p1_${digest}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and deterministically orders a per-run projection plan. This is
|
||||
* the only path that turns a virtual root into a physical container target.
|
||||
*/
|
||||
export function resolveAuthorizedVirtualProjectionMountPlan(params: {
|
||||
agentWorkspaceDir: string;
|
||||
plan?: AuthorizedVirtualProjectionMountPlan;
|
||||
}): readonly ResolvedAuthorizedVirtualProjectionMount[] {
|
||||
const plan = params.plan;
|
||||
if (!plan) {
|
||||
return [];
|
||||
}
|
||||
if (plan.version !== 1) {
|
||||
throw new Error("Sandbox authorized projection plan version is unsupported.");
|
||||
}
|
||||
requireOpaqueId(plan.viewId, "view id");
|
||||
requireOpaqueId(plan.revision, "revision");
|
||||
requireOpaqueId(plan.stagingId, "staging id");
|
||||
if (plan.mounts.length === 0) {
|
||||
throw new Error("Sandbox authorized projection plan must contain at least one mount.");
|
||||
}
|
||||
|
||||
const handles = new Set<string>();
|
||||
const targets = new Set<string>();
|
||||
const resolved = plan.mounts.map((mount) => {
|
||||
const mountHandle = requireOpaqueId(mount.mountHandle, "mount handle");
|
||||
if (mount.access !== "read") {
|
||||
throw new Error("Sandbox authorized projection mounts must be read-only.");
|
||||
}
|
||||
if (
|
||||
!VIRTUAL_ROOT_PATTERN.test(mount.virtualRoot) ||
|
||||
mount.virtualRoot !== mount.virtualRoot.normalize("NFC")
|
||||
) {
|
||||
throw new Error("Sandbox authorized projection virtual root is invalid.");
|
||||
}
|
||||
const virtualRoot = mount.virtualRoot;
|
||||
const containerPath = `${AUTHORIZED_PROJECTION_CONTAINER_ROOT}/${virtualRoot}`;
|
||||
const targetKey = containerPath.toLocaleLowerCase("en-US");
|
||||
if (handles.has(mountHandle) || targets.has(targetKey)) {
|
||||
throw new Error("Sandbox authorized projection mounts must not collide.");
|
||||
}
|
||||
handles.add(mountHandle);
|
||||
targets.add(targetKey);
|
||||
const expectedSourcePath = resolveAuthorizedVirtualProjectionSourcePath({
|
||||
agentWorkspaceDir: params.agentWorkspaceDir,
|
||||
viewId: plan.viewId,
|
||||
revision: plan.revision,
|
||||
stagingId: plan.stagingId,
|
||||
mountHandle,
|
||||
});
|
||||
if (path.resolve(mount.sourcePath) !== expectedSourcePath) {
|
||||
throw new Error(
|
||||
"Sandbox authorized projection source is not the core-issued projection path.",
|
||||
);
|
||||
}
|
||||
const sourcePath = assertCoreProjectionSource({
|
||||
agentWorkspaceDir: params.agentWorkspaceDir,
|
||||
sourcePath: mount.sourcePath,
|
||||
});
|
||||
assertCoreProjectionFiles(sourcePath);
|
||||
return Object.freeze({
|
||||
mountHandle,
|
||||
virtualRoot,
|
||||
sourcePath,
|
||||
access: "read" as const,
|
||||
containerPath,
|
||||
});
|
||||
});
|
||||
return Object.freeze(
|
||||
resolved.toSorted((left, right) => {
|
||||
const target = left.containerPath.localeCompare(right.containerPath);
|
||||
return target !== 0 ? target : left.mountHandle.localeCompare(right.mountHandle);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Stable hash state; reordered equivalent plans do not recreate a container. */
|
||||
export function formatAuthorizedVirtualProjectionMountHashState(
|
||||
plan: AuthorizedVirtualProjectionMountPlan | undefined,
|
||||
mounts: readonly ResolvedAuthorizedVirtualProjectionMount[],
|
||||
): readonly string[] {
|
||||
if (!plan) {
|
||||
return [];
|
||||
}
|
||||
return Object.freeze([
|
||||
`view:${plan.viewId}`,
|
||||
`revision:${plan.revision}`,
|
||||
`staging:${plan.stagingId}`,
|
||||
...mounts.map(
|
||||
(mount) => `${mount.containerPath}:${mount.access}:${mount.mountHandle}:${mount.sourcePath}`,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Docker bind specs are deliberately always physically read-only and SELinux-shared. */
|
||||
export function appendAuthorizedVirtualProjectionMountArgs(params: {
|
||||
args: string[];
|
||||
mounts: readonly ResolvedAuthorizedVirtualProjectionMount[];
|
||||
}): void {
|
||||
for (const mount of params.mounts) {
|
||||
params.args.push("-v", `${mount.sourcePath}:${mount.containerPath}:ro,z`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reject user-defined binds that could shadow, expose, or nest an authorized virtual root. */
|
||||
export function assertNoBindsCollideWithAuthorizedVirtualProjectionMounts(params: {
|
||||
binds: readonly string[] | undefined;
|
||||
mounts: readonly ResolvedAuthorizedVirtualProjectionMount[];
|
||||
}): void {
|
||||
if (!params.binds?.length || params.mounts.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (const bind of params.binds) {
|
||||
const parsed = splitSandboxBindSpec(bind);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
const target = normalizeContainerPath(parsed.container);
|
||||
if (params.mounts.some((mount) => pathsCollide(target, mount.containerPath))) {
|
||||
throw new Error(
|
||||
`Sandbox bind mount "${bind}" conflicts with an authorized virtual projection target.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
resolveAuthorizedVirtualProjectionMountPlan,
|
||||
resolveAuthorizedVirtualProjectionRoot,
|
||||
resolveAuthorizedVirtualProjectionSourcePath,
|
||||
} from "./authorized-virtual-projection-mounts.js";
|
||||
import { stageAuthorizedVirtualProjectionMountPlan } from "./authorized-virtual-projection-staging.js";
|
||||
|
||||
const tmpDirs: string[] = [];
|
||||
|
||||
function makeTempDir(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-authorized-projection-stage-"));
|
||||
tmpDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function createBroker(readFile = vi.fn(async (virtualPath: string) => `contents:${virtualPath}`)) {
|
||||
return {
|
||||
view: {
|
||||
version: 1 as const,
|
||||
viewId: "opaque-view",
|
||||
planId: "opaque-plan",
|
||||
contextFingerprint: "opaque-context",
|
||||
revision: "opaque-revision",
|
||||
roots: [
|
||||
{
|
||||
version: 1 as const,
|
||||
mountHandle: "opaque-a",
|
||||
virtualRoot: "private",
|
||||
access: "read" as const,
|
||||
},
|
||||
{
|
||||
version: 1 as const,
|
||||
mountHandle: "opaque-b",
|
||||
virtualRoot: "shared",
|
||||
access: "read" as const,
|
||||
},
|
||||
],
|
||||
files: [
|
||||
{ version: 1 as const, mountHandle: "opaque-b", virtualPath: "shared/2.md" },
|
||||
{ version: 1 as const, mountHandle: "opaque-a", virtualPath: "private/1.md" },
|
||||
],
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
},
|
||||
readFile,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
for (const dir of tmpDirs.splice(0)) {
|
||||
fs.rmSync(resolveAuthorizedVirtualProjectionRoot(dir), { recursive: true, force: true });
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("authorized virtual projection staging", () => {
|
||||
it("stages only broker-returned logical files and emits a core-issued mount plan", async () => {
|
||||
const agentWorkspaceDir = makeTempDir();
|
||||
const broker = createBroker();
|
||||
const staged = await stageAuthorizedVirtualProjectionMountPlan({ agentWorkspaceDir, broker });
|
||||
|
||||
expect(staged.plan.mounts[0]!.sourcePath.startsWith(`${agentWorkspaceDir}${path.sep}`)).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
expect(broker.readFile).toHaveBeenNthCalledWith(1, "private/1.md");
|
||||
expect(broker.readFile).toHaveBeenNthCalledWith(2, "shared/2.md");
|
||||
expect(staged.plan.mounts).toEqual([
|
||||
expect.objectContaining({ mountHandle: "opaque-a", virtualRoot: "private", access: "read" }),
|
||||
expect.objectContaining({ mountHandle: "opaque-b", virtualRoot: "shared", access: "read" }),
|
||||
]);
|
||||
for (const mount of staged.plan.mounts) {
|
||||
expect(mount.sourcePath).toBe(
|
||||
resolveAuthorizedVirtualProjectionSourcePath({
|
||||
agentWorkspaceDir,
|
||||
viewId: broker.view.viewId,
|
||||
revision: broker.view.revision,
|
||||
stagingId: staged.plan.stagingId,
|
||||
mountHandle: mount.mountHandle,
|
||||
}),
|
||||
);
|
||||
}
|
||||
expect(fs.readFileSync(path.join(staged.plan.mounts[0]!.sourcePath, "1.md"), "utf8")).toBe(
|
||||
"contents:private/1.md",
|
||||
);
|
||||
expect(
|
||||
resolveAuthorizedVirtualProjectionMountPlan({ agentWorkspaceDir, plan: staged.plan }),
|
||||
).toHaveLength(2);
|
||||
|
||||
await staged.dispose();
|
||||
expect(fs.existsSync(staged.plan.mounts[0]!.sourcePath)).toBe(false);
|
||||
});
|
||||
|
||||
it("fails closed and removes partial staging when the broker withholds a file", async () => {
|
||||
const agentWorkspaceDir = makeTempDir();
|
||||
const broker = createBroker(async (virtualPath) =>
|
||||
virtualPath === "shared/2.md" ? undefined : "private contents",
|
||||
);
|
||||
await expect(
|
||||
stageAuthorizedVirtualProjectionMountPlan({ agentWorkspaceDir, broker }),
|
||||
).rejects.toThrow(/content is unavailable/);
|
||||
const privateSource = resolveAuthorizedVirtualProjectionSourcePath({
|
||||
agentWorkspaceDir,
|
||||
viewId: broker.view.viewId,
|
||||
revision: broker.view.revision,
|
||||
stagingId: "unavailable-stage",
|
||||
mountHandle: "opaque-a",
|
||||
});
|
||||
expect(fs.existsSync(privateSource)).toBe(false);
|
||||
});
|
||||
|
||||
it("never carries undeclared files across repeated or revised whole-directory staging", async () => {
|
||||
const agentWorkspaceDir = makeTempDir();
|
||||
const first = createBroker(async (virtualPath) => `first:${virtualPath}`);
|
||||
first.view = {
|
||||
...first.view,
|
||||
roots: [first.view.roots[0]!],
|
||||
files: [{ version: 1 as const, mountHandle: "opaque-a", virtualPath: "private/old.md" }],
|
||||
};
|
||||
const firstStage = await stageAuthorizedVirtualProjectionMountPlan({
|
||||
agentWorkspaceDir,
|
||||
broker: first,
|
||||
});
|
||||
const firstSource = firstStage.plan.mounts[0]!.sourcePath;
|
||||
expect(fs.existsSync(path.join(firstSource, "old.md"))).toBe(true);
|
||||
|
||||
const sameRevision = createBroker(async (virtualPath) => `same:${virtualPath}`);
|
||||
sameRevision.view = {
|
||||
...sameRevision.view,
|
||||
roots: [sameRevision.view.roots[0]!],
|
||||
files: [{ version: 1 as const, mountHandle: "opaque-a", virtualPath: "private/current.md" }],
|
||||
};
|
||||
const sameRevisionStage = await stageAuthorizedVirtualProjectionMountPlan({
|
||||
agentWorkspaceDir,
|
||||
broker: sameRevision,
|
||||
});
|
||||
const sameRevisionSource = sameRevisionStage.plan.mounts[0]!.sourcePath;
|
||||
expect(sameRevisionSource).not.toBe(firstSource);
|
||||
expect(fs.readdirSync(sameRevisionSource)).toEqual(["current.md"]);
|
||||
expect(fs.readFileSync(path.join(sameRevisionSource, "current.md"), "utf8")).toBe(
|
||||
"same:private/current.md",
|
||||
);
|
||||
|
||||
const revised = createBroker(async (virtualPath) => `revised:${virtualPath}`);
|
||||
revised.view = {
|
||||
...revised.view,
|
||||
revision: "opaque-revision-2",
|
||||
roots: [revised.view.roots[0]!],
|
||||
files: [{ version: 1 as const, mountHandle: "opaque-a", virtualPath: "private/new.md" }],
|
||||
};
|
||||
const revisedStage = await stageAuthorizedVirtualProjectionMountPlan({
|
||||
agentWorkspaceDir,
|
||||
broker: revised,
|
||||
});
|
||||
const revisedSource = revisedStage.plan.mounts[0]!.sourcePath;
|
||||
expect(revisedSource).not.toBe(firstSource);
|
||||
expect(fs.readdirSync(revisedSource)).toEqual(["new.md"]);
|
||||
expect(fs.readFileSync(path.join(revisedSource, "new.md"), "utf8")).toBe(
|
||||
"revised:private/new.md",
|
||||
);
|
||||
|
||||
await Promise.all([firstStage.dispose(), sameRevisionStage.dispose(), revisedStage.dispose()]);
|
||||
});
|
||||
|
||||
it("rejects a manifest path that does not stay beneath its declared virtual root", async () => {
|
||||
const agentWorkspaceDir = makeTempDir();
|
||||
const broker = createBroker();
|
||||
const invalidBroker = {
|
||||
...broker,
|
||||
view: {
|
||||
...broker.view,
|
||||
files: [
|
||||
{ version: 1 as const, mountHandle: "opaque-b", virtualPath: "private/2.md" },
|
||||
broker.view.files[1]!,
|
||||
],
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
stageAuthorizedVirtualProjectionMountPlan({ agentWorkspaceDir, broker: invalidBroker }),
|
||||
).rejects.toThrow(/manifest path is invalid/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
/**
|
||||
* Core-owned staging for one authorized memory virtual view.
|
||||
*
|
||||
* The broker provides only opaque handles and logical virtual paths. Core
|
||||
* materializes their bytes in private sandbox state, then hands Docker a
|
||||
* physical, read-only mount plan. Plugin artifact roots and normal workspace
|
||||
* mounts never cross here.
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { AuthorizedMemoryVirtualView } from "../../../packages/memory-host-sdk/src/host/authorization.js";
|
||||
import {
|
||||
resolveAuthorizedVirtualProjectionMountPlan,
|
||||
resolveAuthorizedVirtualProjectionRoot,
|
||||
resolveAuthorizedVirtualProjectionSourcePath,
|
||||
type AuthorizedVirtualProjectionMountPlan,
|
||||
} from "./authorized-virtual-projection-mounts.js";
|
||||
|
||||
export type AuthorizedVirtualProjectionBroker = Readonly<{
|
||||
view: AuthorizedMemoryVirtualView;
|
||||
readFile: (virtualPath: string) => Promise<string | undefined>;
|
||||
}>;
|
||||
|
||||
export type StagedAuthorizedVirtualProjectionMountPlan = Readonly<{
|
||||
plan: AuthorizedVirtualProjectionMountPlan;
|
||||
dispose: () => Promise<void>;
|
||||
}>;
|
||||
|
||||
function assertVirtualPathForRoot(params: { virtualPath: string; virtualRoot: string }): string {
|
||||
const normalized = params.virtualPath.normalize("NFC");
|
||||
const parts = normalized.split("/");
|
||||
if (
|
||||
normalized !== params.virtualPath ||
|
||||
parts.length !== 2 ||
|
||||
parts[0] !== params.virtualRoot ||
|
||||
!parts[1] ||
|
||||
parts[1] === "." ||
|
||||
parts[1] === ".." ||
|
||||
parts[1]!.includes("\\")
|
||||
) {
|
||||
throw new Error("Sandbox authorized projection manifest path is invalid.");
|
||||
}
|
||||
return parts[1]!;
|
||||
}
|
||||
|
||||
async function ensureRealProjectionRoot(agentWorkspaceDir: string): Promise<void> {
|
||||
const root = resolveAuthorizedVirtualProjectionRoot(agentWorkspaceDir);
|
||||
await fs.mkdir(root, { recursive: true, mode: 0o700 });
|
||||
const stat = await fs.lstat(root);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
||||
throw new Error("Sandbox authorized projection root must be a real directory.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stages all manifest files before returning a mount plan. `readFile` records
|
||||
* durable exposure before resolving its bytes, so no staged file can race a
|
||||
* missing exposure receipt. Any unavailable file removes this partial view.
|
||||
*/
|
||||
export async function stageAuthorizedVirtualProjectionMountPlan(params: {
|
||||
agentWorkspaceDir: string;
|
||||
broker: AuthorizedVirtualProjectionBroker;
|
||||
}): Promise<StagedAuthorizedVirtualProjectionMountPlan> {
|
||||
const { view } = params.broker;
|
||||
const stagingId = `mst1_${randomUUID()}`;
|
||||
await ensureRealProjectionRoot(params.agentWorkspaceDir);
|
||||
|
||||
const stagedPaths: string[] = [];
|
||||
try {
|
||||
const mounts = [];
|
||||
for (const root of view.roots) {
|
||||
if (root.access !== "read") {
|
||||
throw new Error("Sandbox authorized projection mounts must be read-only.");
|
||||
}
|
||||
const sourcePath = resolveAuthorizedVirtualProjectionSourcePath({
|
||||
agentWorkspaceDir: params.agentWorkspaceDir,
|
||||
viewId: view.viewId,
|
||||
revision: view.revision,
|
||||
stagingId,
|
||||
mountHandle: root.mountHandle,
|
||||
});
|
||||
// A repeated exact-revision staging attempt must not retain a file that
|
||||
// no longer appears in the broker manifest before its directory is mounted.
|
||||
await fs.rm(sourcePath, { recursive: true, force: true });
|
||||
await fs.mkdir(sourcePath, { mode: 0o700 });
|
||||
stagedPaths.push(sourcePath);
|
||||
const files = view.files
|
||||
.filter((file) => file.mountHandle === root.mountHandle)
|
||||
.toSorted((left, right) => left.virtualPath.localeCompare(right.virtualPath));
|
||||
for (const file of files) {
|
||||
const filename = assertVirtualPathForRoot({
|
||||
virtualPath: file.virtualPath,
|
||||
virtualRoot: root.virtualRoot,
|
||||
});
|
||||
const content = await params.broker.readFile(file.virtualPath);
|
||||
if (content === undefined) {
|
||||
throw new Error("Sandbox authorized projection content is unavailable.");
|
||||
}
|
||||
const temporaryPath = path.join(sourcePath, `.${filename}.tmp`);
|
||||
await fs.writeFile(temporaryPath, content, { encoding: "utf8", mode: 0o600 });
|
||||
await fs.rename(temporaryPath, path.join(sourcePath, filename));
|
||||
}
|
||||
mounts.push(
|
||||
Object.freeze({
|
||||
mountHandle: root.mountHandle,
|
||||
virtualRoot: root.virtualRoot,
|
||||
sourcePath,
|
||||
access: "read" as const,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const plan = Object.freeze({
|
||||
version: 1 as const,
|
||||
viewId: view.viewId,
|
||||
revision: view.revision,
|
||||
stagingId,
|
||||
mounts: Object.freeze(mounts),
|
||||
});
|
||||
// Validate the staged paths before releasing a plan to a backend. This
|
||||
// keeps a partially staged or forged source from becoming a bind mount.
|
||||
resolveAuthorizedVirtualProjectionMountPlan({
|
||||
agentWorkspaceDir: params.agentWorkspaceDir,
|
||||
plan,
|
||||
});
|
||||
return Object.freeze({
|
||||
plan,
|
||||
dispose: async () => {
|
||||
await Promise.all(
|
||||
stagedPaths.map((sourcePath) => fs.rm(sourcePath, { recursive: true, force: true })),
|
||||
);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
await Promise.all(
|
||||
stagedPaths.map((sourcePath) => fs.rm(sourcePath, { recursive: true, force: true })),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
* Runtime creation and lifecycle cleanup stay behind this backend boundary.
|
||||
*/
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import type { AuthorizedVirtualProjectionMountPlan } from "./authorized-virtual-projection-mounts.js";
|
||||
import type { SandboxBackendHandle } from "./backend-handle.types.js";
|
||||
import type { SandboxRegistryEntry } from "./registry.js";
|
||||
import type { SandboxConfig } from "./types.js";
|
||||
@@ -38,6 +39,8 @@ export type CreateSandboxBackendParams = {
|
||||
workspaceDir: string;
|
||||
agentWorkspaceDir: string;
|
||||
skillsWorkspaceDir?: string;
|
||||
/** Per-run core-staged authorized projections; never part of user sandbox config. */
|
||||
authorizedVirtualProjectionMountPlan?: AuthorizedVirtualProjectionMountPlan;
|
||||
cfg: SandboxConfig;
|
||||
requireCurrentConfig?: boolean;
|
||||
};
|
||||
|
||||
@@ -152,6 +152,52 @@ describe("computeSandboxConfigHash", () => {
|
||||
|
||||
expect(withoutSkills).not.toBe(withSkills);
|
||||
});
|
||||
|
||||
it("changes for an authorized virtual projection view or revision", () => {
|
||||
const shared = {
|
||||
docker: createDockerConfig(),
|
||||
workspaceAccess: "rw" as const,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
agentWorkspaceDir: "/tmp/workspace",
|
||||
mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION,
|
||||
createArgsEpoch: SANDBOX_DOCKER_CREATE_ARGS_EPOCH,
|
||||
};
|
||||
const before = computeSandboxConfigHash({
|
||||
...shared,
|
||||
authorizedVirtualProjectionMounts: [
|
||||
"view:opaque-view",
|
||||
"revision:revision-1",
|
||||
"/memory/private:read:opaque-mount:/tmp/workspace/.openclaw/authorized-memory-projections/p1_a",
|
||||
],
|
||||
});
|
||||
const changedView = computeSandboxConfigHash({
|
||||
...shared,
|
||||
authorizedVirtualProjectionMounts: [
|
||||
"view:opaque-view-2",
|
||||
"revision:revision-1",
|
||||
"/memory/private:read:opaque-mount:/tmp/workspace/.openclaw/authorized-memory-projections/p1_a",
|
||||
],
|
||||
});
|
||||
const changedRevision = computeSandboxConfigHash({
|
||||
...shared,
|
||||
authorizedVirtualProjectionMounts: [
|
||||
"view:opaque-view",
|
||||
"revision:revision-2",
|
||||
"/memory/private:read:opaque-mount:/tmp/workspace/.openclaw/authorized-memory-projections/p1_a",
|
||||
],
|
||||
});
|
||||
const changedMount = computeSandboxConfigHash({
|
||||
...shared,
|
||||
authorizedVirtualProjectionMounts: [
|
||||
"view:opaque-view",
|
||||
"revision:revision-1",
|
||||
"/memory/private:read:opaque-mount-rebound:/tmp/workspace/.openclaw/authorized-memory-projections/p1_a",
|
||||
],
|
||||
});
|
||||
expect(before).not.toBe(changedView);
|
||||
expect(before).not.toBe(changedRevision);
|
||||
expect(before).not.toBe(changedMount);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeSandboxBrowserConfigHash", () => {
|
||||
|
||||
@@ -23,6 +23,7 @@ type SandboxHashInput = {
|
||||
mountFormatVersion: number;
|
||||
createArgsEpoch: string;
|
||||
readOnlyWorkspaceSkillMounts?: readonly string[];
|
||||
authorizedVirtualProjectionMounts?: readonly string[];
|
||||
};
|
||||
|
||||
type SandboxBrowserHashInput = {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveSandboxContext } from "./context.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function makeTempDir(): Promise<string> {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-projection-backend-"));
|
||||
tempDirs.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempDirs.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe("authorized projection backend boundary", () => {
|
||||
it("rejects SSH before it asks the broker to stage or upload private projection bytes", async () => {
|
||||
const workspaceDir = await makeTempDir();
|
||||
const stage = vi.fn(async () => {
|
||||
throw new Error("projection staging must not run");
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveSandboxContext({
|
||||
agentId: "main",
|
||||
config: {
|
||||
agents: {
|
||||
entries: { main: { default: true } },
|
||||
defaults: {
|
||||
sandbox: {
|
||||
mode: "all",
|
||||
backend: "ssh",
|
||||
ssh: { target: "sandbox@example.test:22" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
sessionKey: "agent:main:direct:alice",
|
||||
workspaceDir,
|
||||
prepareAuthorizedVirtualProjectionMountPlan: stage,
|
||||
}),
|
||||
).rejects.toThrow(/does not support authorized memory projections/);
|
||||
expect(stage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+143
-97
@@ -17,6 +17,8 @@ import { defaultRuntime } from "../../runtime.js";
|
||||
import { createLazyRuntimeNamedExport } from "../../shared/lazy-runtime.js";
|
||||
import type { SkillEligibilityContext, SkillSnapshot, SkillUsagePath } from "../../skills/types.js";
|
||||
import type { ExecPolicyOverrides } from "../exec-defaults.js";
|
||||
import type { AuthorizedVirtualProjectionMountPlan } from "./authorized-virtual-projection-mounts.js";
|
||||
import type { StagedAuthorizedVirtualProjectionMountPlan } from "./authorized-virtual-projection-staging.js";
|
||||
import { getSandboxBackendWorkdirResolver, requireSandboxBackendFactory } from "./backend.js";
|
||||
import { ensureSandboxBrowser } from "./browser.js";
|
||||
import { resolveSandboxConfigForAgent } from "./config.js";
|
||||
@@ -194,6 +196,11 @@ type ResolveSandboxContextParams = {
|
||||
requireCurrentConfig?: boolean;
|
||||
sessionKey?: string;
|
||||
skillsSnapshot?: SkillSnapshot;
|
||||
authorizedVirtualProjectionMountPlan?: AuthorizedVirtualProjectionMountPlan;
|
||||
/** Stages one admitted opaque virtual view after the controlled agent root exists. */
|
||||
prepareAuthorizedVirtualProjectionMountPlan?: (params: {
|
||||
agentWorkspaceDir: string;
|
||||
}) => Promise<StagedAuthorizedVirtualProjectionMountPlan | undefined>;
|
||||
workspaceDir?: string;
|
||||
};
|
||||
|
||||
@@ -242,107 +249,142 @@ async function resolveProvisionedSandboxContext(
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
|
||||
const docker = await resolveSandboxDockerUser({
|
||||
backend: cfg.backend,
|
||||
docker: cfg.docker,
|
||||
workspaceDir,
|
||||
});
|
||||
const resolvedCfg = docker === cfg.docker ? cfg : { ...cfg, docker };
|
||||
|
||||
const backendFactory = requireSandboxBackendFactory(resolvedCfg.backend);
|
||||
const registeredRuntimeIds = await readRegisteredSandboxRuntimeIds({
|
||||
backendId: resolvedCfg.backend,
|
||||
scopeKey,
|
||||
});
|
||||
const backend = await backendFactory({
|
||||
sessionKey: rawSessionKey,
|
||||
scopeKey,
|
||||
...(registeredRuntimeIds.length > 0 ? { registeredRuntimeIds } : {}),
|
||||
workspaceDir,
|
||||
agentWorkspaceDir,
|
||||
skillsWorkspaceDir,
|
||||
cfg: resolvedCfg,
|
||||
...(params.requireCurrentConfig !== undefined
|
||||
? { requireCurrentConfig: params.requireCurrentConfig }
|
||||
: {}),
|
||||
});
|
||||
await updateRegistry({
|
||||
containerName: backend.runtimeId,
|
||||
backendId: backend.id,
|
||||
runtimeLabel: backend.runtimeLabel,
|
||||
sessionKey: scopeKey,
|
||||
createdAtMs: Date.now(),
|
||||
lastUsedAtMs: Date.now(),
|
||||
image: backend.configLabel ?? resolvedCfg.docker.image,
|
||||
configLabelKind: backend.configLabelKind ?? "Image",
|
||||
});
|
||||
|
||||
const resolvedBrowserConfig = resolvedCfg.browser.enabled
|
||||
? resolveBrowserConfig(params.config?.browser, params.config)
|
||||
: undefined;
|
||||
const evaluateEnabled =
|
||||
resolvedBrowserConfig?.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED;
|
||||
|
||||
const bridgeAuth = cfg.browser.enabled
|
||||
? await (async () => {
|
||||
// Sandbox browser bridge server runs on a loopback TCP port; always wire up
|
||||
// the same auth that loopback browser clients will send (token/password).
|
||||
const cfgForAuth =
|
||||
params.config ?? (await import("../../config/config.js")).getRuntimeConfig();
|
||||
let browserAuth = resolveBrowserControlAuth(cfgForAuth);
|
||||
try {
|
||||
const ensured = await ensureBrowserControlAuth({ cfg: cfgForAuth });
|
||||
browserAuth = ensured.auth;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : JSON.stringify(error);
|
||||
defaultRuntime.error?.(`Sandbox browser auth ensure failed: ${message}`);
|
||||
}
|
||||
return browserAuth;
|
||||
})()
|
||||
: undefined;
|
||||
if (resolvedCfg.browser.enabled && backend.capabilities?.browser !== true) {
|
||||
throw new Error(`Sandbox backend "${backend.id}" does not support browser sandboxes yet.`);
|
||||
// Reject unsupported transports before asking the broker for any private
|
||||
// bytes. Staging first would turn an unavailable SSH/custom backend into a
|
||||
// host-side projection upload attempt.
|
||||
if (
|
||||
(params.prepareAuthorizedVirtualProjectionMountPlan ||
|
||||
params.authorizedVirtualProjectionMountPlan) &&
|
||||
cfg.backend !== "docker" &&
|
||||
cfg.backend !== "podman"
|
||||
) {
|
||||
throw new Error(
|
||||
`Sandbox backend "${cfg.backend}" does not support authorized memory projections.`,
|
||||
);
|
||||
}
|
||||
const browser =
|
||||
resolvedCfg.browser.enabled && backend.capabilities?.browser === true
|
||||
? await ensureSandboxBrowser({
|
||||
scopeKey,
|
||||
workspaceDir,
|
||||
agentWorkspaceDir,
|
||||
skillsWorkspaceDir,
|
||||
cfg: resolvedCfg,
|
||||
evaluateEnabled,
|
||||
bridgeAuth,
|
||||
ssrfPolicy: resolvedBrowserConfig?.ssrfPolicy,
|
||||
})
|
||||
: null;
|
||||
|
||||
const sandboxContext: SandboxContext = {
|
||||
enabled: true,
|
||||
backendId: backend.id,
|
||||
sessionKey: rawSessionKey,
|
||||
workspaceDir,
|
||||
agentWorkspaceDir,
|
||||
skillsWorkspaceDir,
|
||||
...(skillsEligibility ? { skillsEligibility } : {}),
|
||||
...(skillUsagePaths ? { skillUsagePaths } : {}),
|
||||
workspaceAccess: resolvedCfg.workspaceAccess,
|
||||
runtimeId: backend.runtimeId,
|
||||
runtimeLabel: backend.runtimeLabel,
|
||||
containerName: backend.runtimeId,
|
||||
containerWorkdir: backend.workdir,
|
||||
docker: resolvedCfg.docker,
|
||||
tools: resolvedCfg.tools,
|
||||
browserAllowHostControl: resolvedCfg.browser.allowHostControl,
|
||||
browser: browser ?? undefined,
|
||||
backend,
|
||||
};
|
||||
const stagedAuthorizedVirtualProjection =
|
||||
await params.prepareAuthorizedVirtualProjectionMountPlan?.({ agentWorkspaceDir });
|
||||
if (stagedAuthorizedVirtualProjection && params.authorizedVirtualProjectionMountPlan) {
|
||||
await stagedAuthorizedVirtualProjection.dispose();
|
||||
throw new Error("Sandbox received two authorized virtual projection plans.");
|
||||
}
|
||||
const authorizedVirtualProjectionMountPlan =
|
||||
stagedAuthorizedVirtualProjection?.plan ?? params.authorizedVirtualProjectionMountPlan;
|
||||
|
||||
sandboxContext.fsBridge =
|
||||
backend.createFsBridge?.({ sandbox: sandboxContext }) ??
|
||||
createSandboxFsBridge({ sandbox: sandboxContext });
|
||||
return await (async () => {
|
||||
const docker = await resolveSandboxDockerUser({
|
||||
backend: cfg.backend,
|
||||
docker: cfg.docker,
|
||||
workspaceDir,
|
||||
});
|
||||
const resolvedCfg = docker === cfg.docker ? cfg : { ...cfg, docker };
|
||||
|
||||
return sandboxContext;
|
||||
// A projection is a confinement contract, not a generic workspace copy.
|
||||
// Backends must explicitly implement its isolated `/memory` transfer/mount
|
||||
// semantics before core hands them staged bytes.
|
||||
const backendFactory = requireSandboxBackendFactory(resolvedCfg.backend);
|
||||
const registeredRuntimeIds = await readRegisteredSandboxRuntimeIds({
|
||||
backendId: resolvedCfg.backend,
|
||||
scopeKey,
|
||||
});
|
||||
const backend = await backendFactory({
|
||||
sessionKey: rawSessionKey,
|
||||
scopeKey,
|
||||
...(registeredRuntimeIds.length > 0 ? { registeredRuntimeIds } : {}),
|
||||
workspaceDir,
|
||||
agentWorkspaceDir,
|
||||
skillsWorkspaceDir,
|
||||
...(authorizedVirtualProjectionMountPlan ? { authorizedVirtualProjectionMountPlan } : {}),
|
||||
cfg: resolvedCfg,
|
||||
...(params.requireCurrentConfig !== undefined
|
||||
? { requireCurrentConfig: params.requireCurrentConfig }
|
||||
: {}),
|
||||
});
|
||||
await updateRegistry({
|
||||
containerName: backend.runtimeId,
|
||||
backendId: backend.id,
|
||||
runtimeLabel: backend.runtimeLabel,
|
||||
sessionKey: scopeKey,
|
||||
createdAtMs: Date.now(),
|
||||
lastUsedAtMs: Date.now(),
|
||||
image: backend.configLabel ?? resolvedCfg.docker.image,
|
||||
configLabelKind: backend.configLabelKind ?? "Image",
|
||||
});
|
||||
|
||||
const resolvedBrowserConfig = resolvedCfg.browser.enabled
|
||||
? resolveBrowserConfig(params.config?.browser, params.config)
|
||||
: undefined;
|
||||
const evaluateEnabled =
|
||||
resolvedBrowserConfig?.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED;
|
||||
|
||||
const bridgeAuth = cfg.browser.enabled
|
||||
? await (async () => {
|
||||
// Sandbox browser bridge server runs on a loopback TCP port; always wire up
|
||||
// the same auth that loopback browser clients will send (token/password).
|
||||
const cfgForAuth =
|
||||
params.config ?? (await import("../../config/config.js")).getRuntimeConfig();
|
||||
let browserAuth = resolveBrowserControlAuth(cfgForAuth);
|
||||
try {
|
||||
const ensured = await ensureBrowserControlAuth({ cfg: cfgForAuth });
|
||||
browserAuth = ensured.auth;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : JSON.stringify(error);
|
||||
defaultRuntime.error?.(`Sandbox browser auth ensure failed: ${message}`);
|
||||
}
|
||||
return browserAuth;
|
||||
})()
|
||||
: undefined;
|
||||
if (resolvedCfg.browser.enabled && backend.capabilities?.browser !== true) {
|
||||
throw new Error(`Sandbox backend "${backend.id}" does not support browser sandboxes yet.`);
|
||||
}
|
||||
const browser =
|
||||
resolvedCfg.browser.enabled && backend.capabilities?.browser === true
|
||||
? await ensureSandboxBrowser({
|
||||
scopeKey,
|
||||
workspaceDir,
|
||||
agentWorkspaceDir,
|
||||
skillsWorkspaceDir,
|
||||
cfg: resolvedCfg,
|
||||
evaluateEnabled,
|
||||
bridgeAuth,
|
||||
ssrfPolicy: resolvedBrowserConfig?.ssrfPolicy,
|
||||
})
|
||||
: null;
|
||||
|
||||
const sandboxContext: SandboxContext = {
|
||||
enabled: true,
|
||||
backendId: backend.id,
|
||||
sessionKey: rawSessionKey,
|
||||
workspaceDir,
|
||||
agentWorkspaceDir,
|
||||
skillsWorkspaceDir,
|
||||
...(skillsEligibility ? { skillsEligibility } : {}),
|
||||
...(skillUsagePaths ? { skillUsagePaths } : {}),
|
||||
workspaceAccess: resolvedCfg.workspaceAccess,
|
||||
runtimeId: backend.runtimeId,
|
||||
runtimeLabel: backend.runtimeLabel,
|
||||
containerName: backend.runtimeId,
|
||||
containerWorkdir: backend.workdir,
|
||||
docker: resolvedCfg.docker,
|
||||
tools: resolvedCfg.tools,
|
||||
browserAllowHostControl: resolvedCfg.browser.allowHostControl,
|
||||
browser: browser ?? undefined,
|
||||
...(stagedAuthorizedVirtualProjection
|
||||
? { disposeAuthorizedVirtualProjectionMountPlan: stagedAuthorizedVirtualProjection.dispose }
|
||||
: {}),
|
||||
backend,
|
||||
};
|
||||
|
||||
sandboxContext.fsBridge =
|
||||
backend.createFsBridge?.({ sandbox: sandboxContext }) ??
|
||||
createSandboxFsBridge({ sandbox: sandboxContext });
|
||||
|
||||
return sandboxContext;
|
||||
})().catch(async (error) => {
|
||||
await stagedAuthorizedVirtualProjection?.dispose();
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveSandboxContext(params: {
|
||||
@@ -352,6 +394,10 @@ export async function resolveSandboxContext(params: {
|
||||
requireCurrentConfig?: boolean;
|
||||
sessionKey?: string;
|
||||
skillsSnapshot?: SkillSnapshot;
|
||||
authorizedVirtualProjectionMountPlan?: AuthorizedVirtualProjectionMountPlan;
|
||||
prepareAuthorizedVirtualProjectionMountPlan?: (params: {
|
||||
agentWorkspaceDir: string;
|
||||
}) => Promise<StagedAuthorizedVirtualProjectionMountPlan | undefined>;
|
||||
workspaceDir?: string;
|
||||
}): Promise<SandboxContext | null> {
|
||||
const resolved = resolveSandboxSession(params);
|
||||
|
||||
@@ -59,6 +59,7 @@ async function createContainerSandboxBackend(
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentWorkspaceDir: params.agentWorkspaceDir,
|
||||
skillsWorkspaceDir: params.skillsWorkspaceDir,
|
||||
authorizedVirtualProjectionMountPlan: params.authorizedVirtualProjectionMountPlan,
|
||||
cfg: params.cfg,
|
||||
...(params.requireCurrentConfig !== undefined
|
||||
? { requireCurrentConfig: params.requireCurrentConfig }
|
||||
|
||||
@@ -5,6 +5,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
|
||||
import { resolveAuthorizedVirtualProjectionSourcePath } from "./authorized-virtual-projection-mounts.js";
|
||||
import {
|
||||
computeSandboxConfigHash,
|
||||
SANDBOX_DOCKER_EXPLICIT_ENV_POLICY_EPOCH,
|
||||
@@ -26,6 +27,7 @@ const spawnState = vi.hoisted(() => ({
|
||||
inspectRunning: true,
|
||||
inspectError: "",
|
||||
labelHash: "",
|
||||
labelAuthorizedMemoryProjection: "",
|
||||
podmanInfo: "true\tfalse\t\t5.0.0\n",
|
||||
podmanConnections: "[]\n",
|
||||
podmanMachines: "[]\n",
|
||||
@@ -115,6 +117,12 @@ async function spawnDockerProcess(commandAndArgs: string[]) {
|
||||
} else {
|
||||
stdout = `${spawnState.labelHash}\n`;
|
||||
}
|
||||
} else if (
|
||||
args[0] === "inspect" &&
|
||||
args[1] === "-f" &&
|
||||
args[2]?.includes('index .Config.Labels "openclaw.authorizedMemoryProjection"')
|
||||
) {
|
||||
stdout = `${spawnState.labelAuthorizedMemoryProjection}\n`;
|
||||
} else if (command === "podman" && args[0] === "info") {
|
||||
stdout = spawnState.podmanInfo;
|
||||
} else if (command === "podman" && args[0] === "system") {
|
||||
@@ -137,6 +145,11 @@ async function spawnDockerProcess(commandAndArgs: string[]) {
|
||||
args
|
||||
.find((arg) => arg.startsWith("openclaw.configHash="))
|
||||
?.slice("openclaw.configHash=".length) ?? "";
|
||||
spawnState.labelAuthorizedMemoryProjection = args.includes(
|
||||
"openclaw.authorizedMemoryProjection=1",
|
||||
)
|
||||
? "1"
|
||||
: "";
|
||||
}
|
||||
} else if (args[0] === "start") {
|
||||
spawnState.inspectRunning = true;
|
||||
@@ -262,6 +275,7 @@ describe("ensureSandboxContainer config-hash recreation", () => {
|
||||
spawnState.inspectRunning = true;
|
||||
spawnState.inspectError = "";
|
||||
spawnState.labelHash = "";
|
||||
spawnState.labelAuthorizedMemoryProjection = "";
|
||||
spawnState.podmanInfo = "true\tfalse\t\t5.0.0\n";
|
||||
spawnState.podmanConnections = "[]\n";
|
||||
spawnState.podmanMachines = "[]\n";
|
||||
@@ -385,6 +399,240 @@ describe("ensureSandboxContainer config-hash recreation", () => {
|
||||
expect(registryUpdate?.configHash).toBe(newHash);
|
||||
});
|
||||
|
||||
it("mounts only a core projection read-only and recreates when its view revision changes", async () => {
|
||||
const workspaceDir = tempDirs.make("openclaw-docker-projection-");
|
||||
const cfg = createSandboxConfig([], [`${workspaceDir}:/workspace:rw`]);
|
||||
const sourcePath = resolveAuthorizedVirtualProjectionSourcePath({
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
viewId: "opaque-view",
|
||||
revision: "revision-1",
|
||||
stagingId: "stage-1",
|
||||
mountHandle: "opaque-mount",
|
||||
});
|
||||
fs.mkdirSync(sourcePath, { recursive: true });
|
||||
const resolvedSourcePath = fs.realpathSync(sourcePath);
|
||||
const firstPlan = {
|
||||
version: 1 as const,
|
||||
viewId: "opaque-view",
|
||||
revision: "revision-1",
|
||||
stagingId: "stage-1",
|
||||
mounts: [
|
||||
{
|
||||
mountHandle: "opaque-mount",
|
||||
virtualRoot: "private",
|
||||
sourcePath,
|
||||
access: "read" as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
spawnState.containerExists = false;
|
||||
spawnState.inspectRunning = false;
|
||||
registryMocks.readRegistryEntry.mockResolvedValue({
|
||||
containerName: "oc-test-shared",
|
||||
sessionKey: "shared",
|
||||
createdAtMs: 1,
|
||||
lastUsedAtMs: 0,
|
||||
image: cfg.docker.image,
|
||||
});
|
||||
|
||||
await ensureSandboxContainer({
|
||||
scopeKey: "shared",
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
cfg,
|
||||
authorizedVirtualProjectionMountPlan: firstPlan,
|
||||
});
|
||||
const firstCreate = spawnState.calls.find((call) => call.args[0] === "create");
|
||||
expect(firstCreate).toBeDefined();
|
||||
expect(collectDockerFlagValues(firstCreate!.args, "-v")).toContain(
|
||||
`${resolvedSourcePath}:/memory/private:ro,z`,
|
||||
);
|
||||
|
||||
spawnState.calls.length = 0;
|
||||
const secondSourcePath = resolveAuthorizedVirtualProjectionSourcePath({
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
viewId: "opaque-view",
|
||||
revision: "revision-2",
|
||||
stagingId: "stage-2",
|
||||
mountHandle: "opaque-mount",
|
||||
});
|
||||
fs.mkdirSync(secondSourcePath, { recursive: true });
|
||||
await ensureSandboxContainer({
|
||||
scopeKey: "shared",
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
cfg,
|
||||
authorizedVirtualProjectionMountPlan: {
|
||||
...firstPlan,
|
||||
revision: "revision-2",
|
||||
stagingId: "stage-2",
|
||||
mounts: [{ ...firstPlan.mounts[0]!, sourcePath: secondSourcePath }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(spawnState.calls.some((call) => call.args[0] === "rm")).toBe(true);
|
||||
expect(spawnState.calls.some((call) => call.args[0] === "create")).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["rw", "ro", "none"] as const)(
|
||||
"keeps private projection staging out of ordinary %s workspace binds",
|
||||
async (workspaceAccess) => {
|
||||
const workspaceDir = tempDirs.make(`openclaw-docker-projection-${workspaceAccess}-`);
|
||||
const cfg = createSandboxConfig(
|
||||
[],
|
||||
[`${workspaceDir}:/workspace:${workspaceAccess === "rw" ? "rw" : "ro"}`],
|
||||
workspaceAccess,
|
||||
);
|
||||
const sourcePath = resolveAuthorizedVirtualProjectionSourcePath({
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
viewId: `opaque-view-${workspaceAccess}`,
|
||||
revision: "revision-1",
|
||||
stagingId: "stage-1",
|
||||
mountHandle: "opaque-mount",
|
||||
});
|
||||
fs.mkdirSync(sourcePath, { recursive: true });
|
||||
const resolvedSourcePath = fs.realpathSync(sourcePath);
|
||||
spawnState.containerExists = false;
|
||||
spawnState.inspectRunning = false;
|
||||
registryMocks.readRegistryEntry.mockResolvedValue(null);
|
||||
|
||||
await ensureSandboxContainer({
|
||||
scopeKey: "shared",
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
cfg,
|
||||
authorizedVirtualProjectionMountPlan: {
|
||||
version: 1,
|
||||
viewId: `opaque-view-${workspaceAccess}`,
|
||||
revision: "revision-1",
|
||||
stagingId: "stage-1",
|
||||
mounts: [
|
||||
{
|
||||
mountHandle: "opaque-mount",
|
||||
virtualRoot: "private",
|
||||
sourcePath,
|
||||
access: "read",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const create = spawnState.calls.find((call) => call.args[0] === "create");
|
||||
if (!create) {
|
||||
throw new Error("expected sandbox container creation");
|
||||
}
|
||||
const binds = collectDockerFlagValues(create.args, "-v");
|
||||
expect(path.relative(workspaceDir, sourcePath).startsWith("..")).toBe(true);
|
||||
expect(binds.filter((bind) => bind.startsWith(`${resolvedSourcePath}:`))).toEqual([
|
||||
`${resolvedSourcePath}:/memory/private:ro,z`,
|
||||
]);
|
||||
expect(binds).not.toContain(`${sourcePath}:/workspace:rw`);
|
||||
expect(binds).not.toContain(`${sourcePath}:/workspace:ro`);
|
||||
},
|
||||
);
|
||||
|
||||
it("recreates a hot container even when the next projection has the same view and revision", async () => {
|
||||
const workspaceDir = tempDirs.make("openclaw-docker-projection-hot-");
|
||||
const cfg = createSandboxConfig([], [`${workspaceDir}:/workspace:rw`]);
|
||||
const sourcePath = resolveAuthorizedVirtualProjectionSourcePath({
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
viewId: "opaque-view",
|
||||
revision: "revision-1",
|
||||
stagingId: "stage-1",
|
||||
mountHandle: "opaque-mount",
|
||||
});
|
||||
fs.mkdirSync(sourcePath, { recursive: true });
|
||||
const plan = {
|
||||
version: 1 as const,
|
||||
viewId: "opaque-view",
|
||||
revision: "revision-1",
|
||||
stagingId: "stage-1",
|
||||
mounts: [
|
||||
{
|
||||
mountHandle: "opaque-mount",
|
||||
virtualRoot: "private",
|
||||
sourcePath,
|
||||
access: "read" as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
spawnState.containerExists = false;
|
||||
spawnState.inspectRunning = false;
|
||||
registryMocks.readRegistryEntry.mockResolvedValue(null);
|
||||
await ensureSandboxContainer({
|
||||
scopeKey: "shared",
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
cfg,
|
||||
authorizedVirtualProjectionMountPlan: plan,
|
||||
});
|
||||
|
||||
spawnState.calls.length = 0;
|
||||
await ensureSandboxContainer({
|
||||
scopeKey: "shared",
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
cfg,
|
||||
// The first attempt has already removed this staged source. Docker keeps
|
||||
// its old bind inode, so an equal mount hash must still not reuse it.
|
||||
authorizedVirtualProjectionMountPlan: plan,
|
||||
});
|
||||
expect(spawnState.calls.some((call) => call.args[0] === "rm")).toBe(true);
|
||||
expect(spawnState.calls.some((call) => call.args[0] === "create")).toBe(true);
|
||||
});
|
||||
|
||||
it("recreates a hot projected container before an unprojected run", async () => {
|
||||
const workspaceDir = tempDirs.make("openclaw-docker-projection-to-none-");
|
||||
const cfg = createSandboxConfig([], [`${workspaceDir}:/workspace:rw`]);
|
||||
const sourcePath = resolveAuthorizedVirtualProjectionSourcePath({
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
viewId: "opaque-view",
|
||||
revision: "revision-1",
|
||||
stagingId: "stage-1",
|
||||
mountHandle: "opaque-mount",
|
||||
});
|
||||
fs.mkdirSync(sourcePath, { recursive: true });
|
||||
const plan = {
|
||||
version: 1 as const,
|
||||
viewId: "opaque-view",
|
||||
revision: "revision-1",
|
||||
stagingId: "stage-1",
|
||||
mounts: [
|
||||
{
|
||||
mountHandle: "opaque-mount",
|
||||
virtualRoot: "private",
|
||||
sourcePath,
|
||||
access: "read" as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
spawnState.containerExists = false;
|
||||
spawnState.inspectRunning = false;
|
||||
registryMocks.readRegistryEntry.mockResolvedValue(null);
|
||||
await ensureSandboxContainer({
|
||||
scopeKey: "shared",
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
cfg,
|
||||
authorizedVirtualProjectionMountPlan: plan,
|
||||
});
|
||||
|
||||
spawnState.calls.length = 0;
|
||||
await ensureSandboxContainer({
|
||||
scopeKey: "shared",
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(spawnState.calls.some((call) => call.args[0] === "rm")).toBe(true);
|
||||
const replacement = spawnState.calls.find((call) => call.args[0] === "create");
|
||||
expect(replacement).toBeDefined();
|
||||
expect(collectDockerFlagValues(replacement!.args, "-v")).not.toContain(
|
||||
`${sourcePath}:/memory/private:ro,z`,
|
||||
);
|
||||
});
|
||||
|
||||
it("recreates a cold container when the shared Docker create-args epoch changes", async () => {
|
||||
const workspaceDir = tempDirs.make("openclaw-docker-mounts-");
|
||||
// Keep the create-args epoch as the only hash delta in this scenario.
|
||||
|
||||
+111
-16
@@ -6,6 +6,14 @@ import { markOpenClawExecEnv } from "../../infra/openclaw-exec-env.js";
|
||||
*/
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js";
|
||||
import {
|
||||
appendAuthorizedVirtualProjectionMountArgs,
|
||||
assertNoBindsCollideWithAuthorizedVirtualProjectionMounts,
|
||||
formatAuthorizedVirtualProjectionMountHashState,
|
||||
resolveAuthorizedVirtualProjectionMountPlan,
|
||||
type AuthorizedVirtualProjectionMountPlan,
|
||||
type ResolvedAuthorizedVirtualProjectionMount,
|
||||
} from "./authorized-virtual-projection-mounts.js";
|
||||
import { computeSandboxConfigHash } from "./config-hash.js";
|
||||
import { DEFAULT_SANDBOX_IMAGE, SANDBOX_DOCKER_CREATE_ARGS_EPOCH } from "./constants.js";
|
||||
import {
|
||||
@@ -74,6 +82,31 @@ export async function execDockerRaw(
|
||||
return await execContainerRaw(DOCKER_SANDBOX_ENGINE, args, opts);
|
||||
}
|
||||
|
||||
import {
|
||||
appendAuthorizedVirtualProjectionMountArgs,
|
||||
assertNoBindsCollideWithAuthorizedVirtualProjectionMounts,
|
||||
formatAuthorizedVirtualProjectionMountHashState,
|
||||
resolveAuthorizedVirtualProjectionMountPlan,
|
||||
type AuthorizedVirtualProjectionMountPlan,
|
||||
type ResolvedAuthorizedVirtualProjectionMount,
|
||||
} from "./authorized-virtual-projection-mounts.js";
|
||||
import { computeSandboxConfigHash } from "./config-hash.js";
|
||||
import { DEFAULT_SANDBOX_IMAGE, SANDBOX_DOCKER_CREATE_ARGS_EPOCH } from "./constants.js";
|
||||
import { handleHotSandboxConfigMismatch } from "./current-config.js";
|
||||
import { readRegistryEntry, removeRegistryEntry, updateRegistry } from "./registry.js";
|
||||
import { buildSandboxContainerName, slugifySessionKey } from "./shared.js";
|
||||
import type { SandboxConfig, SandboxDockerConfig, SandboxWorkspaceAccess } from "./types.js";
|
||||
import { validateSandboxSecurity } from "./validate-sandbox-security.js";
|
||||
import {
|
||||
appendReadOnlyWorkspaceSkillMountArgs,
|
||||
appendWorkspaceMountArgs,
|
||||
filterBindsConflictingWithProtectedMounts,
|
||||
formatReadOnlyWorkspaceSkillMountHashState,
|
||||
resolveReadOnlyWorkspaceSkillMounts,
|
||||
resolveProtectedSkillMountContainerPaths,
|
||||
SANDBOX_MOUNT_FORMAT_VERSION,
|
||||
type ReadOnlyWorkspaceSkillMount,
|
||||
} from "./workspace-mounts.js";
|
||||
const log = createSubsystemLogger("docker");
|
||||
|
||||
const HOT_CONTAINER_WINDOW_MS = 5 * 60 * 1000;
|
||||
@@ -329,6 +362,8 @@ export function buildSandboxCreateArgs(params: {
|
||||
allowSourcesOutsideAllowedRoots?: boolean;
|
||||
allowReservedContainerTargets?: boolean;
|
||||
allowContainerNamespaceJoin?: boolean;
|
||||
/** Core-managed projection binds validated with, but emitted after, normal config binds. */
|
||||
managedReadOnlyBinds?: readonly string[];
|
||||
}) {
|
||||
// Runtime security validation: blocks dangerous bind mounts, network modes, and profiles.
|
||||
validateSandboxSecurity({
|
||||
@@ -343,6 +378,7 @@ export function buildSandboxCreateArgs(params: {
|
||||
dangerouslyAllowContainerNamespaceJoin:
|
||||
params.allowContainerNamespaceJoin ??
|
||||
params.cfg.dangerouslyAllowContainerNamespaceJoin === true,
|
||||
managedReadOnlyBinds: params.managedReadOnlyBinds,
|
||||
});
|
||||
|
||||
const createdAtMs = params.createdAtMs ?? Date.now();
|
||||
@@ -464,6 +500,7 @@ async function createSandboxContainer(params: {
|
||||
scopeKey: string;
|
||||
configHash?: string;
|
||||
readOnlyWorkspaceSkillMounts: readonly ReadOnlyWorkspaceSkillMount[];
|
||||
authorizedVirtualProjectionMounts: readonly ResolvedAuthorizedVirtualProjectionMount[];
|
||||
podmanRuntimeInfo?: PodmanSandboxRuntimeInfo;
|
||||
}) {
|
||||
const { engine, name, cfg, workspaceDir, scopeKey } = params;
|
||||
@@ -488,8 +525,18 @@ async function createSandboxContainer(params: {
|
||||
scopeKey,
|
||||
configHash: params.configHash,
|
||||
includeBinds: false,
|
||||
bindSourceRoots: [workspaceDir, params.agentWorkspaceDir],
|
||||
bindSourceRoots: [
|
||||
workspaceDir,
|
||||
params.agentWorkspaceDir,
|
||||
...params.authorizedVirtualProjectionMounts.map((mount) => mount.sourcePath),
|
||||
],
|
||||
managedReadOnlyBinds: params.authorizedVirtualProjectionMounts.map(
|
||||
(mount) => `${mount.sourcePath}:${mount.containerPath}:ro,z`,
|
||||
),
|
||||
});
|
||||
if (params.authorizedVirtualProjectionMounts.length > 0) {
|
||||
args.push("--label", "openclaw.authorizedMemoryProjection=1");
|
||||
}
|
||||
if (podmanPolicy) {
|
||||
args.push(...podmanPolicy.extraCreateArgs);
|
||||
}
|
||||
@@ -504,6 +551,10 @@ async function createSandboxContainer(params: {
|
||||
readOnlyWorkspaceSkillMounts: params.readOnlyWorkspaceSkillMounts,
|
||||
includeReadOnlyWorkspaceSkillMounts: false,
|
||||
});
|
||||
appendAuthorizedVirtualProjectionMountArgs({
|
||||
args,
|
||||
mounts: params.authorizedVirtualProjectionMounts,
|
||||
});
|
||||
// Protected skill overlays are authoritative. Remove exact destination
|
||||
// collisions before Docker or Podman sees duplicate mount arguments.
|
||||
const protectedPaths = resolveProtectedSkillMountContainerPaths(
|
||||
@@ -541,6 +592,15 @@ async function readContainerConfigHash(
|
||||
return await readContainerLabel(engine, containerName, "openclaw.configHash");
|
||||
}
|
||||
|
||||
async function hasAuthorizedMemoryProjection(
|
||||
engine: SandboxContainerEngine,
|
||||
containerName: string,
|
||||
): Promise<boolean> {
|
||||
return (
|
||||
(await readContainerLabel(engine, containerName, "openclaw.authorizedMemoryProjection")) === "1"
|
||||
);
|
||||
}
|
||||
|
||||
type EnsureSandboxContainerParams = {
|
||||
engine?: SandboxContainerEngine;
|
||||
podmanTarget?: SandboxContainerEngineTarget;
|
||||
@@ -548,6 +608,7 @@ type EnsureSandboxContainerParams = {
|
||||
workspaceDir: string;
|
||||
agentWorkspaceDir: string;
|
||||
skillsWorkspaceDir?: string;
|
||||
authorizedVirtualProjectionMountPlan?: AuthorizedVirtualProjectionMountPlan;
|
||||
cfg: SandboxConfig;
|
||||
requireCurrentConfig?: boolean;
|
||||
};
|
||||
@@ -615,6 +676,14 @@ async function ensureSandboxContainerLifecycle(
|
||||
workdir: params.cfg.docker.workdir,
|
||||
workspaceAccess: params.cfg.workspaceAccess,
|
||||
});
|
||||
const authorizedVirtualProjectionMounts = resolveAuthorizedVirtualProjectionMountPlan({
|
||||
agentWorkspaceDir: params.agentWorkspaceDir,
|
||||
plan: params.authorizedVirtualProjectionMountPlan,
|
||||
});
|
||||
assertNoBindsCollideWithAuthorizedVirtualProjectionMounts({
|
||||
binds: params.cfg.docker.binds,
|
||||
mounts: authorizedVirtualProjectionMounts,
|
||||
});
|
||||
const genericConfigHash = computeSandboxConfigHash({
|
||||
docker: params.cfg.docker,
|
||||
dockerEnvPolicyEpoch: resolveDockerEnvPolicyEpoch(params.cfg.docker.env),
|
||||
@@ -626,6 +695,14 @@ async function ensureSandboxContainerLifecycle(
|
||||
readOnlyWorkspaceSkillMounts: formatReadOnlyWorkspaceSkillMountHashState(
|
||||
readOnlyWorkspaceSkillMounts,
|
||||
),
|
||||
...(params.authorizedVirtualProjectionMountPlan
|
||||
? {
|
||||
authorizedVirtualProjectionMounts: formatAuthorizedVirtualProjectionMountHashState(
|
||||
params.authorizedVirtualProjectionMountPlan,
|
||||
authorizedVirtualProjectionMounts,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
const expectedHash =
|
||||
engine.id === "podman"
|
||||
@@ -648,24 +725,41 @@ async function ensureSandboxContainerLifecycle(
|
||||
currentHash = registryEntry?.configHash ?? null;
|
||||
}
|
||||
hashMismatch = !currentHash || currentHash !== expectedHash;
|
||||
if (hashMismatch) {
|
||||
const lastUsedAtMs = registryEntry?.lastUsedAtMs;
|
||||
const isHot =
|
||||
running &&
|
||||
(typeof lastUsedAtMs !== "number" || now - lastUsedAtMs < HOT_CONTAINER_WINDOW_MS);
|
||||
if (isHot) {
|
||||
handleHotSandboxConfigMismatch({
|
||||
containerName,
|
||||
scope: params.cfg.scope,
|
||||
sessionKey: params.scopeKey,
|
||||
...(params.requireCurrentConfig !== undefined
|
||||
? { requireCurrentConfig: params.requireCurrentConfig }
|
||||
: {}),
|
||||
});
|
||||
} else {
|
||||
const hasAuthorizedProjectionPlan = params.authorizedVirtualProjectionMountPlan !== undefined;
|
||||
if (hasAuthorizedProjectionPlan || hashMismatch) {
|
||||
// Attempt cleanup removes staged projection directories. A hot container
|
||||
// retains its old bind inode even when the next plan hashes identically,
|
||||
// so every projection-bearing run must recreate before it can read bytes.
|
||||
if (hasAuthorizedProjectionPlan) {
|
||||
await execContainer(engine, ["rm", "-f", containerName], { allowFailure: true });
|
||||
hasContainer = false;
|
||||
running = false;
|
||||
} else {
|
||||
const hasProjection = await hasAuthorizedMemoryProjection(engine, containerName);
|
||||
// An unprojected run must also remove an older projection mount rather
|
||||
// than reusing its staged bytes as a normal hot workspace container.
|
||||
if (hasProjection) {
|
||||
await execContainer(engine, ["rm", "-f", containerName], { allowFailure: true });
|
||||
hasContainer = false;
|
||||
running = false;
|
||||
} else {
|
||||
const lastUsedAtMs = registryEntry?.lastUsedAtMs;
|
||||
const isHot =
|
||||
running &&
|
||||
(typeof lastUsedAtMs !== "number" || now - lastUsedAtMs < HOT_CONTAINER_WINDOW_MS);
|
||||
if (isHot) {
|
||||
handleHotSandboxConfigMismatch({
|
||||
containerName,
|
||||
scope: params.cfg.scope,
|
||||
sessionKey: params.scopeKey,
|
||||
requireCurrentConfig: params.requireCurrentConfig,
|
||||
});
|
||||
} else {
|
||||
await execContainer(engine, ["rm", "-f", containerName], { allowFailure: true });
|
||||
hasContainer = false;
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -682,6 +776,7 @@ async function ensureSandboxContainerLifecycle(
|
||||
scopeKey: params.scopeKey,
|
||||
configHash: expectedHash,
|
||||
readOnlyWorkspaceSkillMounts,
|
||||
authorizedVirtualProjectionMounts,
|
||||
podmanRuntimeInfo,
|
||||
});
|
||||
} else if (!running) {
|
||||
|
||||
@@ -113,6 +113,8 @@ export type SandboxContext = {
|
||||
browserAllowHostControl: boolean;
|
||||
browser?: SandboxBrowserContext;
|
||||
fsBridge?: SandboxFsBridge;
|
||||
/** Releases the run-scoped core staging directory after the sandbox no longer uses it. */
|
||||
disposeAuthorizedVirtualProjectionMountPlan?: () => Promise<void>;
|
||||
backend?: SandboxBackendHandle;
|
||||
};
|
||||
|
||||
|
||||
@@ -365,6 +365,22 @@ describe("validateBindMounts", () => {
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("requires core-managed projection mounts to stay physically read-only", () => {
|
||||
const projectRoot = mkdtempSync(join(tmpdir(), "openclaw-sbx-managed-projection-"));
|
||||
expect(() =>
|
||||
validateSandboxSecurity({
|
||||
allowedSourceRoots: [projectRoot],
|
||||
managedReadOnlyBinds: [`${projectRoot}:/memory/private:rw,z`],
|
||||
}),
|
||||
).toThrow(/must be physically read-only/);
|
||||
expect(
|
||||
validateSandboxSecurity({
|
||||
allowedSourceRoots: [projectRoot],
|
||||
managedReadOnlyBinds: [`${projectRoot}:/memory/private:ro,z`],
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
function normalizePathForSnapshot(input: string): string {
|
||||
|
||||
@@ -62,6 +62,8 @@ type ValidateBindMountsOptions = {
|
||||
allowedSourceRoots?: string[];
|
||||
allowSourcesOutsideAllowedRoots?: boolean;
|
||||
allowReservedContainerTargets?: boolean;
|
||||
/** Core-created mounts must pass the same source/root validation as user binds. */
|
||||
managedReadOnlyBinds?: readonly string[];
|
||||
};
|
||||
|
||||
type ValidateNetworkModeOptions = {
|
||||
@@ -324,14 +326,26 @@ function validateBindMounts(
|
||||
binds: string[] | undefined,
|
||||
options?: ValidateBindMountsOptions,
|
||||
): void {
|
||||
if (!binds?.length) {
|
||||
const configuredBinds = binds ?? [];
|
||||
const managedBinds = options?.managedReadOnlyBinds ?? [];
|
||||
if (configuredBinds.length === 0 && managedBinds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const bind of managedBinds) {
|
||||
const parsed = splitSandboxBindSpec(bind);
|
||||
const modes = new Set(parsed?.options.split(",").filter(Boolean) ?? []);
|
||||
if (!parsed || !modes.has("ro") || !modes.has("z") || modes.has("rw")) {
|
||||
throw new Error(
|
||||
`Sandbox security: managed projection bind "${bind}" must be physically read-only with SELinux sharing.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const allowedRoots = normalizeAllowedRoots(options?.allowedSourceRoots);
|
||||
const blockedHostPaths = getBlockedHostPaths();
|
||||
|
||||
for (const rawBind of binds) {
|
||||
for (const rawBind of [...configuredBinds, ...managedBinds]) {
|
||||
const bind = rawBind.trim();
|
||||
if (!bind) {
|
||||
continue;
|
||||
|
||||
@@ -335,6 +335,35 @@ describe("read tool", () => {
|
||||
).rejects.toThrow(/ambiguous.*d'accord\.txt.*d\u2019accord\.txt/i);
|
||||
});
|
||||
|
||||
it("keeps fallback spellings ambiguous when a remote backend cannot prove identity", async () => {
|
||||
const requested = "/workspace/d\u2018accord.txt";
|
||||
const straight = "/workspace/d'accord.txt";
|
||||
const curly = "/workspace/d\u2019accord.txt";
|
||||
const tool = createReadToolDefinition("/workspace", {
|
||||
operations: {
|
||||
access: async (filePath) => {
|
||||
if (filePath === requested) {
|
||||
throw Object.assign(new Error("missing"), { code: "ENOENT" });
|
||||
}
|
||||
if (filePath !== straight && filePath !== curly) {
|
||||
throw Object.assign(new Error("missing"), { code: "ENOENT" });
|
||||
}
|
||||
},
|
||||
readFile: async () => Buffer.from("remote"),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
tool.execute(
|
||||
"call-remote-unicode",
|
||||
{ path: "d\u2018accord.txt" },
|
||||
undefined,
|
||||
undefined,
|
||||
{} as never,
|
||||
),
|
||||
).rejects.toThrow(/Read path is ambiguous/);
|
||||
});
|
||||
|
||||
it("suggests a close filename without reading it", async () => {
|
||||
const tempDir = tempDirs.make("openclaw-read-suggestion-");
|
||||
await fs.writeFile(path.join(tempDir, "AGENTS.md"), "instructions");
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { constants } from "node:fs";
|
||||
import { access as fsAccess, readdir as fsReaddir, stat as fsStat } from "node:fs/promises";
|
||||
import {
|
||||
access as fsAccess,
|
||||
readdir as fsReaddir,
|
||||
realpath as fsRealpath,
|
||||
stat as fsStat,
|
||||
} from "node:fs/promises";
|
||||
import { basename, dirname, isAbsolute, relative, resolve as resolvePath, sep } from "node:path";
|
||||
import { Text } from "@earendil-works/pi-tui";
|
||||
import { Type } from "typebox";
|
||||
@@ -188,6 +193,12 @@ export interface ReadOperations {
|
||||
readFile: (absolutePath: string) => Promise<Buffer>;
|
||||
/** Check if file is readable (throw if not) */
|
||||
access: (absolutePath: string) => Promise<void>;
|
||||
/**
|
||||
* Return a backend-proven file identity for a successful fallback candidate.
|
||||
* Backends without identity proof must omit this so distinct spellings remain
|
||||
* ambiguous instead of being silently collapsed.
|
||||
*/
|
||||
resolveFileIdentity?: (absolutePath: string) => Promise<string | undefined>;
|
||||
/** Detect image MIME type, return null or undefined for non-images */
|
||||
detectImageMimeType?: (
|
||||
absolutePath: string,
|
||||
@@ -200,6 +211,7 @@ const defaultReadOperations: ReadOperations = {
|
||||
decodeText: ({ buffer }) => decodeWindowsTextFileBuffer({ buffer }),
|
||||
readFile: async (filePath) => (await readRegularFile({ filePath })).buffer,
|
||||
access: assertLocalReadableFile,
|
||||
resolveFileIdentity: async (filePath) => await fsRealpath(filePath),
|
||||
};
|
||||
|
||||
async function detectReadImageMimeType(
|
||||
@@ -331,11 +343,16 @@ async function resolveReadToolPath(
|
||||
throw error;
|
||||
}
|
||||
|
||||
const matches: string[] = [];
|
||||
const matches: Array<{ path: string; identity?: string }> = [];
|
||||
for (const candidate of getReadPathVariants(absolutePath)) {
|
||||
try {
|
||||
await ops.access(candidate);
|
||||
matches.push(candidate);
|
||||
const identity = await ops.resolveFileIdentity?.(candidate);
|
||||
// APFS can resolve NFC/NFD spellings to one entry. Keep the fallback
|
||||
// conservative for remote backends, but collapse only proven aliases.
|
||||
if (!identity || !matches.some((match) => match.identity === identity)) {
|
||||
matches.push({ path: candidate, identity });
|
||||
}
|
||||
} catch (candidateError) {
|
||||
if (!hasErrnoCode(candidateError, "ENOENT") && !hasErrnoCode(candidateError, "ENOTDIR")) {
|
||||
throw candidateError;
|
||||
@@ -345,15 +362,15 @@ async function resolveReadToolPath(
|
||||
|
||||
if (matches.length > 1) {
|
||||
throw new Error(
|
||||
`Read path is ambiguous: ${basename(absolutePath)} matches ${matches.map((match) => basename(match)).join(", ")}.`,
|
||||
`Read path is ambiguous: ${basename(absolutePath)} matches ${matches.map((match) => basename(match.path)).join(", ")}.`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
const match = matches[0];
|
||||
if (match !== undefined) {
|
||||
return {
|
||||
absolutePath: match,
|
||||
note: `[Resolved filename: ${basename(absolutePath)} -> ${basename(match)}.]`,
|
||||
absolutePath: match.path,
|
||||
note: `[Resolved filename: ${basename(absolutePath)} -> ${basename(match.path)}.]`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,13 @@ import { mergeAlsoAllowPolicy, resolveToolProfilePolicy } from "./tool-policy.js
|
||||
export type { PreparedSessionPermissionPolicy, ToolFsPolicy } from "./tool-fs-policy.types.js";
|
||||
export { resolveSessionPermissionExecMode } from "./session-permission-exec-mode.js";
|
||||
|
||||
export function createToolFsPolicy(params: { workspaceOnly?: boolean }): ToolFsPolicy {
|
||||
return Object.freeze({
|
||||
kind: "workspace" as const,
|
||||
workspaceOnly: params.workspaceOnly === true,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveToolFsConfig(params: { cfg?: OpenClawConfig; agentId?: string }): {
|
||||
workspaceOnly?: boolean;
|
||||
} {
|
||||
|
||||
@@ -6,7 +6,20 @@ export type PreparedSessionPermissionPolicy = Readonly<{
|
||||
}>;
|
||||
|
||||
/** Filesystem policy for agent tools that can touch local paths. */
|
||||
export type ToolFsPolicy = {
|
||||
workspaceOnly: boolean;
|
||||
root?: string;
|
||||
};
|
||||
export type ToolFsPolicy =
|
||||
| Readonly<{ kind: "workspace"; workspaceOnly: boolean }>
|
||||
| Readonly<{
|
||||
kind: "authorized-memory-view";
|
||||
workspaceOnly: true;
|
||||
viewId: string;
|
||||
revision: string;
|
||||
virtualRoots: readonly string[];
|
||||
}>
|
||||
| Readonly<{
|
||||
kind: "sandbox-mount-plan";
|
||||
workspaceOnly: true;
|
||||
viewId: string;
|
||||
revision: string;
|
||||
mountTargets: readonly string[];
|
||||
}>
|
||||
| Readonly<{ kind: "memory-unavailable"; workspaceOnly: true }>;
|
||||
|
||||
@@ -2152,7 +2152,7 @@ describe("image tool implicit imageModel config", () => {
|
||||
config: cfg,
|
||||
agentDir,
|
||||
workspaceDir,
|
||||
fsPolicy: { workspaceOnly: true },
|
||||
fsPolicy: { kind: "workspace", workspaceOnly: true },
|
||||
});
|
||||
|
||||
// File inside workspace is allowed.
|
||||
@@ -2185,7 +2185,7 @@ describe("image tool implicit imageModel config", () => {
|
||||
const tool = createRequiredImageTool({
|
||||
config: cfg,
|
||||
agentDir,
|
||||
fsPolicy: { workspaceOnly: false },
|
||||
fsPolicy: { kind: "workspace", workspaceOnly: false },
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -2451,7 +2451,7 @@ describe("image tool implicit imageModel config", () => {
|
||||
agentDir,
|
||||
workspaceDir: sandboxRoot,
|
||||
sandbox: { root: sandboxRoot, bridge },
|
||||
fsPolicy: { workspaceOnly: true },
|
||||
fsPolicy: { kind: "workspace", workspaceOnly: true },
|
||||
});
|
||||
|
||||
await expectImageToolExecOk(tool, image);
|
||||
@@ -2476,7 +2476,7 @@ describe("image tool implicit imageModel config", () => {
|
||||
agentDir,
|
||||
workspaceDir: sandboxRoot,
|
||||
sandbox: { root: sandboxRoot, bridge },
|
||||
fsPolicy: { workspaceOnly: true },
|
||||
fsPolicy: { kind: "workspace", workspaceOnly: true },
|
||||
});
|
||||
await expect(
|
||||
imageTool.execute("t1", {
|
||||
@@ -2892,7 +2892,7 @@ describe("image tool managed inbound media", () => {
|
||||
config: createMinimaxImageConfig(),
|
||||
agentDir,
|
||||
workspaceDir,
|
||||
fsPolicy: { workspaceOnly: true },
|
||||
fsPolicy: { kind: "workspace", workspaceOnly: true },
|
||||
});
|
||||
|
||||
await expectImageToolExecOk(tool, `media://inbound/${mediaId}`);
|
||||
@@ -2909,7 +2909,7 @@ describe("image tool managed inbound media", () => {
|
||||
const tool = createRequiredImageTool({
|
||||
config: createMinimaxImageConfig(),
|
||||
agentDir,
|
||||
fsPolicy: { workspaceOnly: true },
|
||||
fsPolicy: { kind: "workspace", workspaceOnly: true },
|
||||
});
|
||||
|
||||
await expectImageToolExecOk(tool, mediaPath);
|
||||
|
||||
@@ -347,7 +347,7 @@ describe("createPdfTool", () => {
|
||||
config: cfg,
|
||||
agentDir,
|
||||
workspaceDir,
|
||||
fsPolicy: { workspaceOnly: true },
|
||||
fsPolicy: { kind: "workspace", workspaceOnly: true },
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -388,7 +388,7 @@ describe("createPdfTool", () => {
|
||||
(await loadCreatePdfTool())({
|
||||
config: cfg,
|
||||
agentDir,
|
||||
fsPolicy: { workspaceOnly: true },
|
||||
fsPolicy: { kind: "workspace", workspaceOnly: true },
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -431,7 +431,7 @@ describe("createPdfTool", () => {
|
||||
root: workspaceDir,
|
||||
bridge: createContainerWorkspaceSandboxFsBridge(workspaceDir),
|
||||
},
|
||||
fsPolicy: { workspaceOnly: true },
|
||||
fsPolicy: { kind: "workspace", workspaceOnly: true },
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -514,7 +514,7 @@ describe("createPdfTool", () => {
|
||||
(await loadCreatePdfTool())({
|
||||
config: cfg,
|
||||
agentDir,
|
||||
fsPolicy: { workspaceOnly: true },
|
||||
fsPolicy: { kind: "workspace", workspaceOnly: true },
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
MemoryEgressAdmission,
|
||||
TrustedMemoryEgressDeliveryFactsSource,
|
||||
} from "../agents/memory-egress-admission.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { createReplyDispatcher } from "./reply/reply-dispatcher.js";
|
||||
import { buildTestCtx } from "./reply/test-ctx.js";
|
||||
|
||||
type DispatchReplyFromConfigFn =
|
||||
typeof import("./reply/dispatch-from-config.js").dispatchReplyFromConfig;
|
||||
type PrepareMemoryEgressAuthorizationFn =
|
||||
typeof import("../agents/memory-egress-admission.js").prepareMemoryEgressAuthorization;
|
||||
type AdmitMemoryEgressAtDeliveryFn =
|
||||
typeof import("../agents/memory-egress-admission.js").admitMemoryEgressAtDelivery;
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
dispatchReplyFromConfig: vi.fn(),
|
||||
prepareMemoryEgressAuthorization: vi.fn(),
|
||||
admitMemoryEgressAtDelivery: vi.fn(),
|
||||
queueRoute: undefined as string | undefined,
|
||||
deliveryRoute: undefined as string | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("./reply/dispatch-from-config.js", () => ({
|
||||
dispatchReplyFromConfig: (...args: Parameters<DispatchReplyFromConfigFn>) =>
|
||||
hoisted.dispatchReplyFromConfig(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../agents/memory-egress-admission.js", () => ({
|
||||
MEMORY_EGRESS_CAPABILITY_REPLY_FINAL: "reply.final",
|
||||
prepareMemoryEgressAuthorization: (...args: Parameters<PrepareMemoryEgressAuthorizationFn>) =>
|
||||
hoisted.prepareMemoryEgressAuthorization(...args),
|
||||
admitMemoryEgressAtDelivery: (...args: Parameters<AdmitMemoryEgressAtDeliveryFn>) =>
|
||||
hoisted.admitMemoryEgressAtDelivery(...args),
|
||||
memoryEgressPayloadAuthorization: (admission: MemoryEgressAdmission) =>
|
||||
admission.allowed
|
||||
? { kind: "authorized" as const, authorization: admission.authorization }
|
||||
: { kind: "denied" as const, reason: admission.reason },
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/memory-cutover.js", () => ({
|
||||
isMemoryIsolationCutoverAgent: (agentId: string) => agentId === "memory-agent",
|
||||
}));
|
||||
|
||||
const { dispatchInboundMessage } = await import("./dispatch.js");
|
||||
|
||||
function readRoute(source: TrustedMemoryEgressDeliveryFactsSource | undefined): string | undefined {
|
||||
return source?.()?.deliveryContext?.to;
|
||||
}
|
||||
|
||||
function allowedFinalAdmission(): MemoryEgressAdmission {
|
||||
return {
|
||||
allowed: true,
|
||||
authorization: {
|
||||
version: 1,
|
||||
capabilityId: "reply.final",
|
||||
agentId: "memory-agent",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:memory-agent:direct:alice",
|
||||
runId: "run-1",
|
||||
deliveryRevision: "delivery-1",
|
||||
egressRegistryRevision: "registry-1",
|
||||
audiences: [{ kind: "user", id: "alice" }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("memory egress at final dispatch", () => {
|
||||
it("uses the installed final gate and denies a direct block plus a rebound final route", async () => {
|
||||
const delivered = vi.fn(async () => undefined);
|
||||
const dispatcher = createReplyDispatcher({ deliver: delivered });
|
||||
const ctx = buildTestCtx({
|
||||
AgentId: "memory-agent",
|
||||
SessionId: "session-1",
|
||||
SessionKey: "agent:memory-agent:direct:alice",
|
||||
Surface: "telegram",
|
||||
OriginatingChannel: "telegram",
|
||||
OriginatingTo: "chat-before",
|
||||
AccountId: "default",
|
||||
});
|
||||
|
||||
hoisted.prepareMemoryEgressAuthorization.mockImplementation(
|
||||
(params: Parameters<PrepareMemoryEgressAuthorizationFn>[0]) => {
|
||||
const route = readRoute(params.resolveDeliveryFacts);
|
||||
if (params.capabilityId === "reply.final") {
|
||||
hoisted.queueRoute = route;
|
||||
return allowedFinalAdmission();
|
||||
}
|
||||
return { allowed: false, reason: "unregistered" };
|
||||
},
|
||||
);
|
||||
hoisted.admitMemoryEgressAtDelivery.mockImplementation(
|
||||
(params: Parameters<AdmitMemoryEgressAtDeliveryFn>[0]) => {
|
||||
hoisted.deliveryRoute = readRoute(params.resolveDeliveryFacts);
|
||||
return { allowed: false, reason: "stale" };
|
||||
},
|
||||
);
|
||||
hoisted.dispatchReplyFromConfig.mockImplementation(
|
||||
async ({ ctx: dispatchCtx, dispatcher: dispatchDispatcher }) => {
|
||||
dispatchDispatcher.sendBlockReply({ text: "streamed block" });
|
||||
dispatchDispatcher.sendFinalReply({ text: "final" });
|
||||
// This happens after queue-time preparation but before the serialized
|
||||
// dispatcher reaches its final platform deliverer.
|
||||
dispatchCtx.OriginatingTo = "chat-rebound";
|
||||
return { queuedFinal: true, counts: { tool: 0, block: 1, final: 1 } };
|
||||
},
|
||||
);
|
||||
|
||||
await dispatchInboundMessage({
|
||||
ctx,
|
||||
cfg: {} as OpenClawConfig,
|
||||
dispatcher,
|
||||
replyOptions: { runId: "run-1" },
|
||||
});
|
||||
|
||||
expect(hoisted.queueRoute).toBe("chat-before");
|
||||
expect(hoisted.deliveryRoute).toBe("chat-rebound");
|
||||
expect(hoisted.prepareMemoryEgressAuthorization).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ capabilityId: "reply.block" }),
|
||||
);
|
||||
expect(delivered).not.toHaveBeenCalled();
|
||||
expect(dispatcher.getCancelledCounts?.()).toEqual({ tool: 0, block: 1, final: 1 });
|
||||
});
|
||||
});
|
||||
+108
-1
@@ -1,5 +1,11 @@
|
||||
/** Auto-reply dispatch orchestration, hook composition, and foreground delivery fencing. */
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import {
|
||||
admitMemoryEgressAtDelivery,
|
||||
memoryEgressPayloadAuthorization,
|
||||
prepareMemoryEgressAuthorization,
|
||||
MEMORY_EGRESS_CAPABILITY_REPLY_FINAL,
|
||||
} from "../agents/memory-egress-admission.js";
|
||||
import { normalizeChatType } from "../channels/chat-type.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { isDiagnosticsEnabled } from "../infra/diagnostic-events.js";
|
||||
@@ -14,6 +20,8 @@ import {
|
||||
type ReplyPayloadSuppressedObserver,
|
||||
} from "../infra/outbound/deliver-hooks.js";
|
||||
import { logMessageReceived } from "../logging/diagnostic.js";
|
||||
import { hasOutboundReplyContent } from "../plugin-sdk/reply-payload.js";
|
||||
import { isMemoryIsolationCutoverAgent } from "../plugins/memory-cutover.js";
|
||||
import { createKeyedFifoLeaseRegistry, type KeyedFifoLease } from "../shared/keyed-fifo-lease.js";
|
||||
import type { SilentReplyConversationType } from "../shared/silent-reply-policy.js";
|
||||
import {
|
||||
@@ -21,6 +29,7 @@ import {
|
||||
resolveCommandTurnTargetSessionKey,
|
||||
} from "./command-turn-context.js";
|
||||
import { withReplyDispatcher } from "./dispatch-dispatcher.js";
|
||||
import { getReplyPayloadMetadata, setReplyPayloadMetadata } from "./reply-payload.js";
|
||||
import type { CommandSessionMetadataChange } from "./reply/command-session-metadata.js";
|
||||
import { dispatchReplyFromConfig } from "./reply/dispatch-from-config.js";
|
||||
import type { DispatchFromConfigResult } from "./reply/dispatch-from-config.types.js";
|
||||
@@ -31,6 +40,7 @@ import type {
|
||||
import { finalizeInboundContext } from "./reply/inbound-context.js";
|
||||
import {
|
||||
composeReplyDispatchBeforeDeliver,
|
||||
appendReplyDispatcherPayloadPrepare,
|
||||
createReplyDispatcher,
|
||||
createReplyDispatcherWithTyping,
|
||||
markReplyDispatchBeforeDeliverDeadlineOwned,
|
||||
@@ -51,6 +61,7 @@ const replyPayloadSendingDispatchers = new WeakSet<ReplyDispatcher>();
|
||||
const foregroundReplyLeases = createKeyedFifoLeaseRegistry(
|
||||
Symbol.for("openclaw.foregroundReplyFences"),
|
||||
);
|
||||
const memoryEgressDispatchers = new WeakSet<ReplyDispatcher>();
|
||||
|
||||
function applyRuntimeToolsAllow(
|
||||
replyOptions: InternalDispatchReplyOptions | undefined,
|
||||
@@ -165,6 +176,100 @@ function installReplyPayloadSendingBeforeDeliver(
|
||||
replyPayloadSendingDispatchers.add(dispatcher);
|
||||
}
|
||||
|
||||
function resolveMemoryEgressDeliveryContext(finalized: FinalizedMsgContext) {
|
||||
return {
|
||||
channel: finalized.OriginatingChannel ?? finalized.Surface ?? finalized.Provider,
|
||||
to: finalized.OriginatingTo ?? finalized.To ?? finalized.NativeChannelId ?? finalized.ChatId,
|
||||
accountId: finalized.AccountId,
|
||||
threadId: finalized.MessageThreadId ?? finalized.TransportThreadId,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCurrentMemoryEgressDeliveryFacts(finalized: FinalizedMsgContext) {
|
||||
const deliveryContext = resolveMemoryEgressDeliveryContext(finalized);
|
||||
return {
|
||||
deliveryContext,
|
||||
messageChannel: deliveryContext.channel,
|
||||
agentAccountId: deliveryContext.accountId,
|
||||
};
|
||||
}
|
||||
|
||||
/** Installs the constrained Phase 1D egress gate after all ordinary outbound stages. */
|
||||
function installMemoryEgressAdmission(
|
||||
dispatcher: ReplyDispatcher,
|
||||
finalized: FinalizedMsgContext,
|
||||
runState: ReplyPayloadRunState,
|
||||
): void {
|
||||
if (memoryEgressDispatchers.has(dispatcher) || !dispatcher.appendBeforeDeliver) {
|
||||
return;
|
||||
}
|
||||
// Read this source separately at queue time and immediately before the
|
||||
// dispatcher enters its platform deliverer. A queued final cannot reuse a
|
||||
// stale route snapshot after routing or recipient ownership changes.
|
||||
const resolveDeliveryFacts = () => resolveCurrentMemoryEgressDeliveryFacts(finalized);
|
||||
const memoryRunIdentity = {
|
||||
agentId: finalized.AgentId,
|
||||
sessionId: finalized.SessionId,
|
||||
sessionKey: finalized.SessionKey,
|
||||
};
|
||||
if (
|
||||
!appendReplyDispatcherPayloadPrepare(dispatcher, (payload, info) => {
|
||||
if (info.kind !== "final") {
|
||||
return;
|
||||
}
|
||||
setReplyPayloadMetadata(payload, {
|
||||
memoryEgressAuthorization: memoryEgressPayloadAuthorization(
|
||||
prepareMemoryEgressAuthorization({
|
||||
capabilityId: MEMORY_EGRESS_CAPABILITY_REPLY_FINAL,
|
||||
runId: runState.runId,
|
||||
...memoryRunIdentity,
|
||||
resolveDeliveryFacts,
|
||||
}),
|
||||
),
|
||||
});
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
dispatcher.appendBeforeDeliver((payload, info) => {
|
||||
if (info.kind !== "final") {
|
||||
// The pilot has no block/tool delivery capability. Keep this final
|
||||
// platform boundary as a backstop for direct and streamed callbacks
|
||||
// that would otherwise bypass the agent-runner's earlier deny.
|
||||
const admission = prepareMemoryEgressAuthorization({
|
||||
capabilityId: `reply.${info.kind}`,
|
||||
runId: runState.runId,
|
||||
...memoryRunIdentity,
|
||||
resolveDeliveryFacts,
|
||||
});
|
||||
return admission.allowed && !isMemoryIsolationCutoverAgent(admission.authorization.agentId)
|
||||
? payload
|
||||
: null;
|
||||
}
|
||||
const authorization = getReplyPayloadMetadata(payload)?.memoryEgressAuthorization;
|
||||
if (!authorization || authorization.kind === "denied") {
|
||||
// A final for a known cutover run must have been admitted while queued. Missing metadata is
|
||||
// therefore a deny, not an opportunity to mint a newer authority at the platform boundary.
|
||||
const prepared = prepareMemoryEgressAuthorization({
|
||||
capabilityId: MEMORY_EGRESS_CAPABILITY_REPLY_FINAL,
|
||||
runId: runState.runId,
|
||||
...memoryRunIdentity,
|
||||
resolveDeliveryFacts,
|
||||
});
|
||||
return prepared.allowed && !isMemoryIsolationCutoverAgent(prepared.authorization.agentId)
|
||||
? payload
|
||||
: null;
|
||||
}
|
||||
return admitMemoryEgressAtDelivery({
|
||||
authorization: authorization.authorization,
|
||||
resolveDeliveryFacts,
|
||||
}).allowed
|
||||
? payload
|
||||
: null;
|
||||
});
|
||||
memoryEgressDispatchers.add(dispatcher);
|
||||
}
|
||||
|
||||
function markReplyPayloadSendingBeforeDeliverInstalled(
|
||||
dispatcher: ReplyDispatcher,
|
||||
beforeDeliver: ReplyDispatchBeforeDeliver | undefined,
|
||||
@@ -210,7 +315,6 @@ export async function dispatchInboundMessage(params: {
|
||||
const replyPayloadRunState = params.replyPayloadRunState ?? {
|
||||
runId: replyOptions?.runId,
|
||||
};
|
||||
const replyOptionsWithRunState = bindReplyPayloadRunState(replyOptions, replyPayloadRunState);
|
||||
const finalized = measureDiagnosticsTimelineSpanSync(
|
||||
"auto_reply.finalize_context",
|
||||
() => finalizeInboundContext(params.ctx),
|
||||
@@ -220,6 +324,7 @@ export async function dispatchInboundMessage(params: {
|
||||
attributes: buildDispatchTimelineAttributes(params.ctx),
|
||||
},
|
||||
);
|
||||
const replyOptionsWithRunState = bindReplyPayloadRunState(replyOptions, replyPayloadRunState);
|
||||
if (isDiagnosticsEnabled(params.cfg)) {
|
||||
logMessageReceived({
|
||||
sessionKey: finalized.SessionKey,
|
||||
@@ -233,6 +338,8 @@ export async function dispatchInboundMessage(params: {
|
||||
installReplyPayloadSendingBeforeDeliver(params.dispatcher, finalized, replyPayloadRunState);
|
||||
}
|
||||
let settledReceipt: DispatchFromConfigResult["settledReceipt"];
|
||||
installMemoryEgressAdmission(params.dispatcher, finalized, replyPayloadRunState);
|
||||
let settledReceipt: DispatchFromConfigResult["settledReceipt"];
|
||||
const result = await withReplyDispatcher({
|
||||
dispatcher: params.dispatcher,
|
||||
onSettled: params.onSettled,
|
||||
|
||||
@@ -3,8 +3,9 @@ import {
|
||||
readNonBlankString,
|
||||
readNonBlankString as normalizeTtsSupplementSpokenText,
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
import type { OutboundLocation } from "../channels/location.js";
|
||||
/** Reply payload contracts and metadata helpers shared by dispatch and channel renderers. */
|
||||
import type { MemoryEgressPayloadAuthorization } from "../agents/memory-egress-admission.js";
|
||||
import type { OutboundLocation } from "../channels/location.js";
|
||||
import type { ReplyToMode } from "../config/types.base.js";
|
||||
import type {
|
||||
InteractiveReply,
|
||||
@@ -302,6 +303,8 @@ export type ReplyPayloadMetadata = {
|
||||
heartbeatTerminalToolFailure?: {
|
||||
toolName: string;
|
||||
};
|
||||
/** Queue-time memory egress authority for a final reply; never serialized to a channel. */
|
||||
memoryEgressAuthorization?: MemoryEgressPayloadAuthorization;
|
||||
};
|
||||
|
||||
const replyPayloadMetadata = new WeakMap<object, ReplyPayloadMetadata>();
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { resolveDefaultAgentId } from "../../agents/agent-scope-config.js";
|
||||
import { prepareMemoryEgressAuthorization } from "../../agents/memory-egress-admission.js";
|
||||
import { settleProgressVisibilityCallbackResult } from "../../channels/progress-visibility.js";
|
||||
import { hasRestartRecoverySourceClaim } from "../../config/sessions/restart-recovery-state.js";
|
||||
import { loadSessionEntry, updateSessionEntry } from "../../config/sessions/session-accessor.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { measureDiagnosticsTimelineSpan } from "../../infra/diagnostics-timeline.js";
|
||||
import { hasOutboundReplyContent } from "../../plugin-sdk/reply-payload.js";
|
||||
import { normalizeDeliveryContext } from "../../utils/delivery-context.shared.js";
|
||||
import { markReplyPayloadForSourceSuppressionDelivery } from "../reply-payload.js";
|
||||
import type { OriginatingChannelType } from "../templating.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
@@ -385,10 +387,52 @@ export async function runReplyAgent(
|
||||
requesterSenderUsername: followupRun.run.senderUsername,
|
||||
requesterSenderE164: followupRun.run.senderE164,
|
||||
});
|
||||
const memoryEgressDeliveryContext = normalizeDeliveryContext({
|
||||
channel:
|
||||
followupRun.originatingChannel ??
|
||||
sessionCtx.OriginatingChannel ??
|
||||
sessionCtx.Surface ??
|
||||
sessionCtx.Provider ??
|
||||
followupRun.run.messageProvider,
|
||||
to:
|
||||
followupRun.originatingTo ??
|
||||
sessionCtx.OriginatingTo ??
|
||||
sessionCtx.To ??
|
||||
sessionCtx.NativeChannelId ??
|
||||
sessionCtx.ChatId,
|
||||
accountId:
|
||||
followupRun.originatingAccountId ?? sessionCtx.AccountId ?? followupRun.run.agentAccountId,
|
||||
threadId:
|
||||
followupRun.originatingThreadId ?? sessionCtx.MessageThreadId ?? sessionCtx.TransportThreadId,
|
||||
});
|
||||
const blockEgressAllowed = () =>
|
||||
prepareMemoryEgressAuthorization({
|
||||
// Streaming, direct blocks, and maintenance notices remain outside the pilot. They must
|
||||
// never inherit final-reply authority just because they share a visible reply callback.
|
||||
capabilityId: "reply.block",
|
||||
runId: runOpts?.runId,
|
||||
agentId: followupRun.run.agentId,
|
||||
sessionId: followupRun.run.sessionId,
|
||||
sessionKey: sessionKey ?? followupRun.run.sessionKey,
|
||||
deliveryContext: memoryEgressDeliveryContext,
|
||||
messageChannel: memoryEgressDeliveryContext?.channel,
|
||||
agentAccountId: memoryEgressDeliveryContext?.accountId,
|
||||
}).allowed;
|
||||
const memoryEgressRunOpts = runOpts?.onBlockReply
|
||||
? {
|
||||
...runOpts,
|
||||
onBlockReply: async (...args: Parameters<NonNullable<typeof runOpts.onBlockReply>>) => {
|
||||
if (!blockEgressAllowed()) {
|
||||
return;
|
||||
}
|
||||
return await runOpts.onBlockReply?.(...args);
|
||||
},
|
||||
}
|
||||
: runOpts;
|
||||
const compactionNoticeMessageId = sessionCtx.MessageSidFull ?? sessionCtx.MessageSid;
|
||||
const sendDirectCompactionNotice = shouldNotifyUserAboutCompaction(cfg)
|
||||
? async (phase: CompactionNoticePhase, text?: string) => {
|
||||
if (!opts?.onBlockReply) {
|
||||
if (!memoryEgressRunOpts?.onBlockReply) {
|
||||
return;
|
||||
}
|
||||
const noticePayload = createCompactionNoticePayload({
|
||||
@@ -398,14 +442,14 @@ export async function runReplyAgent(
|
||||
applyReplyToMode,
|
||||
});
|
||||
try {
|
||||
await opts.onBlockReply(noticePayload);
|
||||
await memoryEgressRunOpts.onBlockReply(noticePayload);
|
||||
} catch (err) {
|
||||
logVerbose(`context maintenance notice delivery failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
const blockReplyCoalescing =
|
||||
blockStreamingEnabled && opts?.onBlockReply
|
||||
blockStreamingEnabled && memoryEgressRunOpts?.onBlockReply
|
||||
? resolveEffectiveBlockStreamingConfig({
|
||||
cfg,
|
||||
provider: sessionCtx.Provider,
|
||||
@@ -414,9 +458,9 @@ export async function runReplyAgent(
|
||||
}).coalescing
|
||||
: undefined;
|
||||
const blockReplyPipeline =
|
||||
blockStreamingEnabled && opts?.onBlockReply
|
||||
blockStreamingEnabled && memoryEgressRunOpts?.onBlockReply
|
||||
? createBlockReplyPipeline({
|
||||
onBlockReply: opts.onBlockReply,
|
||||
onBlockReply: memoryEgressRunOpts.onBlockReply,
|
||||
timeoutMs: blockReplyTimeoutMs,
|
||||
coalescing: blockReplyCoalescing,
|
||||
buffer: createAudioAsVoiceBuffer({ isAudioPayload }),
|
||||
@@ -580,7 +624,7 @@ export async function runReplyAgent(
|
||||
getActiveSessionEntry: () => activeSessionEntry,
|
||||
isHeartbeat,
|
||||
isRestartRecoveryArmed,
|
||||
opts: runOpts,
|
||||
opts: memoryEgressRunOpts,
|
||||
pendingToolTasks,
|
||||
performSessionReset: resetSession,
|
||||
queueKey,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { isAskUserPromptPending } from "../../agents/tools/ask-user-tool.js";
|
||||
import { normalizeAgentPlanSteps } from "../../channels/streaming.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { isMemoryIsolationCutoverAgent } from "../../plugins/memory-cutover.js";
|
||||
import { cleanDeferredFinalText } from "../../tts/captioned-final.js";
|
||||
import {
|
||||
copyReplyPayloadMetadata,
|
||||
@@ -80,6 +81,12 @@ export async function executeDispatch(state: PrepareDispatchExecutionReadyState)
|
||||
params.configOverride ? undefined : state.preparedReplyDispatchRuntime,
|
||||
state.replyResolver,
|
||||
);
|
||||
const resolverConfigOverride =
|
||||
state.preparedReplyDispatchRuntime && !params.configOverride ? undefined : replyConfig;
|
||||
// Cutover egress admits a final only. This is the one shared construction point for every
|
||||
// channel-visible progress callback, so transports cannot bypass the constrained pilot through
|
||||
// streaming drafts, block boundaries, tool updates, or presentation callbacks.
|
||||
const suppressMemoryProgress = isMemoryIsolationCutoverAgent(sessionAgentId);
|
||||
let deliberateSilentTerminalReply = false;
|
||||
let pendingContinuation = false;
|
||||
let didDeliverVisiblePartialReply = false;
|
||||
@@ -595,6 +602,36 @@ export async function executeDispatch(state: PrepareDispatchExecutionReadyState)
|
||||
};
|
||||
return run();
|
||||
},
|
||||
...(suppressMemoryProgress
|
||||
? {
|
||||
onPartialReply: undefined,
|
||||
onReasoningStream: undefined,
|
||||
onReasoningProgress: undefined,
|
||||
streamReasoningInNonStreamModes: false,
|
||||
onReasoningEnd: undefined,
|
||||
onAssistantMessageStart: undefined,
|
||||
onBlockReplyQueued: undefined,
|
||||
onBlockReply: undefined,
|
||||
onToolStart: undefined,
|
||||
onToolResult: undefined,
|
||||
onItemEvent: undefined,
|
||||
onNarrationUpdate: undefined,
|
||||
onProgressNarratorLifecycle: undefined,
|
||||
isProgressDraftVisible: undefined,
|
||||
onVerboseProgressVisibility: undefined,
|
||||
preserveProgressCallbackStartOrder: false,
|
||||
progressPreambleEnabled: false,
|
||||
onPlanUpdate: undefined,
|
||||
onApprovalEvent: undefined,
|
||||
onCommandOutput: undefined,
|
||||
onPatchSummary: undefined,
|
||||
onCompactionStart: undefined,
|
||||
onCompactionEnd: undefined,
|
||||
commentaryProgressEnabled: false,
|
||||
reasoningPayloadsEnabled: false,
|
||||
commentaryPayloadsEnabled: false,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
state.preparedReplyDispatchRuntime && !params.configOverride
|
||||
? undefined
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
import { createDispatcher, emptyConfig } from "./dispatch-from-config.shared.test-harness.js";
|
||||
import {
|
||||
dispatchReplyFromConfig,
|
||||
describe0BeforeEach0,
|
||||
globalBeforeAll0,
|
||||
setNoAbort,
|
||||
} from "./dispatch-from-config.test-harness.js";
|
||||
import { createReplyDispatcher } from "./reply-dispatcher.js";
|
||||
import { buildTestCtx } from "./test-ctx.js";
|
||||
|
||||
const memoryCutover = vi.hoisted(() => ({ enabled: false }));
|
||||
|
||||
vi.mock("../../plugins/memory-cutover.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../plugins/memory-cutover.js")>()),
|
||||
isMemoryIsolationCutoverAgent: () => memoryCutover.enabled,
|
||||
}));
|
||||
|
||||
beforeAll(globalBeforeAll0);
|
||||
|
||||
describe("memory egress progress confinement", () => {
|
||||
beforeEach(describe0BeforeEach0);
|
||||
|
||||
it("omits every channel-visible progress callback for a cutover run while retaining final delivery", async () => {
|
||||
setNoAbort();
|
||||
memoryCutover.enabled = true;
|
||||
const finalDelivery = vi.fn(async () => undefined);
|
||||
const dispatcher = createReplyDispatcher({ deliver: finalDelivery });
|
||||
const replyResolver = vi.fn(async (_ctx: MsgContext, options?: GetReplyOptions) => {
|
||||
expect(options).toEqual(
|
||||
expect.objectContaining({
|
||||
onPartialReply: undefined,
|
||||
onReasoningStream: undefined,
|
||||
onReasoningProgress: undefined,
|
||||
onReasoningEnd: undefined,
|
||||
onAssistantMessageStart: undefined,
|
||||
onBlockReplyQueued: undefined,
|
||||
onBlockReply: undefined,
|
||||
onToolStart: undefined,
|
||||
onToolResult: undefined,
|
||||
onItemEvent: undefined,
|
||||
onNarrationUpdate: undefined,
|
||||
onProgressNarratorLifecycle: undefined,
|
||||
onPlanUpdate: undefined,
|
||||
onApprovalEvent: undefined,
|
||||
onCommandOutput: undefined,
|
||||
onPatchSummary: undefined,
|
||||
onCompactionStart: undefined,
|
||||
onCompactionEnd: undefined,
|
||||
commentaryProgressEnabled: false,
|
||||
reasoningPayloadsEnabled: false,
|
||||
commentaryPayloadsEnabled: false,
|
||||
}),
|
||||
);
|
||||
return { text: "admitted final" } satisfies ReplyPayload;
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await dispatchReplyFromConfig({
|
||||
ctx: buildTestCtx({
|
||||
AgentId: "memory-agent",
|
||||
SessionKey: "agent:memory-agent:direct:alice",
|
||||
}),
|
||||
cfg: emptyConfig,
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
onPartialReply: vi.fn(),
|
||||
onReasoningStream: vi.fn(),
|
||||
onBlockReplyQueued: vi.fn(),
|
||||
onToolResult: vi.fn(),
|
||||
onItemEvent: vi.fn(),
|
||||
onNarrationUpdate: vi.fn(),
|
||||
onProgressNarratorLifecycle: vi.fn(),
|
||||
onPlanUpdate: vi.fn(),
|
||||
onApprovalEvent: vi.fn(),
|
||||
onCommandOutput: vi.fn(),
|
||||
onPatchSummary: vi.fn(),
|
||||
onCompactionStart: vi.fn(),
|
||||
onCompactionEnd: vi.fn(),
|
||||
},
|
||||
replyResolver,
|
||||
});
|
||||
|
||||
await dispatcher.waitForIdle();
|
||||
expect(result).toMatchObject({ queuedFinal: true, counts: { final: 1 } });
|
||||
expect(finalDelivery).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
memoryCutover.enabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps progress callbacks available outside cutover", async () => {
|
||||
setNoAbort();
|
||||
const onPartialReply = vi.fn();
|
||||
const replyResolver = vi.fn(async (_ctx: MsgContext, options?: GetReplyOptions) => {
|
||||
expect(options?.onPartialReply).toBeTypeOf("function");
|
||||
return { text: "ordinary final" } satisfies ReplyPayload;
|
||||
});
|
||||
|
||||
await dispatchReplyFromConfig({
|
||||
ctx: buildTestCtx({
|
||||
AgentId: "ordinary-agent",
|
||||
SessionKey: "agent:ordinary-agent:direct:alice",
|
||||
}),
|
||||
cfg: emptyConfig,
|
||||
dispatcher: createDispatcher(),
|
||||
replyOptions: { onPartialReply },
|
||||
replyResolver,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getReplyPayloadMetadata, setReplyPayloadMetadata } from "../reply-payload.js";
|
||||
import { appendReplyDispatcherPayloadPrepare, createReplyDispatcher } from "./reply-dispatcher.js";
|
||||
|
||||
describe("reply dispatcher queue-time egress metadata", () => {
|
||||
it("does not call mocked final delivery when the queued authority is stale", async () => {
|
||||
const deliver = vi.fn(async () => undefined);
|
||||
const dispatcher = createReplyDispatcher({ deliver });
|
||||
let exposureRevision = 1;
|
||||
|
||||
expect(
|
||||
appendReplyDispatcherPayloadPrepare(dispatcher, (payload, info) => {
|
||||
if (info.kind === "final") {
|
||||
setReplyPayloadMetadata(payload, {
|
||||
memoryEgressAuthorization: { exposureRevision } as never,
|
||||
});
|
||||
}
|
||||
}),
|
||||
).toBe(true);
|
||||
dispatcher.appendBeforeDeliver?.((payload, info) =>
|
||||
info.kind !== "final" ||
|
||||
(
|
||||
getReplyPayloadMetadata(payload)?.memoryEgressAuthorization as unknown as {
|
||||
exposureRevision?: number;
|
||||
}
|
||||
)?.exposureRevision === exposureRevision
|
||||
? payload
|
||||
: null,
|
||||
);
|
||||
|
||||
expect(dispatcher.sendFinalReply({ text: "queued" })).toBe(true);
|
||||
exposureRevision = 2;
|
||||
await dispatcher.waitForIdle();
|
||||
|
||||
expect(deliver).not.toHaveBeenCalled();
|
||||
expect(dispatcher.getCancelledCounts?.().final).toBe(1);
|
||||
});
|
||||
|
||||
it("delivers a final whose queue-time authorization still matches", async () => {
|
||||
const deliver = vi.fn(async () => undefined);
|
||||
const dispatcher = createReplyDispatcher({ deliver });
|
||||
expect(
|
||||
appendReplyDispatcherPayloadPrepare(dispatcher, (payload, info) => {
|
||||
if (info.kind === "final") {
|
||||
setReplyPayloadMetadata(payload, {
|
||||
memoryEgressAuthorization: { exposureRevision: 1 } as never,
|
||||
});
|
||||
}
|
||||
}),
|
||||
).toBe(true);
|
||||
dispatcher.appendBeforeDeliver?.((payload) => payload);
|
||||
|
||||
dispatcher.sendFinalReply({ text: "current" });
|
||||
await dispatcher.waitForIdle();
|
||||
|
||||
expect(deliver).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -68,6 +68,7 @@ type ReplyDispatchCancelHandler = (
|
||||
) => Promise<void> | void;
|
||||
|
||||
export { isReplyDispatchProvenInvisible, type ReplyDispatchDeliveryOutcome };
|
||||
type ReplyDispatchPayloadPrepare = (payload: ReplyPayload, info: ReplyDispatchRuntimeInfo) => void;
|
||||
|
||||
function isRetryableNoSendFailure(error: unknown): boolean {
|
||||
return (
|
||||
@@ -105,6 +106,8 @@ export type { ReplyDispatchBeforeDeliver };
|
||||
export { composeReplyDispatchBeforeDeliver, markReplyDispatchBeforeDeliverDeadlineOwned };
|
||||
|
||||
const silentReplyLogger = createSubsystemLogger("silent-reply/dispatcher");
|
||||
const beforeDeliverCancelledHooks = new WeakMap<ReplyDispatcher, ReplyDispatchCancelHandler[]>();
|
||||
const payloadPrepareHooks = new WeakMap<ReplyDispatcher, ReplyDispatchPayloadPrepare[]>();
|
||||
const deliveryOutcomeTrackers = new WeakMap<ReplyPayload, ReplyDispatchDeliveryOutcomeTracker>();
|
||||
const undeliveredFallbacks = new WeakMap<ReplyPayload, ReplyPayload>();
|
||||
const replyDispatcherPreparers = new WeakMap<
|
||||
@@ -115,6 +118,31 @@ const replyDispatcherPreparers = new WeakMap<
|
||||
}
|
||||
>();
|
||||
|
||||
/** Adds a core-internal cancellation observer without expanding the plugin-facing dispatcher. */
|
||||
export function appendReplyDispatcherBeforeDeliverCancelled(
|
||||
dispatcher: ReplyDispatcher,
|
||||
hook: ReplyDispatchCancelHandler,
|
||||
): boolean {
|
||||
const hooks = beforeDeliverCancelledHooks.get(dispatcher);
|
||||
if (!hooks) {
|
||||
return false;
|
||||
}
|
||||
hooks.push(hook);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Adds a synchronous core-only queue-time payload preparer. */
|
||||
export function appendReplyDispatcherPayloadPrepare(
|
||||
dispatcher: ReplyDispatcher,
|
||||
hook: ReplyDispatchPayloadPrepare,
|
||||
): boolean {
|
||||
const hooks = payloadPrepareHooks.get(dispatcher);
|
||||
if (!hooks) {
|
||||
return false;
|
||||
}
|
||||
hooks.push(hook);
|
||||
return true;
|
||||
}
|
||||
/** Capture one core-dispatcher delivery outcome without changing send* return types. */
|
||||
export function captureReplyDispatchDeliveryOutcome(payload: ReplyPayload): {
|
||||
promise: Promise<ReplyDispatchDeliveryOutcome>;
|
||||
@@ -255,6 +283,8 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis
|
||||
? { hook: options.beforeDeliver, options: options.beforeDeliverOptions }
|
||||
: undefined,
|
||||
);
|
||||
const appendedBeforeDeliverCancelledHooks: ReplyDispatchCancelHandler[] = [];
|
||||
const appendedPayloadPrepareHooks: ReplyDispatchPayloadPrepare[] = [];
|
||||
// Track in-flight deliveries so we can emit a reliable "idle" signal.
|
||||
// Start with pending=1 as a "reservation" to prevent premature gateway restart.
|
||||
// This is decremented when markComplete() is called to signal no more replies will come.
|
||||
@@ -351,24 +381,29 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis
|
||||
payload: ReplyPayload,
|
||||
info: ReplyDispatchRuntimeInfo,
|
||||
) => {
|
||||
const observer = options.onBeforeDeliverCancelled;
|
||||
if (!observer) {
|
||||
const observers = [
|
||||
...(options.onBeforeDeliverCancelled ? [options.onBeforeDeliverCancelled] : []),
|
||||
...appendedBeforeDeliverCancelledHooks,
|
||||
];
|
||||
if (observers.length === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await runReplyDispatchBeforeDeliverStage(
|
||||
{
|
||||
hook: async (current, currentInfo) => {
|
||||
await observer(current, currentInfo);
|
||||
return current;
|
||||
for (const observer of observers) {
|
||||
try {
|
||||
await runReplyDispatchBeforeDeliverStage(
|
||||
{
|
||||
hook: async (current, currentInfo) => {
|
||||
await observer(current, currentInfo);
|
||||
return current;
|
||||
},
|
||||
timeoutMs: DEFAULT_BEFORE_DELIVER_TIMEOUT_MS,
|
||||
},
|
||||
timeoutMs: DEFAULT_BEFORE_DELIVER_TIMEOUT_MS,
|
||||
},
|
||||
payload,
|
||||
info,
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
reportObserverError(err, info);
|
||||
payload,
|
||||
info,
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
reportObserverError(err, info);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -479,6 +514,10 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis
|
||||
});
|
||||
|
||||
const enqueue = (kind: ReplyDispatchKind, payload: ReplyPayload) => {
|
||||
const queueInfo = buildReplyDispatchRuntimeInfo(payload, kind);
|
||||
for (const prepare of appendedPayloadPrepareHooks) {
|
||||
prepare(payload, queueInfo);
|
||||
}
|
||||
const fallback = undeliveredFallbacks.get(payload);
|
||||
undeliveredFallbacks.delete(payload);
|
||||
const originalWasExactSilent = isSilentReplyText(payload.text, SILENT_REPLY_TOKEN);
|
||||
@@ -634,6 +673,8 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis
|
||||
owner: dispatcher,
|
||||
normalize: (kind, payload) => normalizeForDispatch(kind, payload, true),
|
||||
});
|
||||
beforeDeliverCancelledHooks.set(dispatcher, appendedBeforeDeliverCancelledHooks);
|
||||
payloadPrepareHooks.set(dispatcher, appendedPayloadPrepareHooks);
|
||||
return dispatcher;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type AuthorizedMemoryRuntime,
|
||||
type MemoryAuthorizationCapabilityName,
|
||||
} from "../plugin-sdk/memory-authorization.js";
|
||||
import type { MemoryPluginVirtualViewProvider } from "./registry-contribution-types.js";
|
||||
|
||||
const AUTHORIZED_MEMORY_RUNTIME_METHODS = [
|
||||
"authorize",
|
||||
@@ -31,7 +32,8 @@ const AUTHORIZED_MEMORY_READ_CAPABILITIES = [
|
||||
|
||||
export type AdmittedAuthorizedMemoryReadRuntime = Readonly<
|
||||
Pick<AuthorizedMemoryRuntime, (typeof AUTHORIZED_MEMORY_READ_METHODS)[number]>
|
||||
>;
|
||||
> &
|
||||
Readonly<{ virtualView?: MemoryPluginVirtualViewProvider }>;
|
||||
|
||||
export type MemoryAuthorizationReadAdmission =
|
||||
| Readonly<{ ok: true; runtime: AdmittedAuthorizedMemoryReadRuntime }>
|
||||
@@ -191,6 +193,12 @@ function readCallable(value: unknown, key: string): ((...args: never[]) => unkno
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readVirtualViewProvider(value: unknown): MemoryPluginVirtualViewProvider | undefined {
|
||||
const materialize = readCallable(value, "materializeAuthorizedVirtualView");
|
||||
const readFile = readCallable(value, "readAuthorizedVirtualFile");
|
||||
return materialize && readFile ? (value as MemoryPluginVirtualViewProvider) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforced callers use this admission result directly. A failed alternate has no legacy runtime
|
||||
* in the result, so it cannot silently broaden a scoped read through the old search manager.
|
||||
@@ -201,6 +209,7 @@ export async function admitMemoryAuthorizationReadRuntime(
|
||||
const authorization = readDataProperty(capability, "authorization");
|
||||
const runtime = readDataProperty(capability, "runtime");
|
||||
const conformance = readDataProperty(capability, "authorizationConformance");
|
||||
const virtualView = readDataProperty(capability, "virtualView");
|
||||
const authorizationCapabilities =
|
||||
authorization.kind === "data" && isMemoryAuthorizationCapabilities(authorization.value)
|
||||
? authorization.value
|
||||
@@ -238,6 +247,9 @@ export async function admitMemoryAuthorizationReadRuntime(
|
||||
readAuthorized: (readAuthorized as AuthorizedMemoryRuntime["readAuthorized"]).bind(
|
||||
runtime.value,
|
||||
),
|
||||
...(virtualView.kind === "data" && readVirtualViewProvider(virtualView.value)
|
||||
? { virtualView: readVirtualViewProvider(virtualView.value) }
|
||||
: {}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ vi.mock("../logger.js", () => ({
|
||||
const {
|
||||
MEMORY_INVOCATION_UNAVAILABLE,
|
||||
createAuthorizedMemoryReadInvocation,
|
||||
materializeAuthorizedMemoryVirtualView,
|
||||
readAuthorizedMemoryVirtualFile,
|
||||
readAuthorizedMemoryForInvocation,
|
||||
readAuthorizedMemoryRunExposure,
|
||||
searchAuthorizedMemoryForInvocation,
|
||||
@@ -686,4 +688,245 @@ describe("authorized memory read invocation", () => {
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("binds virtual reads to the admitted provider and exact manifest members", async () => {
|
||||
const admittedVirtualView = {
|
||||
materializeAuthorizedVirtualView: vi.fn(async () => ({
|
||||
version: 1 as const,
|
||||
viewId: "view-1",
|
||||
planId: "plan-1",
|
||||
contextFingerprint: "fingerprint-1",
|
||||
revision: "revision-1",
|
||||
roots: [
|
||||
{
|
||||
version: 1 as const,
|
||||
mountHandle: "mount-1",
|
||||
virtualRoot: "private",
|
||||
access: "read" as const,
|
||||
},
|
||||
],
|
||||
files: [{ version: 1 as const, mountHandle: "mount-1", virtualPath: "private/1.md" }],
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
})),
|
||||
readAuthorizedVirtualFile: vi.fn(async () =>
|
||||
createEnvelope({ text: "allowed", path: "memory/private/1.md" }),
|
||||
),
|
||||
};
|
||||
const plan = {
|
||||
...createPlan(),
|
||||
mounts: [
|
||||
{
|
||||
version: 1 as const,
|
||||
agentId: "main",
|
||||
mountHandle: "mount-1",
|
||||
capabilities: ["read"] as const,
|
||||
audienceRevision: "audience-1",
|
||||
},
|
||||
],
|
||||
};
|
||||
const runtime = {
|
||||
authorize: vi.fn().mockResolvedValue(plan),
|
||||
searchAuthorized: vi.fn(),
|
||||
readAuthorized: vi.fn(),
|
||||
virtualView: admittedVirtualView,
|
||||
};
|
||||
mocks.materialize.mockReturnValue(createContext());
|
||||
mocks.admit.mockResolvedValue({ ok: true, runtime });
|
||||
|
||||
const invocation = await createAuthorizedMemoryReadInvocation({ context: {} as never });
|
||||
if (invocation === MEMORY_INVOCATION_UNAVAILABLE) {
|
||||
throw new Error("fixture failed to admit virtual provider");
|
||||
}
|
||||
const view = await materializeAuthorizedMemoryVirtualView({ invocation });
|
||||
if (view === MEMORY_INVOCATION_UNAVAILABLE) {
|
||||
throw new Error("fixture failed to materialize virtual view");
|
||||
}
|
||||
await expect(
|
||||
readAuthorizedMemoryVirtualFile({ invocation, view, virtualPath: "private/2.md" }),
|
||||
).resolves.toBe(MEMORY_INVOCATION_UNAVAILABLE);
|
||||
expect(admittedVirtualView.readAuthorizedVirtualFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects malformed duplicate and case-colliding virtual views before any broker read", async () => {
|
||||
const admittedVirtualView = {
|
||||
materializeAuthorizedVirtualView: vi.fn(async () => ({
|
||||
version: 1 as const,
|
||||
viewId: "view-duplicate",
|
||||
planId: "plan-1",
|
||||
contextFingerprint: "fingerprint-1",
|
||||
revision: "revision-1",
|
||||
roots: [
|
||||
{
|
||||
version: 1 as const,
|
||||
mountHandle: "mount-1",
|
||||
virtualRoot: "private",
|
||||
access: "read" as const,
|
||||
},
|
||||
],
|
||||
files: [
|
||||
{ version: 1 as const, mountHandle: "mount-1", virtualPath: "private/Note.md" },
|
||||
{ version: 1 as const, mountHandle: "mount-1", virtualPath: "private/note.md" },
|
||||
],
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
})),
|
||||
readAuthorizedVirtualFile: vi.fn(),
|
||||
};
|
||||
const runtime = {
|
||||
authorize: vi.fn().mockResolvedValue({
|
||||
...createPlan(),
|
||||
mounts: [
|
||||
{
|
||||
version: 1 as const,
|
||||
agentId: "main",
|
||||
mountHandle: "mount-1",
|
||||
capabilities: ["read"] as const,
|
||||
audienceRevision: "audience-1",
|
||||
},
|
||||
],
|
||||
}),
|
||||
searchAuthorized: vi.fn(),
|
||||
readAuthorized: vi.fn(),
|
||||
virtualView: admittedVirtualView,
|
||||
};
|
||||
mocks.materialize.mockReturnValue(createContext());
|
||||
mocks.admit.mockResolvedValue({ ok: true, runtime });
|
||||
|
||||
const invocation = await createAuthorizedMemoryReadInvocation({ context: {} as never });
|
||||
if (invocation === MEMORY_INVOCATION_UNAVAILABLE) {
|
||||
throw new Error("fixture failed to admit virtual provider");
|
||||
}
|
||||
await expect(materializeAuthorizedMemoryVirtualView({ invocation })).resolves.toBe(
|
||||
MEMORY_INVOCATION_UNAVAILABLE,
|
||||
);
|
||||
expect(admittedVirtualView.readAuthorizedVirtualFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("freezes the admitted virtual revision and denies caller-shaped replacement views", async () => {
|
||||
const issuedView = {
|
||||
version: 1 as const,
|
||||
viewId: "view-frozen",
|
||||
planId: "plan-1",
|
||||
contextFingerprint: "fingerprint-1",
|
||||
revision: "revision-1",
|
||||
roots: [
|
||||
{
|
||||
version: 1 as const,
|
||||
mountHandle: "mount-1",
|
||||
virtualRoot: "private",
|
||||
access: "read" as const,
|
||||
},
|
||||
],
|
||||
files: [{ version: 1 as const, mountHandle: "mount-1", virtualPath: "private/1.md" }],
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
};
|
||||
const admittedVirtualView = {
|
||||
materializeAuthorizedVirtualView: vi.fn(async () => issuedView),
|
||||
readAuthorizedVirtualFile: vi.fn(async () =>
|
||||
createEnvelope({ text: "allowed", path: "memory/private/1.md" }),
|
||||
),
|
||||
};
|
||||
const runtime = {
|
||||
authorize: vi.fn().mockResolvedValue({
|
||||
...createPlan(),
|
||||
mounts: [
|
||||
{
|
||||
version: 1 as const,
|
||||
agentId: "main",
|
||||
mountHandle: "mount-1",
|
||||
capabilities: ["read"] as const,
|
||||
audienceRevision: "audience-1",
|
||||
},
|
||||
],
|
||||
}),
|
||||
searchAuthorized: vi.fn(),
|
||||
readAuthorized: vi.fn(),
|
||||
virtualView: admittedVirtualView,
|
||||
};
|
||||
mocks.materialize.mockReturnValue(createContext());
|
||||
mocks.admit.mockResolvedValue({ ok: true, runtime });
|
||||
|
||||
const invocation = await createAuthorizedMemoryReadInvocation({ context: {} as never });
|
||||
if (invocation === MEMORY_INVOCATION_UNAVAILABLE) {
|
||||
throw new Error("fixture failed to admit virtual provider");
|
||||
}
|
||||
const view = await materializeAuthorizedMemoryVirtualView({ invocation });
|
||||
if (view === MEMORY_INVOCATION_UNAVAILABLE) {
|
||||
throw new Error("fixture failed to materialize virtual view");
|
||||
}
|
||||
issuedView.revision = "replacement-revision";
|
||||
issuedView.files[0]!.virtualPath = "private/replacement.md";
|
||||
|
||||
await expect(
|
||||
readAuthorizedMemoryVirtualFile({ invocation, view, virtualPath: "private/1.md" }),
|
||||
).resolves.toEqual({ text: "allowed", path: "memory/private/1.md" });
|
||||
await expect(
|
||||
readAuthorizedMemoryVirtualFile({
|
||||
invocation,
|
||||
view: { ...view, revision: "forged-revision" },
|
||||
virtualPath: "private/1.md",
|
||||
}),
|
||||
).resolves.toBe(MEMORY_INVOCATION_UNAVAILABLE);
|
||||
expect(admittedVirtualView.readAuthorizedVirtualFile).toHaveBeenCalledOnce();
|
||||
expect(admittedVirtualView.readAuthorizedVirtualFile.mock.calls[0]?.[0]).toMatchObject({
|
||||
view: expect.objectContaining({ revision: "revision-1" }),
|
||||
virtualPath: "private/1.md",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the admitted virtual provider when the mutable runtime registry object is replaced", async () => {
|
||||
const admitted = {
|
||||
materializeAuthorizedVirtualView: vi.fn(async () => ({
|
||||
version: 1 as const,
|
||||
viewId: "view-admitted",
|
||||
planId: "plan-1",
|
||||
contextFingerprint: "fingerprint-1",
|
||||
revision: "revision-1",
|
||||
roots: [
|
||||
{
|
||||
version: 1 as const,
|
||||
mountHandle: "mount-1",
|
||||
virtualRoot: "private",
|
||||
access: "read" as const,
|
||||
},
|
||||
],
|
||||
files: [{ version: 1 as const, mountHandle: "mount-1", virtualPath: "private/1.md" }],
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
})),
|
||||
readAuthorizedVirtualFile: vi.fn(),
|
||||
};
|
||||
const replacement = {
|
||||
materializeAuthorizedVirtualView: vi.fn(),
|
||||
readAuthorizedVirtualFile: vi.fn(),
|
||||
};
|
||||
const runtime = {
|
||||
authorize: vi.fn().mockResolvedValue({
|
||||
...createPlan(),
|
||||
mounts: [
|
||||
{
|
||||
version: 1 as const,
|
||||
agentId: "main",
|
||||
mountHandle: "mount-1",
|
||||
capabilities: ["read"] as const,
|
||||
audienceRevision: "audience-1",
|
||||
},
|
||||
],
|
||||
}),
|
||||
searchAuthorized: vi.fn(),
|
||||
readAuthorized: vi.fn(),
|
||||
virtualView: admitted,
|
||||
};
|
||||
mocks.materialize.mockReturnValue(createContext());
|
||||
mocks.admit.mockResolvedValue({ ok: true, runtime });
|
||||
|
||||
const invocation = await createAuthorizedMemoryReadInvocation({ context: {} as never });
|
||||
if (invocation === MEMORY_INVOCATION_UNAVAILABLE) {
|
||||
throw new Error("fixture failed to admit virtual provider");
|
||||
}
|
||||
runtime.virtualView = replacement;
|
||||
await expect(materializeAuthorizedMemoryVirtualView({ invocation })).resolves.toMatchObject({
|
||||
viewId: "view-admitted",
|
||||
});
|
||||
expect(admitted.materializeAuthorizedVirtualView).toHaveBeenCalledOnce();
|
||||
expect(replacement.materializeAuthorizedVirtualView).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { logWarn } from "../logger.js";
|
||||
import type {
|
||||
AuthorizedMemoryVirtualView,
|
||||
AuthorizedMemoryPlan,
|
||||
AuthorizedMemoryResultEnvelope,
|
||||
AuthorizedResourceHandle,
|
||||
@@ -25,7 +26,10 @@ import {
|
||||
} from "./memory-run-exposure-ledger.js";
|
||||
import { prepareMemoryRunExposure, publishMemoryRunExposure } from "./memory-run-exposure.js";
|
||||
import { resolveSelectedMemoryCapabilityRegistration } from "./memory-state.js";
|
||||
import type { MemoryPluginCapability } from "./registry-contribution-types.js";
|
||||
import type {
|
||||
MemoryPluginCapability,
|
||||
MemoryPluginVirtualViewProvider,
|
||||
} from "./registry-contribution-types.js";
|
||||
import { requireActivePluginRegistry } from "./runtime.js";
|
||||
|
||||
export type MemoryInvocationUnavailable = Readonly<{
|
||||
@@ -52,6 +56,10 @@ type InvocationState = Readonly<{
|
||||
plan: AuthorizedMemoryPlan & Readonly<{ operation: "read" }>;
|
||||
authorizationStartedAtMs: number;
|
||||
runtime: AdmittedAuthorizedMemoryReadRuntime;
|
||||
/** Bound at admission; registry changes cannot replace this run's provider. */
|
||||
virtualView?: MemoryPluginVirtualViewProvider;
|
||||
/** Canonical broker-issued views for this invocation; caller-shaped lookalikes never authorize reads. */
|
||||
virtualViews: Map<string, AuthorizedMemoryVirtualView>;
|
||||
handles: Map<string, AuthorizedResourceHandle>;
|
||||
sourcePolicySetIds: Set<string>;
|
||||
exposedRevisionHandles: Set<string>;
|
||||
@@ -60,6 +68,8 @@ type InvocationState = Readonly<{
|
||||
runExposureRevisions: Set<string>;
|
||||
}>;
|
||||
|
||||
const VIRTUAL_ROOT_PATTERN = /^[a-z][a-z0-9-]{0,63}$/u;
|
||||
|
||||
const invocationStates = new WeakMap<object, InvocationState>();
|
||||
|
||||
type MemoryInvocationDiagnostic =
|
||||
@@ -272,6 +282,84 @@ function readState(invocation: AuthorizedMemoryReadInvocation): InvocationState
|
||||
return invocationStates.get(invocation);
|
||||
}
|
||||
|
||||
function canonicalizeAuthorizedVirtualView(params: {
|
||||
view: AuthorizedMemoryVirtualView;
|
||||
context: MemoryContentAccessContext<"read">;
|
||||
plan: AuthorizedMemoryPlan & Readonly<{ operation: "read" }>;
|
||||
}): AuthorizedMemoryVirtualView | undefined {
|
||||
const { view, context, plan } = params;
|
||||
const mountHandles = new Set(plan.mounts.map((mount) => mount.mountHandle));
|
||||
const rootNames = new Map<string, string>();
|
||||
const rootHandles = new Set<string>();
|
||||
const virtualPaths = new Set<string>();
|
||||
const expiresAt = Date.parse(view.expiresAt);
|
||||
const valid =
|
||||
view.version === 1 &&
|
||||
view.planId === plan.planId &&
|
||||
view.contextFingerprint === context.contextFingerprint &&
|
||||
typeof view.viewId === "string" &&
|
||||
view.viewId.trim().length > 0 &&
|
||||
typeof view.revision === "string" &&
|
||||
view.revision.trim().length > 0 &&
|
||||
Number.isFinite(expiresAt) &&
|
||||
expiresAt > Date.now() &&
|
||||
view.roots.length > 0 &&
|
||||
view.roots.every((root) => {
|
||||
const normalized = root.virtualRoot.normalize("NFC");
|
||||
const rootKey = normalized.toLocaleLowerCase("en-US");
|
||||
if (
|
||||
root.version !== 1 ||
|
||||
root.access !== "read" ||
|
||||
!mountHandles.has(root.mountHandle) ||
|
||||
!normalized ||
|
||||
!VIRTUAL_ROOT_PATTERN.test(normalized) ||
|
||||
normalized !== root.virtualRoot ||
|
||||
rootNames.has(rootKey) ||
|
||||
rootHandles.has(root.mountHandle)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
rootNames.set(rootKey, root.mountHandle);
|
||||
rootHandles.add(root.mountHandle);
|
||||
return true;
|
||||
}) &&
|
||||
view.files.every((file) => {
|
||||
const normalized = file.virtualPath.normalize("NFC");
|
||||
const parts = normalized.split("/");
|
||||
const root = parts[0]!;
|
||||
const pathKey = normalized.toLocaleLowerCase("en-US");
|
||||
return file.version === 1 &&
|
||||
typeof file.mountHandle === "string" &&
|
||||
file.mountHandle.trim().length > 0 &&
|
||||
normalized === file.virtualPath &&
|
||||
parts.length === 2 &&
|
||||
Boolean(root) &&
|
||||
Boolean(parts[1]) &&
|
||||
parts[1] !== "." &&
|
||||
parts[1] !== ".." &&
|
||||
!parts[1]!.includes("\\") &&
|
||||
rootNames.get(root.toLocaleLowerCase("en-US")) === file.mountHandle &&
|
||||
!virtualPaths.has(pathKey)
|
||||
? (virtualPaths.add(pathKey), true)
|
||||
: false;
|
||||
});
|
||||
if (!valid) {
|
||||
return undefined;
|
||||
}
|
||||
// Keep the exact opaque revision and manifest that the admitted provider issued.
|
||||
// Shallow-freezing provider data would let a later caller retarget a broker read.
|
||||
return Object.freeze({
|
||||
version: 1 as const,
|
||||
viewId: view.viewId,
|
||||
planId: view.planId,
|
||||
contextFingerprint: view.contextFingerprint,
|
||||
revision: view.revision,
|
||||
roots: Object.freeze(view.roots.map((root) => Object.freeze({ ...root }))),
|
||||
files: Object.freeze(view.files.map((file) => Object.freeze({ ...file }))),
|
||||
expiresAt: view.expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a process-local, opaque read invocation. No caller can inject a serializable identity,
|
||||
* audience, plan, or continuation: all of those come from the trusted context and selected backend.
|
||||
@@ -311,6 +399,8 @@ export async function createAuthorizedMemoryReadInvocation(params: {
|
||||
plan,
|
||||
authorizationStartedAtMs,
|
||||
runtime: admission.runtime,
|
||||
...(admission.runtime.virtualView ? { virtualView: admission.runtime.virtualView } : {}),
|
||||
virtualViews: new Map(),
|
||||
handles: new Map(),
|
||||
sourcePolicySetIds: new Set<string>(),
|
||||
exposedRevisionHandles: new Set<string>(),
|
||||
@@ -326,6 +416,83 @@ export async function createAuthorizedMemoryReadInvocation(params: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtains a selected-plugin projection for generic FS and sandbox consumers.
|
||||
* It is deliberately separate from search/read: no tool argument can turn a
|
||||
* host path, artifact locator, or mount handle into a projection request.
|
||||
*/
|
||||
export async function materializeAuthorizedMemoryVirtualView(params: {
|
||||
invocation: AuthorizedMemoryReadInvocation;
|
||||
}): Promise<AuthorizedMemoryVirtualView | MemoryInvocationUnavailable> {
|
||||
const state = readState(params.invocation);
|
||||
const context = state ? readCurrentContext(state) : undefined;
|
||||
if (!state || !context || !state.virtualView) {
|
||||
return MEMORY_INVOCATION_UNAVAILABLE;
|
||||
}
|
||||
try {
|
||||
const view = await state.virtualView.materializeAuthorizedVirtualView({
|
||||
context,
|
||||
plan: state.plan,
|
||||
});
|
||||
const canonical = view
|
||||
? canonicalizeAuthorizedVirtualView({ view, context, plan: state.plan })
|
||||
: undefined;
|
||||
if (!canonical) {
|
||||
return MEMORY_INVOCATION_UNAVAILABLE;
|
||||
}
|
||||
state.virtualViews.set(canonical.viewId, canonical);
|
||||
return canonical;
|
||||
} catch {
|
||||
return MEMORY_INVOCATION_UNAVAILABLE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one broker-addressed file from an opaque virtual view. The selected
|
||||
* plugin never gives core a storage path, and the durable exposure receipt is
|
||||
* committed before its content becomes visible to a generic file tool.
|
||||
*/
|
||||
export async function readAuthorizedMemoryVirtualFile(params: {
|
||||
invocation: AuthorizedMemoryReadInvocation;
|
||||
view: AuthorizedMemoryVirtualView;
|
||||
virtualPath: string;
|
||||
}): Promise<MemoryReadResult | MemoryInvocationUnavailable> {
|
||||
const state = readState(params.invocation);
|
||||
const context = state ? readCurrentContext(state) : undefined;
|
||||
if (
|
||||
!state ||
|
||||
!context ||
|
||||
!state.virtualView ||
|
||||
!isCurrentPlan({ context, plan: state.plan, nowMs: Date.now() }) ||
|
||||
state.virtualViews.get(params.view.viewId) !== params.view ||
|
||||
!params.view.files.some((file) => file.virtualPath === params.virtualPath)
|
||||
) {
|
||||
return MEMORY_INVOCATION_UNAVAILABLE;
|
||||
}
|
||||
try {
|
||||
const envelope = await state.virtualView.readAuthorizedVirtualFile({
|
||||
context,
|
||||
plan: state.plan,
|
||||
view: params.view,
|
||||
virtualPath: params.virtualPath,
|
||||
});
|
||||
if (
|
||||
!validateEnvelope({
|
||||
state,
|
||||
context,
|
||||
expectedRevisionHandles: envelope.exposureReceipt.exposedRevisionHandles,
|
||||
envelope,
|
||||
})
|
||||
) {
|
||||
return MEMORY_INVOCATION_UNAVAILABLE;
|
||||
}
|
||||
recordEnvelopeExposure({ state, context, envelope });
|
||||
return Object.freeze({ ...envelope.value });
|
||||
} catch {
|
||||
return MEMORY_INVOCATION_UNAVAILABLE;
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchAuthorizedMemoryForInvocation(params: {
|
||||
invocation: AuthorizedMemoryReadInvocation;
|
||||
query: string;
|
||||
|
||||
@@ -30,6 +30,7 @@ const {
|
||||
hydrateMemoryRunExposureFromLedger,
|
||||
persistMemoryRunExposureBeforeContent,
|
||||
readDurableMemoryRunExposure,
|
||||
readLatestDurableMemoryRunExposure,
|
||||
} = await import("./memory-run-exposure-ledger.js");
|
||||
const { clearMemoryRunExposureForTest, prepareMemoryRunExposure } =
|
||||
await import("./memory-run-exposure.js");
|
||||
@@ -75,6 +76,30 @@ function prepare(sessionId: string) {
|
||||
}
|
||||
|
||||
describe("memory pre-output exposure ledger", () => {
|
||||
it("distinguishes an absent delivery tail from a current durable exposure", () => {
|
||||
expect(
|
||||
readLatestDurableMemoryRunExposure({
|
||||
agentId: "main",
|
||||
sessionId: "session-a",
|
||||
runId: "shared-run-id",
|
||||
}),
|
||||
).toEqual({ kind: "absent" });
|
||||
|
||||
const snapshot = prepare("session-a");
|
||||
expect(persistMemoryRunExposureBeforeContent(snapshot)).toBe(true);
|
||||
|
||||
expect(
|
||||
readLatestDurableMemoryRunExposure({
|
||||
agentId: "main",
|
||||
sessionId: "session-a",
|
||||
runId: "shared-run-id",
|
||||
}),
|
||||
).toMatchObject({
|
||||
kind: "current",
|
||||
snapshot: { exposureSetId: snapshot.exposureSetId, revisionNumber: snapshot.revisionNumber },
|
||||
});
|
||||
});
|
||||
|
||||
it("commits content-free rows before publication and separates the same raw run across sessions", () => {
|
||||
const first = prepare("session-a");
|
||||
const second = prepare("session-b");
|
||||
|
||||
@@ -40,6 +40,11 @@ type MemoryPreoutputExposureLedgerDatabase = {
|
||||
|
||||
type MemoryExposureLedgerDiagnostic = "hydrate-failed" | "persist-failed";
|
||||
|
||||
export type DurableMemoryRunExposureLookup =
|
||||
| Readonly<{ kind: "absent" }>
|
||||
| Readonly<{ kind: "current"; snapshot: MemoryRunExposureSnapshot }>
|
||||
| Readonly<{ kind: "unavailable" }>;
|
||||
|
||||
function logMemoryExposureLedgerDiagnostic(diagnostic: MemoryExposureLedgerDiagnostic): void {
|
||||
// Ledger errors can carry SQLite paths or other sensitive runtime details. The read already
|
||||
// fails closed, so emit only a stable outcome code for operators and tests.
|
||||
@@ -53,7 +58,9 @@ function canonicalStrings(values: readonly string[]): string | undefined {
|
||||
return JSON.stringify([...new Set(values)].toSorted());
|
||||
}
|
||||
|
||||
function canonicalAudiences(snapshot: MemoryRunExposureSnapshot): string | undefined {
|
||||
function canonicalAudiences(
|
||||
snapshot: Pick<MemoryRunExposureSnapshot, "deliveryAudiences">,
|
||||
): string | undefined {
|
||||
const audiences = snapshot.deliveryAudiences.map((audience) => ({
|
||||
kind: audience.kind,
|
||||
id: audience.id,
|
||||
@@ -121,8 +128,9 @@ function parseCanonicalAudiences(value: string): readonly AudienceRef[] | undefi
|
||||
}),
|
||||
);
|
||||
}
|
||||
const snapshot = { deliveryAudiences: audiences } as MemoryRunExposureSnapshot;
|
||||
return canonicalAudiences(snapshot) === value ? Object.freeze(audiences) : undefined;
|
||||
return canonicalAudiences({ deliveryAudiences: audiences }) === value
|
||||
? Object.freeze(audiences)
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
@@ -288,6 +296,32 @@ export function readDurableMemoryRunExposure(params: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the ledger's durable tail for a delivery decision. Absence is distinct from an
|
||||
* unreadable/corrupt ledger: an unexposed run may reply, but a scoped run never guesses.
|
||||
*/
|
||||
export function readLatestDurableMemoryRunExposure(params: {
|
||||
agentId: string;
|
||||
sessionId: string;
|
||||
runId: string;
|
||||
}): DurableMemoryRunExposureLookup {
|
||||
try {
|
||||
const database = openOpenClawAgentDatabase({ agentId: params.agentId });
|
||||
ensureMemoryPreoutputExposureLedgerSchemaInTransaction(database.db);
|
||||
const snapshot = readDurableMemoryRunExposureOrThrow({
|
||||
database,
|
||||
sessionId: params.sessionId,
|
||||
runId: params.runId,
|
||||
});
|
||||
return snapshot
|
||||
? Object.freeze({ kind: "current", snapshot })
|
||||
: Object.freeze({ kind: "absent" });
|
||||
} catch {
|
||||
logMemoryExposureLedgerDiagnostic("hydrate-failed");
|
||||
return Object.freeze({ kind: "unavailable" });
|
||||
}
|
||||
}
|
||||
|
||||
function readDurableMemoryRunExposureOrThrow(params: {
|
||||
database: OpenClawAgentDatabase;
|
||||
sessionId: string;
|
||||
|
||||
@@ -6,9 +6,14 @@ import type { ContextEngine } from "../context-engine/types.js";
|
||||
import type { MemoryAuthorizationConformanceAdapter } from "../memory-host-sdk/host/authorization-conformance.js";
|
||||
import type {
|
||||
AuthorizedMemoryRuntime,
|
||||
AuthorizedMemoryContentPlan,
|
||||
AuthorizedMemoryResultEnvelope,
|
||||
AuthorizedMemoryVirtualView,
|
||||
MemoryAuthorizationCapabilities,
|
||||
MemoryContentAccessContext,
|
||||
} from "../memory-host-sdk/host/authorization.js";
|
||||
import type { MemorySearchManager, MemorySearchResult } from "../memory-host-sdk/host/types.js";
|
||||
import type { MemoryReadResult } from "../memory-host-sdk/host/types.js";
|
||||
import type {
|
||||
EmbeddingProvider,
|
||||
EmbeddingProviderAdapter,
|
||||
@@ -327,11 +332,31 @@ export type MemoryPluginPublicArtifactsProvider = {
|
||||
listArtifacts(params: { cfg: OpenClawConfig }): Promise<MemoryPluginPublicArtifact[]>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Selected-memory-only virtual projection. The implementation owns artifact
|
||||
* access and returns an opaque, read-only view; core only mounts the returned
|
||||
* projection and never derives paths from resource metadata.
|
||||
*/
|
||||
export type MemoryPluginVirtualViewProvider = {
|
||||
materializeAuthorizedVirtualView(params: {
|
||||
context: MemoryContentAccessContext<"read">;
|
||||
plan: AuthorizedMemoryContentPlan<"read">;
|
||||
}): Promise<AuthorizedMemoryVirtualView | undefined>;
|
||||
readAuthorizedVirtualFile(params: {
|
||||
context: MemoryContentAccessContext<"read">;
|
||||
plan: AuthorizedMemoryContentPlan<"read">;
|
||||
view: AuthorizedMemoryVirtualView;
|
||||
/** Virtual-only slash-separated path, never a host filesystem path. */
|
||||
virtualPath: string;
|
||||
}): Promise<AuthorizedMemoryResultEnvelope<MemoryReadResult>>;
|
||||
};
|
||||
|
||||
export type MemoryPluginCapability = {
|
||||
/** Declares the selected backend's authorization support even when it has no runtime. */
|
||||
authorization?: MemoryAuthorizationCapabilities;
|
||||
/** Plugin-owned pure evaluator; core verifies it before an enforced read admission. */
|
||||
authorizationConformance?: MemoryAuthorizationConformanceAdapter;
|
||||
virtualView?: MemoryPluginVirtualViewProvider;
|
||||
promptBuilder?: MemoryPromptSectionBuilder;
|
||||
flushPlanResolver?: MemoryFlushPlanResolver;
|
||||
runtime?: MemoryPluginRuntime;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { generateSecureUuid } from "../infra/secure-random.js";
|
||||
import {
|
||||
adminLinkAdmittedMemoryIdentity as linkAdmittedMemoryIdentity,
|
||||
recheckMemoryIdentityBinding,
|
||||
recheckMemoryIdentityBindingRecipient,
|
||||
resolveMemoryIdentityBindingFromAdmission,
|
||||
} from "./memory-identity.js";
|
||||
import {
|
||||
@@ -123,6 +124,44 @@ describe("memory identity binding", () => {
|
||||
).toEqual({ kind: "expired" });
|
||||
});
|
||||
|
||||
it("attests a direct delivery target without exposing the persisted sender identity", () => {
|
||||
const { env, profileId } = fixture();
|
||||
const binding = adminLinkAdmittedMemoryIdentity({
|
||||
admission: admitted("telegram", "default", "sender-recipient"),
|
||||
authenticatedOperatorProfileId: profileId,
|
||||
authenticatedOperatorScopes: ["operator.admin"],
|
||||
options: { env },
|
||||
});
|
||||
|
||||
expect(
|
||||
recheckMemoryIdentityBindingRecipient({
|
||||
bindingId: binding.bindingId,
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
recipientId: "sender-recipient",
|
||||
options: { env },
|
||||
}),
|
||||
).toMatchObject({ kind: "current", binding: { bindingId: binding.bindingId } });
|
||||
expect(
|
||||
recheckMemoryIdentityBindingRecipient({
|
||||
bindingId: binding.bindingId,
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
recipientId: "another-recipient",
|
||||
options: { env },
|
||||
}),
|
||||
).toEqual({ kind: "unbound" });
|
||||
expect(
|
||||
recheckMemoryIdentityBindingRecipient({
|
||||
bindingId: binding.bindingId,
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
recipientId: "sender-recipient",
|
||||
options: { env },
|
||||
}),
|
||||
).toEqual({ kind: "unbound" });
|
||||
});
|
||||
|
||||
it("fails closed when a damaged shared database contains conflicting active bindings", () => {
|
||||
const { env, profileId } = fixture();
|
||||
const binding = adminLinkAdmittedMemoryIdentity({
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "../channels/message-access/memory-identity-admission.js";
|
||||
import { generateSecureUuid } from "../infra/secure-random.js";
|
||||
import { normalizeAccountId } from "../routing/account-id.js";
|
||||
import { safeEqualSecret } from "../security/secret-equal.js";
|
||||
import { MEMORY_IDENTITY_SCHEMA_SQL } from "./memory-identity-schema.js";
|
||||
import {
|
||||
openOpenClawStateDatabase,
|
||||
@@ -781,6 +782,49 @@ export function recheckMemoryIdentityBinding(params: {
|
||||
return { kind: "current", binding: toBinding(row) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Recheck a direct-recipient route against the binding's retained sender proof.
|
||||
* The route keeps its raw target at the transport boundary; this helper reduces
|
||||
* it to the same scoped HMAC before comparison so callers cannot recover IDs.
|
||||
*/
|
||||
export function recheckMemoryIdentityBindingRecipient(params: {
|
||||
bindingId: string;
|
||||
channel: string;
|
||||
accountId: string;
|
||||
recipientId: string;
|
||||
options?: OpenClawStateDatabaseOptions;
|
||||
}): MemoryIdentityBindingCheck {
|
||||
const current = recheckMemoryIdentityBinding({
|
||||
bindingId: params.bindingId,
|
||||
options: params.options,
|
||||
});
|
||||
if (current.kind !== "current") {
|
||||
return current;
|
||||
}
|
||||
const channel = requireText(params.channel, "channel").toLowerCase();
|
||||
const accountId = normalizeAccountId(requireText(params.accountId, "accountId"));
|
||||
if (current.binding.channel !== channel || current.binding.accountId !== accountId) {
|
||||
return { kind: "unbound" };
|
||||
}
|
||||
const options = params.options ?? {};
|
||||
ensureMemoryIdentitySchema(options);
|
||||
const database = openOpenClawStateDatabase(options);
|
||||
const row = database.db
|
||||
.prepare(
|
||||
"SELECT sender_lookup_hmac FROM memory_identity_bindings WHERE binding_id = ? AND revoked_at IS NULL",
|
||||
)
|
||||
.get(current.binding.bindingId) as { sender_lookup_hmac: string } | undefined;
|
||||
const recipientLookup = lookupHmac(
|
||||
database.db,
|
||||
channel,
|
||||
accountId,
|
||||
requireText(params.recipientId, "recipientId"),
|
||||
);
|
||||
return row && safeEqualSecret(row.sender_lookup_hmac, recipientLookup)
|
||||
? current
|
||||
: { kind: "unbound" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active binding from a consumed adapter proof. This is deliberately
|
||||
* core-only: raw channel ids and sender text are evidence, never an input that
|
||||
|
||||
@@ -353,7 +353,13 @@ describe("memory session subject", () => {
|
||||
sessionId,
|
||||
options: agentOptions,
|
||||
}),
|
||||
).toMatchObject({ kind: "current", context: { subject: { kind: "conversation" } } });
|
||||
).toMatchObject({
|
||||
kind: "current",
|
||||
context: {
|
||||
subject: { kind: "conversation" },
|
||||
conversation: { deliveryTarget: `${chatType}-1` },
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ export type CurrentMemorySessionContext = Readonly<{
|
||||
channel: string;
|
||||
accountId: string;
|
||||
primaryConversationId: string;
|
||||
deliveryTarget: string;
|
||||
}>;
|
||||
authorityRevision: string;
|
||||
fingerprint: string;
|
||||
@@ -266,13 +267,14 @@ export function createCurrentMemorySessionContext(params: {
|
||||
ss.session_id AS snapshot_session_id, ss.session_key AS snapshot_session_key,
|
||||
ss.subject_revision AS snapshot_subject_revision,
|
||||
ss.session_identity_revision,
|
||||
sw.channel AS conversation_channel, sw.account_id AS conversation_account_id,
|
||||
sw.primary_conversation_id
|
||||
c.channel AS conversation_channel, c.account_id AS conversation_account_id,
|
||||
sw.primary_conversation_id, c.delivery_target AS conversation_delivery_target
|
||||
FROM session_nodes sn
|
||||
LEFT JOIN session_memory_subjects ms ON ms.session_key = sn.session_key
|
||||
LEFT JOIN session_memory_subject_snapshots ss ON ss.session_id = sn.current_session_id
|
||||
LEFT JOIN session_windows sw
|
||||
ON sw.session_id = sn.current_session_id AND sw.session_key = sn.session_key
|
||||
LEFT JOIN conversations c ON c.conversation_id = sw.primary_conversation_id
|
||||
WHERE sn.session_key = ?`,
|
||||
)
|
||||
.get(sessionKey) as
|
||||
@@ -289,6 +291,7 @@ export function createCurrentMemorySessionContext(params: {
|
||||
conversation_channel: string | null;
|
||||
conversation_account_id: string | null;
|
||||
primary_conversation_id: string | null;
|
||||
conversation_delivery_target: string | null;
|
||||
}
|
||||
| undefined;
|
||||
if (
|
||||
@@ -318,11 +321,13 @@ export function createCurrentMemorySessionContext(params: {
|
||||
persisted.subject.kind === "conversation" &&
|
||||
row.conversation_channel &&
|
||||
row.conversation_account_id &&
|
||||
row.primary_conversation_id
|
||||
row.primary_conversation_id &&
|
||||
row.conversation_delivery_target
|
||||
? Object.freeze({
|
||||
channel: row.conversation_channel,
|
||||
accountId: row.conversation_account_id,
|
||||
primaryConversationId: row.primary_conversation_id,
|
||||
deliveryTarget: row.conversation_delivery_target,
|
||||
})
|
||||
: undefined;
|
||||
if (persisted.subject.kind === "conversation" && !conversation) {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { DefaultResourceLoader } from "../agents/sessions/resource-loader.js";
|
||||
import { createAgentSession } from "../agents/sessions/sdk.js";
|
||||
import { SessionManager } from "../agents/sessions/session-manager.js";
|
||||
import { SettingsManager } from "../agents/sessions/settings-manager.js";
|
||||
import { createToolFsPolicy } from "../agents/tool-fs-policy.js";
|
||||
import { resolveToolLoopDetectionConfig } from "../agents/tool-loop-detection-config.js";
|
||||
import { wrapToolWithGatewayCallerIdentity } from "../agents/tools/gateway-caller-context.js";
|
||||
import { DEFAULT_AGENTS_FILENAME, loadWorkspaceBootstrapFiles } from "../agents/workspace.js";
|
||||
@@ -174,7 +175,7 @@ export async function runWorkerEmbeddedTurn(params: RunWorkerEmbeddedTurnParams)
|
||||
containmentRoot: params.workerContainmentRoot,
|
||||
includeBaseCodingTools: true,
|
||||
includeShellTools: !params.memoryIsolationCutover,
|
||||
workspaceOnly: permissionToolPolicy?.workspaceOnly ?? false,
|
||||
fsPolicy: createToolFsPolicy({ workspaceOnly: permissionToolPolicy?.workspaceOnly }),
|
||||
readOnly: params.memoryIsolationCutover || permissionToolPolicy?.readOnly === true,
|
||||
modelContextWindowTokens: model.contextWindow,
|
||||
imageSanitization: {},
|
||||
|
||||
Reference in New Issue
Block a user