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;
label: string;
contentType: string;
@ -10,8 +10,8 @@ export type XWorkmateArtifact = {
encoding?: "base64";
content?: string;
};
export type XWorkmateArtifactScopeKind = "task";
export type XWorkmateArtifactExport = {
type XWorkmateArtifactScopeKind = "task";
type XWorkmateArtifactExport = {
runId: string;
sessionKey: string;
remoteWorkingDirectory: string;
@ -25,7 +25,7 @@ export type XWorkmateArtifactExport = {
constraintSatisfied: boolean;
missingRequiredExtensions: string[];
};
export type XWorkmateArtifactPrepare = {
type XWorkmateArtifactPrepare = {
runId: string;
sessionKey: string;
remoteWorkingDirectory: string;
@ -38,11 +38,11 @@ export type XWorkmateArtifactPrepare = {
expectedArtifactDirs: string[];
expectedArtifactDirStatus: XWorkmateExpectedArtifactDirStatus[];
};
export type XWorkmateExpectedArtifactDirStatus = {
type XWorkmateExpectedArtifactDirStatus = {
relativePath: string;
exists: boolean;
};
export type XWorkmateArtifactSnapshot = {
type XWorkmateArtifactSnapshot = {
runId: string;
sessionKey: 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 exportXWorkmateArtifacts(input: ExportInput): 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: {
remoteWorkingDirectory: string;
artifactScope?: string;

View File

@ -2,6 +2,7 @@ import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypt
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { normalizeExpectedArtifactDirs } from "./expectedArtifactDirs.js";
const DEFAULT_MAX_FILES = 64;
const DEFAULT_MAX_INLINE_BYTES = 10 * 1024 * 1024;
const TASK_SCOPE_ROOT = "tasks";
@ -369,24 +370,7 @@ export async function readXWorkmateArtifact(input) {
};
return result;
}
export function normalizeExpectedArtifactDirs(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) {
function normalizeRequiredExtensions(value) {
if (!Array.isArray(value)) {
return [];
}

View File

@ -1,5 +1,4 @@
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 type XWorkmateTaskMetadataV1 = {
schemaVersion: 1;
@ -35,17 +34,6 @@ export declare function recordXWorkmateSessionMapping(input: {
artifactScope?: string;
source?: XWorkmateSessionMappingSource;
}): 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: {
api: OpenClawPluginApi;
params: Record<string, unknown>;

31
dist/src/taskState.js vendored
View File

@ -1,5 +1,6 @@
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 function registerXWorkmateSessionExtension(api) {
const registerExtension = api.session?.state?.registerSessionExtension ?? api.registerSessionExtension;
@ -28,7 +29,7 @@ export async function recordXWorkmateSessionMapping(input) {
source: input.source ?? "bridge_prepare",
});
}
export function normalizeXWorkmateTaskMetadataV1(input) {
function normalizeXWorkmateTaskMetadataV1(input) {
const envelope = asRecord(input.xworkmate) ?? asRecord(input.xworkmateMetadata) ?? input;
const schemaVersion = Number(envelope.schemaVersion ?? 1);
if (schemaVersion !== 1) {
@ -46,29 +47,7 @@ export function normalizeXWorkmateTaskMetadataV1(input) {
createdAt,
});
}
export function normalizeExpectedArtifactDirs(value) {
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) {
async function upsertXWorkmateSessionMapping(api, input) {
const patchSessionEntry = resolvePatchSessionEntry(api);
if (!patchSessionEntry) {
throw new Error("OpenClaw runtime session patch API is unavailable");
@ -114,7 +93,7 @@ export async function upsertXWorkmateSessionMapping(api, input) {
}
return mapping;
}
export async function readXWorkmateSessionMapping(api, lookup) {
async function readXWorkmateSessionMapping(api, lookup) {
const getSessionEntry = resolveGetSessionEntry(api);
if (!getSessionEntry) {
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 os from "node:os";
import path from "node:path";
import { normalizeExpectedArtifactDirs } from "./expectedArtifactDirs.js";
const DEFAULT_MAX_FILES = 64;
const DEFAULT_MAX_INLINE_BYTES = 10 * 1024 * 1024;
@ -20,7 +21,7 @@ const SKIPPED_DIRS = new Set([
"node_modules",
]);
export type XWorkmateArtifact = {
type XWorkmateArtifact = {
relativePath: string;
label: string;
contentType: string;
@ -33,9 +34,9 @@ export type XWorkmateArtifact = {
content?: string;
};
export type XWorkmateArtifactScopeKind = "task";
type XWorkmateArtifactScopeKind = "task";
export type XWorkmateArtifactExport = {
type XWorkmateArtifactExport = {
runId: string;
sessionKey: string;
remoteWorkingDirectory: string;
@ -50,7 +51,7 @@ export type XWorkmateArtifactExport = {
missingRequiredExtensions: string[];
};
export type XWorkmateArtifactPrepare = {
type XWorkmateArtifactPrepare = {
runId: string;
sessionKey: string;
remoteWorkingDirectory: string;
@ -64,12 +65,12 @@ export type XWorkmateArtifactPrepare = {
expectedArtifactDirStatus: XWorkmateExpectedArtifactDirStatus[];
};
export type XWorkmateExpectedArtifactDirStatus = {
type XWorkmateExpectedArtifactDirStatus = {
relativePath: string;
exists: boolean;
};
export type XWorkmateArtifactSnapshot = {
type XWorkmateArtifactSnapshot = {
runId: string;
sessionKey: string;
remoteWorkingDirectory: string;
@ -491,25 +492,7 @@ export async function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmate
return result;
}
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 = 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[] {
function normalizeRequiredExtensions(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}

View File

@ -3,14 +3,13 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
XWORKMATE_PLUGIN_ID,
XWORKMATE_SESSION_EXTENSION_NAMESPACE,
getXWorkmateTaskSnapshot,
normalizeXWorkmateTaskMetadataV1,
recordXWorkmateSessionMapping,
readXWorkmateSessionMapping,
} from "./taskState.js";
const XWORKMATE_PLUGIN_ID = "openclaw-multi-session-plugins";
function createApiFixture(tasks: Record<string, unknown> = {}, pluginConfig: Record<string, unknown> = {}) {
const sessions = new Map<string, any>();
const api = {
@ -66,14 +65,19 @@ async function createWorkspaceFixture() {
}
describe("xworkmate task state mapping", () => {
it("requires typed appThreadKey metadata", () => {
expect(() =>
normalizeXWorkmateTaskMetadataV1({
schemaVersion: 1,
sessionKey: "draft:legacy",
expectedArtifactDirs: ["artifacts/"],
it("requires typed appThreadKey metadata", async () => {
const { api } = createApiFixture();
await expect(
recordXWorkmateSessionMapping({
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 () => {
@ -273,7 +277,13 @@ describe("xworkmate task state mapping", () => {
});
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({
api,
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",
openclawSessionKey: "draft:lookup",
});

View File

@ -1,7 +1,8 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
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 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 schemaVersion = Number(envelope.schemaVersion ?? 1);
if (schemaVersion !== 1) {
@ -120,30 +121,7 @@ export function normalizeXWorkmateTaskMetadataV1(input: Record<string, unknown>)
}) as XWorkmateTaskMetadataV1;
}
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 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(
async function upsertXWorkmateSessionMapping(
api: OpenClawPluginApi,
input: {
metadata: XWorkmateTaskMetadataV1;
@ -198,7 +176,7 @@ export async function upsertXWorkmateSessionMapping(
return mapping;
}
export async function readXWorkmateSessionMapping(
async function readXWorkmateSessionMapping(
api: OpenClawPluginApi,
lookup: {
appThreadKey?: string;