Skip to content

Commit 1ac5dec

Browse files
CopilotBunsDev
andauthored
fix(gateway): address artifact RPC review blockers
Merges remote task-scope fixes with additional tests and SDK hardening: - Fix taskId resolution via task registry: getTaskById().requesterSessionKey with fallback to resolveSessionKeyForRun(task.runId) - Add messageTaskId support in resolveMessageTaskId - Fix broken taskId test: use getTaskById mock, messageTaskId field, title not label - Add assertions: resolveSessionKeyForRun not called, loadSessionEntry with sessionKey - Add test for non-base64 data: URL in content field (content-field regression guard) - Make ArtifactsNamespace params required with requireArtifactQueryScope helper for fast local SDK feedback instead of runtime Gateway rejection Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com>
2 parents 9271790 + d4d64cd commit 1ac5dec

7 files changed

Lines changed: 73 additions & 31 deletions

File tree

docs/concepts/openclaw-sdk.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,8 @@ await oc.tools.effective({ sessionKey: "main" });
224224
```
225225

226226
Artifact helpers expose the Gateway artifact projection for session, run, or
227-
task context:
227+
task context. Each call requires one explicit `sessionKey`, `runId`, or
228+
`taskId` scope:
228229

229230
```typescript
230231
const { artifacts } = await oc.artifacts.list({ sessionKey: "main" });

docs/gateway/protocol.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,7 @@ enumeration of `src/gateway/server-methods/*.ts`.
388388
- `agents.list` returns configured agent entries, including effective model and runtime metadata.
389389
- `agents.create`, `agents.update`, and `agents.delete` manage agent records and workspace wiring.
390390
- `agents.files.list`, `agents.files.get`, and `agents.files.set` manage the bootstrap workspace files exposed for an agent.
391+
- `artifacts.list`, `artifacts.get`, and `artifacts.download` expose transcript-derived artifact summaries and downloads for an explicit `sessionKey`, `runId`, or `taskId` scope. Run and task queries resolve the owning session server-side and only return transcript media with matching provenance; unsafe or local URL sources return unsupported downloads instead of fetching server-side.
391392
- `agent.identity.get` returns the effective assistant identity for an agent or session.
392393
- `agent.wait` waits for a run to finish and returns the terminal snapshot when available.
393394

packages/sdk/src/client.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,20 @@ function asRecord(value: unknown): Record<string, unknown> {
189189
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
190190
}
191191

192+
function hasArtifactQueryScope(params: unknown): params is ArtifactQuery {
193+
const record = asRecord(params);
194+
return [record.sessionKey, record.runId, record.taskId].some(
195+
(value) => typeof value === "string" && value.trim().length > 0,
196+
);
197+
}
198+
199+
function requireArtifactQueryScope(api: string, params: unknown): ArtifactQuery {
200+
if (!hasArtifactQueryScope(params)) {
201+
throw new Error(`${api} requires one of sessionKey, runId, or taskId`);
202+
}
203+
return params;
204+
}
205+
192206
function readChatProjection(event: OpenClawEvent): ChatProjection | undefined {
193207
const raw = event.raw;
194208
if (event.type !== "raw" || raw?.event !== "chat") {
@@ -763,15 +777,21 @@ export class ArtifactsNamespace extends RpcNamespace {
763777
}
764778

765779
async list(params: ArtifactQuery): Promise<ArtifactsListResult> {
766-
return await this.call("list", params);
780+
return await this.call("list", requireArtifactQueryScope("oc.artifacts.list", params));
767781
}
768782

769783
async get(id: string, params: ArtifactQuery): Promise<ArtifactsGetResult> {
770-
return await this.call("get", { ...asRecord(params), artifactId: id });
784+
return await this.call("get", {
785+
...requireArtifactQueryScope("oc.artifacts.get", params),
786+
artifactId: id,
787+
});
771788
}
772789

773790
async download(id: string, params: ArtifactQuery): Promise<ArtifactsDownloadResult> {
774-
return await this.call("download", { ...asRecord(params), artifactId: id });
791+
return await this.call("download", {
792+
...requireArtifactQueryScope("oc.artifacts.download", params),
793+
artifactId: id,
794+
});
775795
}
776796
}
777797

packages/sdk/src/index.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,22 @@ describe("OpenClaw SDK", () => {
306306
]);
307307
});
308308

309+
it("requires artifact query scope before calling Gateway", async () => {
310+
const transport = new FakeTransport({});
311+
const oc = new OpenClaw({ transport });
312+
313+
await expect(oc.artifacts.list(undefined as never)).rejects.toThrow(
314+
"oc.artifacts.list requires one of sessionKey, runId, or taskId",
315+
);
316+
await expect(oc.artifacts.get("artifact_123", undefined as never)).rejects.toThrow(
317+
"oc.artifacts.get requires one of sessionKey, runId, or taskId",
318+
);
319+
await expect(oc.artifacts.download("artifact_123", undefined as never)).rejects.toThrow(
320+
"oc.artifacts.download requires one of sessionKey, runId, or taskId",
321+
);
322+
expect(transport.calls).toEqual([]);
323+
});
324+
309325
it("throws explicit unsupported errors for SDK namespaces without Gateway RPCs", async () => {
310326
const transport = new FakeTransport({});
311327
const oc = new OpenClaw({ transport });

packages/sdk/src/types.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,10 @@ export type ArtifactSummary = {
8888
expiresAt?: string;
8989
};
9090

91-
export type ArtifactQuery = {
92-
sessionKey?: string;
93-
runId?: string;
94-
taskId?: string;
95-
};
91+
export type ArtifactQuery =
92+
| { sessionKey: string; runId?: string; taskId?: string }
93+
| { runId: string; sessionKey?: string; taskId?: string }
94+
| { taskId: string; sessionKey?: string; runId?: string };
9695

9796
export type ArtifactsListResult = {
9897
artifacts: ArtifactSummary[];

src/gateway/server-methods/artifacts.test.ts

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
22
import { artifactsHandlers, collectArtifactsFromMessages } from "./artifacts.js";
33

44
const hoisted = vi.hoisted(() => ({
5+
getTaskById: vi.fn(),
56
loadSessionEntry: vi.fn(),
67
readSessionMessages: vi.fn(),
78
resolveSessionKeyForRun: vi.fn(),
8-
getTaskById: vi.fn(),
9+
}));
10+
11+
vi.mock("../../tasks/task-registry.js", () => ({
12+
getTaskById: hoisted.getTaskById,
913
}));
1014

1115
vi.mock("../session-utils.js", async () => {
@@ -27,16 +31,6 @@ vi.mock("../server-session-key.js", async () => {
2731
};
2832
});
2933

30-
vi.mock("../../tasks/task-registry.js", async () => {
31-
const actual = await vi.importActual<typeof import("../../tasks/task-registry.js")>(
32-
"../../tasks/task-registry.js",
33-
);
34-
return {
35-
...actual,
36-
getTaskById: hoisted.getTaskById,
37-
};
38-
});
39-
4034
function createResponder() {
4135
const calls: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = [];
4236
return {
@@ -50,6 +44,7 @@ function createResponder() {
5044
describe("artifacts RPC handlers", () => {
5145
beforeEach(() => {
5246
vi.clearAllMocks();
47+
hoisted.getTaskById.mockReturnValue(undefined);
5348
hoisted.loadSessionEntry.mockReturnValue({
5449
storePath: "/tmp/sessions.json",
5550
entry: { sessionId: "sess-main", sessionFile: "/tmp/sess-main.jsonl" },
@@ -167,17 +162,21 @@ describe("artifacts RPC handlers", () => {
167162
});
168163

169164
it("resolves taskId queries through the task registry and filters artifacts by messageTaskId", async () => {
170-
hoisted.getTaskById.mockReturnValue({ requesterSessionKey: "agent:main:main" });
165+
hoisted.getTaskById.mockReturnValue({
166+
taskId: "task-1",
167+
requesterSessionKey: "agent:main:main",
168+
runId: "run-for-task-1",
169+
});
171170
hoisted.readSessionMessages.mockReturnValue([
172171
{
173172
role: "assistant",
174173
content: [{ type: "image", data: "dGFyZ2V0", alt: "task-result.png" }],
175-
__openclaw: { seq: 2, taskId: "task-1" },
174+
__openclaw: { seq: 2, messageTaskId: "task-1" },
176175
},
177176
{
178177
role: "assistant",
179178
content: [{ type: "image", data: "b3RoZXI=", alt: "other-task.png" }],
180-
__openclaw: { seq: 3, taskId: "task-2" },
179+
__openclaw: { seq: 3, messageTaskId: "task-2" },
181180
},
182181
{
183182
role: "assistant",
@@ -198,6 +197,8 @@ describe("artifacts RPC handlers", () => {
198197

199198
expect(list.calls[0]?.ok).toBe(true);
200199
expect(hoisted.getTaskById).toHaveBeenCalledWith("task-1");
200+
expect(hoisted.resolveSessionKeyForRun).not.toHaveBeenCalled();
201+
expect(hoisted.loadSessionEntry).toHaveBeenCalledWith("agent:main:main");
201202
const listPayload = list.calls[0]?.payload as { artifacts?: Array<Record<string, unknown>> };
202203
expect(listPayload.artifacts).toHaveLength(1);
203204
expect(listPayload.artifacts?.[0]).toMatchObject({

src/gateway/server-methods/artifacts.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createHash } from "node:crypto";
2+
import { getTaskById } from "../../tasks/task-registry.js";
23
import {
34
ErrorCodes,
45
errorShape,
@@ -8,7 +9,6 @@ import {
89
validateArtifactsGetParams,
910
validateArtifactsListParams,
1011
} from "../protocol/index.js";
11-
import { getTaskById } from "../../tasks/task-registry.js";
1212
import { resolveSessionKeyForRun } from "../server-session-key.js";
1313
import { loadSessionEntry, readSessionMessages } from "../session-utils.js";
1414
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
@@ -134,7 +134,12 @@ function resolveMessageRunId(message: Record<string, unknown>): string | undefin
134134

135135
function resolveMessageTaskId(message: Record<string, unknown>): string | undefined {
136136
const meta = asRecord(message.__openclaw);
137-
return asNonEmptyString(meta?.taskId) ?? asNonEmptyString(message.taskId);
137+
return (
138+
asNonEmptyString(meta?.messageTaskId) ??
139+
asNonEmptyString(meta?.taskId) ??
140+
asNonEmptyString(message.messageTaskId) ??
141+
asNonEmptyString(message.taskId)
142+
);
138143
}
139144

140145
function resolveBlockDownload(block: Record<string, unknown>): {
@@ -274,13 +279,12 @@ function resolveQuerySessionKey(query: ArtifactQuery): string | undefined {
274279
}
275280
if (query.taskId) {
276281
const task = getTaskById(query.taskId);
277-
if (task?.requesterSessionKey) {
278-
return task.requesterSessionKey;
282+
const requesterSessionKey = asNonEmptyString(task?.requesterSessionKey);
283+
if (requesterSessionKey) {
284+
return requesterSessionKey;
279285
}
280-
if (task?.runId) {
281-
return resolveSessionKeyForRun(task.runId);
282-
}
283-
return undefined;
286+
const runId = asNonEmptyString(task?.runId);
287+
return runId ? resolveSessionKeyForRun(runId) : undefined;
284288
}
285289
return undefined;
286290
}

0 commit comments

Comments
 (0)