fix: declare xworkmate artifact tool contract

This commit is contained in:
Haitao Pan 2026-05-06 18:34:05 +08:00
parent ac3a285dc2
commit 74ea384374
9 changed files with 473 additions and 56 deletions

View File

@ -19,7 +19,7 @@ xworkmate.artifacts.list
xworkmate.artifacts.read xworkmate.artifacts.read
``` ```
`prepare` creates a per-task artifact scope under the resolved OpenClaw workspace. `export` `prepare` creates a per-task artifact scope under `tasks/` in the resolved OpenClaw workspace. `export`
and `read` then return safe, relative artifact entries that XWorkmate Bridge can normalize and `read` then return safe, relative artifact entries that XWorkmate Bridge can normalize
into the APP `artifacts[]` contract. into the APP `artifacts[]` contract.
@ -78,10 +78,10 @@ Prepare response payload:
"sessionKey": "thread-main", "sessionKey": "thread-main",
"remoteWorkingDirectory": "/home/user/.openclaw/workspace", "remoteWorkingDirectory": "/home/user/.openclaw/workspace",
"remoteWorkspaceRefKind": "remotePath", "remoteWorkspaceRefKind": "remotePath",
"artifactScope": ".xworkmate/artifacts/tasks/thread-main-.../turn-1-...", "artifactScope": "tasks/thread-main-.../turn-1-...",
"scopeKind": "task", "scopeKind": "task",
"artifactDirectory": "/home/user/.openclaw/workspace/.xworkmate/artifacts/tasks/thread-main-.../turn-1-...", "artifactDirectory": "/home/user/.openclaw/workspace/tasks/thread-main-.../turn-1-...",
"relativeArtifactDirectory": ".xworkmate/artifacts/tasks/thread-main-.../turn-1-...", "relativeArtifactDirectory": "tasks/thread-main-.../turn-1-...",
"warnings": [] "warnings": []
} }
``` ```
@ -92,7 +92,7 @@ Export request params:
{ {
"sessionKey": "thread-main", "sessionKey": "thread-main",
"runId": "turn-1", "runId": "turn-1",
"artifactScope": ".xworkmate/artifacts/tasks/thread-main-.../turn-1-...", "artifactScope": "tasks/thread-main-.../turn-1-...",
"sinceUnixMs": 1770000000000, "sinceUnixMs": 1770000000000,
"latestIfEmpty": true, "latestIfEmpty": true,
"maxFiles": 64, "maxFiles": 64,
@ -108,7 +108,7 @@ Export response payload:
"sessionKey": "thread-main", "sessionKey": "thread-main",
"remoteWorkingDirectory": "/home/user/.openclaw/workspace", "remoteWorkingDirectory": "/home/user/.openclaw/workspace",
"remoteWorkspaceRefKind": "remotePath", "remoteWorkspaceRefKind": "remotePath",
"artifactScope": ".xworkmate/artifacts/tasks/thread-main-.../turn-1-...", "artifactScope": "tasks/thread-main-.../turn-1-...",
"scopeKind": "task", "scopeKind": "task",
"artifacts": [ "artifacts": [
{ {
@ -117,7 +117,8 @@ Export response payload:
"contentType": "text/markdown", "contentType": "text/markdown",
"sizeBytes": 1234, "sizeBytes": 1234,
"sha256": "...", "sha256": "...",
"artifactScope": ".xworkmate/artifacts/tasks/thread-main-.../turn-1-...", "artifactRef": "...",
"artifactScope": "tasks/thread-main-.../turn-1-...",
"scopeKind": "task" "scopeKind": "task"
} }
], ],
@ -130,7 +131,13 @@ When scoped export finds no task files and `latestIfEmpty` is true, the plugin s
the workspace root for the latest real files and returns them with `scopeKind: the workspace root for the latest real files and returns them with `scopeKind:
"workspace-latest"`. This is a controlled recovery path for existing files already "workspace-latest"`. This is a controlled recovery path for existing files already
present in `/home/ubuntu/.openclaw/workspace`; it still skips plugin metadata and present in `/home/ubuntu/.openclaw/workspace`; it still skips plugin metadata and
runtime directories. runtime directories, including the plugin-owned top-level `tasks/` directory.
Each exported artifact includes `artifactRef`, a plugin-signed reference over
the artifact scope, path, size, and SHA-256 digest. `read` accepts
`artifactScope + relativePath` for task-scope files. Workspace fallback files
must be read with `artifactRef`; there is no unscoped arbitrary workspace read
API.
## View And Download ## View And Download
@ -161,7 +168,8 @@ Gateway clients can use:
- `xworkmate.artifacts.prepare` before `chat.send` to allocate a task artifact directory. - `xworkmate.artifacts.prepare` before `chat.send` to allocate a task artifact directory.
- `xworkmate.artifacts.list` for a metadata-only manifest and Markdown table. - `xworkmate.artifacts.list` for a metadata-only manifest and Markdown table.
- `xworkmate.artifacts.read` with `artifactScope` and `relativePath` for one inline base64 file. - `xworkmate.artifacts.read` with `artifactScope` and `relativePath` for one task file.
- `xworkmate.artifacts.read` with `artifactRef` for a plugin-returned task or `workspace-latest` file.
- `xworkmate.artifacts.export` with `artifactScope` after `agent.wait` for the XWorkmate APP sync path. - `xworkmate.artifacts.export` with `artifactScope` after `agent.wait` for the XWorkmate APP sync path.
Large files are metadata-only in the export payload, but XWorkmate Bridge can Large files are metadata-only in the export payload, but XWorkmate Bridge can
@ -171,10 +179,11 @@ only remote file access path.
## Limits ## Limits
- Only files inside the resolved OpenClaw workspace are exported. - Only files inside the resolved OpenClaw workspace are exported.
- `.git`, `.openclaw`, `.xworkmate`, `.pi`, build outputs, and dependency folders are skipped when scanning the workspace root. - `.git`, `.openclaw`, `.xworkmate`, `.pi`, top-level `tasks/`, build outputs, and dependency folders are skipped when scanning the workspace root.
- Symlinks are skipped to avoid workspace escape. - Symlinks are skipped to avoid workspace escape.
- Files larger than `maxInlineBytes` are listed with metadata and a warning, but are not inlined. - Files larger than `maxInlineBytes` are listed with metadata and a warning, but are not inlined.
- `artifactScope` and `relativePath` must be workspace-relative paths; absolute paths, `..`, empty path segments, and symlink escapes are rejected. - `artifactScope` must be `tasks/<safe-session-key>/<safe-run-id>`.
- `artifactScope`, `artifactRef`, and `relativePath` must stay inside the workspace; absolute paths, `..`, empty path segments, and symlink escapes are rejected.
## Development ## Development

10
dist/index.js vendored
View File

@ -92,7 +92,15 @@ function createXWorkmateArtifactsTool(api, ctx) {
}, },
relativePath: { relativePath: {
type: "string", type: "string",
description: "Artifact path relative to the workspace. Required for action=read.", description: "Artifact path relative to artifactScope. Required for action=read without artifactRef.",
},
artifactScope: {
type: "string",
description: "Task artifact scope returned by prepare/export, for example tasks/<session>/<run>.",
},
artifactRef: {
type: "string",
description: "Plugin-signed artifact reference returned by export/list. Required for workspace-latest reads.",
}, },
sinceUnixMs: { sinceUnixMs: {
type: "number", type: "number",

View File

@ -4,6 +4,7 @@ export type XWorkmateArtifact = {
contentType: string; contentType: string;
sizeBytes: number; sizeBytes: number;
sha256: string; sha256: string;
artifactRef: string;
artifactScope?: string; artifactScope?: string;
scopeKind?: XWorkmateArtifactScopeKind; scopeKind?: XWorkmateArtifactScopeKind;
encoding?: "base64"; encoding?: "base64";

View File

@ -1,9 +1,11 @@
import { createHash } from "node:crypto"; import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
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";
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 GENERATED_ARTIFACT_REF_SECRET = randomBytes(32).toString("hex");
const SKIPPED_DIRS = new Set([ const SKIPPED_DIRS = new Set([
".git", ".git",
".openclaw", ".openclaw",
@ -69,6 +71,7 @@ export async function exportXWorkmateArtifacts(input) {
scanRoot: scopeRoot, scanRoot: scopeRoot,
relativeRoot: scopeRoot, relativeRoot: scopeRoot,
sinceUnixMs, sinceUnixMs,
skipTaskScopeRoot: !scopedExport,
warnings, warnings,
}); });
if (candidates.length === 0 && latestIfEmpty) { if (candidates.length === 0 && latestIfEmpty) {
@ -77,6 +80,7 @@ export async function exportXWorkmateArtifacts(input) {
scanRoot: workspaceRoot, scanRoot: workspaceRoot,
relativeRoot: workspaceRoot, relativeRoot: workspaceRoot,
sinceUnixMs: 0, sinceUnixMs: 0,
skipTaskScopeRoot: true,
warnings: latestWarnings, warnings: latestWarnings,
}); });
if (latestCandidates.length > 0) { if (latestCandidates.length > 0) {
@ -101,12 +105,22 @@ export async function exportXWorkmateArtifacts(input) {
break; break;
} }
const bytes = await fs.readFile(candidate.absolutePath); const bytes = await fs.readFile(candidate.absolutePath);
const sha256 = createHash("sha256").update(bytes).digest("hex");
const artifact = { const artifact = {
relativePath: candidate.relativePath, relativePath: candidate.relativePath,
label: path.posix.basename(candidate.relativePath), label: path.posix.basename(candidate.relativePath),
contentType: contentTypeForPath(candidate.relativePath), contentType: contentTypeForPath(candidate.relativePath),
sizeBytes: bytes.byteLength, sizeBytes: bytes.byteLength,
sha256: createHash("sha256").update(bytes).digest("hex"), sha256,
artifactRef: signArtifactRef({
v: 1,
workspaceRootHash: workspaceRootHash(workspaceRoot),
scopeKind,
...(scopeKind === "task" && artifactScope ? { artifactScope } : {}),
relativePath: candidate.relativePath,
sizeBytes: bytes.byteLength,
sha256,
}, pluginConfig),
scopeKind, scopeKind,
}; };
if (scopeKind === "task" && artifactScope) { if (scopeKind === "task" && artifactScope) {
@ -141,8 +155,10 @@ export async function readXWorkmateArtifact(input) {
const pluginConfig = input.pluginConfig ?? {}; const pluginConfig = input.pluginConfig ?? {};
const runId = optionalString(params.runId) || "read"; const runId = optionalString(params.runId) || "read";
const sessionKey = requiredString(params.sessionKey, "sessionKey required"); const sessionKey = requiredString(params.sessionKey, "sessionKey required");
const relativePath = safeInputRelativePath(params.relativePath, "relativePath"); const requestedArtifactRef = optionalString(params.artifactRef);
const artifactScope = optionalArtifactScope(params.artifactScope); let relativePath = "";
let artifactScope = optionalArtifactScope(params.artifactScope);
let refPayload;
const maxInlineBytes = nonNegativeInteger(params.maxInlineBytes, pluginConfig.maxInlineBytes, DEFAULT_MAX_INLINE_BYTES); const maxInlineBytes = nonNegativeInteger(params.maxInlineBytes, pluginConfig.maxInlineBytes, DEFAULT_MAX_INLINE_BYTES);
const workspaceDir = resolveWorkspaceDir({ const workspaceDir = resolveWorkspaceDir({
config: input.config, config: input.config,
@ -151,8 +167,29 @@ export async function readXWorkmateArtifact(input) {
sessionKey, sessionKey,
}); });
const workspaceRoot = await fs.realpath(workspaceDir); const workspaceRoot = await fs.realpath(workspaceDir);
if (requestedArtifactRef) {
refPayload = verifyArtifactRef(requestedArtifactRef, workspaceRoot, pluginConfig);
relativePath = refPayload.relativePath;
if (refPayload.artifactScope) {
artifactScope = refPayload.artifactScope;
}
const requestedPath = optionalString(params.relativePath);
if (requestedPath && safeInputRelativePath(requestedPath, "relativePath") !== relativePath) {
throw new Error("artifactRef does not match relativePath");
}
const requestedScope = optionalArtifactScope(params.artifactScope);
if (requestedScope && requestedScope !== artifactScope) {
throw new Error("artifactRef does not match artifactScope");
}
}
else {
if (!artifactScope) {
throw new Error("artifactScope or artifactRef required");
}
relativePath = safeInputRelativePath(params.relativePath, "relativePath");
}
const scopeRoot = artifactScope ? resolveScopeRoot(workspaceRoot, artifactScope) : workspaceRoot; const scopeRoot = artifactScope ? resolveScopeRoot(workspaceRoot, artifactScope) : workspaceRoot;
const scopeKind = artifactScope ? "task" : "workspace"; const scopeKind = refPayload?.scopeKind ?? "task";
const absolutePath = path.join(scopeRoot, relativePath.split("/").join(path.sep)); const absolutePath = path.join(scopeRoot, relativePath.split("/").join(path.sep));
const realPath = await fs.realpath(absolutePath); const realPath = await fs.realpath(absolutePath);
if (!isWithinRoot(scopeRoot, realPath)) { if (!isWithinRoot(scopeRoot, realPath)) {
@ -163,12 +200,26 @@ export async function readXWorkmateArtifact(input) {
throw new Error("relativePath must point to a file"); throw new Error("relativePath must point to a file");
} }
const bytes = await fs.readFile(realPath); const bytes = await fs.readFile(realPath);
const sha256 = createHash("sha256").update(bytes).digest("hex");
if (refPayload && (refPayload.sizeBytes !== bytes.byteLength || refPayload.sha256 !== sha256)) {
throw new Error("artifactRef does not match file content");
}
const artifact = { const artifact = {
relativePath: safeRelativePath(scopeRoot, realPath), relativePath: safeRelativePath(scopeRoot, realPath),
label: path.posix.basename(relativePath), label: path.posix.basename(relativePath),
contentType: contentTypeForPath(relativePath), contentType: contentTypeForPath(relativePath),
sizeBytes: bytes.byteLength, sizeBytes: bytes.byteLength,
sha256: createHash("sha256").update(bytes).digest("hex"), sha256,
artifactRef: requestedArtifactRef ||
signArtifactRef({
v: 1,
workspaceRootHash: workspaceRootHash(workspaceRoot),
scopeKind,
...(artifactScope ? { artifactScope } : {}),
relativePath: safeRelativePath(scopeRoot, realPath),
sizeBytes: bytes.byteLength,
sha256,
}, pluginConfig),
scopeKind, scopeKind,
}; };
if (artifactScope) { if (artifactScope) {
@ -247,6 +298,9 @@ async function collectCandidates(input) {
continue; continue;
} }
if (entry.isDirectory()) { if (entry.isDirectory()) {
if (input.skipTaskScopeRoot && currentDir === input.relativeRoot && entry.name === TASK_SCOPE_ROOT) {
continue;
}
if (SKIPPED_DIRS.has(entry.name)) { if (SKIPPED_DIRS.has(entry.name)) {
continue; continue;
} }
@ -281,9 +335,7 @@ async function collectCandidates(input) {
} }
function artifactScopeFor(sessionKey, runId) { function artifactScopeFor(sessionKey, runId) {
return [ return [
".xworkmate", TASK_SCOPE_ROOT,
"artifacts",
"tasks",
safeScopeSegment(sessionKey), safeScopeSegment(sessionKey),
safeScopeSegment(runId), safeScopeSegment(runId),
].join("/"); ].join("/");
@ -303,7 +355,15 @@ function optionalArtifactScope(value) {
if (!scope) { if (!scope) {
return ""; return "";
} }
return safeInputRelativePath(scope, "artifactScope"); return safeTaskArtifactScope(scope);
}
function safeTaskArtifactScope(value) {
const scope = safeInputRelativePath(value, "artifactScope");
const parts = scope.split("/");
if (parts.length !== 3 || parts[0] !== TASK_SCOPE_ROOT) {
throw new Error("artifactScope must be a task artifact scope");
}
return scope;
} }
function safeInputRelativePath(value, label) { function safeInputRelativePath(value, label) {
const relativePath = optionalString(value); const relativePath = optionalString(value);
@ -320,7 +380,7 @@ function safeInputRelativePath(value, label) {
return normalized; return normalized;
} }
function resolveScopeRoot(workspaceRoot, artifactScope) { function resolveScopeRoot(workspaceRoot, artifactScope) {
const normalizedScope = safeInputRelativePath(artifactScope, "artifactScope"); const normalizedScope = safeTaskArtifactScope(artifactScope);
const scopeRoot = path.join(workspaceRoot, normalizedScope.split("/").join(path.sep)); const scopeRoot = path.join(workspaceRoot, normalizedScope.split("/").join(path.sep));
if (!isWithinRoot(workspaceRoot, scopeRoot)) { if (!isWithinRoot(workspaceRoot, scopeRoot)) {
throw new Error("artifactScope must stay inside the workspace"); throw new Error("artifactScope must stay inside the workspace");
@ -463,6 +523,75 @@ function nonNegativeNumber(value, fallback) {
} }
return fallback; return fallback;
} }
function signArtifactRef(payload, pluginConfig) {
const body = base64UrlEncode(JSON.stringify(payload));
const signature = createHmac("sha256", artifactRefSigningSecret(pluginConfig)).update(body).digest("base64url");
return `${body}.${signature}`;
}
function verifyArtifactRef(artifactRef, workspaceRoot, pluginConfig) {
const [body, signature, ...extra] = artifactRef.split(".");
if (!body || !signature || extra.length > 0) {
throw new Error("invalid artifactRef");
}
const expectedSignature = createHmac("sha256", artifactRefSigningSecret(pluginConfig)).update(body).digest("base64url");
if (!constantTimeEqual(signature, expectedSignature)) {
throw new Error("invalid artifactRef");
}
let parsed;
try {
parsed = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
}
catch {
throw new Error("invalid artifactRef");
}
const payload = objectRecord(parsed);
const scopeKind = optionalString(payload.scopeKind);
if (!["task", "workspace", "workspace-latest"].includes(scopeKind)) {
throw new Error("invalid artifactRef");
}
const relativePath = safeInputRelativePath(payload.relativePath, "artifactRef relativePath");
const artifactScope = optionalArtifactScope(payload.artifactScope);
if (scopeKind === "task" && !artifactScope) {
throw new Error("invalid artifactRef");
}
if (scopeKind !== "task" && artifactScope) {
throw new Error("invalid artifactRef");
}
const sizeBytes = nonNegativeInteger(payload.sizeBytes, undefined, -1);
const sha256 = optionalString(payload.sha256).toLowerCase();
if (payload.v !== 1 || sizeBytes < 0 || !/^[a-f0-9]{64}$/.test(sha256)) {
throw new Error("invalid artifactRef");
}
if (optionalString(payload.workspaceRootHash) !== workspaceRootHash(workspaceRoot)) {
throw new Error("artifactRef does not match workspace");
}
return {
v: 1,
workspaceRootHash: workspaceRootHash(workspaceRoot),
scopeKind,
...(artifactScope ? { artifactScope } : {}),
relativePath,
sizeBytes,
sha256,
};
}
function artifactRefSigningSecret(pluginConfig) {
return (optionalString(pluginConfig.artifactRefSigningSecret) ||
optionalString(process.env.XWORKMATE_ARTIFACT_REF_SIGNING_SECRET) ||
optionalString(process.env.XWORKMATE_ARTIFACT_DOWNLOAD_SIGNING_SECRET) ||
GENERATED_ARTIFACT_REF_SECRET);
}
function workspaceRootHash(workspaceRoot) {
return createHash("sha256").update(path.resolve(workspaceRoot)).digest("hex");
}
function base64UrlEncode(value) {
return Buffer.from(value, "utf8").toString("base64url");
}
function constantTimeEqual(left, right) {
const leftBytes = Buffer.from(left);
const rightBytes = Buffer.from(right);
return leftBytes.byteLength === rightBytes.byteLength && timingSafeEqual(leftBytes, rightBytes);
}
function expandUserPath(value) { function expandUserPath(value) {
if (value === "~") { if (value === "~") {
return os.homedir(); return os.homedir();

View File

@ -1,3 +1,4 @@
import fs from "node:fs";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import plugin from "./index.js"; import plugin from "./index.js";
@ -5,6 +6,16 @@ import plugin from "./index.js";
type GatewayMethodHandler = Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1]; type GatewayMethodHandler = Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1];
describe("plugin registration", () => { describe("plugin registration", () => {
it("declares registered agent tools in the manifest contract", () => {
const manifest = JSON.parse(fs.readFileSync("openclaw.plugin.json", "utf8")) as {
contracts?: { tools?: string[] };
configSchema?: { properties?: Record<string, unknown> };
};
expect(manifest.contracts?.tools).toContain("xworkmate_artifacts");
expect(manifest.configSchema?.properties?.artifactRefSigningSecret).toBeTruthy();
});
it("registers the xworkmate artifact export gateway method", () => { it("registers the xworkmate artifact export gateway method", () => {
const methods: Array<{ method: string; handler: GatewayMethodHandler }> = []; const methods: Array<{ method: string; handler: GatewayMethodHandler }> = [];
const tools: unknown[] = []; const tools: unknown[] = [];

View File

@ -111,7 +111,15 @@ function createXWorkmateArtifactsTool(
}, },
relativePath: { relativePath: {
type: "string", type: "string",
description: "Artifact path relative to the workspace. Required for action=read.", description: "Artifact path relative to artifactScope. Required for action=read without artifactRef.",
},
artifactScope: {
type: "string",
description: "Task artifact scope returned by prepare/export, for example tasks/<session>/<run>.",
},
artifactRef: {
type: "string",
description: "Plugin-signed artifact reference returned by export/list. Required for workspace-latest reads.",
}, },
sinceUnixMs: { sinceUnixMs: {
type: "number", type: "number",

View File

@ -5,6 +5,9 @@
"activation": { "activation": {
"onStartup": true "onStartup": true
}, },
"contracts": {
"tools": ["xworkmate_artifacts"]
},
"configSchema": { "configSchema": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
@ -20,6 +23,10 @@
"maxInlineBytes": { "maxInlineBytes": {
"type": "number", "type": "number",
"description": "Default maximum file size to inline as base64." "description": "Default maximum file size to inline as base64."
},
"artifactRefSigningSecret": {
"type": "string",
"description": "Optional stable secret used to sign artifactRef values. Defaults to an in-process secret."
} }
} }
}, },
@ -35,6 +42,11 @@
"maxInlineBytes": { "maxInlineBytes": {
"label": "Max Inline Bytes", "label": "Max Inline Bytes",
"help": "Upper bound for base64 inline artifact content. Default: 10485760." "help": "Upper bound for base64 inline artifact content. Default: 10485760."
},
"artifactRefSigningSecret": {
"label": "Artifact Ref Signing Secret",
"help": "Optional stable secret for plugin artifact references. Leave blank for process-local refs.",
"sensitive": true
} }
} }
} }

View File

@ -22,8 +22,8 @@ describe("exportXWorkmateArtifacts", () => {
pluginConfig: { workspaceDir: root }, pluginConfig: { workspaceDir: root },
}); });
expect(first.artifactScope).toMatch(/^\.xworkmate\/artifacts\/tasks\/thread-main-[a-f0-9]{12}\/turn-1-[a-f0-9]{12}$/); expect(first.artifactScope).toMatch(/^tasks\/thread-main-[a-f0-9]{12}\/turn-1-[a-f0-9]{12}$/);
expect(second.artifactScope).toMatch(/^\.xworkmate\/artifacts\/tasks\/thread-main-[a-f0-9]{12}\/turn-2-[a-f0-9]{12}$/); expect(second.artifactScope).toMatch(/^tasks\/thread-main-[a-f0-9]{12}\/turn-2-[a-f0-9]{12}$/);
expect(first.artifactScope).not.toBe(second.artifactScope); expect(first.artifactScope).not.toBe(second.artifactScope);
expect((await fs.stat(first.artifactDirectory)).isDirectory()).toBe(true); expect((await fs.stat(first.artifactDirectory)).isDirectory()).toBe(true);
expect(first.remoteWorkingDirectory).toBe(await fs.realpath(root)); expect(first.remoteWorkingDirectory).toBe(await fs.realpath(root));
@ -58,6 +58,7 @@ describe("exportXWorkmateArtifacts", () => {
encoding: "base64", encoding: "base64",
content: Buffer.from("# Done\n").toString("base64"), content: Buffer.from("# Done\n").toString("base64"),
}); });
expect(result.artifacts[0]?.artifactRef).toContain(".");
expect(result.manifestMarkdown).toContain("reports/final.md"); expect(result.manifestMarkdown).toContain("reports/final.md");
expect(result.manifestMarkdown).toContain("text/markdown"); expect(result.manifestMarkdown).toContain("text/markdown");
}); });
@ -140,7 +141,12 @@ describe("exportXWorkmateArtifacts", () => {
params: { sessionKey: "thread-main", runId: "turn-1" }, params: { sessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root }, pluginConfig: { workspaceDir: root },
}); });
const otherTask = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-2" },
pluginConfig: { workspaceDir: root },
});
await fs.writeFile(path.join(root, "existing.pdf"), "pdf"); await fs.writeFile(path.join(root, "existing.pdf"), "pdf");
await fs.writeFile(path.join(otherTask.artifactDirectory, "other-task.txt"), "other");
await fs.mkdir(path.join(root, ".xworkmate", "metadata"), { recursive: true }); await fs.mkdir(path.join(root, ".xworkmate", "metadata"), { recursive: true });
await fs.writeFile(path.join(root, ".xworkmate", "metadata", "internal.json"), "{}"); await fs.writeFile(path.join(root, ".xworkmate", "metadata", "internal.json"), "{}");
const stat = await fs.stat(path.join(root, "existing.pdf")); const stat = await fs.stat(path.join(root, "existing.pdf"));
@ -161,6 +167,7 @@ describe("exportXWorkmateArtifacts", () => {
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["existing.pdf"]); expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["existing.pdf"]);
expect(result.artifacts[0]?.artifactScope).toBeUndefined(); expect(result.artifacts[0]?.artifactScope).toBeUndefined();
expect(result.artifacts[0]?.scopeKind).toBe("workspace-latest"); expect(result.artifacts[0]?.scopeKind).toBe("workspace-latest");
expect(result.artifacts[0]?.artifactRef).toContain(".");
expect(result.warnings).toContain("scoped artifact directory is empty; exported latest workspace files instead"); expect(result.warnings).toContain("scoped artifact directory is empty; exported latest workspace files instead");
}); });
@ -242,27 +249,21 @@ describe("exportXWorkmateArtifacts", () => {
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["agent.txt"]); expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["agent.txt"]);
}); });
it("reads one artifact by relative path", async () => { it("rejects unscoped artifact reads by relative path", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-")); const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-"));
await fs.mkdir(path.join(root, "reports"), { recursive: true }); await fs.mkdir(path.join(root, "reports"), { recursive: true });
await fs.writeFile(path.join(root, "reports", "final.txt"), "final"); await fs.writeFile(path.join(root, "reports", "final.txt"), "final");
const result = await readXWorkmateArtifact({ await expect(
params: { readXWorkmateArtifact({
sessionKey: "thread-main", params: {
runId: "run-1", sessionKey: "thread-main",
relativePath: "reports/final.txt", runId: "run-1",
}, relativePath: "reports/final.txt",
pluginConfig: { workspaceDir: root }, },
}); pluginConfig: { workspaceDir: root },
}),
expect(result.artifacts).toHaveLength(1); ).rejects.toThrow("artifactScope or artifactRef required");
expect(result.artifacts[0]).toMatchObject({
relativePath: "reports/final.txt",
contentType: "text/plain",
encoding: "base64",
content: Buffer.from("final").toString("base64"),
});
}); });
it("reads one artifact inside a task artifact scope", async () => { it("reads one artifact inside a task artifact scope", async () => {
@ -293,16 +294,85 @@ describe("exportXWorkmateArtifacts", () => {
encoding: "base64", encoding: "base64",
content: Buffer.from("final").toString("base64"), content: Buffer.from("final").toString("base64"),
}); });
expect(result.artifacts[0]?.artifactRef).toContain(".");
});
it("reads a latest workspace artifact only through its artifactRef", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-"));
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
});
await fs.writeFile(path.join(root, "existing.txt"), "existing");
const exported = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
runId: "turn-1",
artifactScope: prepared.artifactScope,
sinceUnixMs: Date.now() + 10_000,
latestIfEmpty: true,
},
pluginConfig: { workspaceDir: root },
});
const result = await readXWorkmateArtifact({
params: {
sessionKey: "thread-main",
runId: "turn-1",
artifactRef: exported.artifacts[0]?.artifactRef,
},
pluginConfig: { workspaceDir: root },
});
expect(result.scopeKind).toBe("workspace-latest");
expect(result.artifactScope).toBeUndefined();
expect(result.artifacts[0]).toMatchObject({
relativePath: "existing.txt",
scopeKind: "workspace-latest",
encoding: "base64",
content: Buffer.from("existing").toString("base64"),
});
});
it("rejects tampered artifact refs", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-"));
await fs.writeFile(path.join(root, "existing.txt"), "existing");
const exported = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
runId: "run-1",
},
pluginConfig: { workspaceDir: root },
});
const artifactRef = exported.artifacts[0]?.artifactRef ?? "";
const tampered = `${artifactRef}x`;
await expect(
readXWorkmateArtifact({
params: {
sessionKey: "thread-main",
runId: "run-1",
artifactRef: tampered,
},
pluginConfig: { workspaceDir: root },
}),
).rejects.toThrow("invalid artifactRef");
}); });
it("reads artifact metadata without inline content when the file exceeds the limit", async () => { it("reads artifact metadata without inline content when the file exceeds the limit", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-")); const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-"));
await fs.writeFile(path.join(root, "large.bin"), Buffer.from("large-content")); const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
});
await fs.writeFile(path.join(prepared.artifactDirectory, "large.bin"), Buffer.from("large-content"));
const result = await readXWorkmateArtifact({ const result = await readXWorkmateArtifact({
params: { params: {
sessionKey: "thread-main", sessionKey: "thread-main",
runId: "run-1", runId: "run-1",
artifactScope: prepared.artifactScope,
relativePath: "large.bin", relativePath: "large.bin",
maxInlineBytes: 2, maxInlineBytes: 2,
}, },
@ -323,12 +393,17 @@ describe("exportXWorkmateArtifacts", () => {
it("rejects relative path traversal when reading artifacts", async () => { it("rejects relative path traversal when reading artifacts", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-")); const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-"));
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
});
await expect( await expect(
readXWorkmateArtifact({ readXWorkmateArtifact({
params: { params: {
sessionKey: "thread-main", sessionKey: "thread-main",
runId: "run-1", runId: "run-1",
artifactScope: prepared.artifactScope,
relativePath: "../outside.txt", relativePath: "../outside.txt",
}, },
pluginConfig: { workspaceDir: root }, pluginConfig: { workspaceDir: root },
@ -357,13 +432,18 @@ describe("exportXWorkmateArtifacts", () => {
const outsideRoot = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-outside-")); const outsideRoot = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-outside-"));
const outsideFile = path.join(outsideRoot, "secret.txt"); const outsideFile = path.join(outsideRoot, "secret.txt");
await fs.writeFile(outsideFile, "secret"); await fs.writeFile(outsideFile, "secret");
await fs.symlink(outsideFile, path.join(root, "linked-secret.txt")); const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
});
await fs.symlink(outsideFile, path.join(prepared.artifactDirectory, "linked-secret.txt"));
await expect( await expect(
readXWorkmateArtifact({ readXWorkmateArtifact({
params: { params: {
sessionKey: "thread-main", sessionKey: "thread-main",
runId: "run-1", runId: "run-1",
artifactScope: prepared.artifactScope,
relativePath: "linked-secret.txt", relativePath: "linked-secret.txt",
}, },
pluginConfig: { workspaceDir: root }, pluginConfig: { workspaceDir: root },

View File

@ -1,10 +1,12 @@
import { createHash } from "node:crypto"; import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
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";
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 GENERATED_ARTIFACT_REF_SECRET = randomBytes(32).toString("hex");
const SKIPPED_DIRS = new Set([ const SKIPPED_DIRS = new Set([
".git", ".git",
@ -25,6 +27,7 @@ export type XWorkmateArtifact = {
contentType: string; contentType: string;
sizeBytes: number; sizeBytes: number;
sha256: string; sha256: string;
artifactRef: string;
artifactScope?: string; artifactScope?: string;
scopeKind?: XWorkmateArtifactScopeKind; scopeKind?: XWorkmateArtifactScopeKind;
encoding?: "base64"; encoding?: "base64";
@ -69,6 +72,16 @@ type ReadInput = {
pluginConfig?: Record<string, unknown>; pluginConfig?: Record<string, unknown>;
}; };
type ArtifactRefPayload = {
v: 1;
workspaceRootHash: string;
scopeKind: XWorkmateArtifactScopeKind;
artifactScope?: string;
relativePath: string;
sizeBytes: number;
sha256: string;
};
type Candidate = { type Candidate = {
absolutePath: string; absolutePath: string;
relativePath: string; relativePath: string;
@ -135,6 +148,7 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
scanRoot: scopeRoot, scanRoot: scopeRoot,
relativeRoot: scopeRoot, relativeRoot: scopeRoot,
sinceUnixMs, sinceUnixMs,
skipTaskScopeRoot: !scopedExport,
warnings, warnings,
}); });
@ -144,6 +158,7 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
scanRoot: workspaceRoot, scanRoot: workspaceRoot,
relativeRoot: workspaceRoot, relativeRoot: workspaceRoot,
sinceUnixMs: 0, sinceUnixMs: 0,
skipTaskScopeRoot: true,
warnings: latestWarnings, warnings: latestWarnings,
}); });
if (latestCandidates.length > 0) { if (latestCandidates.length > 0) {
@ -170,12 +185,25 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
break; break;
} }
const bytes = await fs.readFile(candidate.absolutePath); const bytes = await fs.readFile(candidate.absolutePath);
const sha256 = createHash("sha256").update(bytes).digest("hex");
const artifact: XWorkmateArtifact = { const artifact: XWorkmateArtifact = {
relativePath: candidate.relativePath, relativePath: candidate.relativePath,
label: path.posix.basename(candidate.relativePath), label: path.posix.basename(candidate.relativePath),
contentType: contentTypeForPath(candidate.relativePath), contentType: contentTypeForPath(candidate.relativePath),
sizeBytes: bytes.byteLength, sizeBytes: bytes.byteLength,
sha256: createHash("sha256").update(bytes).digest("hex"), sha256,
artifactRef: signArtifactRef(
{
v: 1,
workspaceRootHash: workspaceRootHash(workspaceRoot),
scopeKind,
...(scopeKind === "task" && artifactScope ? { artifactScope } : {}),
relativePath: candidate.relativePath,
sizeBytes: bytes.byteLength,
sha256,
},
pluginConfig,
),
scopeKind, scopeKind,
}; };
if (scopeKind === "task" && artifactScope) { if (scopeKind === "task" && artifactScope) {
@ -211,8 +239,10 @@ export async function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmate
const pluginConfig = input.pluginConfig ?? {}; const pluginConfig = input.pluginConfig ?? {};
const runId = optionalString(params.runId) || "read"; const runId = optionalString(params.runId) || "read";
const sessionKey = requiredString(params.sessionKey, "sessionKey required"); const sessionKey = requiredString(params.sessionKey, "sessionKey required");
const relativePath = safeInputRelativePath(params.relativePath, "relativePath"); const requestedArtifactRef = optionalString(params.artifactRef);
const artifactScope = optionalArtifactScope(params.artifactScope); let relativePath = "";
let artifactScope = optionalArtifactScope(params.artifactScope);
let refPayload: ArtifactRefPayload | undefined;
const maxInlineBytes = nonNegativeInteger( const maxInlineBytes = nonNegativeInteger(
params.maxInlineBytes, params.maxInlineBytes,
pluginConfig.maxInlineBytes, pluginConfig.maxInlineBytes,
@ -225,8 +255,28 @@ export async function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmate
sessionKey, sessionKey,
}); });
const workspaceRoot = await fs.realpath(workspaceDir); const workspaceRoot = await fs.realpath(workspaceDir);
if (requestedArtifactRef) {
refPayload = verifyArtifactRef(requestedArtifactRef, workspaceRoot, pluginConfig);
relativePath = refPayload.relativePath;
if (refPayload.artifactScope) {
artifactScope = refPayload.artifactScope;
}
const requestedPath = optionalString(params.relativePath);
if (requestedPath && safeInputRelativePath(requestedPath, "relativePath") !== relativePath) {
throw new Error("artifactRef does not match relativePath");
}
const requestedScope = optionalArtifactScope(params.artifactScope);
if (requestedScope && requestedScope !== artifactScope) {
throw new Error("artifactRef does not match artifactScope");
}
} else {
if (!artifactScope) {
throw new Error("artifactScope or artifactRef required");
}
relativePath = safeInputRelativePath(params.relativePath, "relativePath");
}
const scopeRoot = artifactScope ? resolveScopeRoot(workspaceRoot, artifactScope) : workspaceRoot; const scopeRoot = artifactScope ? resolveScopeRoot(workspaceRoot, artifactScope) : workspaceRoot;
const scopeKind: XWorkmateArtifactScopeKind = artifactScope ? "task" : "workspace"; const scopeKind: XWorkmateArtifactScopeKind = refPayload?.scopeKind ?? "task";
const absolutePath = path.join(scopeRoot, relativePath.split("/").join(path.sep)); const absolutePath = path.join(scopeRoot, relativePath.split("/").join(path.sep));
const realPath = await fs.realpath(absolutePath); const realPath = await fs.realpath(absolutePath);
if (!isWithinRoot(scopeRoot, realPath)) { if (!isWithinRoot(scopeRoot, realPath)) {
@ -237,12 +287,30 @@ export async function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmate
throw new Error("relativePath must point to a file"); throw new Error("relativePath must point to a file");
} }
const bytes = await fs.readFile(realPath); const bytes = await fs.readFile(realPath);
const sha256 = createHash("sha256").update(bytes).digest("hex");
if (refPayload && (refPayload.sizeBytes !== bytes.byteLength || refPayload.sha256 !== sha256)) {
throw new Error("artifactRef does not match file content");
}
const artifact: XWorkmateArtifact = { const artifact: XWorkmateArtifact = {
relativePath: safeRelativePath(scopeRoot, realPath), relativePath: safeRelativePath(scopeRoot, realPath),
label: path.posix.basename(relativePath), label: path.posix.basename(relativePath),
contentType: contentTypeForPath(relativePath), contentType: contentTypeForPath(relativePath),
sizeBytes: bytes.byteLength, sizeBytes: bytes.byteLength,
sha256: createHash("sha256").update(bytes).digest("hex"), sha256,
artifactRef:
requestedArtifactRef ||
signArtifactRef(
{
v: 1,
workspaceRootHash: workspaceRootHash(workspaceRoot),
scopeKind,
...(artifactScope ? { artifactScope } : {}),
relativePath: safeRelativePath(scopeRoot, realPath),
sizeBytes: bytes.byteLength,
sha256,
},
pluginConfig,
),
scopeKind, scopeKind,
}; };
if (artifactScope) { if (artifactScope) {
@ -311,6 +379,7 @@ async function collectCandidates(input: {
scanRoot: string; scanRoot: string;
relativeRoot: string; relativeRoot: string;
sinceUnixMs: number; sinceUnixMs: number;
skipTaskScopeRoot: boolean;
warnings: string[]; warnings: string[];
}): Promise<Candidate[]> { }): Promise<Candidate[]> {
const candidates: Candidate[] = []; const candidates: Candidate[] = [];
@ -336,6 +405,9 @@ async function collectCandidates(input: {
continue; continue;
} }
if (entry.isDirectory()) { if (entry.isDirectory()) {
if (input.skipTaskScopeRoot && currentDir === input.relativeRoot && entry.name === TASK_SCOPE_ROOT) {
continue;
}
if (SKIPPED_DIRS.has(entry.name)) { if (SKIPPED_DIRS.has(entry.name)) {
continue; continue;
} }
@ -371,9 +443,7 @@ async function collectCandidates(input: {
function artifactScopeFor(sessionKey: string, runId: string): string { function artifactScopeFor(sessionKey: string, runId: string): string {
return [ return [
".xworkmate", TASK_SCOPE_ROOT,
"artifacts",
"tasks",
safeScopeSegment(sessionKey), safeScopeSegment(sessionKey),
safeScopeSegment(runId), safeScopeSegment(runId),
].join("/"); ].join("/");
@ -395,7 +465,16 @@ function optionalArtifactScope(value: unknown): string {
if (!scope) { if (!scope) {
return ""; return "";
} }
return safeInputRelativePath(scope, "artifactScope"); return safeTaskArtifactScope(scope);
}
function safeTaskArtifactScope(value: unknown): string {
const scope = safeInputRelativePath(value, "artifactScope");
const parts = scope.split("/");
if (parts.length !== 3 || parts[0] !== TASK_SCOPE_ROOT) {
throw new Error("artifactScope must be a task artifact scope");
}
return scope;
} }
function safeInputRelativePath(value: unknown, label: string): string { function safeInputRelativePath(value: unknown, label: string): string {
@ -414,7 +493,7 @@ function safeInputRelativePath(value: unknown, label: string): string {
} }
function resolveScopeRoot(workspaceRoot: string, artifactScope: string): string { function resolveScopeRoot(workspaceRoot: string, artifactScope: string): string {
const normalizedScope = safeInputRelativePath(artifactScope, "artifactScope"); const normalizedScope = safeTaskArtifactScope(artifactScope);
const scopeRoot = path.join(workspaceRoot, normalizedScope.split("/").join(path.sep)); const scopeRoot = path.join(workspaceRoot, normalizedScope.split("/").join(path.sep));
if (!isWithinRoot(workspaceRoot, scopeRoot)) { if (!isWithinRoot(workspaceRoot, scopeRoot)) {
throw new Error("artifactScope must stay inside the workspace"); throw new Error("artifactScope must stay inside the workspace");
@ -577,6 +656,86 @@ function nonNegativeNumber(value: unknown, fallback: number): number {
return fallback; return fallback;
} }
function signArtifactRef(payload: ArtifactRefPayload, pluginConfig: Record<string, unknown>): string {
const body = base64UrlEncode(JSON.stringify(payload));
const signature = createHmac("sha256", artifactRefSigningSecret(pluginConfig)).update(body).digest("base64url");
return `${body}.${signature}`;
}
function verifyArtifactRef(
artifactRef: string,
workspaceRoot: string,
pluginConfig: Record<string, unknown>,
): ArtifactRefPayload {
const [body, signature, ...extra] = artifactRef.split(".");
if (!body || !signature || extra.length > 0) {
throw new Error("invalid artifactRef");
}
const expectedSignature = createHmac("sha256", artifactRefSigningSecret(pluginConfig)).update(body).digest("base64url");
if (!constantTimeEqual(signature, expectedSignature)) {
throw new Error("invalid artifactRef");
}
let parsed: unknown;
try {
parsed = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
} catch {
throw new Error("invalid artifactRef");
}
const payload = objectRecord(parsed);
const scopeKind = optionalString(payload.scopeKind) as XWorkmateArtifactScopeKind;
if (!["task", "workspace", "workspace-latest"].includes(scopeKind)) {
throw new Error("invalid artifactRef");
}
const relativePath = safeInputRelativePath(payload.relativePath, "artifactRef relativePath");
const artifactScope = optionalArtifactScope(payload.artifactScope);
if (scopeKind === "task" && !artifactScope) {
throw new Error("invalid artifactRef");
}
if (scopeKind !== "task" && artifactScope) {
throw new Error("invalid artifactRef");
}
const sizeBytes = nonNegativeInteger(payload.sizeBytes, undefined, -1);
const sha256 = optionalString(payload.sha256).toLowerCase();
if (payload.v !== 1 || sizeBytes < 0 || !/^[a-f0-9]{64}$/.test(sha256)) {
throw new Error("invalid artifactRef");
}
if (optionalString(payload.workspaceRootHash) !== workspaceRootHash(workspaceRoot)) {
throw new Error("artifactRef does not match workspace");
}
return {
v: 1,
workspaceRootHash: workspaceRootHash(workspaceRoot),
scopeKind,
...(artifactScope ? { artifactScope } : {}),
relativePath,
sizeBytes,
sha256,
};
}
function artifactRefSigningSecret(pluginConfig: Record<string, unknown>): string {
return (
optionalString(pluginConfig.artifactRefSigningSecret) ||
optionalString(process.env.XWORKMATE_ARTIFACT_REF_SIGNING_SECRET) ||
optionalString(process.env.XWORKMATE_ARTIFACT_DOWNLOAD_SIGNING_SECRET) ||
GENERATED_ARTIFACT_REF_SECRET
);
}
function workspaceRootHash(workspaceRoot: string): string {
return createHash("sha256").update(path.resolve(workspaceRoot)).digest("hex");
}
function base64UrlEncode(value: string): string {
return Buffer.from(value, "utf8").toString("base64url");
}
function constantTimeEqual(left: string, right: string): boolean {
const leftBytes = Buffer.from(left);
const rightBytes = Buffer.from(right);
return leftBytes.byteLength === rightBytes.byteLength && timingSafeEqual(leftBytes, rightBytes);
}
function expandUserPath(value: string): string { function expandUserPath(value: string): string {
if (value === "~") { if (value === "~") {
return os.homedir(); return os.homedir();