refactor: share expected artifact dir normalization

This commit is contained in:
Haitao Pan 2026-06-12 14:49:54 +08:00
parent bec9567f13
commit 0682fbf7cf
10 changed files with 127 additions and 128 deletions

1
dist/src/expectedArtifactDirs.d.ts vendored Normal file
View File

@ -0,0 +1 @@
export declare function normalizeExpectedArtifactDirs(value: unknown): string[];

33
dist/src/expectedArtifactDirs.js vendored Normal file
View File

@ -0,0 +1,33 @@
function optionalString(value) {
return typeof value === "string" ? value.trim() : "";
}
function safeExpectedArtifactDir(value) {
const relativePath = optionalString(value);
if (!relativePath) {
return "";
}
if (/^[A-Za-z]:[\\/]/u.test(relativePath) || relativePath.startsWith("/") || relativePath.includes("\0")) {
throw new Error("expectedArtifactDir must stay inside the workspace");
}
const normalized = relativePath.split(/[\\/]/).filter(Boolean).join("/");
if (!normalized || normalized.split("/").some((part) => part === ".." || part === ".")) {
throw new Error("expectedArtifactDir must stay inside the workspace");
}
return normalized.endsWith("/") ? normalized : `${normalized}/`;
}
export function normalizeExpectedArtifactDirs(value) {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set();
const result = [];
for (const entry of value) {
const normalized = safeExpectedArtifactDir(entry);
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
result.push(normalized);
}
return result;
}

View File

@ -1,4 +1,4 @@
export type XWorkmateArtifact = { type XWorkmateArtifact = {
relativePath: string; relativePath: string;
label: string; label: string;
contentType: string; contentType: string;
@ -10,8 +10,8 @@ export type XWorkmateArtifact = {
encoding?: "base64"; encoding?: "base64";
content?: string; content?: string;
}; };
export type XWorkmateArtifactScopeKind = "task"; type XWorkmateArtifactScopeKind = "task";
export type XWorkmateArtifactExport = { type XWorkmateArtifactExport = {
runId: string; runId: string;
sessionKey: string; sessionKey: string;
remoteWorkingDirectory: string; remoteWorkingDirectory: string;
@ -25,7 +25,7 @@ export type XWorkmateArtifactExport = {
constraintSatisfied: boolean; constraintSatisfied: boolean;
missingRequiredExtensions: string[]; missingRequiredExtensions: string[];
}; };
export type XWorkmateArtifactPrepare = { type XWorkmateArtifactPrepare = {
runId: string; runId: string;
sessionKey: string; sessionKey: string;
remoteWorkingDirectory: string; remoteWorkingDirectory: string;
@ -38,11 +38,11 @@ export type XWorkmateArtifactPrepare = {
expectedArtifactDirs: string[]; expectedArtifactDirs: string[];
expectedArtifactDirStatus: XWorkmateExpectedArtifactDirStatus[]; expectedArtifactDirStatus: XWorkmateExpectedArtifactDirStatus[];
}; };
export type XWorkmateExpectedArtifactDirStatus = { type XWorkmateExpectedArtifactDirStatus = {
relativePath: string; relativePath: string;
exists: boolean; exists: boolean;
}; };
export type XWorkmateArtifactSnapshot = { type XWorkmateArtifactSnapshot = {
runId: string; runId: string;
sessionKey: string; sessionKey: string;
remoteWorkingDirectory: string; remoteWorkingDirectory: string;
@ -68,8 +68,6 @@ export declare function prepareXWorkmateArtifacts(input: ExportInput): Promise<X
export declare function collectAndSnapshotXWorkmateArtifacts(input: ExportInput): Promise<XWorkmateArtifactSnapshot>; export declare function collectAndSnapshotXWorkmateArtifacts(input: ExportInput): Promise<XWorkmateArtifactSnapshot>;
export declare function exportXWorkmateArtifacts(input: ExportInput): Promise<XWorkmateArtifactExport>; export declare function exportXWorkmateArtifacts(input: ExportInput): Promise<XWorkmateArtifactExport>;
export declare function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmateArtifactExport>; export declare function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmateArtifactExport>;
export declare function normalizeExpectedArtifactDirs(value: unknown): string[];
export declare function normalizeRequiredExtensions(value: unknown): string[];
export declare function formatArtifactManifestMarkdown(input: { export declare function formatArtifactManifestMarkdown(input: {
remoteWorkingDirectory: string; remoteWorkingDirectory: string;
artifactScope?: string; artifactScope?: string;

View File

@ -2,6 +2,7 @@ import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypt
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { normalizeExpectedArtifactDirs } from "./expectedArtifactDirs.js";
const DEFAULT_MAX_FILES = 64; const DEFAULT_MAX_FILES = 64;
const DEFAULT_MAX_INLINE_BYTES = 10 * 1024 * 1024; const DEFAULT_MAX_INLINE_BYTES = 10 * 1024 * 1024;
const TASK_SCOPE_ROOT = "tasks"; const TASK_SCOPE_ROOT = "tasks";
@ -369,24 +370,7 @@ export async function readXWorkmateArtifact(input) {
}; };
return result; return result;
} }
export function normalizeExpectedArtifactDirs(value) { function normalizeRequiredExtensions(value) {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set();
const result = [];
for (const entry of value) {
const normalized = safeInputRelativePath(entry, "expectedArtifactDir");
const withSlash = normalized.endsWith("/") ? normalized : `${normalized}/`;
if (seen.has(withSlash)) {
continue;
}
seen.add(withSlash);
result.push(withSlash);
}
return result;
}
export function normalizeRequiredExtensions(value) {
if (!Array.isArray(value)) { if (!Array.isArray(value)) {
return []; return [];
} }

View File

@ -1,5 +1,4 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
export declare const XWORKMATE_PLUGIN_ID = "openclaw-multi-session-plugins";
export declare const XWORKMATE_SESSION_EXTENSION_NAMESPACE = "xworkmate.sessionMapping"; export declare const XWORKMATE_SESSION_EXTENSION_NAMESPACE = "xworkmate.sessionMapping";
export type XWorkmateTaskMetadataV1 = { export type XWorkmateTaskMetadataV1 = {
schemaVersion: 1; schemaVersion: 1;
@ -35,17 +34,6 @@ export declare function recordXWorkmateSessionMapping(input: {
artifactScope?: string; artifactScope?: string;
source?: XWorkmateSessionMappingSource; source?: XWorkmateSessionMappingSource;
}): Promise<XWorkmateSessionMappingV1>; }): Promise<XWorkmateSessionMappingV1>;
export declare function normalizeXWorkmateTaskMetadataV1(input: Record<string, unknown>): XWorkmateTaskMetadataV1;
export declare function normalizeExpectedArtifactDirs(value: unknown): string[];
export declare function upsertXWorkmateSessionMapping(api: OpenClawPluginApi, input: {
metadata: XWorkmateTaskMetadataV1;
openclawSessionKey: string;
source: XWorkmateSessionMappingSource;
}): Promise<XWorkmateSessionMappingV1>;
export declare function readXWorkmateSessionMapping(api: OpenClawPluginApi, lookup: {
appThreadKey?: string;
openclawSessionKey?: string;
}): Promise<XWorkmateSessionMappingV1 | undefined>;
export declare function getXWorkmateTaskSnapshot(input: { export declare function getXWorkmateTaskSnapshot(input: {
api: OpenClawPluginApi; api: OpenClawPluginApi;
params: Record<string, unknown>; params: Record<string, unknown>;

31
dist/src/taskState.js vendored
View File

@ -1,5 +1,6 @@
import { exportXWorkmateArtifacts } from "./exportArtifacts.js"; import { exportXWorkmateArtifacts } from "./exportArtifacts.js";
export const XWORKMATE_PLUGIN_ID = "openclaw-multi-session-plugins"; import { normalizeExpectedArtifactDirs } from "./expectedArtifactDirs.js";
const XWORKMATE_PLUGIN_ID = "openclaw-multi-session-plugins";
export const XWORKMATE_SESSION_EXTENSION_NAMESPACE = "xworkmate.sessionMapping"; export const XWORKMATE_SESSION_EXTENSION_NAMESPACE = "xworkmate.sessionMapping";
export function registerXWorkmateSessionExtension(api) { export function registerXWorkmateSessionExtension(api) {
const registerExtension = api.session?.state?.registerSessionExtension ?? api.registerSessionExtension; const registerExtension = api.session?.state?.registerSessionExtension ?? api.registerSessionExtension;
@ -28,7 +29,7 @@ export async function recordXWorkmateSessionMapping(input) {
source: input.source ?? "bridge_prepare", source: input.source ?? "bridge_prepare",
}); });
} }
export function normalizeXWorkmateTaskMetadataV1(input) { function normalizeXWorkmateTaskMetadataV1(input) {
const envelope = asRecord(input.xworkmate) ?? asRecord(input.xworkmateMetadata) ?? input; const envelope = asRecord(input.xworkmate) ?? asRecord(input.xworkmateMetadata) ?? input;
const schemaVersion = Number(envelope.schemaVersion ?? 1); const schemaVersion = Number(envelope.schemaVersion ?? 1);
if (schemaVersion !== 1) { if (schemaVersion !== 1) {
@ -46,29 +47,7 @@ export function normalizeXWorkmateTaskMetadataV1(input) {
createdAt, createdAt,
}); });
} }
export function normalizeExpectedArtifactDirs(value) { async function upsertXWorkmateSessionMapping(api, input) {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set();
const result = [];
for (const entry of value) {
const text = optionalString(entry).replaceAll("\\", "/").replace(/^\.\/+/u, "");
if (!text || seen.has(text)) {
continue;
}
if (text.startsWith("/") || /^[A-Za-z]:\//u.test(text) || text.split("/").includes("..")) {
throw new Error("expectedArtifactDirs must be relative paths without traversal");
}
const normalized = text.endsWith("/") ? text : `${text}/`;
if (!seen.has(normalized)) {
seen.add(normalized);
result.push(normalized);
}
}
return result;
}
export async function upsertXWorkmateSessionMapping(api, input) {
const patchSessionEntry = resolvePatchSessionEntry(api); const patchSessionEntry = resolvePatchSessionEntry(api);
if (!patchSessionEntry) { if (!patchSessionEntry) {
throw new Error("OpenClaw runtime session patch API is unavailable"); throw new Error("OpenClaw runtime session patch API is unavailable");
@ -114,7 +93,7 @@ export async function upsertXWorkmateSessionMapping(api, input) {
} }
return mapping; return mapping;
} }
export async function readXWorkmateSessionMapping(api, lookup) { async function readXWorkmateSessionMapping(api, lookup) {
const getSessionEntry = resolveGetSessionEntry(api); const getSessionEntry = resolveGetSessionEntry(api);
if (!getSessionEntry) { if (!getSessionEntry) {
return undefined; return undefined;

View File

@ -0,0 +1,35 @@
function optionalString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function safeExpectedArtifactDir(value: unknown): string {
const relativePath = optionalString(value);
if (!relativePath) {
return "";
}
if (/^[A-Za-z]:[\\/]/u.test(relativePath) || relativePath.startsWith("/") || relativePath.includes("\0")) {
throw new Error("expectedArtifactDir must stay inside the workspace");
}
const normalized = relativePath.split(/[\\/]/).filter(Boolean).join("/");
if (!normalized || normalized.split("/").some((part) => part === ".." || part === ".")) {
throw new Error("expectedArtifactDir must stay inside the workspace");
}
return normalized.endsWith("/") ? normalized : `${normalized}/`;
}
export function normalizeExpectedArtifactDirs(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set<string>();
const result: string[] = [];
for (const entry of value) {
const normalized = safeExpectedArtifactDir(entry);
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
result.push(normalized);
}
return result;
}

View File

@ -2,6 +2,7 @@ import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypt
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { normalizeExpectedArtifactDirs } from "./expectedArtifactDirs.js";
const DEFAULT_MAX_FILES = 64; const DEFAULT_MAX_FILES = 64;
const DEFAULT_MAX_INLINE_BYTES = 10 * 1024 * 1024; const DEFAULT_MAX_INLINE_BYTES = 10 * 1024 * 1024;
@ -20,7 +21,7 @@ const SKIPPED_DIRS = new Set([
"node_modules", "node_modules",
]); ]);
export type XWorkmateArtifact = { type XWorkmateArtifact = {
relativePath: string; relativePath: string;
label: string; label: string;
contentType: string; contentType: string;
@ -33,9 +34,9 @@ export type XWorkmateArtifact = {
content?: string; content?: string;
}; };
export type XWorkmateArtifactScopeKind = "task"; type XWorkmateArtifactScopeKind = "task";
export type XWorkmateArtifactExport = { type XWorkmateArtifactExport = {
runId: string; runId: string;
sessionKey: string; sessionKey: string;
remoteWorkingDirectory: string; remoteWorkingDirectory: string;
@ -50,7 +51,7 @@ export type XWorkmateArtifactExport = {
missingRequiredExtensions: string[]; missingRequiredExtensions: string[];
}; };
export type XWorkmateArtifactPrepare = { type XWorkmateArtifactPrepare = {
runId: string; runId: string;
sessionKey: string; sessionKey: string;
remoteWorkingDirectory: string; remoteWorkingDirectory: string;
@ -64,12 +65,12 @@ export type XWorkmateArtifactPrepare = {
expectedArtifactDirStatus: XWorkmateExpectedArtifactDirStatus[]; expectedArtifactDirStatus: XWorkmateExpectedArtifactDirStatus[];
}; };
export type XWorkmateExpectedArtifactDirStatus = { type XWorkmateExpectedArtifactDirStatus = {
relativePath: string; relativePath: string;
exists: boolean; exists: boolean;
}; };
export type XWorkmateArtifactSnapshot = { type XWorkmateArtifactSnapshot = {
runId: string; runId: string;
sessionKey: string; sessionKey: string;
remoteWorkingDirectory: string; remoteWorkingDirectory: string;
@ -491,25 +492,7 @@ export async function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmate
return result; return result;
} }
export function normalizeExpectedArtifactDirs(value: unknown): string[] { function normalizeRequiredExtensions(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set<string>();
const result: string[] = [];
for (const entry of value) {
const normalized = safeInputRelativePath(entry, "expectedArtifactDir");
const withSlash = normalized.endsWith("/") ? normalized : `${normalized}/`;
if (seen.has(withSlash)) {
continue;
}
seen.add(withSlash);
result.push(withSlash);
}
return result;
}
export function normalizeRequiredExtensions(value: unknown): string[] {
if (!Array.isArray(value)) { if (!Array.isArray(value)) {
return []; return [];
} }

View File

@ -3,14 +3,13 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
XWORKMATE_PLUGIN_ID,
XWORKMATE_SESSION_EXTENSION_NAMESPACE, XWORKMATE_SESSION_EXTENSION_NAMESPACE,
getXWorkmateTaskSnapshot, getXWorkmateTaskSnapshot,
normalizeXWorkmateTaskMetadataV1,
recordXWorkmateSessionMapping, recordXWorkmateSessionMapping,
readXWorkmateSessionMapping,
} from "./taskState.js"; } from "./taskState.js";
const XWORKMATE_PLUGIN_ID = "openclaw-multi-session-plugins";
function createApiFixture(tasks: Record<string, unknown> = {}, pluginConfig: Record<string, unknown> = {}) { function createApiFixture(tasks: Record<string, unknown> = {}, pluginConfig: Record<string, unknown> = {}) {
const sessions = new Map<string, any>(); const sessions = new Map<string, any>();
const api = { const api = {
@ -66,14 +65,19 @@ async function createWorkspaceFixture() {
} }
describe("xworkmate task state mapping", () => { describe("xworkmate task state mapping", () => {
it("requires typed appThreadKey metadata", () => { it("requires typed appThreadKey metadata", async () => {
expect(() => const { api } = createApiFixture();
normalizeXWorkmateTaskMetadataV1({
schemaVersion: 1, await expect(
sessionKey: "draft:legacy", recordXWorkmateSessionMapping({
expectedArtifactDirs: ["artifacts/"], api,
params: {
schemaVersion: 1,
sessionKey: "draft:legacy",
expectedArtifactDirs: ["artifacts/"],
},
}), }),
).toThrow("appThreadKey required"); ).rejects.toThrow("appThreadKey required");
}); });
it("writes a durable pluginExtensions mapping without deriving the OpenClaw key", async () => { it("writes a durable pluginExtensions mapping without deriving the OpenClaw key", async () => {
@ -273,7 +277,13 @@ describe("xworkmate task state mapping", () => {
}); });
it("can read mapping by appThreadKey from pluginExtensions", async () => { it("can read mapping by appThreadKey from pluginExtensions", async () => {
const { api } = createApiFixture(); const { api } = createApiFixture({
"draft:lookup:run-1": {
taskId: "task-1",
runId: "run-1",
status: "succeeded",
},
});
await recordXWorkmateSessionMapping({ await recordXWorkmateSessionMapping({
api, api,
params: { params: {
@ -284,7 +294,17 @@ describe("xworkmate task state mapping", () => {
}, },
}); });
await expect(readXWorkmateSessionMapping(api, { appThreadKey: "draft:lookup" })).resolves.toMatchObject({ await expect(
getXWorkmateTaskSnapshot({
api,
params: {
appThreadKey: "draft:lookup",
runId: "run-1",
includeArtifacts: false,
},
}),
).resolves.toMatchObject({
success: true,
appThreadKey: "draft:lookup", appThreadKey: "draft:lookup",
openclawSessionKey: "draft:lookup", openclawSessionKey: "draft:lookup",
}); });

View File

@ -1,7 +1,8 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { exportXWorkmateArtifacts } from "./exportArtifacts.js"; import { exportXWorkmateArtifacts } from "./exportArtifacts.js";
import { normalizeExpectedArtifactDirs } from "./expectedArtifactDirs.js";
export const XWORKMATE_PLUGIN_ID = "openclaw-multi-session-plugins"; const XWORKMATE_PLUGIN_ID = "openclaw-multi-session-plugins";
export const XWORKMATE_SESSION_EXTENSION_NAMESPACE = "xworkmate.sessionMapping"; export const XWORKMATE_SESSION_EXTENSION_NAMESPACE = "xworkmate.sessionMapping";
export type XWorkmateTaskMetadataV1 = { export type XWorkmateTaskMetadataV1 = {
@ -101,7 +102,7 @@ export async function recordXWorkmateSessionMapping(input: {
}); });
} }
export function normalizeXWorkmateTaskMetadataV1(input: Record<string, unknown>): XWorkmateTaskMetadataV1 { function normalizeXWorkmateTaskMetadataV1(input: Record<string, unknown>): XWorkmateTaskMetadataV1 {
const envelope = asRecord(input.xworkmate) ?? asRecord(input.xworkmateMetadata) ?? input; const envelope = asRecord(input.xworkmate) ?? asRecord(input.xworkmateMetadata) ?? input;
const schemaVersion = Number(envelope.schemaVersion ?? 1); const schemaVersion = Number(envelope.schemaVersion ?? 1);
if (schemaVersion !== 1) { if (schemaVersion !== 1) {
@ -120,30 +121,7 @@ export function normalizeXWorkmateTaskMetadataV1(input: Record<string, unknown>)
}) as XWorkmateTaskMetadataV1; }) as XWorkmateTaskMetadataV1;
} }
export function normalizeExpectedArtifactDirs(value: unknown): string[] { async function upsertXWorkmateSessionMapping(
if (!Array.isArray(value)) {
return [];
}
const seen = new Set<string>();
const result: string[] = [];
for (const entry of value) {
const text = optionalString(entry).replaceAll("\\", "/").replace(/^\.\/+/u, "");
if (!text || seen.has(text)) {
continue;
}
if (text.startsWith("/") || /^[A-Za-z]:\//u.test(text) || text.split("/").includes("..")) {
throw new Error("expectedArtifactDirs must be relative paths without traversal");
}
const normalized = text.endsWith("/") ? text : `${text}/`;
if (!seen.has(normalized)) {
seen.add(normalized);
result.push(normalized);
}
}
return result;
}
export async function upsertXWorkmateSessionMapping(
api: OpenClawPluginApi, api: OpenClawPluginApi,
input: { input: {
metadata: XWorkmateTaskMetadataV1; metadata: XWorkmateTaskMetadataV1;
@ -198,7 +176,7 @@ export async function upsertXWorkmateSessionMapping(
return mapping; return mapping;
} }
export async function readXWorkmateSessionMapping( async function readXWorkmateSessionMapping(
api: OpenClawPluginApi, api: OpenClawPluginApi,
lookup: { lookup: {
appThreadKey?: string; appThreadKey?: string;