@rivus/agent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +1051 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +22 -0
- package/dist/index.d.ts +3423 -0
- package/dist/index.js +7337 -0
- package/dist/rivus-daemon-cli.js +2599 -0
- package/dist/rivus-plugin-registry.js +316 -0
- package/dist/rivus-plugin-testkit.d.ts +291 -0
- package/dist/rivus-plugin-testkit.js +62 -0
- package/dist/testing/index.d.ts +55 -0
- package/dist/testing/index.js +94 -0
- package/examples/current-weather.mjs +143 -0
- package/examples/html-drive-tools.mjs +262 -0
- package/examples/langfuse-drive-e2e.mjs +175 -0
- package/examples/pi-feishu-deployment.bootstrap.ts +542 -0
- package/examples/pi-feishu.bootstrap.ts +225 -0
- package/examples/rivus-agents.plugin.mjs +238 -0
- package/examples/rivus-langfuse-demo.config.json +36 -0
- package/examples/rivus.config.json +83 -0
- package/package.json +112 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { a as assertRivusPluginConforms, i as RivusPluginLifecycleProbe, n as RivusPluginConformanceInput, o as createFakeRivusPlugin, r as RivusPluginConformanceReport, t as RivusPluginConformanceError } from "../rivus-plugin-testkit.js";
|
|
2
|
+
|
|
3
|
+
//#region src/testing/langfuse-drive-e2e.d.ts
|
|
4
|
+
interface LangfuseObservation {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly name: string;
|
|
7
|
+
readonly level?: string;
|
|
8
|
+
readonly output?: unknown;
|
|
9
|
+
readonly parentObservationId?: string;
|
|
10
|
+
readonly sessionId?: string;
|
|
11
|
+
readonly startTime: string;
|
|
12
|
+
readonly traceId: string;
|
|
13
|
+
readonly type: string;
|
|
14
|
+
}
|
|
15
|
+
interface LangfuseObservationWindow {
|
|
16
|
+
readonly deadlineAt: string;
|
|
17
|
+
readonly fromStartTime: string;
|
|
18
|
+
readonly sessionKey: string;
|
|
19
|
+
readonly toStartTime: string;
|
|
20
|
+
}
|
|
21
|
+
interface LangfuseDriveE2EDependencies {
|
|
22
|
+
inspectDrive(url: string): Promise<unknown>;
|
|
23
|
+
listObservations(window: LangfuseObservationWindow): Promise<ReadonlyArray<LangfuseObservation>>;
|
|
24
|
+
now(): Date;
|
|
25
|
+
readDriveFile(fileToken: string): Promise<string>;
|
|
26
|
+
runAgent(input: {
|
|
27
|
+
readonly prompt: string;
|
|
28
|
+
readonly sessionKey: string;
|
|
29
|
+
}): Promise<string>;
|
|
30
|
+
sleep(milliseconds: number): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
interface RunLangfuseDriveE2EOptions {
|
|
33
|
+
readonly dependencies: LangfuseDriveE2EDependencies;
|
|
34
|
+
readonly expectedDriveContentMarker: string;
|
|
35
|
+
readonly expectedDriveTitle: string;
|
|
36
|
+
readonly pollIntervalMs?: number;
|
|
37
|
+
readonly prompt: string;
|
|
38
|
+
readonly sessionKey: string;
|
|
39
|
+
readonly timeoutMs?: number;
|
|
40
|
+
}
|
|
41
|
+
interface LangfuseDriveE2EResult {
|
|
42
|
+
readonly drive: {
|
|
43
|
+
readonly title: string;
|
|
44
|
+
readonly type: "file";
|
|
45
|
+
readonly url: string;
|
|
46
|
+
};
|
|
47
|
+
readonly modelCallCount: number;
|
|
48
|
+
readonly observationCount: number;
|
|
49
|
+
readonly observationNames: ReadonlyArray<string>;
|
|
50
|
+
readonly sessionKey: string;
|
|
51
|
+
readonly traceId: string;
|
|
52
|
+
}
|
|
53
|
+
declare function runLangfuseDriveE2E(options: RunLangfuseDriveE2EOptions): Promise<LangfuseDriveE2EResult>;
|
|
54
|
+
//#endregion
|
|
55
|
+
export { RivusPluginConformanceError, RivusPluginConformanceInput, RivusPluginConformanceReport, RivusPluginLifecycleProbe, assertRivusPluginConforms, createFakeRivusPlugin, runLangfuseDriveE2E };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { n as assertRivusPluginConforms, r as createFakeRivusPlugin, t as RivusPluginConformanceError } from "../rivus-plugin-testkit.js";
|
|
2
|
+
//#region src/testing/langfuse-drive-e2e.ts
|
|
3
|
+
async function runLangfuseDriveE2E(options) {
|
|
4
|
+
const timeoutMs = options.timeoutMs ?? 12e4;
|
|
5
|
+
const pollIntervalMs = options.pollIntervalMs ?? 2e3;
|
|
6
|
+
const startedAt = options.dependencies.now();
|
|
7
|
+
const driveUrl = extractDriveFileUrl(await options.dependencies.runAgent({
|
|
8
|
+
prompt: options.prompt,
|
|
9
|
+
sessionKey: options.sessionKey
|
|
10
|
+
}));
|
|
11
|
+
const drive = readDriveInspection(await options.dependencies.inspectDrive(driveUrl));
|
|
12
|
+
if (drive.title !== options.expectedDriveTitle) throw new Error(`Feishu Drive title mismatch: expected ${options.expectedDriveTitle}, received ${drive.title}`);
|
|
13
|
+
if (!(await options.dependencies.readDriveFile(drive.fileToken)).includes(options.expectedDriveContentMarker)) throw new Error("Feishu Drive file does not contain the expected E2E marker");
|
|
14
|
+
const deadline = options.dependencies.now().getTime() + timeoutMs;
|
|
15
|
+
while (options.dependencies.now().getTime() <= deadline) {
|
|
16
|
+
const trace = findCompleteTrace(await options.dependencies.listObservations({
|
|
17
|
+
deadlineAt: new Date(deadline).toISOString(),
|
|
18
|
+
fromStartTime: startedAt.toISOString(),
|
|
19
|
+
sessionKey: options.sessionKey,
|
|
20
|
+
toStartTime: new Date(options.dependencies.now().getTime() + 6e4).toISOString()
|
|
21
|
+
}), options.sessionKey, driveUrl);
|
|
22
|
+
if (trace) return {
|
|
23
|
+
drive: {
|
|
24
|
+
title: drive.title,
|
|
25
|
+
type: "file",
|
|
26
|
+
url: driveUrl
|
|
27
|
+
},
|
|
28
|
+
modelCallCount: trace.observations.filter(({ type }) => type === "GENERATION").length,
|
|
29
|
+
observationCount: trace.observations.length,
|
|
30
|
+
observationNames: trace.observations.map(({ name }) => name),
|
|
31
|
+
sessionKey: options.sessionKey,
|
|
32
|
+
traceId: trace.traceId
|
|
33
|
+
};
|
|
34
|
+
await options.dependencies.sleep(pollIntervalMs);
|
|
35
|
+
}
|
|
36
|
+
throw new Error(`Langfuse trace did not become complete within ${timeoutMs}ms for session ${options.sessionKey}`);
|
|
37
|
+
}
|
|
38
|
+
function extractDriveFileUrl(output) {
|
|
39
|
+
const match = output.match(/https:\/\/[^\s<>"']+\/file\/[a-zA-Z0-9_-]+/u);
|
|
40
|
+
if (!match) throw new Error("Agent output does not contain a Feishu Drive file URL");
|
|
41
|
+
return match[0];
|
|
42
|
+
}
|
|
43
|
+
function readDriveInspection(value) {
|
|
44
|
+
if (!isRecord(value) || value.type !== "file" || typeof value.title !== "string" || value.title.trim() === "" || typeof value.token !== "string" || value.token.trim() === "") throw new Error("Feishu Drive inspection did not resolve a named file");
|
|
45
|
+
if (!value.title.toLowerCase().endsWith(".html")) throw new Error("Feishu Drive inspection did not resolve an HTML file");
|
|
46
|
+
return {
|
|
47
|
+
fileToken: value.token,
|
|
48
|
+
title: value.title
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function findCompleteTrace(observations, sessionKey, driveUrl) {
|
|
52
|
+
const byTrace = /* @__PURE__ */ new Map();
|
|
53
|
+
for (const observation of observations) {
|
|
54
|
+
if (observation.sessionId !== sessionKey) continue;
|
|
55
|
+
byTrace.set(observation.traceId, [...byTrace.get(observation.traceId) ?? [], observation]);
|
|
56
|
+
}
|
|
57
|
+
for (const [traceId, candidates] of byTrace) {
|
|
58
|
+
const ordered = [...candidates].sort((left, right) => left.startTime.localeCompare(right.startTime));
|
|
59
|
+
const root = ordered.find(({ name, parentObservationId }) => name === "Agent 执行" && !parentObservationId);
|
|
60
|
+
const turn = root ? ordered.find(({ name, parentObservationId }) => name === "Agent 处理" && parentObservationId === root.id) : void 0;
|
|
61
|
+
if (!turn) continue;
|
|
62
|
+
const sequence = findExpectedSequence(ordered.filter(({ parentObservationId }) => parentObservationId === turn.id));
|
|
63
|
+
const upload = sequence?.at(-2);
|
|
64
|
+
if (sequence && upload && upload.level !== "ERROR" && outputContainsDriveFile(upload.output, driveUrl)) return {
|
|
65
|
+
observations: ordered,
|
|
66
|
+
traceId
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function findExpectedSequence(observations) {
|
|
71
|
+
const predicates = [
|
|
72
|
+
({ type }) => type === "GENERATION",
|
|
73
|
+
({ name, type }) => type === "SPAN" && name.startsWith("技能 · "),
|
|
74
|
+
({ type }) => type === "GENERATION",
|
|
75
|
+
({ name, type }) => type === "TOOL" && isToolObservation(name, "write-html-artifact"),
|
|
76
|
+
({ type }) => type === "GENERATION",
|
|
77
|
+
({ name, type }) => type === "TOOL" && isToolObservation(name, "lark-drive-upload-html"),
|
|
78
|
+
({ type }) => type === "GENERATION"
|
|
79
|
+
];
|
|
80
|
+
return observations.length === predicates.length && observations.every((observation, index) => predicates[index](observation)) ? observations : void 0;
|
|
81
|
+
}
|
|
82
|
+
function outputContainsDriveFile(output, driveUrl) {
|
|
83
|
+
const fileToken = driveUrl.slice(driveUrl.lastIndexOf("/") + 1);
|
|
84
|
+
if (!fileToken) return false;
|
|
85
|
+
return typeof output === "string" ? output.includes(fileToken) : output !== void 0 && JSON.stringify(output).includes(fileToken);
|
|
86
|
+
}
|
|
87
|
+
function isToolObservation(name, toolName) {
|
|
88
|
+
return name === toolName || name === `工具调用 · ${toolName}`;
|
|
89
|
+
}
|
|
90
|
+
function isRecord(value) {
|
|
91
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
92
|
+
}
|
|
93
|
+
//#endregion
|
|
94
|
+
export { RivusPluginConformanceError, assertRivusPluginConforms, createFakeRivusPlugin, runLangfuseDriveE2E };
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { get } from "node:https";
|
|
2
|
+
|
|
3
|
+
export async function readCurrentWeather(input) {
|
|
4
|
+
if (input !== undefined && (typeof input !== "object" || input === null || Array.isArray(input))) {
|
|
5
|
+
throw new Error("current-weather input must be an object");
|
|
6
|
+
}
|
|
7
|
+
const requestedLocation = input?.location;
|
|
8
|
+
if (
|
|
9
|
+
requestedLocation !== undefined &&
|
|
10
|
+
(typeof requestedLocation !== "string" || !requestedLocation.trim() || requestedLocation.length > 100)
|
|
11
|
+
) {
|
|
12
|
+
throw new Error("current-weather location must be a non-empty string up to 100 characters");
|
|
13
|
+
}
|
|
14
|
+
const location = requestedLocation?.trim() || process.env.RIVUS_WEATHER_DEFAULT_LOCATION?.trim() || "北京";
|
|
15
|
+
const geocodingUrl = new URL("https://geocoding-api.open-meteo.com/v1/search");
|
|
16
|
+
geocodingUrl.search = new URLSearchParams({ count: "1", format: "json", language: "zh", name: location }).toString();
|
|
17
|
+
const geocoding = await fetchJson(geocodingUrl, "weather location lookup");
|
|
18
|
+
const place = Array.isArray(geocoding.results) ? geocoding.results[0] : undefined;
|
|
19
|
+
if (
|
|
20
|
+
!place ||
|
|
21
|
+
typeof place.name !== "string" ||
|
|
22
|
+
typeof place.latitude !== "number" ||
|
|
23
|
+
typeof place.longitude !== "number"
|
|
24
|
+
) {
|
|
25
|
+
throw new Error(`current-weather could not resolve location: ${location}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const forecastUrl = new URL("https://api.open-meteo.com/v1/forecast");
|
|
29
|
+
forecastUrl.search = new URLSearchParams({
|
|
30
|
+
current: "temperature_2m,apparent_temperature,weather_code,wind_speed_10m",
|
|
31
|
+
daily: "temperature_2m_max,temperature_2m_min,precipitation_probability_max",
|
|
32
|
+
forecast_days: "1",
|
|
33
|
+
latitude: String(place.latitude),
|
|
34
|
+
longitude: String(place.longitude),
|
|
35
|
+
timezone: "auto"
|
|
36
|
+
}).toString();
|
|
37
|
+
const forecast = await fetchJson(forecastUrl, "weather forecast lookup");
|
|
38
|
+
const current = forecast.current;
|
|
39
|
+
const daily = forecast.daily;
|
|
40
|
+
if (!current || typeof current !== "object" || !daily || typeof daily !== "object") {
|
|
41
|
+
throw new Error("current-weather provider returned an incomplete forecast");
|
|
42
|
+
}
|
|
43
|
+
const weatherCode = readNumber(current.weather_code, "weather_code");
|
|
44
|
+
return {
|
|
45
|
+
apparentTemperatureC: readNumber(current.apparent_temperature, "apparent_temperature"),
|
|
46
|
+
condition: describeWeatherCode(weatherCode),
|
|
47
|
+
date: readFirst(daily.time, "date"),
|
|
48
|
+
location: place.country ? `${place.name}, ${place.country}` : place.name,
|
|
49
|
+
observedAt: readString(current.time, "observed time"),
|
|
50
|
+
precipitationProbabilityPercent: readFirstNumber(daily.precipitation_probability_max, "precipitation probability"),
|
|
51
|
+
temperatureC: readNumber(current.temperature_2m, "temperature"),
|
|
52
|
+
temperatureMaxC: readFirstNumber(daily.temperature_2m_max, "maximum temperature"),
|
|
53
|
+
temperatureMinC: readFirstNumber(daily.temperature_2m_min, "minimum temperature"),
|
|
54
|
+
timezone: readString(forecast.timezone ?? place.timezone, "timezone"),
|
|
55
|
+
weatherCode,
|
|
56
|
+
windSpeedKmh: readNumber(current.wind_speed_10m, "wind speed")
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function fetchJson(url, operation) {
|
|
61
|
+
let response;
|
|
62
|
+
try {
|
|
63
|
+
response = await requestJson(url);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
throw new Error(`${operation} failed: ${error instanceof Error ? error.message : String(error)}`, {
|
|
66
|
+
cause: error
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
if (response.status < 200 || response.status >= 300) {
|
|
70
|
+
throw new Error(`${operation} failed with HTTP ${response.status}`);
|
|
71
|
+
}
|
|
72
|
+
let value;
|
|
73
|
+
try {
|
|
74
|
+
value = JSON.parse(response.body);
|
|
75
|
+
} catch {
|
|
76
|
+
throw new Error(`${operation} returned invalid JSON`);
|
|
77
|
+
}
|
|
78
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
79
|
+
throw new Error(`${operation} returned invalid JSON`);
|
|
80
|
+
}
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function requestJson(url) {
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
const request = get(
|
|
87
|
+
url,
|
|
88
|
+
{
|
|
89
|
+
family: 4,
|
|
90
|
+
headers: { accept: "application/json" },
|
|
91
|
+
signal: AbortSignal.timeout(10_000)
|
|
92
|
+
},
|
|
93
|
+
(response) => {
|
|
94
|
+
const status = response.statusCode ?? 0;
|
|
95
|
+
response.once("error", reject);
|
|
96
|
+
if (status < 200 || status >= 300) {
|
|
97
|
+
response.resume();
|
|
98
|
+
resolve({ body: "", status });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
let body = "";
|
|
102
|
+
response.setEncoding("utf8");
|
|
103
|
+
response.on("data", (chunk) => {
|
|
104
|
+
body += chunk;
|
|
105
|
+
});
|
|
106
|
+
response.once("end", () => resolve({ body, status }));
|
|
107
|
+
}
|
|
108
|
+
);
|
|
109
|
+
request.once("error", reject);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function readNumber(value, field) {
|
|
114
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`current-weather missing ${field}`);
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function readString(value, field) {
|
|
119
|
+
if (typeof value !== "string" || !value) throw new Error(`current-weather missing ${field}`);
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function readFirst(value, field) {
|
|
124
|
+
return readString(Array.isArray(value) ? value[0] : undefined, field);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function readFirstNumber(value, field) {
|
|
128
|
+
return readNumber(Array.isArray(value) ? value[0] : undefined, field);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function describeWeatherCode(code) {
|
|
132
|
+
if (code === 0) return "晴";
|
|
133
|
+
if (code === 1 || code === 2) return "晴间多云";
|
|
134
|
+
if (code === 3) return "阴";
|
|
135
|
+
if (code === 45 || code === 48) return "有雾";
|
|
136
|
+
if (code >= 51 && code <= 57) return "毛毛雨";
|
|
137
|
+
if (code >= 61 && code <= 67) return "雨";
|
|
138
|
+
if (code >= 71 && code <= 77) return "雪";
|
|
139
|
+
if (code >= 80 && code <= 82) return "阵雨";
|
|
140
|
+
if (code >= 85 && code <= 86) return "阵雪";
|
|
141
|
+
if (code >= 95 && code <= 99) return "雷暴";
|
|
142
|
+
return "未知";
|
|
143
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { lstat, mkdir, mkdtemp, open, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
const DEFAULT_ARTIFACT_ROOT = ".rivus/deployment/html-artifacts";
|
|
10
|
+
|
|
11
|
+
export function createHtmlArtifactWriter(options = {}) {
|
|
12
|
+
const cwd = options.cwd ?? process.cwd();
|
|
13
|
+
const artifactRoot = resolveArtifactRoot(cwd, options.artifactRoot);
|
|
14
|
+
return {
|
|
15
|
+
async execute(input, context) {
|
|
16
|
+
const html = readCompleteHtml(input);
|
|
17
|
+
const operationId = readOperationId(context);
|
|
18
|
+
const agentDirectory = resolveAgentDirectory(artifactRoot, context?.agentId);
|
|
19
|
+
const artifactId = createHash("sha256")
|
|
20
|
+
.update(JSON.stringify({ agentId: context.agentId, operationId }))
|
|
21
|
+
.digest("hex")
|
|
22
|
+
.slice(0, 32);
|
|
23
|
+
const trustedDirectory = await createTrustedDirectory(cwd, agentDirectory);
|
|
24
|
+
const filePath = resolve(trustedDirectory, `${artifactId}.html`);
|
|
25
|
+
try {
|
|
26
|
+
await writeFile(filePath, html, { encoding: "utf8", flag: "wx" });
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (!isFileAlreadyPresent(error) || (await readFile(filePath, "utf8")) !== html) throw error;
|
|
29
|
+
}
|
|
30
|
+
return Object.freeze({
|
|
31
|
+
artifactId,
|
|
32
|
+
path: relative(cwd, resolve(agentDirectory, `${artifactId}.html`)),
|
|
33
|
+
sha256: createHash("sha256").update(html).digest("hex"),
|
|
34
|
+
size: Buffer.byteLength(html)
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createLarkDriveHtmlUploader(options = {}) {
|
|
41
|
+
const cwd = options.cwd ?? process.cwd();
|
|
42
|
+
const artifactRoot = resolveArtifactRoot(cwd, options.artifactRoot);
|
|
43
|
+
const onCleanupError = options.onCleanupError ?? reportCleanupError;
|
|
44
|
+
const removeDirectory = options.removeDirectory ?? rm;
|
|
45
|
+
const runCommand = options.runCommand ?? runLarkCli;
|
|
46
|
+
return {
|
|
47
|
+
async execute(input, context) {
|
|
48
|
+
readOperationId(context);
|
|
49
|
+
const { artifactId, name } = readUploadInput(input);
|
|
50
|
+
const agentDirectory = resolveAgentDirectory(artifactRoot, context?.agentId);
|
|
51
|
+
const trustedDirectory = await resolveExistingTrustedDirectory(cwd, agentDirectory);
|
|
52
|
+
const filePath = resolve(trustedDirectory, `${artifactId}.html`);
|
|
53
|
+
const bytes = await readRegularFileNoFollow(filePath, artifactId);
|
|
54
|
+
const stagingRoot = resolve(cwd, ".rivus/deployment/upload-staging");
|
|
55
|
+
const trustedStagingRoot = await createTrustedDirectory(cwd, stagingRoot);
|
|
56
|
+
const trustedWorkspace = await realpath(cwd);
|
|
57
|
+
const stagingDirectory = await mkdtemp(join(trustedStagingRoot, "upload-"));
|
|
58
|
+
const stagedPath = resolve(stagingDirectory, name);
|
|
59
|
+
try {
|
|
60
|
+
await writeFile(stagedPath, bytes, { flag: "wx", mode: 0o600 });
|
|
61
|
+
const command = process.env.RIVUS_LARK_CLI_PATH?.trim() || "lark-cli";
|
|
62
|
+
const { stdout } = await runCommand(
|
|
63
|
+
command,
|
|
64
|
+
[
|
|
65
|
+
"drive",
|
|
66
|
+
"+upload",
|
|
67
|
+
"--as",
|
|
68
|
+
"user",
|
|
69
|
+
"--file",
|
|
70
|
+
relative(trustedWorkspace, stagedPath),
|
|
71
|
+
"--name",
|
|
72
|
+
name,
|
|
73
|
+
"--format",
|
|
74
|
+
"json"
|
|
75
|
+
],
|
|
76
|
+
{ cwd, timeout: 120_000 }
|
|
77
|
+
);
|
|
78
|
+
return readUploadResult(stdout, name);
|
|
79
|
+
} finally {
|
|
80
|
+
await cleanupStaging(removeDirectory, onCleanupError, stagingDirectory);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function readRegularFileNoFollow(filePath, artifactId) {
|
|
87
|
+
const linkMetadata = await lstat(filePath);
|
|
88
|
+
if (linkMetadata.isSymbolicLink()) throw new Error(`HTML artifact cannot be a symbolic link: ${artifactId}`);
|
|
89
|
+
if (typeof constants.O_NOFOLLOW !== "number") {
|
|
90
|
+
throw new Error("HTML artifact upload requires filesystem no-follow support");
|
|
91
|
+
}
|
|
92
|
+
const handle = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
93
|
+
try {
|
|
94
|
+
const metadata = await handle.stat();
|
|
95
|
+
if (!metadata.isFile()) throw new Error(`HTML artifact is not a regular file: ${artifactId}`);
|
|
96
|
+
if (metadata.size > 256 * 1024) throw new Error(`HTML artifact exceeds the 256 KiB limit: ${artifactId}`);
|
|
97
|
+
return await handle.readFile();
|
|
98
|
+
} finally {
|
|
99
|
+
await handle.close();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function cleanupStaging(removeDirectory, onCleanupError, stagingDirectory) {
|
|
104
|
+
try {
|
|
105
|
+
await removeDirectory(stagingDirectory, { force: true, recursive: true });
|
|
106
|
+
} catch (error) {
|
|
107
|
+
try {
|
|
108
|
+
onCleanupError(error);
|
|
109
|
+
} catch {
|
|
110
|
+
// Cleanup diagnostics must not change the known remote upload outcome.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function reportCleanupError(error) {
|
|
116
|
+
process.emitWarning(
|
|
117
|
+
`Could not remove the private HTML upload staging directory: ${error instanceof Error ? error.message : String(error)}`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function runLarkCli(command, args, options) {
|
|
122
|
+
return execFileAsync(command, args, {
|
|
123
|
+
...options,
|
|
124
|
+
env: {
|
|
125
|
+
...process.env,
|
|
126
|
+
LARKSUITE_CLI_NO_SKILLS_NOTIFIER: "1",
|
|
127
|
+
LARKSUITE_CLI_NO_UPDATE_NOTIFIER: "1"
|
|
128
|
+
},
|
|
129
|
+
maxBuffer: 1024 * 1024
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function readCompleteHtml(input) {
|
|
134
|
+
if (!isRecord(input) || typeof input.html !== "string") {
|
|
135
|
+
throw new Error("write-html-artifact requires an html string");
|
|
136
|
+
}
|
|
137
|
+
const html = input.html.trim();
|
|
138
|
+
if (!/^<!doctype html>/i.test(html) || !/<html(?:\s|>)/i.test(html) || !/<\/html>\s*$/i.test(html)) {
|
|
139
|
+
throw new Error("write-html-artifact requires a complete HTML document");
|
|
140
|
+
}
|
|
141
|
+
if (Buffer.byteLength(html) > 256 * 1024) throw new Error("HTML artifact exceeds the 256 KiB limit");
|
|
142
|
+
return html;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function readUploadInput(input) {
|
|
146
|
+
if (!isRecord(input) || typeof input.artifactId !== "string" || !/^[a-f0-9]{32}$/.test(input.artifactId)) {
|
|
147
|
+
throw new Error("lark-drive-upload-html requires a valid artifactId");
|
|
148
|
+
}
|
|
149
|
+
if (
|
|
150
|
+
typeof input.name !== "string" ||
|
|
151
|
+
!/^[^/\\]{1,120}\.html$/i.test(input.name) ||
|
|
152
|
+
input.name === ".html" ||
|
|
153
|
+
input.name.includes("..")
|
|
154
|
+
) {
|
|
155
|
+
throw new Error("lark-drive-upload-html requires a safe .html file name");
|
|
156
|
+
}
|
|
157
|
+
return { artifactId: input.artifactId, name: input.name };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function readUploadResult(stdout, requestedName) {
|
|
161
|
+
let parsed;
|
|
162
|
+
try {
|
|
163
|
+
parsed = JSON.parse(stdout);
|
|
164
|
+
} catch {
|
|
165
|
+
throw new Error("lark-cli upload returned invalid JSON");
|
|
166
|
+
}
|
|
167
|
+
const result = isRecord(parsed?.data) ? parsed.data : parsed;
|
|
168
|
+
if (!isRecord(result)) throw new Error("lark-cli upload returned an invalid result");
|
|
169
|
+
const fileToken = readFirstString(result, ["file_token", "fileToken", "token"]);
|
|
170
|
+
const url = readFirstString(result, ["url", "web_url", "file_url"]);
|
|
171
|
+
const name = readFirstString(result, ["name", "file_name"]) ?? requestedName;
|
|
172
|
+
if (!fileToken || !url) throw new Error("lark-cli upload result is missing file token or URL");
|
|
173
|
+
return Object.freeze({ fileToken, name, url });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function readFirstString(value, keys) {
|
|
177
|
+
for (const key of keys) {
|
|
178
|
+
const candidate = value[key];
|
|
179
|
+
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
|
|
180
|
+
}
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function resolveArtifactRoot(cwd, artifactRoot = DEFAULT_ARTIFACT_ROOT) {
|
|
185
|
+
const root = resolve(cwd, artifactRoot);
|
|
186
|
+
const relativeRoot = relative(cwd, root);
|
|
187
|
+
if (relativeRoot === ".." || relativeRoot.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)) {
|
|
188
|
+
throw new Error("HTML artifact root must stay inside the workspace");
|
|
189
|
+
}
|
|
190
|
+
return root;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function resolveAgentDirectory(artifactRoot, agentId) {
|
|
194
|
+
if (typeof agentId !== "string" || !agentId.trim()) throw new Error("HTML artifact tool requires an agent id");
|
|
195
|
+
return resolve(artifactRoot, createHash("sha256").update(agentId).digest("hex").slice(0, 16));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function createTrustedDirectory(cwd, directory) {
|
|
199
|
+
return resolveTrustedDirectory(cwd, directory, true);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function resolveExistingTrustedDirectory(cwd, directory) {
|
|
203
|
+
return resolveTrustedDirectory(cwd, directory, false);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function resolveTrustedDirectory(cwd, directory, create) {
|
|
207
|
+
const lexicalWorkspace = resolve(cwd);
|
|
208
|
+
const lexicalDirectory = resolve(directory);
|
|
209
|
+
const lexicalRelativePath = relative(lexicalWorkspace, lexicalDirectory);
|
|
210
|
+
if (lexicalRelativePath === ".." || lexicalRelativePath.startsWith(`..${sep}`)) {
|
|
211
|
+
throw new Error("HTML artifact directory must stay inside the workspace");
|
|
212
|
+
}
|
|
213
|
+
const segments = lexicalRelativePath.split(sep).filter(Boolean);
|
|
214
|
+
let current = lexicalWorkspace;
|
|
215
|
+
for (const segment of segments) {
|
|
216
|
+
current = resolve(current, segment);
|
|
217
|
+
const metadata = await readOrCreateDirectory(current, create);
|
|
218
|
+
if (metadata.isSymbolicLink()) {
|
|
219
|
+
throw new Error("HTML artifact directory cannot contain symbolic links");
|
|
220
|
+
}
|
|
221
|
+
if (!metadata.isDirectory()) throw new Error("HTML artifact path component is not a directory");
|
|
222
|
+
}
|
|
223
|
+
const [workspacePath, directoryPath] = await Promise.all([realpath(lexicalWorkspace), realpath(lexicalDirectory)]);
|
|
224
|
+
const resolvedRelativePath = relative(workspacePath, directoryPath);
|
|
225
|
+
if (resolvedRelativePath === ".." || resolvedRelativePath.startsWith(`..${sep}`)) {
|
|
226
|
+
throw new Error("HTML artifact directory resolved outside the workspace");
|
|
227
|
+
}
|
|
228
|
+
return directoryPath;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function readOrCreateDirectory(directory, create) {
|
|
232
|
+
try {
|
|
233
|
+
return await lstat(directory);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (!isMissingFile(error) || !create) throw error;
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
await mkdir(directory);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (!isFileAlreadyPresent(error)) throw error;
|
|
241
|
+
}
|
|
242
|
+
return lstat(directory);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function readOperationId(context) {
|
|
246
|
+
if (typeof context?.operationId !== "string" || !context.operationId.trim()) {
|
|
247
|
+
throw new Error("HTML artifact tool requires a stable operation id");
|
|
248
|
+
}
|
|
249
|
+
return context.operationId;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function isRecord(value) {
|
|
253
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function isFileAlreadyPresent(error) {
|
|
257
|
+
return error instanceof Error && "code" in error && error.code === "EEXIST";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function isMissingFile(error) {
|
|
261
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
262
|
+
}
|