fix: declare xworkmate artifact tool contract
This commit is contained in:
parent
ac3a285dc2
commit
74ea384374
31
README.md
31
README.md
@ -19,7 +19,7 @@ xworkmate.artifacts.list
|
||||
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
|
||||
into the APP `artifacts[]` contract.
|
||||
|
||||
@ -78,10 +78,10 @@ Prepare response payload:
|
||||
"sessionKey": "thread-main",
|
||||
"remoteWorkingDirectory": "/home/user/.openclaw/workspace",
|
||||
"remoteWorkspaceRefKind": "remotePath",
|
||||
"artifactScope": ".xworkmate/artifacts/tasks/thread-main-.../turn-1-...",
|
||||
"artifactScope": "tasks/thread-main-.../turn-1-...",
|
||||
"scopeKind": "task",
|
||||
"artifactDirectory": "/home/user/.openclaw/workspace/.xworkmate/artifacts/tasks/thread-main-.../turn-1-...",
|
||||
"relativeArtifactDirectory": ".xworkmate/artifacts/tasks/thread-main-.../turn-1-...",
|
||||
"artifactDirectory": "/home/user/.openclaw/workspace/tasks/thread-main-.../turn-1-...",
|
||||
"relativeArtifactDirectory": "tasks/thread-main-.../turn-1-...",
|
||||
"warnings": []
|
||||
}
|
||||
```
|
||||
@ -92,7 +92,7 @@ Export request params:
|
||||
{
|
||||
"sessionKey": "thread-main",
|
||||
"runId": "turn-1",
|
||||
"artifactScope": ".xworkmate/artifacts/tasks/thread-main-.../turn-1-...",
|
||||
"artifactScope": "tasks/thread-main-.../turn-1-...",
|
||||
"sinceUnixMs": 1770000000000,
|
||||
"latestIfEmpty": true,
|
||||
"maxFiles": 64,
|
||||
@ -108,7 +108,7 @@ Export response payload:
|
||||
"sessionKey": "thread-main",
|
||||
"remoteWorkingDirectory": "/home/user/.openclaw/workspace",
|
||||
"remoteWorkspaceRefKind": "remotePath",
|
||||
"artifactScope": ".xworkmate/artifacts/tasks/thread-main-.../turn-1-...",
|
||||
"artifactScope": "tasks/thread-main-.../turn-1-...",
|
||||
"scopeKind": "task",
|
||||
"artifacts": [
|
||||
{
|
||||
@ -117,7 +117,8 @@ Export response payload:
|
||||
"contentType": "text/markdown",
|
||||
"sizeBytes": 1234,
|
||||
"sha256": "...",
|
||||
"artifactScope": ".xworkmate/artifacts/tasks/thread-main-.../turn-1-...",
|
||||
"artifactRef": "...",
|
||||
"artifactScope": "tasks/thread-main-.../turn-1-...",
|
||||
"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:
|
||||
"workspace-latest"`. This is a controlled recovery path for existing files already
|
||||
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
|
||||
|
||||
@ -161,7 +168,8 @@ Gateway clients can use:
|
||||
|
||||
- `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.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.
|
||||
|
||||
Large files are metadata-only in the export payload, but XWorkmate Bridge can
|
||||
@ -171,10 +179,11 @@ only remote file access path.
|
||||
## Limits
|
||||
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
|
||||
10
dist/index.js
vendored
10
dist/index.js
vendored
@ -92,7 +92,15 @@ function createXWorkmateArtifactsTool(api, ctx) {
|
||||
},
|
||||
relativePath: {
|
||||
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: {
|
||||
type: "number",
|
||||
|
||||
1
dist/src/exportArtifacts.d.ts
vendored
1
dist/src/exportArtifacts.d.ts
vendored
@ -4,6 +4,7 @@ export type XWorkmateArtifact = {
|
||||
contentType: string;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
artifactRef: string;
|
||||
artifactScope?: string;
|
||||
scopeKind?: XWorkmateArtifactScopeKind;
|
||||
encoding?: "base64";
|
||||
|
||||
151
dist/src/exportArtifacts.js
vendored
151
dist/src/exportArtifacts.js
vendored
@ -1,9 +1,11 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
const DEFAULT_MAX_FILES = 64;
|
||||
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([
|
||||
".git",
|
||||
".openclaw",
|
||||
@ -69,6 +71,7 @@ export async function exportXWorkmateArtifacts(input) {
|
||||
scanRoot: scopeRoot,
|
||||
relativeRoot: scopeRoot,
|
||||
sinceUnixMs,
|
||||
skipTaskScopeRoot: !scopedExport,
|
||||
warnings,
|
||||
});
|
||||
if (candidates.length === 0 && latestIfEmpty) {
|
||||
@ -77,6 +80,7 @@ export async function exportXWorkmateArtifacts(input) {
|
||||
scanRoot: workspaceRoot,
|
||||
relativeRoot: workspaceRoot,
|
||||
sinceUnixMs: 0,
|
||||
skipTaskScopeRoot: true,
|
||||
warnings: latestWarnings,
|
||||
});
|
||||
if (latestCandidates.length > 0) {
|
||||
@ -101,12 +105,22 @@ export async function exportXWorkmateArtifacts(input) {
|
||||
break;
|
||||
}
|
||||
const bytes = await fs.readFile(candidate.absolutePath);
|
||||
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
const artifact = {
|
||||
relativePath: candidate.relativePath,
|
||||
label: path.posix.basename(candidate.relativePath),
|
||||
contentType: contentTypeForPath(candidate.relativePath),
|
||||
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,
|
||||
};
|
||||
if (scopeKind === "task" && artifactScope) {
|
||||
@ -141,8 +155,10 @@ export async function readXWorkmateArtifact(input) {
|
||||
const pluginConfig = input.pluginConfig ?? {};
|
||||
const runId = optionalString(params.runId) || "read";
|
||||
const sessionKey = requiredString(params.sessionKey, "sessionKey required");
|
||||
const relativePath = safeInputRelativePath(params.relativePath, "relativePath");
|
||||
const artifactScope = optionalArtifactScope(params.artifactScope);
|
||||
const requestedArtifactRef = optionalString(params.artifactRef);
|
||||
let relativePath = "";
|
||||
let artifactScope = optionalArtifactScope(params.artifactScope);
|
||||
let refPayload;
|
||||
const maxInlineBytes = nonNegativeInteger(params.maxInlineBytes, pluginConfig.maxInlineBytes, DEFAULT_MAX_INLINE_BYTES);
|
||||
const workspaceDir = resolveWorkspaceDir({
|
||||
config: input.config,
|
||||
@ -151,8 +167,29 @@ export async function readXWorkmateArtifact(input) {
|
||||
sessionKey,
|
||||
});
|
||||
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 scopeKind = artifactScope ? "task" : "workspace";
|
||||
const scopeKind = refPayload?.scopeKind ?? "task";
|
||||
const absolutePath = path.join(scopeRoot, relativePath.split("/").join(path.sep));
|
||||
const realPath = await fs.realpath(absolutePath);
|
||||
if (!isWithinRoot(scopeRoot, realPath)) {
|
||||
@ -163,12 +200,26 @@ export async function readXWorkmateArtifact(input) {
|
||||
throw new Error("relativePath must point to a file");
|
||||
}
|
||||
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 = {
|
||||
relativePath: safeRelativePath(scopeRoot, realPath),
|
||||
label: path.posix.basename(relativePath),
|
||||
contentType: contentTypeForPath(relativePath),
|
||||
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,
|
||||
};
|
||||
if (artifactScope) {
|
||||
@ -247,6 +298,9 @@ async function collectCandidates(input) {
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
if (input.skipTaskScopeRoot && currentDir === input.relativeRoot && entry.name === TASK_SCOPE_ROOT) {
|
||||
continue;
|
||||
}
|
||||
if (SKIPPED_DIRS.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
@ -281,9 +335,7 @@ async function collectCandidates(input) {
|
||||
}
|
||||
function artifactScopeFor(sessionKey, runId) {
|
||||
return [
|
||||
".xworkmate",
|
||||
"artifacts",
|
||||
"tasks",
|
||||
TASK_SCOPE_ROOT,
|
||||
safeScopeSegment(sessionKey),
|
||||
safeScopeSegment(runId),
|
||||
].join("/");
|
||||
@ -303,7 +355,15 @@ function optionalArtifactScope(value) {
|
||||
if (!scope) {
|
||||
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) {
|
||||
const relativePath = optionalString(value);
|
||||
@ -320,7 +380,7 @@ function safeInputRelativePath(value, label) {
|
||||
return normalized;
|
||||
}
|
||||
function resolveScopeRoot(workspaceRoot, artifactScope) {
|
||||
const normalizedScope = safeInputRelativePath(artifactScope, "artifactScope");
|
||||
const normalizedScope = safeTaskArtifactScope(artifactScope);
|
||||
const scopeRoot = path.join(workspaceRoot, normalizedScope.split("/").join(path.sep));
|
||||
if (!isWithinRoot(workspaceRoot, scopeRoot)) {
|
||||
throw new Error("artifactScope must stay inside the workspace");
|
||||
@ -463,6 +523,75 @@ function nonNegativeNumber(value, 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) {
|
||||
if (value === "~") {
|
||||
return os.homedir();
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import fs from "node:fs";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import plugin from "./index.js";
|
||||
@ -5,6 +6,16 @@ import plugin from "./index.js";
|
||||
type GatewayMethodHandler = Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1];
|
||||
|
||||
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", () => {
|
||||
const methods: Array<{ method: string; handler: GatewayMethodHandler }> = [];
|
||||
const tools: unknown[] = [];
|
||||
|
||||
10
index.ts
10
index.ts
@ -111,7 +111,15 @@ function createXWorkmateArtifactsTool(
|
||||
},
|
||||
relativePath: {
|
||||
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: {
|
||||
type: "number",
|
||||
|
||||
@ -5,6 +5,9 @@
|
||||
"activation": {
|
||||
"onStartup": true
|
||||
},
|
||||
"contracts": {
|
||||
"tools": ["xworkmate_artifacts"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
@ -20,6 +23,10 @@
|
||||
"maxInlineBytes": {
|
||||
"type": "number",
|
||||
"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": {
|
||||
"label": "Max Inline Bytes",
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,8 +22,8 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
|
||||
expect(first.artifactScope).toMatch(/^\.xworkmate\/artifacts\/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(first.artifactScope).toMatch(/^tasks\/thread-main-[a-f0-9]{12}\/turn-1-[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((await fs.stat(first.artifactDirectory)).isDirectory()).toBe(true);
|
||||
expect(first.remoteWorkingDirectory).toBe(await fs.realpath(root));
|
||||
@ -58,6 +58,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
encoding: "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("text/markdown");
|
||||
});
|
||||
@ -140,7 +141,12 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
params: { sessionKey: "thread-main", runId: "turn-1" },
|
||||
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(otherTask.artifactDirectory, "other-task.txt"), "other");
|
||||
await fs.mkdir(path.join(root, ".xworkmate", "metadata"), { recursive: true });
|
||||
await fs.writeFile(path.join(root, ".xworkmate", "metadata", "internal.json"), "{}");
|
||||
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[0]?.artifactScope).toBeUndefined();
|
||||
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");
|
||||
});
|
||||
|
||||
@ -242,27 +249,21 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
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-"));
|
||||
await fs.mkdir(path.join(root, "reports"), { recursive: true });
|
||||
await fs.writeFile(path.join(root, "reports", "final.txt"), "final");
|
||||
|
||||
const result = await readXWorkmateArtifact({
|
||||
params: {
|
||||
sessionKey: "thread-main",
|
||||
runId: "run-1",
|
||||
relativePath: "reports/final.txt",
|
||||
},
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
|
||||
expect(result.artifacts).toHaveLength(1);
|
||||
expect(result.artifacts[0]).toMatchObject({
|
||||
relativePath: "reports/final.txt",
|
||||
contentType: "text/plain",
|
||||
encoding: "base64",
|
||||
content: Buffer.from("final").toString("base64"),
|
||||
});
|
||||
await expect(
|
||||
readXWorkmateArtifact({
|
||||
params: {
|
||||
sessionKey: "thread-main",
|
||||
runId: "run-1",
|
||||
relativePath: "reports/final.txt",
|
||||
},
|
||||
pluginConfig: { workspaceDir: root },
|
||||
}),
|
||||
).rejects.toThrow("artifactScope or artifactRef required");
|
||||
});
|
||||
|
||||
it("reads one artifact inside a task artifact scope", async () => {
|
||||
@ -293,16 +294,85 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
encoding: "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 () => {
|
||||
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({
|
||||
params: {
|
||||
sessionKey: "thread-main",
|
||||
runId: "run-1",
|
||||
artifactScope: prepared.artifactScope,
|
||||
relativePath: "large.bin",
|
||||
maxInlineBytes: 2,
|
||||
},
|
||||
@ -323,12 +393,17 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
|
||||
it("rejects relative path traversal when reading artifacts", 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 expect(
|
||||
readXWorkmateArtifact({
|
||||
params: {
|
||||
sessionKey: "thread-main",
|
||||
runId: "run-1",
|
||||
artifactScope: prepared.artifactScope,
|
||||
relativePath: "../outside.txt",
|
||||
},
|
||||
pluginConfig: { workspaceDir: root },
|
||||
@ -357,13 +432,18 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
const outsideRoot = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-outside-"));
|
||||
const outsideFile = path.join(outsideRoot, "secret.txt");
|
||||
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(
|
||||
readXWorkmateArtifact({
|
||||
params: {
|
||||
sessionKey: "thread-main",
|
||||
runId: "run-1",
|
||||
artifactScope: prepared.artifactScope,
|
||||
relativePath: "linked-secret.txt",
|
||||
},
|
||||
pluginConfig: { workspaceDir: root },
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const DEFAULT_MAX_FILES = 64;
|
||||
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([
|
||||
".git",
|
||||
@ -25,6 +27,7 @@ export type XWorkmateArtifact = {
|
||||
contentType: string;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
artifactRef: string;
|
||||
artifactScope?: string;
|
||||
scopeKind?: XWorkmateArtifactScopeKind;
|
||||
encoding?: "base64";
|
||||
@ -69,6 +72,16 @@ type ReadInput = {
|
||||
pluginConfig?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type ArtifactRefPayload = {
|
||||
v: 1;
|
||||
workspaceRootHash: string;
|
||||
scopeKind: XWorkmateArtifactScopeKind;
|
||||
artifactScope?: string;
|
||||
relativePath: string;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
};
|
||||
|
||||
type Candidate = {
|
||||
absolutePath: string;
|
||||
relativePath: string;
|
||||
@ -135,6 +148,7 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
||||
scanRoot: scopeRoot,
|
||||
relativeRoot: scopeRoot,
|
||||
sinceUnixMs,
|
||||
skipTaskScopeRoot: !scopedExport,
|
||||
warnings,
|
||||
});
|
||||
|
||||
@ -144,6 +158,7 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
||||
scanRoot: workspaceRoot,
|
||||
relativeRoot: workspaceRoot,
|
||||
sinceUnixMs: 0,
|
||||
skipTaskScopeRoot: true,
|
||||
warnings: latestWarnings,
|
||||
});
|
||||
if (latestCandidates.length > 0) {
|
||||
@ -170,12 +185,25 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
||||
break;
|
||||
}
|
||||
const bytes = await fs.readFile(candidate.absolutePath);
|
||||
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
const artifact: XWorkmateArtifact = {
|
||||
relativePath: candidate.relativePath,
|
||||
label: path.posix.basename(candidate.relativePath),
|
||||
contentType: contentTypeForPath(candidate.relativePath),
|
||||
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,
|
||||
};
|
||||
if (scopeKind === "task" && artifactScope) {
|
||||
@ -211,8 +239,10 @@ export async function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmate
|
||||
const pluginConfig = input.pluginConfig ?? {};
|
||||
const runId = optionalString(params.runId) || "read";
|
||||
const sessionKey = requiredString(params.sessionKey, "sessionKey required");
|
||||
const relativePath = safeInputRelativePath(params.relativePath, "relativePath");
|
||||
const artifactScope = optionalArtifactScope(params.artifactScope);
|
||||
const requestedArtifactRef = optionalString(params.artifactRef);
|
||||
let relativePath = "";
|
||||
let artifactScope = optionalArtifactScope(params.artifactScope);
|
||||
let refPayload: ArtifactRefPayload | undefined;
|
||||
const maxInlineBytes = nonNegativeInteger(
|
||||
params.maxInlineBytes,
|
||||
pluginConfig.maxInlineBytes,
|
||||
@ -225,8 +255,28 @@ export async function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmate
|
||||
sessionKey,
|
||||
});
|
||||
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 scopeKind: XWorkmateArtifactScopeKind = artifactScope ? "task" : "workspace";
|
||||
const scopeKind: XWorkmateArtifactScopeKind = refPayload?.scopeKind ?? "task";
|
||||
const absolutePath = path.join(scopeRoot, relativePath.split("/").join(path.sep));
|
||||
const realPath = await fs.realpath(absolutePath);
|
||||
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");
|
||||
}
|
||||
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 = {
|
||||
relativePath: safeRelativePath(scopeRoot, realPath),
|
||||
label: path.posix.basename(relativePath),
|
||||
contentType: contentTypeForPath(relativePath),
|
||||
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,
|
||||
};
|
||||
if (artifactScope) {
|
||||
@ -311,6 +379,7 @@ async function collectCandidates(input: {
|
||||
scanRoot: string;
|
||||
relativeRoot: string;
|
||||
sinceUnixMs: number;
|
||||
skipTaskScopeRoot: boolean;
|
||||
warnings: string[];
|
||||
}): Promise<Candidate[]> {
|
||||
const candidates: Candidate[] = [];
|
||||
@ -336,6 +405,9 @@ async function collectCandidates(input: {
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
if (input.skipTaskScopeRoot && currentDir === input.relativeRoot && entry.name === TASK_SCOPE_ROOT) {
|
||||
continue;
|
||||
}
|
||||
if (SKIPPED_DIRS.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
@ -371,9 +443,7 @@ async function collectCandidates(input: {
|
||||
|
||||
function artifactScopeFor(sessionKey: string, runId: string): string {
|
||||
return [
|
||||
".xworkmate",
|
||||
"artifacts",
|
||||
"tasks",
|
||||
TASK_SCOPE_ROOT,
|
||||
safeScopeSegment(sessionKey),
|
||||
safeScopeSegment(runId),
|
||||
].join("/");
|
||||
@ -395,7 +465,16 @@ function optionalArtifactScope(value: unknown): string {
|
||||
if (!scope) {
|
||||
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 {
|
||||
@ -414,7 +493,7 @@ function safeInputRelativePath(value: unknown, label: 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));
|
||||
if (!isWithinRoot(workspaceRoot, scopeRoot)) {
|
||||
throw new Error("artifactScope must stay inside the workspace");
|
||||
@ -577,6 +656,86 @@ function nonNegativeNumber(value: unknown, fallback: number): number {
|
||||
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 {
|
||||
if (value === "~") {
|
||||
return os.homedir();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user