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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions extensions/openshell/src/backend.exec-workdir.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,9 @@ describe("openshell backend exec workdir validation", () => {
cwd: workspaceDir,
});
expect(backend.runtimeId).toMatch(/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/);
expect(backend.runtimeId).toContain("somalley-alice");
expect(backend.runtimeId).toMatch(/^os-age-[a-f0-9]{12}$/);
expect(backend.runtimeId).not.toContain("_");
expect(backend.runtimeId.length).toBeLessThanOrEqual(63);
expect(backend.runtimeId.length).toBeLessThanOrEqual(19);
expect(execSpec.env.OPENAI_API_KEY).toBeUndefined();
expect(execSpec.env.ANTHROPIC_API_KEY).toBeUndefined();
expect(execSpec.env.LANG).toBe("en_US.UTF-8");
Expand Down
51 changes: 51 additions & 0 deletions extensions/openshell/src/backend.name.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Openshell tests cover deterministic sandbox naming.
import { describe, expect, it } from "vitest";
import { buildOpenShellSandboxName } from "./backend.js";

const DNS_1123_LABEL_RE = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/;

describe("OpenShell sandbox names", () => {
const scopeKeys = [
"agent:main",
"agent:an-agent-with-a-long-scope-key-that-needs-compaction",
"session:primary",
"shared:team:engineering",
"agent:hello__world..!",
"agent:\u{1F99E}:\u00DCnicode",
"",
];

it.each(scopeKeys)("keeps %j within the OpenShell DNS label limit", (scopeKey) => {
const name = buildOpenShellSandboxName(scopeKey);

expect(name.length).toBeLessThanOrEqual(19);
expect(name).toMatch(DNS_1123_LABEL_RE);
expect(name).not.toContain("--");
});

it("is deterministic across repeated calls", () => {
const scopeKey = "agent:main:thread:123";

expect(buildOpenShellSandboxName(scopeKey)).toBe(buildOpenShellSandboxName(scopeKey));
});

it("keeps representative session, agent, and shared keys distinct", () => {
const names = new Set(
["session:primary", "agent:main", "shared:team"].map(buildOpenShellSandboxName),
);

expect(names.size).toBe(3);
});

it("uses the hash suffix to distinguish values with the same slug", () => {
const first = buildOpenShellSandboxName("agent:foo_bar");
const second = buildOpenShellSandboxName("agent:foo.bar");

expect(first.slice(0, 7)).toBe(second.slice(0, 7));
expect(first).not.toBe(second);
});

it("uses the session fallback for empty input", () => {
expect(buildOpenShellSandboxName(" ")).toBe(buildOpenShellSandboxName("session"));
});
});
16 changes: 8 additions & 8 deletions extensions/openshell/src/backend.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Openshell plugin module implements backend behavior.
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import type {
Expand Down Expand Up @@ -891,17 +892,16 @@ function resolveOpenShellPluginConfigFromConfig(
return resolveOpenShellPluginConfig(pluginConfig);
}

function buildOpenShellSandboxName(scopeKey: string): string {
export function buildOpenShellSandboxName(scopeKey: string): string {
const trimmed = scopeKey.trim() || "session";
const safe = normalizeLowercaseStringOrEmpty(trimmed)
const slug = normalizeLowercaseStringOrEmpty(trimmed)
.replace(/[^a-z0-9-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 32);
const hash = Array.from(trimmed).reduce(
(acc, char) => ((acc * 33) ^ char.charCodeAt(0)) >>> 0,
5381,
);
return `openclaw-${safe || "session"}-${hash.toString(16).slice(0, 8)}`;
.slice(0, 3)
.replace(/-+$/g, "");
const hash = createHash("sha256").update(trimmed).digest("hex").slice(0, 12);
return `os-${slug || "x"}-${hash}`;
}

function resolveRemoteMaterializedSkillsWorkspaceDir(remoteWorkspaceDir: string): string {
Expand Down
69 changes: 64 additions & 5 deletions extensions/openshell/src/openshell-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,66 @@ describe("openshell backend manager", () => {
});
});

it("removes runtimes via openshell sandbox delete", async () => {
it("reuses a found generated sandbox without creating a duplicate", async () => {
const workspaceDir = await makeTempDir("openclaw-openshell-workspace-");
const factory = createOpenShellSandboxBackendFactory({
pluginConfig: resolveOpenShellPluginConfig({ command: "openshell", mode: "remote" }),
});
const backend = await factory({
sessionKey: "agent:main:turn",
scopeKey: "agent:main",
workspaceDir,
agentWorkspaceDir: workspaceDir,
cfg: createOpenShellBackendSandboxConfig(),
});

cliMocks.runOpenShellCli.mockResolvedValue({ code: 0, stdout: "", stderr: "" });
await backend.runShellCommand?.({ script: "true" });
await backend.runShellCommand?.({ script: "true" });

const lifecycleCalls = cliMocks.runOpenShellCli.mock.calls
.map(([params]) => params)
.filter(
(params) => params.args[0] === "sandbox" && ["get", "create"].includes(params.args[1]),
);
expect(lifecycleCalls).toEqual([
expect.objectContaining({ args: ["sandbox", "get", backend.runtimeId] }),
]);
});

it("creates one generated sandbox after a failed lookup", async () => {
const workspaceDir = await makeTempDir("openclaw-openshell-workspace-");
const factory = createOpenShellSandboxBackendFactory({
pluginConfig: resolveOpenShellPluginConfig({ command: "openshell", mode: "remote" }),
});
const backend = await factory({
sessionKey: "agent:main:turn",
scopeKey: "agent:main",
workspaceDir,
agentWorkspaceDir: workspaceDir,
cfg: createOpenShellBackendSandboxConfig(),
});

cliMocks.runOpenShellCli
.mockResolvedValueOnce({ code: 1, stdout: "", stderr: "not found" })
.mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" });
await backend.runShellCommand?.({ script: "true" });
await backend.runShellCommand?.({ script: "true" });

const lifecycleCalls = cliMocks.runOpenShellCli.mock.calls
.map(([params]) => params)
.filter(
(params) => params.args[0] === "sandbox" && ["get", "create"].includes(params.args[1]),
);
expect(lifecycleCalls).toEqual([
expect.objectContaining({ args: ["sandbox", "get", backend.runtimeId] }),
expect.objectContaining({
args: expect.arrayContaining(["sandbox", "create", "--name", backend.runtimeId]),
}),
]);
});

it("removes runtimes using their stored sandbox name", async () => {
cliMocks.runOpenShellCli.mockResolvedValue({
code: 0,
stdout: "",
Expand All @@ -369,9 +428,9 @@ describe("openshell backend manager", () => {

await manager.removeRuntime({
entry: {
containerName: "openclaw-session-5678",
containerName: "openclaw-session-5678-legacy-runtime-name",
backendId: "openshell",
runtimeLabel: "openclaw-session-5678",
runtimeLabel: "openclaw-session-5678-legacy-runtime-name",
sessionKey: "agent:main",
createdAtMs: 1,
lastUsedAtMs: 1,
Expand All @@ -387,10 +446,10 @@ describe("openshell backend manager", () => {
});
expect(cliMocks.runOpenShellCli).toHaveBeenCalledWith({
context: {
sandboxName: "openclaw-session-5678",
sandboxName: "openclaw-session-5678-legacy-runtime-name",
config: expectedConfig,
},
args: ["sandbox", "delete", "openclaw-session-5678"],
args: ["sandbox", "delete", "openclaw-session-5678-legacy-runtime-name"],
});
});

Expand Down
Loading