mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
fix: automation runs appear in creator activity (#128951)
* fix: attribute automation sessions to creators * test: prove cron session creator isolation Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --------- Co-authored-by: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
@@ -34,7 +34,7 @@ import {
|
||||
runEmbeddedAgentMock,
|
||||
} from "./isolated-agent/run.test-harness.js";
|
||||
import { normalizeCronJobCreate } from "./normalize.js";
|
||||
import type { CronJob } from "./types.js";
|
||||
import type { CronJob, CronStoredJob } from "./types.js";
|
||||
|
||||
setupRunCronIsolatedAgentTurnSuite();
|
||||
|
||||
@@ -277,6 +277,40 @@ describe("runCronIsolatedAgentTurn session identity", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("attributes stable and exact run sessions to the stored automation creator", async () => {
|
||||
await useRealCronSessionState();
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
|
||||
mockEmbeddedTranscriptWrite(storePath, "creator-attributed transcript");
|
||||
const job: CronStoredJob = {
|
||||
...makeJob({ kind: "agentTurn", message: "persist this turn" }),
|
||||
createdActor: { type: "human", id: "profile-ada" },
|
||||
delivery: { mode: "none" },
|
||||
};
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath),
|
||||
deps: makeDeps(),
|
||||
job,
|
||||
message: "persist this turn",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status, res.status === "error" ? res.error : undefined).toBe("ok");
|
||||
await expect(readCronSessionEntry(storePath, "agent:main:cron:job-1")).resolves.toMatchObject(
|
||||
{
|
||||
createdVia: "cron",
|
||||
createdActor: { type: "human", id: "profile-ada" },
|
||||
},
|
||||
);
|
||||
await expect(readCronSessionEntry(storePath, res.sessionKey!)).resolves.toMatchObject({
|
||||
createdVia: "cron",
|
||||
createdActor: { type: "human", id: "profile-ada" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("persists rotated transcript identity for current-bound cron runs", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const deps = makeDeps();
|
||||
|
||||
@@ -311,6 +311,7 @@ export async function prepareCronRunContext(params: {
|
||||
const persistSessionEntry = createPersistCronSessionEntry({
|
||||
cronSession,
|
||||
agentSessionKey,
|
||||
createdActor: input.job.createdActor,
|
||||
persistSessionEntry: persistCronSessionRow,
|
||||
});
|
||||
const withRunSession: WithRunSession = (result) => ({
|
||||
@@ -656,6 +657,7 @@ export async function prepareCronRunContext(params: {
|
||||
? createCronRunContinuationSession({
|
||||
cronSession,
|
||||
runSessionKey,
|
||||
createdActor: input.job.createdActor,
|
||||
thinkingLevel: requestedThinkLevel,
|
||||
toolsAllow: agentPayload?.toolsAllow,
|
||||
toolsAllowIsDefault: agentPayload?.toolsAllowIsDefault,
|
||||
|
||||
@@ -268,6 +268,7 @@ describe("createPersistCronSessionEntry", () => {
|
||||
const continuation = createCronRunContinuationSession({
|
||||
cronSession,
|
||||
runSessionKey,
|
||||
createdActor: { type: "human", id: "profile-ada" },
|
||||
thinkingLevel: "high",
|
||||
toolsAllow: ["image_generate", "write"],
|
||||
toolsAllowIsDefault: true,
|
||||
@@ -301,7 +302,7 @@ describe("createPersistCronSessionEntry", () => {
|
||||
});
|
||||
expect(store[runSessionKey]).toMatchObject({
|
||||
createdVia: "cron",
|
||||
createdActor: { type: "system" },
|
||||
createdActor: { type: "human", id: "profile-ada" },
|
||||
createdAt: expect.any(Number),
|
||||
sessionId: "run-session-id",
|
||||
modelProvider: "claude-cli",
|
||||
@@ -385,6 +386,7 @@ describe("createPersistCronSessionEntry", () => {
|
||||
const persist = createPersistCronSessionEntry({
|
||||
cronSession,
|
||||
agentSessionKey: "agent:main:cron:job",
|
||||
createdActor: { type: "human", id: "profile-ada" },
|
||||
persistSessionEntry,
|
||||
});
|
||||
|
||||
@@ -392,12 +394,12 @@ describe("createPersistCronSessionEntry", () => {
|
||||
|
||||
expect(cronSession.store["agent:main:cron:job"]).toMatchObject({
|
||||
createdVia: "cron",
|
||||
createdActor: { type: "system" },
|
||||
createdActor: { type: "human", id: "profile-ada" },
|
||||
createdAt: expect.any(Number),
|
||||
});
|
||||
expect(persistedStore["agent:main:cron:job"]).toMatchObject({
|
||||
createdVia: "cron",
|
||||
createdActor: { type: "system" },
|
||||
createdActor: { type: "human", id: "profile-ada" },
|
||||
createdAt: expect.any(Number),
|
||||
});
|
||||
expect(cronSession.store["agent:main:cron:job:run:run-session-id"]).toBeUndefined();
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { SessionEntry } from "../../config/sessions.js";
|
||||
import { resolveSessionAuthProfileOverrideSource } from "../../config/sessions/auth-profile-override-provenance.js";
|
||||
import { readTranscriptStatsSync } from "../../config/sessions/session-accessor.js";
|
||||
import { buildSessionCreationStamp } from "../../config/sessions/session-entry-provenance.js";
|
||||
import type { SessionCreatedActor } from "../../config/sessions/session-entry-provenance.js";
|
||||
import { mergeSessionSnapshotChanges } from "../../config/sessions/session-snapshot-merge.js";
|
||||
import { isCronSessionKey } from "../../sessions/session-key-utils.js";
|
||||
import { isSessionWorkAdmissionActive } from "../../sessions/session-lifecycle-admission.js";
|
||||
@@ -132,6 +133,7 @@ function toNonResumableCronSessionEntry(entry: SessionEntry): SessionEntry {
|
||||
export function createPersistCronSessionEntry(params: {
|
||||
cronSession: MutableCronSession;
|
||||
agentSessionKey: string;
|
||||
createdActor?: SessionCreatedActor;
|
||||
persistSessionEntry: PersistSessionEntry;
|
||||
}): PersistCronSessionEntry {
|
||||
return async () => {
|
||||
@@ -158,7 +160,7 @@ export function createPersistCronSessionEntry(params: {
|
||||
if (!currentEntry) {
|
||||
const creationStamp = buildSessionCreationStamp({
|
||||
via: "cron",
|
||||
actor: { type: "system" },
|
||||
actor: params.createdActor ?? { type: "system" },
|
||||
});
|
||||
committedEntry = { ...persistedEntry, ...creationStamp };
|
||||
mergedLiveEntry = { ...liveEntry, ...creationStamp };
|
||||
@@ -238,6 +240,7 @@ export function createPersistCronSessionEntry(params: {
|
||||
export function createCronRunContinuationSession(params: {
|
||||
cronSession: MutableCronSession;
|
||||
runSessionKey: string;
|
||||
createdActor?: SessionCreatedActor;
|
||||
thinkingLevel?: string;
|
||||
toolsAllow?: string[];
|
||||
toolsAllowIsDefault?: boolean;
|
||||
@@ -303,7 +306,10 @@ export function createCronRunContinuationSession(params: {
|
||||
...current,
|
||||
...source,
|
||||
...(!current
|
||||
? buildSessionCreationStamp({ via: "cron", actor: { type: "system" } })
|
||||
? buildSessionCreationStamp({
|
||||
via: "cron",
|
||||
actor: params.createdActor ?? { type: "system" },
|
||||
})
|
||||
: {}),
|
||||
...(params.thinkingLevel ? { thinkingLevel: params.thinkingLevel } : {}),
|
||||
cronRunContinuation: {
|
||||
|
||||
@@ -63,6 +63,16 @@ describe("toPublicCronJob", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("strips private creator provenance without mutating the stored job", () => {
|
||||
const job: CronStoredJob = {
|
||||
...makeCronJob({}),
|
||||
createdActor: { type: "human", id: "profile-ada" },
|
||||
};
|
||||
|
||||
expect(toPublicCronJob(job)).not.toHaveProperty("createdActor");
|
||||
expect(job.createdActor).toEqual({ type: "human", id: "profile-ada" });
|
||||
});
|
||||
|
||||
it("strips private runtime authority without mutating the stored job", () => {
|
||||
const runtimeAuthority = {
|
||||
version: 1 as const,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { CronJob, CronStoredJob } from "./types.js";
|
||||
/** Remove scheduler-only state before a cron job crosses a public API boundary. */
|
||||
export function toPublicCronJob(job: CronStoredJob): CronJob {
|
||||
const {
|
||||
createdActor: _createdActor,
|
||||
toolsAllowProvenance: _toolsAllowProvenance,
|
||||
runtimeAuthority: _runtimeAuthority,
|
||||
runtimeAuthorityRecoveryRequired: _runtimeAuthorityRecoveryRequired,
|
||||
|
||||
@@ -198,6 +198,42 @@ describe("CronService declarative jobs", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the first creator across declaration convergence and restart", async () => {
|
||||
const { storePath } = await makeStorePath();
|
||||
const writer = createCronService(storePath);
|
||||
await writer.start();
|
||||
let createdId = "";
|
||||
|
||||
try {
|
||||
const created = declarativeResult(
|
||||
await writer.add(declaration(), {
|
||||
createdActor: { type: "human", id: "profile-ada" },
|
||||
}),
|
||||
);
|
||||
createdId = created.id;
|
||||
expect(created.job).toMatchObject({
|
||||
createdActor: { type: "human", id: "profile-ada" },
|
||||
});
|
||||
|
||||
const converged = declarativeResult(
|
||||
await writer.add(declaration({ displayName: "Updated report" }), {
|
||||
createdActor: { type: "human", id: "profile-bob" },
|
||||
}),
|
||||
);
|
||||
expect(converged).toMatchObject({ created: false, updated: true, id: created.id });
|
||||
expect(converged.job).toMatchObject({
|
||||
createdActor: { type: "human", id: "profile-ada" },
|
||||
});
|
||||
} finally {
|
||||
writer.stop();
|
||||
}
|
||||
|
||||
const reader = createCronService(storePath, false);
|
||||
await expect(reader.readJob(createdId)).resolves.toMatchObject({
|
||||
createdActor: { type: "human", id: "profile-ada" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps declaration-key uniqueness local to the caller visibility predicate", async () => {
|
||||
const { storePath } = await makeStorePath();
|
||||
const cron = createCronService(storePath);
|
||||
|
||||
@@ -433,6 +433,9 @@ export async function add(
|
||||
toolsAllowProvenance: opts?.toolsAllowProvenance,
|
||||
configuredChannels,
|
||||
});
|
||||
if (opts?.createdActor) {
|
||||
job.createdActor = structuredClone(opts.createdActor);
|
||||
}
|
||||
const runtimeAuthorityMutation = consumeRuntimeAuthorityMutationOptions(opts);
|
||||
reconcileRuntimeAuthority({
|
||||
job,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Cron service state tests cover in-memory scheduler state transitions.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createCronServiceState } from "./state.js";
|
||||
import { makeCronJob } from "../delivery.test-helpers.js";
|
||||
import { createCronServiceState, emit } from "./state.js";
|
||||
|
||||
describe("cron service state seam coverage", () => {
|
||||
it("threads heartbeat and session-store dependencies into internal state", () => {
|
||||
@@ -69,4 +70,25 @@ describe("cron service state seam coverage", () => {
|
||||
|
||||
nowSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("projects store-private job provenance before emitting events", () => {
|
||||
const onEvent = vi.fn();
|
||||
const state = createCronServiceState({
|
||||
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
storePath: "/tmp/cron/jobs.json",
|
||||
cronEnabled: false,
|
||||
enqueueSystemEvent: vi.fn(),
|
||||
requestHeartbeat: vi.fn(),
|
||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
|
||||
onEvent,
|
||||
});
|
||||
const job = {
|
||||
...makeCronJob({}),
|
||||
createdActor: { type: "human" as const, id: "profile-ada" },
|
||||
};
|
||||
|
||||
emit(state, { action: "added", jobId: job.id, job });
|
||||
|
||||
expect(onEvent.mock.calls[0]?.[0]?.job).not.toHaveProperty("createdActor");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
import type { AdmittedRunContext } from "../../agents/admitted-run-context.js";
|
||||
import type { ExecutionIdentityAdmissionFacts } from "../../audit/execution-identity-admission.js";
|
||||
import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
|
||||
import type { SessionCreatedActor } from "../../config/sessions/session-entry-provenance.js";
|
||||
import type { CronConfig } from "../../config/types.cron.js";
|
||||
import type { HeartbeatRunResult, HeartbeatWakeRequest } from "../../infra/heartbeat-wake.js";
|
||||
import type { CommandLaneTaskMarker } from "../../process/command-queue.js";
|
||||
import { LEGACY_IMPLICIT_AGENT_ID } from "../../routing/session-key.js";
|
||||
import type { DeliveryContext } from "../../utils/delivery-context.types.js";
|
||||
import type { CronActiveJobMarker } from "../active-jobs.js";
|
||||
import { toPublicCronJob } from "../public-job.js";
|
||||
import type { CronRuntimeAuthority } from "../runtime-authority.js";
|
||||
import type { CronScheduledToolPolicy } from "../scheduled-tool-policy.js";
|
||||
import type { QuarantinedCronConfigJob } from "../store.js";
|
||||
@@ -370,10 +372,11 @@ export function createCronServiceState(deps: CronServiceDeps): CronServiceState
|
||||
/** Dispatches a cron event without letting subscriber errors escape scheduler work. */
|
||||
export function emit(state: CronServiceState, evt: CronEvent, context?: CronEventContext) {
|
||||
try {
|
||||
const publicEvent = evt.job ? { ...evt, job: toPublicCronJob(evt.job) } : evt;
|
||||
if (context) {
|
||||
state.deps.onEvent?.(evt, context);
|
||||
state.deps.onEvent?.(publicEvent, context);
|
||||
} else {
|
||||
state.deps.onEvent?.(evt);
|
||||
state.deps.onEvent?.(publicEvent);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
@@ -438,6 +441,8 @@ export type CronAddOptions = {
|
||||
enabledExplicit?: boolean;
|
||||
/** Gateway/doctor-owned heartbeat jobs require this opt-in at service creation. */
|
||||
systemOwned?: boolean;
|
||||
/** Trusted creator provenance persisted with new jobs; never accepted from public input. */
|
||||
createdActor?: SessionCreatedActor;
|
||||
/** Authenticated caller provenance stamped by the service, never public input. */
|
||||
scheduledToolPolicy?: CronScheduledToolPolicy;
|
||||
/** Private proof from an authenticated agent-runtime caller. */
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { DatabaseSync } from "node:sqlite";
|
||||
import { safeParseJson } from "@openclaw/normalization-core";
|
||||
import { asOptionalObjectRecord, isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import type { SessionCreatedActor } from "../../config/sessions/session-entry-provenance.js";
|
||||
import { executeSqliteQuerySync } from "../../infra/kysely-sync.js";
|
||||
import { normalizeOptionalAccountId } from "../../routing/account-id.js";
|
||||
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
@@ -289,6 +290,22 @@ function pacingFromJobJson(jobJson: Record<string, unknown>): CronPacing | undef
|
||||
};
|
||||
}
|
||||
|
||||
function createdActorFromJobJson(value: unknown): SessionCreatedActor | undefined {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
(value.type !== "human" && value.type !== "agent" && value.type !== "system")
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const id = normalizeOptionalString(typeof value.id === "string" ? value.id : undefined);
|
||||
const label = normalizeOptionalString(typeof value.label === "string" ? value.label : undefined);
|
||||
return {
|
||||
type: value.type,
|
||||
...(id ? { id } : {}),
|
||||
...(label ? { label } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function rowToCronJob(row: CronJobRow, jobJson: Record<string, unknown>): CronStoredJob | null {
|
||||
const jsonOwner = isRecord(jobJson.owner) ? jobJson.owner : undefined;
|
||||
const ownerAccountId = normalizeOptionalAccountId(
|
||||
@@ -300,6 +317,7 @@ function rowToCronJob(row: CronJobRow, jobJson: Record<string, unknown>): CronSt
|
||||
const failureAlert = failureAlertFromRow(row);
|
||||
const trigger = triggerFromRow(row);
|
||||
const pacing = pacingFromJobJson(jobJson);
|
||||
const createdActor = createdActorFromJobJson(jobJson.createdActor);
|
||||
const scheduledToolPolicy = normalizeCronScheduledToolPolicy(jobJson.scheduledToolPolicy);
|
||||
const toolsAllowProvenance =
|
||||
isRecord(jobJson.toolsAllowProvenance) &&
|
||||
@@ -319,6 +337,7 @@ function rowToCronJob(row: CronJobRow, jobJson: Record<string, unknown>): CronSt
|
||||
const createdAtMs = normalizeNumber(row.created_at_ms) ?? Date.now();
|
||||
return {
|
||||
id: row.job_id,
|
||||
...(createdActor ? { createdActor } : {}),
|
||||
...(row.declaration_key ? { declarationKey: row.declaration_key } : {}),
|
||||
...(row.display_name ? { displayName: row.display_name } : {}),
|
||||
...(row.owner_agent_id || row.owner_session_key || ownerAccountId
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { EmbeddedAgentExecutionPhase } from "../agents/embedded-agent-runne
|
||||
/** Cron scheduling, delivery, diagnostics, and store data contracts. */
|
||||
import type { FailoverReason } from "../agents/failover/signal.js";
|
||||
import type { ChannelId } from "../channels/plugins/types.public.js";
|
||||
import type { SessionCreatedActor } from "../config/sessions/session-entry-provenance.js";
|
||||
import type { HookExternalContentSource } from "../security/external-content.js";
|
||||
import type { CronRuntimeAuthority } from "./runtime-authority.js";
|
||||
import type {
|
||||
@@ -523,6 +524,8 @@ export type CronToolsAllowProvenance = {
|
||||
|
||||
/** Persisted row shape; public Gateway and wire contracts use CronJob. */
|
||||
export type CronStoredJob = CronJob & {
|
||||
/** Immutable creator provenance stamped by the trusted cron creation seam. */
|
||||
createdActor?: SessionCreatedActor;
|
||||
toolsAllowProvenance?: CronToolsAllowProvenance;
|
||||
/** Runtime-private authority omitted from public Gateway and wire contracts. */
|
||||
runtimeAuthority?: CronRuntimeAuthority;
|
||||
|
||||
@@ -79,6 +79,7 @@ import {
|
||||
import { isCronInvalidRequestError } from "./cron-error-classification.js";
|
||||
import { listCronPageForCallerScope } from "./cron-list-caller-scope.js";
|
||||
import { cronRunLogPageFilters, filterCronRunLogJobsByAgent } from "./cron-run-log-filters.js";
|
||||
import { resolveOperatorSessionCreation } from "./session-creation-provenance.js";
|
||||
import type {
|
||||
GatewayClient,
|
||||
GatewayRequestContext,
|
||||
@@ -848,6 +849,7 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
const callerScope = readCronCallerScope(client);
|
||||
const createdActor = resolveOperatorSessionCreation(client, { allowTrustedHint: true }).actor;
|
||||
let captureRuntimeAuthority: (() => CronRuntimeAuthority | undefined) | undefined;
|
||||
try {
|
||||
captureRuntimeAuthority = resolveCronCreatorAuthorityCapture(callerScope);
|
||||
@@ -908,6 +910,7 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
try {
|
||||
result = await context.cron.add(jobCreate, {
|
||||
enabledExplicit,
|
||||
...(createdActor ? { createdActor } : {}),
|
||||
...(commitGuard ? { commitGuard } : {}),
|
||||
...(captureRuntimeAuthority ? { captureRuntimeAuthority } : {}),
|
||||
matchesExisting: (job) =>
|
||||
|
||||
@@ -1368,6 +1368,36 @@ describe("cron method validation", () => {
|
||||
expectCronSuccess(respond);
|
||||
});
|
||||
|
||||
it("stamps the authenticated profile as private cron creator provenance", async () => {
|
||||
const client: GatewayClient = {
|
||||
connect: {} as GatewayClient["connect"],
|
||||
authenticatedUserProfile: {
|
||||
profileId: "profile-ada",
|
||||
displayName: "Ada",
|
||||
hasAvatar: false,
|
||||
updatedAt: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const { context, respond } = await invokeCronAdd(agentTurnCronParams(), { client });
|
||||
|
||||
const options = requireRecord(context.cron.add.mock.calls[0]?.[1], "cron.add options");
|
||||
expect(options.createdActor).toEqual({ type: "human", id: "profile-ada" });
|
||||
expect(requireCronAddPayload(context)).not.toHaveProperty("createdActor");
|
||||
expectCronSuccess(respond);
|
||||
});
|
||||
|
||||
it("rejects caller-supplied cron creator provenance", async () => {
|
||||
const { context, respond } = await invokeCronAdd(
|
||||
agentTurnCronParams({
|
||||
createdActor: { type: "human", id: "spoofed-profile" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(context.cron.add).not.toHaveBeenCalled();
|
||||
expectResponseError(respond, { code: "INVALID_REQUEST" });
|
||||
});
|
||||
|
||||
it("consumes an exact live configured-MCP grant once at cron.add commit", async () => {
|
||||
const scope = createCronCreatorAuthorityRunScope("run-add");
|
||||
const grant = mintCronCreatorAuthorityGrant(scope);
|
||||
|
||||
@@ -380,18 +380,20 @@ describe("session sharing policy", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("hides foreign sessions with none access across listings, direct reads, mutation, and broadcasts", async () => {
|
||||
it("hides foreign cron sessions with none access across listings, reads, mutations, and broadcasts", async () => {
|
||||
await withOpenClawTestState({ scenario: "minimal" }, async () => {
|
||||
const cfg = rolePolicyConfig();
|
||||
const creator = roleClient("none", "cron-creator");
|
||||
const creatorId = creator.authenticatedUserProfile!.profileId;
|
||||
const restricted = roleClient("none", "restricted");
|
||||
const restrictedId = restricted.authenticatedUserProfile!.profileId;
|
||||
const foreignKey = "agent:main:team-private";
|
||||
const foreignKey = "agent:main:cron:job-1:run:run-1";
|
||||
const ownKey = "agent:main:team-own";
|
||||
const foreignEntry = {
|
||||
sessionId: "session-team-private",
|
||||
sessionId: "session-cron-run",
|
||||
updatedAt: 1,
|
||||
visibility: "shared" as const,
|
||||
createdActor: { type: "human" as const, id: "another-profile" },
|
||||
createdVia: "cron" as const,
|
||||
createdActor: { type: "human" as const, id: creatorId },
|
||||
};
|
||||
await upsertSessionEntryCore({ agentId: "main", sessionKey: foreignKey }, foreignEntry);
|
||||
await upsertSessionEntryCore(
|
||||
@@ -405,15 +407,13 @@ describe("session sharing policy", () => {
|
||||
);
|
||||
addSessionMember(
|
||||
{ agentId: "main", sessionKey: foreignKey },
|
||||
{
|
||||
identityId: restrictedId,
|
||||
addedBy: "another-profile",
|
||||
expectedSessionId: "session-team-private",
|
||||
},
|
||||
{ identityId: restrictedId, addedBy: creatorId, expectedSessionId: foreignEntry.sessionId },
|
||||
);
|
||||
|
||||
const entryFilter = createSessionListEntryFilter({ cfg, client: restricted });
|
||||
const creatorEntryFilter = createSessionListEntryFilter({ cfg, client: creator });
|
||||
expect(entryFilter?.(foreignKey, foreignEntry)).toBe(false);
|
||||
expect(creatorEntryFilter?.(foreignKey, foreignEntry)).toBe(true);
|
||||
expect(
|
||||
entryFilter?.(ownKey, {
|
||||
sessionId: "session-team-own",
|
||||
@@ -424,6 +424,9 @@ describe("session sharing policy", () => {
|
||||
expect(
|
||||
canReceiveSessionEvent({ cfg, client: restricted as never, sessionKeys: [foreignKey] }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canReceiveSessionEvent({ cfg, client: creator as never, sessionKeys: [foreignKey] }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
canReceiveSessionEvent({ cfg, client: restricted as never, sessionKeys: [ownKey] }),
|
||||
).toBe(true);
|
||||
@@ -471,6 +474,17 @@ describe("session sharing policy", () => {
|
||||
message: `Session "${foreignKey}" was not found.`,
|
||||
});
|
||||
}
|
||||
for (const method of ["chat.history", "chat.send"] as const) {
|
||||
expect(
|
||||
resolveSessionMutationAuthorization({
|
||||
client: creator,
|
||||
method,
|
||||
requestParams: { sessionKey: foreignKey },
|
||||
context,
|
||||
}).error,
|
||||
`creator ${method}`,
|
||||
).toBeNull();
|
||||
}
|
||||
expect(
|
||||
resolveSessionMutationAuthorization({
|
||||
client: restricted,
|
||||
|
||||
@@ -120,6 +120,7 @@ suite.define(() => {
|
||||
owner: {
|
||||
actor: { type: "human", id: "profile-bob", label: "Bob Rivera" },
|
||||
},
|
||||
createdVia: "cron",
|
||||
participants: [{ type: "human", id: "profile-alice", label: "Alice Chen" }],
|
||||
hasAutomation: true,
|
||||
updatedAt: now - 42 * 60_000,
|
||||
@@ -133,6 +134,7 @@ suite.define(() => {
|
||||
owner: {
|
||||
actor: { type: "human", id: "profile-carol", label: "Carol Singh" },
|
||||
},
|
||||
createdVia: "cron",
|
||||
hasAutomation: true,
|
||||
updatedAt: now - 2 * 60 * 60_000,
|
||||
},
|
||||
@@ -145,6 +147,7 @@ suite.define(() => {
|
||||
owner: {
|
||||
actor: { type: "human", id: "profile-carol", label: "Carol Singh" },
|
||||
},
|
||||
createdVia: "cron",
|
||||
hasAutomation: true,
|
||||
updatedAt: now - 3 * 60 * 60_000,
|
||||
},
|
||||
@@ -319,6 +322,26 @@ suite.define(() => {
|
||||
path: path.join(outputDir, "06-person-activity.png"),
|
||||
});
|
||||
|
||||
await activityPage.locator(".activity-feed__people-clear").click();
|
||||
await expect.poll(() => new URL(page.url()).searchParams.get("person")).toBeNull();
|
||||
await activityPage.locator(".activity-feed__people-trigger").click();
|
||||
await activityPage.locator('[data-activity-person="profile-carol"]').click();
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.get("person"))
|
||||
.toBe("profile-carol");
|
||||
await expect.poll(() => activitySession(nightlyMaintenanceKey).count()).toBe(1);
|
||||
await expect
|
||||
.poll(() =>
|
||||
activitySession(nightlyMaintenanceKey)
|
||||
.locator('[data-activity-created-via="cron"]')
|
||||
.count(),
|
||||
)
|
||||
.toBe(1);
|
||||
await page.screenshot({
|
||||
animations: "disabled",
|
||||
path: path.join(outputDir, "06-automation-creator-desktop.png"),
|
||||
});
|
||||
|
||||
await activityPage.locator(".activity-feed__people-clear").click();
|
||||
await expect.poll(() => new URL(page.url()).searchParams.get("person")).toBeNull();
|
||||
await page.setViewportSize({ height: 844, width: 390 });
|
||||
@@ -354,10 +377,19 @@ suite.define(() => {
|
||||
};
|
||||
}),
|
||||
).toEqual({ backgroundColor: "rgba(0, 0, 0, 0)", borderTopWidth: "0px" });
|
||||
await activityPage.locator(".activity-feed__people-trigger").click();
|
||||
await activityPage.locator('[data-activity-person="profile-carol"]').click();
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.get("person"))
|
||||
.toBe("profile-carol");
|
||||
await expect.poll(() => activitySession(nightlyMaintenanceKey).count()).toBe(1);
|
||||
await expect
|
||||
.poll(() => activityFeed.locator('[data-activity-created-via="cron"]').count())
|
||||
.toBe(2);
|
||||
await page.screenshot({
|
||||
animations: "disabled",
|
||||
fullPage: true,
|
||||
path: path.join(outputDir, "07-global-activity-mobile.png"),
|
||||
path: path.join(outputDir, "07-automation-creator-mobile.png"),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3576,6 +3576,7 @@ export const en: TranslationMap = {
|
||||
unknownDate: "Unknown date",
|
||||
noSessions: "No sessions match these filters.",
|
||||
automationGroup: "{count} automation sessions",
|
||||
automation: "Automation",
|
||||
inspectRun: "Inspect run",
|
||||
backToSessions: "Back to sessions",
|
||||
channelLabel: "Channel: {value}",
|
||||
|
||||
@@ -236,6 +236,40 @@ describe("session activity automation grouping", () => {
|
||||
expect(container.querySelectorAll("[data-activity-session]")).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("labels only cron-origin sessions from their recorded creation provenance", () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
|
||||
render(
|
||||
renderSessionActivityView(
|
||||
props({
|
||||
rows: [
|
||||
row("Scheduled report", { id: "owner", label: "Owner" }, Date.now(), {
|
||||
createdVia: "cron",
|
||||
}),
|
||||
row("Automation-bound chat", { id: "owner", label: "Owner" }, Date.now() - 1, {
|
||||
hasAutomation: true,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(
|
||||
container
|
||||
.querySelector(
|
||||
'[data-activity-session="Scheduled report"] [data-activity-created-via="cron"]',
|
||||
)
|
||||
?.textContent?.trim(),
|
||||
).toContain("Automation");
|
||||
expect(
|
||||
container.querySelector(
|
||||
'[data-activity-session="Automation-bound chat"] [data-activity-created-via]',
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("session activity live status", () => {
|
||||
|
||||
@@ -262,6 +262,7 @@ function renderSessionLink(context: ApplicationContext, row: GatewaySessionRow)
|
||||
: row.agentId
|
||||
? t("activityFeed.agentLabel", { value: row.agentId })
|
||||
: null;
|
||||
const source = row.createdVia === "cron" ? t("activityFeed.automation") : null;
|
||||
return html`<div class="activity-feed__session-row">
|
||||
<a
|
||||
class="activity-feed__session"
|
||||
@@ -291,7 +292,11 @@ function renderSessionLink(context: ApplicationContext, row: GatewaySessionRow)
|
||||
data-health=${row.observerDigest?.health ?? nothing}
|
||||
>${headline}</span
|
||||
>`
|
||||
: html`<span>${ownerName}</span>`}${scope
|
||||
: html`<span>${ownerName}</span>`}${source
|
||||
? html`<span class="activity-feed__session-source" data-activity-created-via="cron"
|
||||
>· ${source}${scope ? " ·" : ""}</span
|
||||
>`
|
||||
: nothing}${scope
|
||||
? html`<span class="activity-feed__session-scope">${scope}</span>`
|
||||
: nothing}
|
||||
</span>
|
||||
|
||||
@@ -451,6 +451,10 @@ openclaw-activity-page {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.activity-feed__session-source {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.activity-feed__session-headline,
|
||||
.activity-feed__session-owner {
|
||||
min-width: 0;
|
||||
|
||||
Reference in New Issue
Block a user