mirror of
https://github.com/openclaw/openclaw.git
synced 2026-11-01 04:58:23 +00:00
* fix(session-share): withdraw stale catalog rows during refresh Reauthorize fresh-list catalogs and expanded pages before publication, reject superseded responses, and expose Retry after a failed refresh. Preserve ordinary catalog paging and progressive host updates. Extracted from the Session Share work in https://github.com/openclaw/openclaw/pull/156736; person selection and Open original remain separate slices. * test(session-share): align integration reads with fresh authorization
809 lines
32 KiB
TypeScript
809 lines
32 KiB
TypeScript
import fs from "node:fs";
|
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
|
import { validateJsonSchemaValue } from "openclaw/plugin-sdk/json-schema-runtime";
|
|
import type {
|
|
OpenClawPluginNodeHostCommand,
|
|
OpenClawPluginApi,
|
|
} from "openclaw/plugin-sdk/plugin-entry";
|
|
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
|
|
import {
|
|
createTestPluginApi,
|
|
createTestPluginServiceScheduler,
|
|
} from "openclaw/plugin-sdk/plugin-test-api";
|
|
import { createPluginRuntimeMock } from "openclaw/plugin-sdk/plugin-test-runtime";
|
|
import {
|
|
sessionCatalogPaging,
|
|
type SessionCatalogProvider,
|
|
type SessionCatalogSession,
|
|
type SessionCatalogTranscriptItem,
|
|
} from "openclaw/plugin-sdk/session-catalog";
|
|
import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
|
|
import { appendSessionTranscriptMessageByIdentity } from "openclaw/plugin-sdk/session-transcript-runtime";
|
|
import { withOpenClawTestState } from "openclaw/plugin-sdk/test-state";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import sessionSharePlugin from "../../extensions/session-share/index.js";
|
|
import { setRuntimeConfigSnapshot } from "../../src/config/runtime-snapshot.js";
|
|
import {
|
|
replaceSessionEntry,
|
|
upsertSessionEntryCore,
|
|
} from "../../src/config/sessions/session-accessor.js";
|
|
import type { SessionEntry } from "../../src/config/sessions/types.js";
|
|
import { createPluginRuntime } from "../../src/plugins/runtime/index.js";
|
|
import { createDeferredCore } from "../../src/shared/deferred.js";
|
|
import {
|
|
closeOpenClawAgentDatabasesAsync,
|
|
openOpenClawAgentDatabase,
|
|
} from "../../src/state/openclaw-agent-db.js";
|
|
import * as stateReads from "../../src/state/openclaw-state-db-readonly.js";
|
|
import { openOpenClawStateDatabase } from "../../src/state/openclaw-state-db.js";
|
|
import * as githubIdentities from "../../src/state/user-profile-github-identity.js";
|
|
import { linkEmail, syncGitHubIdentity } from "../../src/state/user-profile-writes.worker.js";
|
|
import { ensureProfileForEmail } from "../../src/state/user-profiles.js";
|
|
import { requireNativeSqliteDatabase } from "../../src/test-utils/native-sqlite.js";
|
|
import { trackSqliteStatementExecutions } from "../helpers/sqlite-statement-execution-counter.js";
|
|
|
|
afterEach(() => vi.restoreAllMocks());
|
|
|
|
function registerSessionShare(runtime: PluginRuntime, config: OpenClawConfig = {}) {
|
|
const nodeCommands: OpenClawPluginNodeHostCommand[] = [];
|
|
const catalogs: SessionCatalogProvider[] = [];
|
|
const services: Parameters<OpenClawPluginApi["registerService"]>[0][] = [];
|
|
const api = createTestPluginApi({
|
|
runtime,
|
|
config,
|
|
registerNodeHostCommand: (command) => nodeCommands.push(command),
|
|
registerSessionCatalog: (catalog) => catalogs.push(catalog),
|
|
registerService: (service) => services.push(service),
|
|
});
|
|
sessionSharePlugin.register(api);
|
|
const catalog = catalogs.find((entry) => entry.id === "openclaw");
|
|
if (!catalog) {
|
|
throw new Error("Session Share did not register its catalog");
|
|
}
|
|
return { commands: nodeCommands, catalog, services, logger: api.logger };
|
|
}
|
|
|
|
type SessionPage = { sessions: SessionCatalogSession[]; nextCursor?: string };
|
|
type TranscriptPage = {
|
|
threadId: string;
|
|
items: SessionCatalogTranscriptItem[];
|
|
nextCursor?: string;
|
|
};
|
|
|
|
function sessionScope(id: string) {
|
|
return { agentId: "main", sessionKey: `agent:main:${id}`, sessionId: id };
|
|
}
|
|
|
|
async function writeSession(id: string, patch: Partial<SessionEntry> = {}) {
|
|
const scope = sessionScope(id);
|
|
await replaceSessionEntry(scope, {
|
|
sessionId: id,
|
|
updatedAt: Date.now(),
|
|
category: "Team",
|
|
...patch,
|
|
});
|
|
return scope;
|
|
}
|
|
|
|
function profileActor(id: string) {
|
|
return { type: "human", source: "profile", id } as const;
|
|
}
|
|
|
|
function githubProfile(accountId: number, login: string, name: string, email?: string) {
|
|
return syncGitHubIdentity({
|
|
identity: { accountId, login, name },
|
|
authenticationAlias: email ? { kind: "email", email } : { kind: "github-login", login },
|
|
});
|
|
}
|
|
|
|
function linkedActor(id: string) {
|
|
return { type: "human", id, identity: { type: "profile", id }, label: "Catalog Person" };
|
|
}
|
|
|
|
async function writeProfileSession(id: string, profileId: string, updatedAt = Date.now()) {
|
|
const scope = sessionScope(id);
|
|
await upsertSessionEntryCore(scope, {
|
|
sessionId: id,
|
|
updatedAt,
|
|
label: "Shared",
|
|
category: "Team",
|
|
createdActor: profileActor(profileId),
|
|
});
|
|
return {
|
|
scope,
|
|
appendUser: (content: string, timestamp?: number) =>
|
|
appendSessionTranscriptMessageByIdentity({
|
|
...scope,
|
|
message: {
|
|
role: "user",
|
|
content,
|
|
timestamp,
|
|
__openclaw: { senderIdentity: { type: "profile", id: profileId } },
|
|
},
|
|
}),
|
|
};
|
|
}
|
|
|
|
function withCommands(run: (fixture: ReturnType<typeof commandFixture>) => Promise<void>) {
|
|
return withOpenClawTestState({ scenario: "minimal" }, () => run(commandFixture()));
|
|
}
|
|
|
|
function expectSingleItem(items: unknown, shape: unknown) {
|
|
expect(items).toMatchObject([shape]);
|
|
}
|
|
|
|
function sqlCounter(
|
|
db: Parameters<typeof requireNativeSqliteDatabase>[0],
|
|
rules: Record<string, RegExp[]>,
|
|
) {
|
|
return trackSqliteStatementExecutions(
|
|
requireNativeSqliteDatabase(db),
|
|
Object.keys(rules),
|
|
(sql) =>
|
|
Object.entries(rules).find(([, patterns]) => patterns.every((re) => re.test(sql)))?.[0] ??
|
|
null,
|
|
);
|
|
}
|
|
|
|
function commandFixture(groups: string[] = ["Team"]) {
|
|
const config: OpenClawConfig = {
|
|
plugins: { entries: { "session-share": { enabled: true, config: { share: { groups } } } } },
|
|
};
|
|
setRuntimeConfigSnapshot(config);
|
|
const runtime = createPluginRuntime();
|
|
runtime.config.current = () => config;
|
|
const commands = registerSessionShare(runtime, config).commands;
|
|
const list = commands.find((command) => command.command === "openclaw.sessions.list.v1")!;
|
|
const read = commands.find((command) => command.command === "openclaw.sessions.read.v1")!;
|
|
return {
|
|
config,
|
|
commands,
|
|
list: async (params: Record<string, unknown> = {}) =>
|
|
JSON.parse(await list.handle(JSON.stringify(params))) as SessionPage,
|
|
read: async (threadId: string, params: Record<string, unknown> = {}) =>
|
|
JSON.parse(await read.handle(JSON.stringify({ threadId, ...params }))) as TranscriptPage,
|
|
};
|
|
}
|
|
|
|
const commands = ["openclaw.sessions.list.v1", "openclaw.sessions.read.v1"];
|
|
const nativeSession: SessionCatalogSession = {
|
|
threadId: "agent:main:shared",
|
|
name: "Shared session",
|
|
status: "idle",
|
|
archived: false,
|
|
canContinue: false,
|
|
canArchive: false,
|
|
canOpenTerminal: false,
|
|
};
|
|
const remoteIdentity = {
|
|
type: "remote" as const,
|
|
pluginId: "session-share",
|
|
domain: "source",
|
|
idKind: "github-account",
|
|
id: "4242",
|
|
};
|
|
|
|
function remoteHuman(threadId: string, id = "4242", domain = remoteIdentity.domain) {
|
|
return {
|
|
...nativeSession,
|
|
threadId,
|
|
createdActor: {
|
|
type: "human" as const,
|
|
id,
|
|
identity: { ...remoteIdentity, id, domain },
|
|
label: "Remote Person",
|
|
},
|
|
};
|
|
}
|
|
|
|
function nodeConfig(
|
|
nodes: Record<string, { owner?: string; linkGitHubIdentities?: boolean }>,
|
|
): OpenClawConfig {
|
|
return { plugins: { entries: { "session-share": { config: { nodes } } } } };
|
|
}
|
|
|
|
function publishedActor(id: string, label: string, idKind = "github-account") {
|
|
return {
|
|
type: "human",
|
|
id,
|
|
label,
|
|
identity: { type: "remote", pluginId: "session-share", domain: "openclaw", idKind, id },
|
|
};
|
|
}
|
|
|
|
function holdStateRead() {
|
|
const entered = createDeferredCore();
|
|
const release = createDeferredCore();
|
|
const execute = stateReads.executeExistingOpenClawStateRead;
|
|
vi.spyOn(stateReads, "executeExistingOpenClawStateRead").mockImplementationOnce(
|
|
async (...args) => {
|
|
const result = await execute(...args);
|
|
entered.resolve();
|
|
await release.promise;
|
|
return result;
|
|
},
|
|
);
|
|
return { entered: entered.promise, release: () => release.resolve() };
|
|
}
|
|
|
|
function catalogFixture(stateDir: string) {
|
|
let config: OpenClawConfig = {};
|
|
const list = vi.fn<PluginRuntime["nodes"]["list"]>().mockResolvedValue({
|
|
nodes: [{ nodeId: "alpha", displayName: " Alpha ", connected: true, commands }],
|
|
});
|
|
const invoke = vi.fn<PluginRuntime["nodes"]["invoke"]>();
|
|
const runtime = createPluginRuntimeMock({
|
|
config: { current: () => config },
|
|
nodes: { list, invoke },
|
|
});
|
|
const { catalog, services, logger } = registerSessionShare(runtime);
|
|
const scheduler = createTestPluginServiceScheduler();
|
|
const serviceContext = { config, stateDir, logger, invokeNode: invoke, scheduler };
|
|
return {
|
|
catalog,
|
|
list,
|
|
invoke,
|
|
read: async (query: Parameters<typeof catalog.list>[0] = {}) => {
|
|
const page = catalog.list(query);
|
|
void page.catch(() => {});
|
|
await vi.advanceTimersByTimeAsync(2);
|
|
return page;
|
|
},
|
|
hydrate: async () => {
|
|
const publications: Promise<void>[] = [];
|
|
await catalog.list({
|
|
allowPartialResults: true,
|
|
onHost: () => {},
|
|
waitUntil: (work) => publications.push(work),
|
|
});
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
await Promise.all(publications);
|
|
},
|
|
start: () => Promise.all(services.map(async (service) => await service.start(serviceContext))),
|
|
stop: async () => {
|
|
scheduler.beginClose();
|
|
try {
|
|
await Promise.all(services.map(async (service) => await service.stop?.(serviceContext)));
|
|
} finally {
|
|
await scheduler.stop();
|
|
}
|
|
},
|
|
setConfig: (next: OpenClawConfig) => {
|
|
config = next;
|
|
},
|
|
};
|
|
}
|
|
|
|
async function withCatalogFixture(
|
|
run: (fixture: ReturnType<typeof catalogFixture>) => Promise<void>,
|
|
) {
|
|
await withOpenClawTestState({ scenario: "minimal" }, async (state) => {
|
|
vi.useFakeTimers();
|
|
const fixture = catalogFixture(state.stateDir);
|
|
try {
|
|
await fixture.start();
|
|
await run(fixture);
|
|
} finally {
|
|
// Stop all publishers before the fixture closes its private databases and environment.
|
|
await fixture.stop().finally(() => vi.useRealTimers());
|
|
}
|
|
});
|
|
}
|
|
|
|
describe("session-share node commands", () => {
|
|
it("refreshes shared metadata without rescanning the inventory on the node thread", async () => {
|
|
await withCommands(async (source) => {
|
|
expect((await source.list()).sessions).toEqual([]);
|
|
const scope = { agentId: "main", sessionKey: "agent:main:shared-cache" };
|
|
const entry = {
|
|
sessionId: "shared-cache",
|
|
updatedAt: 100,
|
|
label: "Original",
|
|
category: "Team",
|
|
};
|
|
await replaceSessionEntry(scope, entry);
|
|
expect((await source.list()).sessions).toMatchObject([{ name: "Original" }]);
|
|
const { db } = openOpenClawAgentDatabase({ agentId: "main" });
|
|
const counter = sqlCounter(db, {
|
|
inventory: [/from "session_nodes" order by "session_key"/, /"entry_json"/],
|
|
});
|
|
try {
|
|
expect((await source.list()).sessions).toMatchObject([{ name: "Original" }]);
|
|
expect(counter.counts.inventory).toBe(0);
|
|
await replaceSessionEntry(scope, { ...entry, label: "Renamed" });
|
|
expect((await source.list()).sessions).toMatchObject([{ name: "Renamed" }]);
|
|
await replaceSessionEntry(scope, { ...entry, category: "Private" });
|
|
expect((await source.list()).sessions).toEqual([]);
|
|
await replaceSessionEntry(scope, { ...entry, sessionId: "replacement" });
|
|
expect((await source.list()).sessions).toMatchObject([{ name: "Original" }]);
|
|
} finally {
|
|
counter.restore();
|
|
}
|
|
});
|
|
});
|
|
|
|
it("derives titles only for the requested page while preserving transcript-title search", async () => {
|
|
await withCatalogFixture(async (receiver) => {
|
|
const source = commandFixture();
|
|
const named = { threadId: "agent:main:named", name: "Named session" };
|
|
const derived = [0, 1, 2].map((index) => ({
|
|
threadId: `agent:main:derived-${index}`,
|
|
name: `Derived title ${index}`,
|
|
}));
|
|
receiver.invoke.mockImplementation(async ({ command, params }) => {
|
|
const handler = source.commands.find((candidate) => candidate.command === command)!;
|
|
return { payloadJSON: await handler.handle(JSON.stringify(params)) };
|
|
});
|
|
const recency = Date.now();
|
|
for (let index = 0; index < 3; index++) {
|
|
const scope = await writeSession(`derived-${index}`, { updatedAt: recency });
|
|
await appendSessionTranscriptMessageByIdentity({
|
|
...scope,
|
|
message: { role: "user", content: `Derived title ${index}` },
|
|
});
|
|
await writeSession(scope.sessionId, {
|
|
updatedAt: recency - index,
|
|
lastInteractionAt: recency - index,
|
|
lastActivityAt: recency - index,
|
|
});
|
|
}
|
|
await writeSession("named", { updatedAt: recency + 1, label: "Named session" });
|
|
const { db } = openOpenClawAgentDatabase({ agentId: "main" });
|
|
const counter = sqlCounter(db, {
|
|
participants: [/\bfrom\s+"?session_participants\b/i],
|
|
transcript: [/\btranscript_events\b/],
|
|
});
|
|
try {
|
|
expectSingleItem((await source.list({ limit: 1 })).sessions, named);
|
|
expect.soft(counter.counts).toMatchObject({ transcript: 0, participants: 0 });
|
|
} finally {
|
|
counter.restore();
|
|
}
|
|
const response = JSON.stringify(await source.list({ limit: 100 }));
|
|
expect(JSON.stringify(await source.list({ limit: 100 }))).toBe(response);
|
|
await receiver.hydrate();
|
|
const first = await receiver.read({ limitPerHost: 1 });
|
|
expectSingleItem(first[0]?.sessions, named);
|
|
expect(first[0]?.nextCursor).toBeDefined();
|
|
const second = await receiver.read({
|
|
limitPerHost: 2,
|
|
cursors: { "node:alpha": first[0]!.nextCursor! },
|
|
});
|
|
expect(second[0]?.sessions.map(({ threadId, name }) => ({ threadId, name }))).toEqual(
|
|
derived.slice(0, 2),
|
|
);
|
|
const last = await receiver.read({
|
|
limitPerHost: 2,
|
|
cursors: { "node:alpha": second[0]!.nextCursor! },
|
|
});
|
|
expectSingleItem(last[0]?.sessions, derived[2]);
|
|
expect(last[0]?.nextCursor).toBeUndefined();
|
|
for (const search of ["Derived title 2", "MAIN:DERIVED-2"]) {
|
|
const found = await receiver.read({ search, limitPerHost: 1 });
|
|
expectSingleItem(found[0]?.sessions, derived[2]);
|
|
}
|
|
// Every page and search reauthorizes the source; inventory reuse remains source-owned.
|
|
expect(receiver.invoke).toHaveBeenCalledTimes(6);
|
|
db.prepare("UPDATE transcript_events SET event_json = ? WHERE session_id = ?").run(
|
|
"invalid-json",
|
|
"derived-0",
|
|
);
|
|
await expect(
|
|
source.list({ limit: 1, cursor: sessionCatalogPaging.encodeCursor(1) }),
|
|
).rejects.toThrow(SyntaxError);
|
|
});
|
|
});
|
|
|
|
it.each(["fixed", "template"])(
|
|
"reads the configured %s store and revokes an in-flight read when its store changes",
|
|
async (kind) => {
|
|
await withOpenClawTestState({ scenario: "minimal" }, async (state) => {
|
|
const fixture = commandFixture();
|
|
const store =
|
|
kind === "template"
|
|
? state.path("configured", "{agentId}", "sessions.json")
|
|
: state.path("configured", "sessions.json");
|
|
const configuredStorePath = resolveStorePath(store, { agentId: "main" });
|
|
const defaultStorePath = resolveStorePath(undefined, { agentId: "main" });
|
|
const scope = { ...sessionScope("configured"), sessionId: "configured-session" };
|
|
for (const [storePath, text] of [
|
|
[defaultStorePath, "Default store copy"],
|
|
[configuredStorePath, "Configured store question"],
|
|
]) {
|
|
await replaceSessionEntry(
|
|
{ ...scope, storePath },
|
|
{ sessionId: scope.sessionId, updatedAt: Date.now(), category: "Team" },
|
|
);
|
|
await appendSessionTranscriptMessageByIdentity({
|
|
...scope,
|
|
storePath,
|
|
message: { role: "user", content: text },
|
|
});
|
|
}
|
|
fixture.config.session = { store };
|
|
expect
|
|
.soft((await fixture.list()).sessions)
|
|
.toMatchObject([{ threadId: scope.sessionKey, name: "Configured store question" }]);
|
|
expect
|
|
.soft((await fixture.read(scope.sessionKey)).items)
|
|
.toMatchObject([{ type: "userMessage", text: "Configured store question" }]);
|
|
|
|
const pendingRead = fixture.read(scope.sessionKey);
|
|
fixture.config.session = { store: defaultStorePath };
|
|
await expect(pendingRead).rejects.toThrow("no longer shared");
|
|
|
|
fixture.config.session = { store };
|
|
await upsertSessionEntryCore(
|
|
{ ...scope, storePath: configuredStorePath },
|
|
{ category: "Private" },
|
|
);
|
|
expect((await fixture.list()).sessions).toEqual([]);
|
|
await expect(fixture.read(scope.sessionKey)).rejects.toThrow("not shared");
|
|
});
|
|
},
|
|
);
|
|
|
|
it("publishes selected root sessions while denying grouped subagents, with stable paging and search", async () => {
|
|
await withCommands(async (fixture) => {
|
|
const profile = githubProfile(4242, "source-person", "Source Person");
|
|
const createdActor = profileActor(profile.id);
|
|
const actor = publishedActor("4242", "Source Person");
|
|
// Keep the tied fixture fresh: subsequent writes prune ancient unarchived sessions.
|
|
const recency = Date.now();
|
|
for (const [key, patch] of [
|
|
[
|
|
"agent:main:alpha",
|
|
{
|
|
label: "Alpha",
|
|
updatedAt: recency,
|
|
color: "blue",
|
|
createdVia: "operator",
|
|
createdActor,
|
|
createdAt: recency - 100,
|
|
execCwd: "/work/alpha",
|
|
spawnedCwd: "/work/ignored",
|
|
parentSessionKey: "agent:main:parent",
|
|
spawnDepth: 0,
|
|
},
|
|
],
|
|
[
|
|
"agent:main:beta",
|
|
{
|
|
label: "Beta",
|
|
updatedAt: recency,
|
|
archivedAt: recency - 1,
|
|
createdActor,
|
|
createdAt: recency - 90,
|
|
worktree: { id: "beta-worktree", repoRoot: "/work/beta", branch: "b".repeat(6010) },
|
|
},
|
|
],
|
|
["agent:main:private", { category: "Other" }],
|
|
["agent:main:draft", { visibility: "draft" }],
|
|
["agent:main:incognito", { incognito: true }],
|
|
["agent:main:catalog:external", {}],
|
|
["agent:main:subagent:key-only", {}],
|
|
["agent:main:dashboard:spawn-owned", { createdVia: "spawn" }],
|
|
["agent:main:acp:resumed-child", { spawnedBy: "agent:main:main", spawnDepth: 1 }],
|
|
["agent:main:main", { updatedAt: recency - 1 }],
|
|
["agent:main:cron:job:run:root", { updatedAt: recency - 1, createdVia: "cron" }],
|
|
] as const) {
|
|
await replaceSessionEntry(
|
|
{ agentId: "main", sessionKey: key },
|
|
{ sessionId: key, category: "Team", updatedAt: recency + 1, ...patch },
|
|
);
|
|
}
|
|
for (const key of ["subagent:key-only", "dashboard:spawn-owned", "acp:resumed-child"]) {
|
|
await expect.soft(fixture.read(`agent:main:${key}`)).rejects.toThrow("not shared");
|
|
}
|
|
const identityReads = vi.spyOn(githubIdentities, "selectStoredGitHubIdentities");
|
|
let cursor: string | undefined;
|
|
for (const [key, name, age, details] of [
|
|
["alpha", "Alpha", 100, { color: "blue" }],
|
|
["beta", "Beta", 90, { gitBranch: "b".repeat(6000), archived: true, status: "archived" }],
|
|
] as const) {
|
|
const page = await fixture.list({ limit: 1, cursor });
|
|
expect(page.sessions).toEqual([
|
|
{
|
|
...nativeSession,
|
|
threadId: `agent:main:${key}`,
|
|
name,
|
|
cwd: `/work/${key}`,
|
|
createdAt: recency - age,
|
|
updatedAt: recency,
|
|
recencyAt: recency,
|
|
createdActor: actor,
|
|
...details,
|
|
},
|
|
]);
|
|
expect.soft(identityReads).not.toHaveBeenCalled();
|
|
expect(page.nextCursor).toBeDefined();
|
|
cursor = page.nextCursor;
|
|
identityReads.mockClear();
|
|
}
|
|
const roots = await fixture.list({ cursor });
|
|
expect(roots.sessions.map((session) => session.threadId)).toEqual([
|
|
"agent:main:cron:job:run:root",
|
|
"agent:main:main",
|
|
]);
|
|
expect.soft(identityReads).not.toHaveBeenCalled();
|
|
expect(roots.nextCursor).toBeUndefined();
|
|
for (const [searchTerm, key] of [
|
|
["ALPHA", "alpha"],
|
|
["MAIN:BETA", "beta"],
|
|
]) {
|
|
expect((await fixture.list({ searchTerm })).sessions.map((s) => s.threadId)).toEqual([
|
|
`agent:main:${key}`,
|
|
]);
|
|
}
|
|
for (const key of ["private", "draft", "incognito", "catalog:external"]) {
|
|
await expect(fixture.read(`agent:main:${key}`)).rejects.toThrow("not shared");
|
|
}
|
|
await expect(fixture.list({ cursor: "invalid" })).rejects.toThrow();
|
|
await expect(fixture.list({ unexpected: true })).rejects.toThrow("Unknown");
|
|
});
|
|
});
|
|
|
|
it("batches distinct and repeated creators without mixing labels or retaining facts across requests", async () => {
|
|
await withCommands(async (fixture) => {
|
|
const creator = githubProfile(
|
|
4242,
|
|
"original-person",
|
|
"Original Person",
|
|
"creator@example.test",
|
|
);
|
|
const target = githubProfile(4343, "merged-person", "Merged Person", "target@example.test");
|
|
const distinct = Array.from({ length: 8 }, (_, index) => ({
|
|
accountId: 5000 + index,
|
|
profile: githubProfile(
|
|
5000 + index,
|
|
`person-${index}`,
|
|
`Person ${index}`,
|
|
`person-${index}@example.test`,
|
|
),
|
|
}));
|
|
const actor = profileActor(creator.id);
|
|
const actors = [
|
|
...Array.from({ length: 8 }, () => actor),
|
|
...distinct.map(({ profile }) => profileActor(profile.id)),
|
|
{ type: "human", source: "profile", id: "missing", label: "First fallback" },
|
|
{ type: "human", source: "profile", id: "missing", label: "Second fallback" },
|
|
{ type: "human", source: "channel", id: creator.id, label: "Channel person" },
|
|
{ type: "agent", id: creator.id, label: "Agent" },
|
|
] as const;
|
|
const now = Date.now();
|
|
for (const [index, createdActor] of actors.entries()) {
|
|
await writeSession(`creator-${index}`, {
|
|
updatedAt: now - index,
|
|
label: `Session ${index}`,
|
|
createdActor,
|
|
});
|
|
}
|
|
const counter = sqlCounter(openOpenClawStateDatabase().db, {
|
|
identities: [/\bfrom\s+"?user_profile_identities\b/i],
|
|
profiles: [/\bfrom\s+"?user_profiles\b/i],
|
|
});
|
|
let first: SessionPage;
|
|
try {
|
|
first = await fixture.list();
|
|
expect.soft(counter.counts).toMatchObject({ identities: 0, profiles: 0 });
|
|
} finally {
|
|
counter.restore();
|
|
}
|
|
expect(first.sessions.map((session) => session.createdActor)).toEqual([
|
|
...Array.from({ length: 8 }, () => publishedActor("4242", "Original Person")),
|
|
...distinct.map(({ accountId }, index) =>
|
|
publishedActor(String(accountId), `Person ${index}`),
|
|
),
|
|
...["First fallback", "Second fallback"].map((label) =>
|
|
publishedActor("missing", label, "profile"),
|
|
),
|
|
{ type: "human", id: creator.id, label: "Channel person" },
|
|
{ type: "agent", id: creator.id, label: "Agent" },
|
|
]);
|
|
linkEmail("creator@example.test", target.id);
|
|
const refreshed = await fixture.list();
|
|
expect(refreshed.sessions.slice(0, 8).map((session) => session.createdActor)).toEqual(
|
|
Array.from({ length: 8 }, () => publishedActor("4343", "Merged Person")),
|
|
);
|
|
expect(refreshed.sessions.slice(8)).toEqual(first.sessions.slice(8));
|
|
openOpenClawStateDatabase()
|
|
.db.prepare("UPDATE user_profiles SET primary_github_account_id = ? WHERE id = ?")
|
|
.run(9007199254740992n, target.id);
|
|
await expect(fixture.list({ limit: 1 })).rejects.toThrow(RangeError);
|
|
const tail = await fixture.list({ cursor: sessionCatalogPaging.encodeCursor(8) });
|
|
expect(tail.sessions).toEqual(first.sessions.slice(8));
|
|
});
|
|
});
|
|
|
|
it("propagates a failed profile reader without replaying creator queries on the host", async () => {
|
|
await withCommands(async (fixture) => {
|
|
const now = Date.now();
|
|
for (const index of [0, 1]) {
|
|
const profile = ensureProfileForEmail(`terminal-${index}@example.test`);
|
|
await writeSession(`terminal-${index}`, {
|
|
updatedAt: now - index,
|
|
label: `Terminal ${index}`,
|
|
createdActor: profileActor(profile.id),
|
|
});
|
|
}
|
|
const cached = openOpenClawStateDatabase();
|
|
const corruption = Object.assign(new Error("database disk image is malformed"), {
|
|
code: "ERR_SQLITE_ERROR",
|
|
errcode: 11,
|
|
});
|
|
const native = vi.spyOn(githubIdentities, "selectStoredGitHubIdentities");
|
|
const failure = vi
|
|
.spyOn(stateReads, "executeExistingOpenClawStateRead")
|
|
.mockRejectedValueOnce(corruption);
|
|
try {
|
|
await expect(fixture.list()).rejects.toBe(corruption);
|
|
expect(native).not.toHaveBeenCalled();
|
|
expect(cached.db.isOpen).toBe(true);
|
|
} finally {
|
|
failure.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
it.each(["list", "read"] as const)(
|
|
"refuses a replaced physical source during %s identity preparation",
|
|
async (operation) => {
|
|
await withCommands(async (fixture) => {
|
|
const profile = ensureProfileForEmail("physical@example.test");
|
|
const { scope, appendUser } = await writeProfileSession("physical", profile.id);
|
|
await appendUser("Shared");
|
|
const pathname = openOpenClawAgentDatabase({ agentId: "main" }).path;
|
|
const gate = holdStateRead();
|
|
const pending = operation === "list" ? fixture.list() : fixture.read(scope.sessionKey);
|
|
try {
|
|
await Promise.race([
|
|
gate.entered,
|
|
pending.then(() => {
|
|
throw new Error("Read was not held");
|
|
}),
|
|
]);
|
|
await closeOpenClawAgentDatabasesAsync();
|
|
fs.renameSync(pathname, `${pathname}.previous`);
|
|
fs.copyFileSync(`${pathname}.previous`, pathname);
|
|
} finally {
|
|
gate.release();
|
|
}
|
|
await expect(pending).rejects.toThrow();
|
|
});
|
|
},
|
|
);
|
|
|
|
it("reads real newest-first transcript rows with portable sender and revokes moved sessions", async () => {
|
|
await withCommands(async (fixture) => {
|
|
const profile = ensureProfileForEmail("source@example.test");
|
|
const { scope, appendUser } = await writeProfileSession("shared", profile.id, 1);
|
|
await appendUser("Shared question", 1);
|
|
await appendSessionTranscriptMessageByIdentity({
|
|
...scope,
|
|
message: {
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "Shared answer" }],
|
|
timestamp: 2,
|
|
},
|
|
});
|
|
const first = await fixture.read(scope.sessionKey, { limit: 1 });
|
|
expectSingleItem(first.items, { type: "agentMessage", text: "Shared answer" });
|
|
const older = await fixture.read(scope.sessionKey, { limit: 1, cursor: first.nextCursor });
|
|
expect(older.items).toEqual([
|
|
expect.objectContaining({
|
|
type: "userMessage",
|
|
text: "Shared question",
|
|
sender: expect.objectContaining({
|
|
identity: publishedActor(profile.id, "", "profile").identity,
|
|
}),
|
|
}),
|
|
]);
|
|
expect((await fixture.list()).sessions[0]?.createdActor?.identity).toEqual(
|
|
older.items[0]?.sender?.identity,
|
|
);
|
|
await upsertSessionEntryCore(scope, { category: "Private" });
|
|
await expect(fixture.read(scope.sessionKey)).rejects.toThrow("not shared");
|
|
expect((await fixture.list()).sessions).toEqual([]);
|
|
});
|
|
});
|
|
|
|
it("does not advertise or publish without share configuration", async () => {
|
|
await withOpenClawTestState({ scenario: "minimal" }, async () => {
|
|
const fixture = commandFixture([]);
|
|
fixture.config.plugins = undefined;
|
|
const manifest = JSON.parse(
|
|
fs.readFileSync(
|
|
new URL("../../extensions/session-share/openclaw.plugin.json", import.meta.url),
|
|
"utf8",
|
|
),
|
|
) as { configSchema: Record<string, unknown> };
|
|
expect(
|
|
validateJsonSchemaValue({
|
|
schema: manifest.configSchema,
|
|
cacheKey: "session-share.disabled-config",
|
|
value: {},
|
|
}).ok,
|
|
).toBe(true);
|
|
for (const command of fixture.commands) {
|
|
expect(command.isAvailable?.({ config: fixture.config, env: {} })).toBe(false);
|
|
}
|
|
expect((await fixture.list()).sessions).toEqual([]);
|
|
await expect(fixture.read("agent:main:unshared")).rejects.toThrow("not shared");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("session-share receiver identity integration", () => {
|
|
it("keeps claims remote by default and applies only explicit owner and numeric GitHub links", async () => {
|
|
const nodeId = "alpha";
|
|
await withCatalogFixture(async (fixture) => {
|
|
const profile = githubProfile(4242, "catalog-person", "Catalog Person");
|
|
fixture.list.mockResolvedValue({ nodes: [{ nodeId, connected: true, commands }] });
|
|
const hostId = `node:${nodeId}`;
|
|
const namespacedIdentity = { ...remoteIdentity, domain: hostId };
|
|
const identityReads = vi.spyOn(githubIdentities, "selectStoredGitHubIdentities");
|
|
const fullIdentityScans = () =>
|
|
identityReads.mock.calls.filter(([, profileIds]) => profileIds === undefined).length;
|
|
const human = remoteHuman(nativeSession.threadId);
|
|
const agent = {
|
|
...nativeSession,
|
|
threadId: "agent:main:agent",
|
|
createdActor: { type: "agent" as const, id: "assistant", label: "Assistant" },
|
|
};
|
|
const unmatched = remoteHuman("agent:main:unmatched", "9999");
|
|
const transcript = {
|
|
threadId: nativeSession.threadId,
|
|
items: [remoteIdentity, remoteIdentity, { ...remoteIdentity, id: "9999" }].map(
|
|
(identity) => ({
|
|
type: "userMessage",
|
|
text: "Question",
|
|
sender: { identity, label: "Remote Person" },
|
|
}),
|
|
),
|
|
};
|
|
fixture.invoke.mockImplementation(async ({ command }) =>
|
|
command === commands[0] ? { sessions: [human, agent, unmatched] } : transcript,
|
|
);
|
|
const namespacedHuman = remoteHuman(human.threadId, "4242", hostId);
|
|
const namespacedUnmatched = remoteHuman(unmatched.threadId, "9999", hostId);
|
|
await fixture.hydrate();
|
|
expect((await fixture.read({}))[0]?.sessions).toEqual([
|
|
namespacedHuman,
|
|
agent,
|
|
namespacedUnmatched,
|
|
]);
|
|
expect(
|
|
(await fixture.catalog.read({ hostId, threadId: nativeSession.threadId })).items[0]?.sender
|
|
?.identity,
|
|
).toEqual(namespacedIdentity);
|
|
expect(fullIdentityScans()).toBe(0);
|
|
for (const identityConfig of [
|
|
{ owner: `profile:${profile.id}` },
|
|
{ owner: "github:CATALOG-PERSON" },
|
|
{ linkGitHubIdentities: true },
|
|
]) {
|
|
const linksHumans = "linkGitHubIdentities" in identityConfig;
|
|
fixture.setConfig(nodeConfig({ [nodeId]: identityConfig }));
|
|
await fixture.hydrate();
|
|
if (linksHumans) {
|
|
identityReads.mockClear();
|
|
}
|
|
const rows = (await fixture.read({}))[0]!.sessions;
|
|
expect(rows[linksHumans ? 0 : 1]?.createdActor).toMatchObject(linkedActor(profile.id));
|
|
expect(rows[linksHumans ? 1 : 0]).toEqual(linksHumans ? agent : namespacedHuman);
|
|
expect(rows[2]).toEqual(namespacedUnmatched);
|
|
if (linksHumans) {
|
|
expect.soft(fullIdentityScans()).toBe(0);
|
|
}
|
|
}
|
|
identityReads.mockClear();
|
|
const page = await fixture.catalog.read({ hostId, threadId: nativeSession.threadId });
|
|
expect.soft(fullIdentityScans()).toBe(0);
|
|
expect(page.items.map((item) => item.sender)).toEqual([
|
|
{ identity: { type: "profile", id: profile.id }, label: "Catalog Person" },
|
|
{ identity: { type: "profile", id: profile.id }, label: "Catalog Person" },
|
|
{ identity: { ...namespacedIdentity, id: "9999" }, label: "Remote Person" },
|
|
]);
|
|
});
|
|
});
|
|
});
|