@riddledc/openclaw-riddledc 0.5.6 → 0.7.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/CHECKSUMS.txt +2 -2
- package/dist/index.cjs +247 -0
- package/dist/index.js +248 -1
- package/openclaw.plugin.json +8 -4
- package/package.json +1 -1
package/CHECKSUMS.txt
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
921867120c7149d34d965a56ab0b727b2ecd2f0abf4ea9980171aefd4e11c39f dist/index.cjs
|
|
2
2
|
94ce04f0e2d84bf64dd68f0500dfdd2f951287a3deccec87f197261961927f6f dist/index.d.cts
|
|
3
3
|
94ce04f0e2d84bf64dd68f0500dfdd2f951287a3deccec87f197261961927f6f dist/index.d.ts
|
|
4
|
-
|
|
4
|
+
06f7ee46883239b2b9e1180a0f82d70b62f03fb2b73c032ff4ae7dafb65ab8e1 dist/index.js
|
package/dist/index.cjs
CHANGED
|
@@ -26,6 +26,9 @@ module.exports = __toCommonJS(index_exports);
|
|
|
26
26
|
var import_typebox = require("@sinclair/typebox");
|
|
27
27
|
var import_promises = require("fs/promises");
|
|
28
28
|
var import_node_path = require("path");
|
|
29
|
+
var import_node_child_process = require("child_process");
|
|
30
|
+
var import_node_util = require("util");
|
|
31
|
+
var execFile = (0, import_node_util.promisify)(import_node_child_process.execFile);
|
|
29
32
|
var INLINE_CAP = 50 * 1024;
|
|
30
33
|
function getCfg(api) {
|
|
31
34
|
const cfg = api?.config ?? {};
|
|
@@ -753,4 +756,248 @@ function register(api) {
|
|
|
753
756
|
},
|
|
754
757
|
{ optional: true }
|
|
755
758
|
);
|
|
759
|
+
api.registerTool(
|
|
760
|
+
{
|
|
761
|
+
name: "riddle_preview",
|
|
762
|
+
description: "Deploy a local build directory as an ephemeral preview site. Tars the directory, uploads to Riddle, and returns a live URL at preview.riddledc.com that can be screenshotted with other riddle_* tools. Previews auto-expire after 24 hours.",
|
|
763
|
+
parameters: import_typebox.Type.Object({
|
|
764
|
+
directory: import_typebox.Type.String({ description: "Absolute path to the build output directory (e.g. /path/to/build or /path/to/dist)" }),
|
|
765
|
+
framework: import_typebox.Type.Optional(import_typebox.Type.String({ description: "Framework hint: 'spa' (default) or 'static'" }))
|
|
766
|
+
}),
|
|
767
|
+
async execute(_id, params) {
|
|
768
|
+
const { apiKey, baseUrl } = getCfg(api);
|
|
769
|
+
if (!apiKey) {
|
|
770
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: "Missing Riddle API key." }, null, 2) }] };
|
|
771
|
+
}
|
|
772
|
+
assertAllowedBaseUrl(baseUrl);
|
|
773
|
+
const dir = params.directory;
|
|
774
|
+
if (!dir || typeof dir !== "string") throw new Error("directory must be an absolute path");
|
|
775
|
+
try {
|
|
776
|
+
const st = await (0, import_promises.stat)(dir);
|
|
777
|
+
if (!st.isDirectory()) throw new Error(`Not a directory: ${dir}`);
|
|
778
|
+
} catch (e) {
|
|
779
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: `Cannot access directory: ${e.message}` }, null, 2) }] };
|
|
780
|
+
}
|
|
781
|
+
const endpoint = baseUrl.replace(/\/$/, "");
|
|
782
|
+
const createRes = await fetch(`${endpoint}/v1/preview`, {
|
|
783
|
+
method: "POST",
|
|
784
|
+
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
|
785
|
+
body: JSON.stringify({ framework: params.framework || "spa" })
|
|
786
|
+
});
|
|
787
|
+
if (!createRes.ok) {
|
|
788
|
+
const err = await createRes.text();
|
|
789
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: `Create failed: HTTP ${createRes.status} ${err}` }, null, 2) }] };
|
|
790
|
+
}
|
|
791
|
+
const created = await createRes.json();
|
|
792
|
+
const tarball = `/tmp/riddle-preview-${created.id}.tar.gz`;
|
|
793
|
+
try {
|
|
794
|
+
await execFile("tar", ["czf", tarball, "-C", dir, "."], { timeout: 6e4 });
|
|
795
|
+
const tarData = await (0, import_promises.readFile)(tarball);
|
|
796
|
+
const uploadRes = await fetch(created.upload_url, {
|
|
797
|
+
method: "PUT",
|
|
798
|
+
headers: { "Content-Type": "application/gzip" },
|
|
799
|
+
body: tarData
|
|
800
|
+
});
|
|
801
|
+
if (!uploadRes.ok) {
|
|
802
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: false, id: created.id, error: `Upload failed: HTTP ${uploadRes.status}` }, null, 2) }] };
|
|
803
|
+
}
|
|
804
|
+
} finally {
|
|
805
|
+
try {
|
|
806
|
+
await (0, import_promises.rm)(tarball, { force: true });
|
|
807
|
+
} catch {
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
const publishRes = await fetch(`${endpoint}/v1/preview/${created.id}/publish`, {
|
|
811
|
+
method: "POST",
|
|
812
|
+
headers: { Authorization: `Bearer ${apiKey}` }
|
|
813
|
+
});
|
|
814
|
+
if (!publishRes.ok) {
|
|
815
|
+
const err = await publishRes.text();
|
|
816
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: false, id: created.id, error: `Publish failed: HTTP ${publishRes.status} ${err}` }, null, 2) }] };
|
|
817
|
+
}
|
|
818
|
+
const published = await publishRes.json();
|
|
819
|
+
return {
|
|
820
|
+
content: [{
|
|
821
|
+
type: "text",
|
|
822
|
+
text: JSON.stringify({
|
|
823
|
+
ok: true,
|
|
824
|
+
id: published.id,
|
|
825
|
+
preview_url: published.preview_url,
|
|
826
|
+
file_count: published.file_count,
|
|
827
|
+
total_bytes: published.total_bytes,
|
|
828
|
+
expires_at: created.expires_at
|
|
829
|
+
}, null, 2)
|
|
830
|
+
}]
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
},
|
|
834
|
+
{ optional: true }
|
|
835
|
+
);
|
|
836
|
+
api.registerTool(
|
|
837
|
+
{
|
|
838
|
+
name: "riddle_preview_delete",
|
|
839
|
+
description: "Delete an ephemeral preview site created by riddle_preview. Removes all files and frees the preview ID immediately instead of waiting for auto-expiry.",
|
|
840
|
+
parameters: import_typebox.Type.Object({
|
|
841
|
+
id: import_typebox.Type.String({ description: "Preview ID (e.g. pv_a1b2c3d4)" })
|
|
842
|
+
}),
|
|
843
|
+
async execute(_id, params) {
|
|
844
|
+
const { apiKey, baseUrl } = getCfg(api);
|
|
845
|
+
if (!apiKey) {
|
|
846
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: "Missing Riddle API key." }, null, 2) }] };
|
|
847
|
+
}
|
|
848
|
+
assertAllowedBaseUrl(baseUrl);
|
|
849
|
+
const res = await fetch(`${baseUrl.replace(/\/$/, "")}/v1/preview/${params.id}`, {
|
|
850
|
+
method: "DELETE",
|
|
851
|
+
headers: { Authorization: `Bearer ${apiKey}` }
|
|
852
|
+
});
|
|
853
|
+
if (!res.ok) {
|
|
854
|
+
const err = await res.text();
|
|
855
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: `Delete failed: HTTP ${res.status} ${err}` }, null, 2) }] };
|
|
856
|
+
}
|
|
857
|
+
const data = await res.json();
|
|
858
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: true, deleted: true, files_removed: data.files_removed }, null, 2) }] };
|
|
859
|
+
}
|
|
860
|
+
},
|
|
861
|
+
{ optional: true }
|
|
862
|
+
);
|
|
863
|
+
api.registerTool(
|
|
864
|
+
{
|
|
865
|
+
name: "riddle_server_preview",
|
|
866
|
+
description: "Run a server-side app (Next.js, Express, Django, etc.) in an isolated Docker container and screenshot it. Tars the build directory, uploads to Riddle, starts the container with the specified image and command, waits for readiness, then takes a Playwright screenshot. Use for apps that need a running server process (not static sites \u2014 use riddle_preview for those).",
|
|
867
|
+
parameters: import_typebox.Type.Object({
|
|
868
|
+
directory: import_typebox.Type.String({ description: "Absolute path to the project/build directory to deploy into the container" }),
|
|
869
|
+
image: import_typebox.Type.String({ description: "Docker image to run (e.g. 'node:20-slim', 'python:3.12-slim')" }),
|
|
870
|
+
command: import_typebox.Type.String({ description: "Command to start the server inside the container (e.g. 'npm start', 'python manage.py runserver 0.0.0.0:3000')" }),
|
|
871
|
+
port: import_typebox.Type.Number({ description: "Port the server listens on inside the container" }),
|
|
872
|
+
path: import_typebox.Type.Optional(import_typebox.Type.String({ description: "URL path to screenshot (default: '/')" })),
|
|
873
|
+
env: import_typebox.Type.Optional(import_typebox.Type.Record(import_typebox.Type.String(), import_typebox.Type.String(), { description: "Non-sensitive environment variables" })),
|
|
874
|
+
sensitive_env: import_typebox.Type.Optional(import_typebox.Type.Record(import_typebox.Type.String(), import_typebox.Type.String(), { description: "Sensitive environment variables (API keys, DB passwords). Stored securely and deleted after use." })),
|
|
875
|
+
timeout: import_typebox.Type.Optional(import_typebox.Type.Number({ description: "Max execution time in seconds (default: 120, max: 600)" })),
|
|
876
|
+
readiness_path: import_typebox.Type.Optional(import_typebox.Type.String({ description: "Path to poll for readiness (default: same as path)" })),
|
|
877
|
+
readiness_timeout: import_typebox.Type.Optional(import_typebox.Type.Number({ description: "Max seconds to wait for server readiness (default: 30)" })),
|
|
878
|
+
script: import_typebox.Type.Optional(import_typebox.Type.String({ description: `Optional Playwright script to run after server is ready (e.g. 'await page.click("button")')` }))
|
|
879
|
+
}),
|
|
880
|
+
async execute(_id, params) {
|
|
881
|
+
const { apiKey, baseUrl } = getCfg(api);
|
|
882
|
+
if (!apiKey) {
|
|
883
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: "Missing Riddle API key." }, null, 2) }] };
|
|
884
|
+
}
|
|
885
|
+
assertAllowedBaseUrl(baseUrl);
|
|
886
|
+
const dir = params.directory;
|
|
887
|
+
if (!dir || typeof dir !== "string") throw new Error("directory must be an absolute path");
|
|
888
|
+
try {
|
|
889
|
+
const st = await (0, import_promises.stat)(dir);
|
|
890
|
+
if (!st.isDirectory()) throw new Error(`Not a directory: ${dir}`);
|
|
891
|
+
} catch (e) {
|
|
892
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: `Cannot access directory: ${e.message}` }, null, 2) }] };
|
|
893
|
+
}
|
|
894
|
+
const endpoint = baseUrl.replace(/\/$/, "");
|
|
895
|
+
let envRef = null;
|
|
896
|
+
if (params.sensitive_env && Object.keys(params.sensitive_env).length > 0) {
|
|
897
|
+
const envRes = await fetch(`${endpoint}/v1/server-preview/env`, {
|
|
898
|
+
method: "POST",
|
|
899
|
+
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
|
900
|
+
body: JSON.stringify({ env: params.sensitive_env })
|
|
901
|
+
});
|
|
902
|
+
if (!envRes.ok) {
|
|
903
|
+
const err = await envRes.text();
|
|
904
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: `Store env failed: HTTP ${envRes.status} ${err}` }, null, 2) }] };
|
|
905
|
+
}
|
|
906
|
+
const envData = await envRes.json();
|
|
907
|
+
envRef = envData.env_ref;
|
|
908
|
+
}
|
|
909
|
+
const createBody = {
|
|
910
|
+
image: params.image,
|
|
911
|
+
command: params.command,
|
|
912
|
+
port: params.port
|
|
913
|
+
};
|
|
914
|
+
if (params.path) createBody.path = params.path;
|
|
915
|
+
if (params.env) createBody.env = params.env;
|
|
916
|
+
if (envRef) createBody.env_ref = envRef;
|
|
917
|
+
if (params.timeout) createBody.timeout = params.timeout;
|
|
918
|
+
if (params.readiness_path) createBody.readiness_path = params.readiness_path;
|
|
919
|
+
if (params.readiness_timeout) createBody.readiness_timeout = params.readiness_timeout;
|
|
920
|
+
if (params.script) createBody.script = params.script;
|
|
921
|
+
const createRes = await fetch(`${endpoint}/v1/server-preview`, {
|
|
922
|
+
method: "POST",
|
|
923
|
+
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
|
924
|
+
body: JSON.stringify(createBody)
|
|
925
|
+
});
|
|
926
|
+
if (!createRes.ok) {
|
|
927
|
+
const err = await createRes.text();
|
|
928
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: `Create failed: HTTP ${createRes.status} ${err}` }, null, 2) }] };
|
|
929
|
+
}
|
|
930
|
+
const created = await createRes.json();
|
|
931
|
+
const tarball = `/tmp/riddle-sp-${created.job_id}.tar.gz`;
|
|
932
|
+
try {
|
|
933
|
+
await execFile("tar", ["czf", tarball, "-C", dir, "."], { timeout: 12e4 });
|
|
934
|
+
const tarData = await (0, import_promises.readFile)(tarball);
|
|
935
|
+
const uploadRes = await fetch(created.upload_url, {
|
|
936
|
+
method: "PUT",
|
|
937
|
+
headers: { "Content-Type": "application/gzip" },
|
|
938
|
+
body: tarData
|
|
939
|
+
});
|
|
940
|
+
if (!uploadRes.ok) {
|
|
941
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: false, job_id: created.job_id, error: `Upload failed: HTTP ${uploadRes.status}` }, null, 2) }] };
|
|
942
|
+
}
|
|
943
|
+
} finally {
|
|
944
|
+
try {
|
|
945
|
+
await (0, import_promises.rm)(tarball, { force: true });
|
|
946
|
+
} catch {
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
const startRes = await fetch(`${endpoint}/v1/server-preview/${created.job_id}/start`, {
|
|
950
|
+
method: "POST",
|
|
951
|
+
headers: { Authorization: `Bearer ${apiKey}` }
|
|
952
|
+
});
|
|
953
|
+
if (!startRes.ok) {
|
|
954
|
+
const err = await startRes.text();
|
|
955
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: false, job_id: created.job_id, error: `Start failed: HTTP ${startRes.status} ${err}` }, null, 2) }] };
|
|
956
|
+
}
|
|
957
|
+
const timeoutMs = ((params.timeout || 120) + 60) * 1e3;
|
|
958
|
+
const pollStart = Date.now();
|
|
959
|
+
const POLL_INTERVAL = 3e3;
|
|
960
|
+
while (Date.now() - pollStart < timeoutMs) {
|
|
961
|
+
const statusRes = await fetch(`${endpoint}/v1/server-preview/${created.job_id}`, {
|
|
962
|
+
headers: { Authorization: `Bearer ${apiKey}` }
|
|
963
|
+
});
|
|
964
|
+
if (!statusRes.ok) {
|
|
965
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: false, job_id: created.job_id, error: `Poll failed: HTTP ${statusRes.status}` }, null, 2) }] };
|
|
966
|
+
}
|
|
967
|
+
const statusData = await statusRes.json();
|
|
968
|
+
if (statusData.status === "completed" || statusData.status === "failed") {
|
|
969
|
+
const result = {
|
|
970
|
+
ok: statusData.status === "completed",
|
|
971
|
+
job_id: created.job_id,
|
|
972
|
+
status: statusData.status,
|
|
973
|
+
outputs: statusData.outputs || [],
|
|
974
|
+
compute_seconds: statusData.compute_seconds,
|
|
975
|
+
egress_bytes: statusData.egress_bytes
|
|
976
|
+
};
|
|
977
|
+
if (statusData.error) result.error = statusData.error;
|
|
978
|
+
const workspace = getWorkspacePath(api);
|
|
979
|
+
for (const output of result.outputs) {
|
|
980
|
+
if (output.name && /\.(png|jpg|jpeg)$/i.test(output.name) && output.url) {
|
|
981
|
+
try {
|
|
982
|
+
const imgRes = await fetch(output.url);
|
|
983
|
+
if (imgRes.ok) {
|
|
984
|
+
const buf = await imgRes.arrayBuffer();
|
|
985
|
+
const base64 = Buffer.from(buf).toString("base64");
|
|
986
|
+
const ref = await writeArtifactBinary(workspace, "screenshots", `${created.job_id}-${output.name}`, base64);
|
|
987
|
+
output.saved = ref.path;
|
|
988
|
+
output.sizeBytes = ref.sizeBytes;
|
|
989
|
+
}
|
|
990
|
+
} catch {
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
995
|
+
}
|
|
996
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL));
|
|
997
|
+
}
|
|
998
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: false, job_id: created.job_id, error: `Job did not complete within ${timeoutMs / 1e3}s` }, null, 2) }] };
|
|
999
|
+
}
|
|
1000
|
+
},
|
|
1001
|
+
{ optional: true }
|
|
1002
|
+
);
|
|
756
1003
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { Type } from "@sinclair/typebox";
|
|
3
|
-
import { writeFile, mkdir } from "fs/promises";
|
|
3
|
+
import { writeFile, mkdir, readFile, stat, rm } from "fs/promises";
|
|
4
4
|
import { join } from "path";
|
|
5
|
+
import { execFile as execFileCb } from "child_process";
|
|
6
|
+
import { promisify } from "util";
|
|
7
|
+
var execFile = promisify(execFileCb);
|
|
5
8
|
var INLINE_CAP = 50 * 1024;
|
|
6
9
|
function getCfg(api) {
|
|
7
10
|
const cfg = api?.config ?? {};
|
|
@@ -729,6 +732,250 @@ function register(api) {
|
|
|
729
732
|
},
|
|
730
733
|
{ optional: true }
|
|
731
734
|
);
|
|
735
|
+
api.registerTool(
|
|
736
|
+
{
|
|
737
|
+
name: "riddle_preview",
|
|
738
|
+
description: "Deploy a local build directory as an ephemeral preview site. Tars the directory, uploads to Riddle, and returns a live URL at preview.riddledc.com that can be screenshotted with other riddle_* tools. Previews auto-expire after 24 hours.",
|
|
739
|
+
parameters: Type.Object({
|
|
740
|
+
directory: Type.String({ description: "Absolute path to the build output directory (e.g. /path/to/build or /path/to/dist)" }),
|
|
741
|
+
framework: Type.Optional(Type.String({ description: "Framework hint: 'spa' (default) or 'static'" }))
|
|
742
|
+
}),
|
|
743
|
+
async execute(_id, params) {
|
|
744
|
+
const { apiKey, baseUrl } = getCfg(api);
|
|
745
|
+
if (!apiKey) {
|
|
746
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: "Missing Riddle API key." }, null, 2) }] };
|
|
747
|
+
}
|
|
748
|
+
assertAllowedBaseUrl(baseUrl);
|
|
749
|
+
const dir = params.directory;
|
|
750
|
+
if (!dir || typeof dir !== "string") throw new Error("directory must be an absolute path");
|
|
751
|
+
try {
|
|
752
|
+
const st = await stat(dir);
|
|
753
|
+
if (!st.isDirectory()) throw new Error(`Not a directory: ${dir}`);
|
|
754
|
+
} catch (e) {
|
|
755
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: `Cannot access directory: ${e.message}` }, null, 2) }] };
|
|
756
|
+
}
|
|
757
|
+
const endpoint = baseUrl.replace(/\/$/, "");
|
|
758
|
+
const createRes = await fetch(`${endpoint}/v1/preview`, {
|
|
759
|
+
method: "POST",
|
|
760
|
+
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
|
761
|
+
body: JSON.stringify({ framework: params.framework || "spa" })
|
|
762
|
+
});
|
|
763
|
+
if (!createRes.ok) {
|
|
764
|
+
const err = await createRes.text();
|
|
765
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: `Create failed: HTTP ${createRes.status} ${err}` }, null, 2) }] };
|
|
766
|
+
}
|
|
767
|
+
const created = await createRes.json();
|
|
768
|
+
const tarball = `/tmp/riddle-preview-${created.id}.tar.gz`;
|
|
769
|
+
try {
|
|
770
|
+
await execFile("tar", ["czf", tarball, "-C", dir, "."], { timeout: 6e4 });
|
|
771
|
+
const tarData = await readFile(tarball);
|
|
772
|
+
const uploadRes = await fetch(created.upload_url, {
|
|
773
|
+
method: "PUT",
|
|
774
|
+
headers: { "Content-Type": "application/gzip" },
|
|
775
|
+
body: tarData
|
|
776
|
+
});
|
|
777
|
+
if (!uploadRes.ok) {
|
|
778
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: false, id: created.id, error: `Upload failed: HTTP ${uploadRes.status}` }, null, 2) }] };
|
|
779
|
+
}
|
|
780
|
+
} finally {
|
|
781
|
+
try {
|
|
782
|
+
await rm(tarball, { force: true });
|
|
783
|
+
} catch {
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
const publishRes = await fetch(`${endpoint}/v1/preview/${created.id}/publish`, {
|
|
787
|
+
method: "POST",
|
|
788
|
+
headers: { Authorization: `Bearer ${apiKey}` }
|
|
789
|
+
});
|
|
790
|
+
if (!publishRes.ok) {
|
|
791
|
+
const err = await publishRes.text();
|
|
792
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: false, id: created.id, error: `Publish failed: HTTP ${publishRes.status} ${err}` }, null, 2) }] };
|
|
793
|
+
}
|
|
794
|
+
const published = await publishRes.json();
|
|
795
|
+
return {
|
|
796
|
+
content: [{
|
|
797
|
+
type: "text",
|
|
798
|
+
text: JSON.stringify({
|
|
799
|
+
ok: true,
|
|
800
|
+
id: published.id,
|
|
801
|
+
preview_url: published.preview_url,
|
|
802
|
+
file_count: published.file_count,
|
|
803
|
+
total_bytes: published.total_bytes,
|
|
804
|
+
expires_at: created.expires_at
|
|
805
|
+
}, null, 2)
|
|
806
|
+
}]
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
},
|
|
810
|
+
{ optional: true }
|
|
811
|
+
);
|
|
812
|
+
api.registerTool(
|
|
813
|
+
{
|
|
814
|
+
name: "riddle_preview_delete",
|
|
815
|
+
description: "Delete an ephemeral preview site created by riddle_preview. Removes all files and frees the preview ID immediately instead of waiting for auto-expiry.",
|
|
816
|
+
parameters: Type.Object({
|
|
817
|
+
id: Type.String({ description: "Preview ID (e.g. pv_a1b2c3d4)" })
|
|
818
|
+
}),
|
|
819
|
+
async execute(_id, params) {
|
|
820
|
+
const { apiKey, baseUrl } = getCfg(api);
|
|
821
|
+
if (!apiKey) {
|
|
822
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: "Missing Riddle API key." }, null, 2) }] };
|
|
823
|
+
}
|
|
824
|
+
assertAllowedBaseUrl(baseUrl);
|
|
825
|
+
const res = await fetch(`${baseUrl.replace(/\/$/, "")}/v1/preview/${params.id}`, {
|
|
826
|
+
method: "DELETE",
|
|
827
|
+
headers: { Authorization: `Bearer ${apiKey}` }
|
|
828
|
+
});
|
|
829
|
+
if (!res.ok) {
|
|
830
|
+
const err = await res.text();
|
|
831
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: `Delete failed: HTTP ${res.status} ${err}` }, null, 2) }] };
|
|
832
|
+
}
|
|
833
|
+
const data = await res.json();
|
|
834
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: true, deleted: true, files_removed: data.files_removed }, null, 2) }] };
|
|
835
|
+
}
|
|
836
|
+
},
|
|
837
|
+
{ optional: true }
|
|
838
|
+
);
|
|
839
|
+
api.registerTool(
|
|
840
|
+
{
|
|
841
|
+
name: "riddle_server_preview",
|
|
842
|
+
description: "Run a server-side app (Next.js, Express, Django, etc.) in an isolated Docker container and screenshot it. Tars the build directory, uploads to Riddle, starts the container with the specified image and command, waits for readiness, then takes a Playwright screenshot. Use for apps that need a running server process (not static sites \u2014 use riddle_preview for those).",
|
|
843
|
+
parameters: Type.Object({
|
|
844
|
+
directory: Type.String({ description: "Absolute path to the project/build directory to deploy into the container" }),
|
|
845
|
+
image: Type.String({ description: "Docker image to run (e.g. 'node:20-slim', 'python:3.12-slim')" }),
|
|
846
|
+
command: Type.String({ description: "Command to start the server inside the container (e.g. 'npm start', 'python manage.py runserver 0.0.0.0:3000')" }),
|
|
847
|
+
port: Type.Number({ description: "Port the server listens on inside the container" }),
|
|
848
|
+
path: Type.Optional(Type.String({ description: "URL path to screenshot (default: '/')" })),
|
|
849
|
+
env: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Non-sensitive environment variables" })),
|
|
850
|
+
sensitive_env: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Sensitive environment variables (API keys, DB passwords). Stored securely and deleted after use." })),
|
|
851
|
+
timeout: Type.Optional(Type.Number({ description: "Max execution time in seconds (default: 120, max: 600)" })),
|
|
852
|
+
readiness_path: Type.Optional(Type.String({ description: "Path to poll for readiness (default: same as path)" })),
|
|
853
|
+
readiness_timeout: Type.Optional(Type.Number({ description: "Max seconds to wait for server readiness (default: 30)" })),
|
|
854
|
+
script: Type.Optional(Type.String({ description: `Optional Playwright script to run after server is ready (e.g. 'await page.click("button")')` }))
|
|
855
|
+
}),
|
|
856
|
+
async execute(_id, params) {
|
|
857
|
+
const { apiKey, baseUrl } = getCfg(api);
|
|
858
|
+
if (!apiKey) {
|
|
859
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: "Missing Riddle API key." }, null, 2) }] };
|
|
860
|
+
}
|
|
861
|
+
assertAllowedBaseUrl(baseUrl);
|
|
862
|
+
const dir = params.directory;
|
|
863
|
+
if (!dir || typeof dir !== "string") throw new Error("directory must be an absolute path");
|
|
864
|
+
try {
|
|
865
|
+
const st = await stat(dir);
|
|
866
|
+
if (!st.isDirectory()) throw new Error(`Not a directory: ${dir}`);
|
|
867
|
+
} catch (e) {
|
|
868
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: `Cannot access directory: ${e.message}` }, null, 2) }] };
|
|
869
|
+
}
|
|
870
|
+
const endpoint = baseUrl.replace(/\/$/, "");
|
|
871
|
+
let envRef = null;
|
|
872
|
+
if (params.sensitive_env && Object.keys(params.sensitive_env).length > 0) {
|
|
873
|
+
const envRes = await fetch(`${endpoint}/v1/server-preview/env`, {
|
|
874
|
+
method: "POST",
|
|
875
|
+
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
|
876
|
+
body: JSON.stringify({ env: params.sensitive_env })
|
|
877
|
+
});
|
|
878
|
+
if (!envRes.ok) {
|
|
879
|
+
const err = await envRes.text();
|
|
880
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: `Store env failed: HTTP ${envRes.status} ${err}` }, null, 2) }] };
|
|
881
|
+
}
|
|
882
|
+
const envData = await envRes.json();
|
|
883
|
+
envRef = envData.env_ref;
|
|
884
|
+
}
|
|
885
|
+
const createBody = {
|
|
886
|
+
image: params.image,
|
|
887
|
+
command: params.command,
|
|
888
|
+
port: params.port
|
|
889
|
+
};
|
|
890
|
+
if (params.path) createBody.path = params.path;
|
|
891
|
+
if (params.env) createBody.env = params.env;
|
|
892
|
+
if (envRef) createBody.env_ref = envRef;
|
|
893
|
+
if (params.timeout) createBody.timeout = params.timeout;
|
|
894
|
+
if (params.readiness_path) createBody.readiness_path = params.readiness_path;
|
|
895
|
+
if (params.readiness_timeout) createBody.readiness_timeout = params.readiness_timeout;
|
|
896
|
+
if (params.script) createBody.script = params.script;
|
|
897
|
+
const createRes = await fetch(`${endpoint}/v1/server-preview`, {
|
|
898
|
+
method: "POST",
|
|
899
|
+
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
|
900
|
+
body: JSON.stringify(createBody)
|
|
901
|
+
});
|
|
902
|
+
if (!createRes.ok) {
|
|
903
|
+
const err = await createRes.text();
|
|
904
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: `Create failed: HTTP ${createRes.status} ${err}` }, null, 2) }] };
|
|
905
|
+
}
|
|
906
|
+
const created = await createRes.json();
|
|
907
|
+
const tarball = `/tmp/riddle-sp-${created.job_id}.tar.gz`;
|
|
908
|
+
try {
|
|
909
|
+
await execFile("tar", ["czf", tarball, "-C", dir, "."], { timeout: 12e4 });
|
|
910
|
+
const tarData = await readFile(tarball);
|
|
911
|
+
const uploadRes = await fetch(created.upload_url, {
|
|
912
|
+
method: "PUT",
|
|
913
|
+
headers: { "Content-Type": "application/gzip" },
|
|
914
|
+
body: tarData
|
|
915
|
+
});
|
|
916
|
+
if (!uploadRes.ok) {
|
|
917
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: false, job_id: created.job_id, error: `Upload failed: HTTP ${uploadRes.status}` }, null, 2) }] };
|
|
918
|
+
}
|
|
919
|
+
} finally {
|
|
920
|
+
try {
|
|
921
|
+
await rm(tarball, { force: true });
|
|
922
|
+
} catch {
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
const startRes = await fetch(`${endpoint}/v1/server-preview/${created.job_id}/start`, {
|
|
926
|
+
method: "POST",
|
|
927
|
+
headers: { Authorization: `Bearer ${apiKey}` }
|
|
928
|
+
});
|
|
929
|
+
if (!startRes.ok) {
|
|
930
|
+
const err = await startRes.text();
|
|
931
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: false, job_id: created.job_id, error: `Start failed: HTTP ${startRes.status} ${err}` }, null, 2) }] };
|
|
932
|
+
}
|
|
933
|
+
const timeoutMs = ((params.timeout || 120) + 60) * 1e3;
|
|
934
|
+
const pollStart = Date.now();
|
|
935
|
+
const POLL_INTERVAL = 3e3;
|
|
936
|
+
while (Date.now() - pollStart < timeoutMs) {
|
|
937
|
+
const statusRes = await fetch(`${endpoint}/v1/server-preview/${created.job_id}`, {
|
|
938
|
+
headers: { Authorization: `Bearer ${apiKey}` }
|
|
939
|
+
});
|
|
940
|
+
if (!statusRes.ok) {
|
|
941
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: false, job_id: created.job_id, error: `Poll failed: HTTP ${statusRes.status}` }, null, 2) }] };
|
|
942
|
+
}
|
|
943
|
+
const statusData = await statusRes.json();
|
|
944
|
+
if (statusData.status === "completed" || statusData.status === "failed") {
|
|
945
|
+
const result = {
|
|
946
|
+
ok: statusData.status === "completed",
|
|
947
|
+
job_id: created.job_id,
|
|
948
|
+
status: statusData.status,
|
|
949
|
+
outputs: statusData.outputs || [],
|
|
950
|
+
compute_seconds: statusData.compute_seconds,
|
|
951
|
+
egress_bytes: statusData.egress_bytes
|
|
952
|
+
};
|
|
953
|
+
if (statusData.error) result.error = statusData.error;
|
|
954
|
+
const workspace = getWorkspacePath(api);
|
|
955
|
+
for (const output of result.outputs) {
|
|
956
|
+
if (output.name && /\.(png|jpg|jpeg)$/i.test(output.name) && output.url) {
|
|
957
|
+
try {
|
|
958
|
+
const imgRes = await fetch(output.url);
|
|
959
|
+
if (imgRes.ok) {
|
|
960
|
+
const buf = await imgRes.arrayBuffer();
|
|
961
|
+
const base64 = Buffer.from(buf).toString("base64");
|
|
962
|
+
const ref = await writeArtifactBinary(workspace, "screenshots", `${created.job_id}-${output.name}`, base64);
|
|
963
|
+
output.saved = ref.path;
|
|
964
|
+
output.sizeBytes = ref.sizeBytes;
|
|
965
|
+
}
|
|
966
|
+
} catch {
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
971
|
+
}
|
|
972
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL));
|
|
973
|
+
}
|
|
974
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: false, job_id: created.job_id, error: `Job did not complete within ${timeoutMs / 1e3}s` }, null, 2) }] };
|
|
975
|
+
}
|
|
976
|
+
},
|
|
977
|
+
{ optional: true }
|
|
978
|
+
);
|
|
732
979
|
}
|
|
733
980
|
export {
|
|
734
981
|
register as default
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
"id": "openclaw-riddledc",
|
|
3
3
|
"name": "Riddle",
|
|
4
4
|
"description": "Riddle (riddledc.com) hosted browser API tools for OpenClaw agents.",
|
|
5
|
-
"version": "0.
|
|
6
|
-
"notes": "0.
|
|
5
|
+
"version": "0.7.0",
|
|
6
|
+
"notes": "0.7.0: Added riddle_server_preview tool for running server-side apps in isolated Docker containers.",
|
|
7
7
|
"type": "plugin",
|
|
8
8
|
"bundledSkills": [],
|
|
9
9
|
"capabilities": {
|
|
10
10
|
"network": {
|
|
11
11
|
"egress": [
|
|
12
|
-
"api.riddledc.com"
|
|
12
|
+
"api.riddledc.com",
|
|
13
|
+
"preview.riddledc.com"
|
|
13
14
|
],
|
|
14
15
|
"enforced": true,
|
|
15
16
|
"note": "Hardcoded allowlist in assertAllowedBaseUrl() - cannot be overridden by config"
|
|
@@ -37,7 +38,10 @@
|
|
|
37
38
|
"riddle_scrape",
|
|
38
39
|
"riddle_map",
|
|
39
40
|
"riddle_crawl",
|
|
40
|
-
"riddle_visual_diff"
|
|
41
|
+
"riddle_visual_diff",
|
|
42
|
+
"riddle_preview",
|
|
43
|
+
"riddle_preview_delete",
|
|
44
|
+
"riddle_server_preview"
|
|
41
45
|
],
|
|
42
46
|
"invokes": [],
|
|
43
47
|
"note": "Provides tools for agent use; does not invoke other agent tools"
|