forge-openclaw-plugin 0.2.3
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/README.md +38 -0
- package/dist/openclaw/api-client.d.ts +40 -0
- package/dist/openclaw/api-client.js +273 -0
- package/dist/openclaw/index.d.ts +10 -0
- package/dist/openclaw/index.js +11 -0
- package/dist/openclaw/parity.d.ts +9 -0
- package/dist/openclaw/parity.js +39 -0
- package/dist/openclaw/plugin-entry-shared.d.ts +8 -0
- package/dist/openclaw/plugin-entry-shared.js +85 -0
- package/dist/openclaw/plugin-sdk-types.d.ts +56 -0
- package/dist/openclaw/plugin-sdk-types.js +1 -0
- package/dist/openclaw/routes.d.ts +27 -0
- package/dist/openclaw/routes.js +1152 -0
- package/dist/openclaw/tools.d.ts +3 -0
- package/dist/openclaw/tools.js +1269 -0
- package/openclaw.plugin.json +60 -0
- package/package.json +42 -0
- package/skills/forge-openclaw/SKILL.md +328 -0
package/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Forge OpenClaw Plugin
|
|
2
|
+
|
|
3
|
+
`forge-openclaw-plugin` is the publishable OpenClaw package for Forge.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Current OpenClaw builds should use package discovery:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
openclaw plugins install forge-openclaw-plugin
|
|
11
|
+
openclaw gateway restart
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Older OpenClaw builds can keep using the repo/manual install path during the transition:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
openclaw plugins install ./projects/forge
|
|
18
|
+
openclaw gateway restart
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
That repo-local path is the fallback only. The published package stays on the SDK `definePluginEntry` entrypoint and does not rename the plugin, routes, tools, or config keys.
|
|
22
|
+
|
|
23
|
+
## Recommended usage
|
|
24
|
+
|
|
25
|
+
The public mental model is intentionally small:
|
|
26
|
+
|
|
27
|
+
1. `forge_get_operator_overview`
|
|
28
|
+
2. `forge_search_entities`
|
|
29
|
+
3. `forge_create_entities` or `forge_update_entities`
|
|
30
|
+
4. `forge_delete_entities` or `forge_restore_entities` when needed
|
|
31
|
+
5. `forge_post_insight` for recommendations
|
|
32
|
+
|
|
33
|
+
The skill is entity-format-driven. It teaches the agent how to:
|
|
34
|
+
|
|
35
|
+
- keep the conversation natural
|
|
36
|
+
- make only gentle end-of-message save suggestions
|
|
37
|
+
- ask only for missing fields
|
|
38
|
+
- capture goals, projects, tasks, values, patterns, behaviors, beliefs, and trigger reports
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
export type ForgeHttpMethod = "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
|
|
3
|
+
export type ForgePluginConfig = {
|
|
4
|
+
baseUrl: string;
|
|
5
|
+
apiToken: string;
|
|
6
|
+
actorLabel: string;
|
|
7
|
+
timeoutMs: number;
|
|
8
|
+
};
|
|
9
|
+
export type CallForgeApiArgs = {
|
|
10
|
+
baseUrl: string;
|
|
11
|
+
apiToken?: string;
|
|
12
|
+
actorLabel?: string;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
method: ForgeHttpMethod;
|
|
15
|
+
path: string;
|
|
16
|
+
body?: unknown;
|
|
17
|
+
idempotencyKey?: string | null;
|
|
18
|
+
extraHeaders?: Record<string, string | null | undefined>;
|
|
19
|
+
};
|
|
20
|
+
export type ForgeProxyResponse = {
|
|
21
|
+
status: number;
|
|
22
|
+
body: unknown;
|
|
23
|
+
};
|
|
24
|
+
export declare class ForgePluginError extends Error {
|
|
25
|
+
readonly status: number;
|
|
26
|
+
readonly code: string;
|
|
27
|
+
constructor(status: number, code: string, message: string);
|
|
28
|
+
}
|
|
29
|
+
export declare function canBootstrapOperatorSession(baseUrl: string): boolean;
|
|
30
|
+
export declare function callForgeApi(args: CallForgeApiArgs): Promise<ForgeProxyResponse>;
|
|
31
|
+
export declare function readJsonRequestBody(request: IncomingMessage, options?: {
|
|
32
|
+
maxBytes?: number;
|
|
33
|
+
emptyObject?: boolean;
|
|
34
|
+
}): Promise<unknown>;
|
|
35
|
+
export declare function readSingleHeaderValue(headers: IncomingMessage["headers"], name: string): string | null;
|
|
36
|
+
export declare function requireApiToken(config: ForgePluginConfig): void;
|
|
37
|
+
export declare function writeJsonResponse(response: ServerResponse, status: number, body: unknown): void;
|
|
38
|
+
export declare function writeForgeProxyResponse(response: ServerResponse, result: ForgeProxyResponse): void;
|
|
39
|
+
export declare function writePluginError(response: ServerResponse, error: unknown): void;
|
|
40
|
+
export declare function expectForgeSuccess(result: ForgeProxyResponse): unknown;
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import packageJson from "../../package.json" with { type: "json" };
|
|
2
|
+
const DEFAULT_REQUEST_BODY_LIMIT = 256_000;
|
|
3
|
+
const DEFAULT_RESPONSE_BODY_LIMIT = 2_000_000;
|
|
4
|
+
const operatorSessionCookies = new Map();
|
|
5
|
+
export class ForgePluginError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
code;
|
|
8
|
+
constructor(status, code, message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.status = status;
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.name = "ForgePluginError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function normalizeBaseUrl(baseUrl) {
|
|
16
|
+
return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
|
17
|
+
}
|
|
18
|
+
function normalizeOrigin(baseUrl) {
|
|
19
|
+
return new URL(normalizeBaseUrl(baseUrl)).origin;
|
|
20
|
+
}
|
|
21
|
+
function isTailscaleIpv4(hostname) {
|
|
22
|
+
const match = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
|
23
|
+
if (!match) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
const [a, b] = match.slice(1).map((value) => Number(value));
|
|
27
|
+
return Number.isInteger(a) && Number.isInteger(b) && a === 100 && b >= 64 && b <= 127;
|
|
28
|
+
}
|
|
29
|
+
export function canBootstrapOperatorSession(baseUrl) {
|
|
30
|
+
const hostname = new URL(normalizeBaseUrl(baseUrl)).hostname.toLowerCase();
|
|
31
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname.endsWith(".ts.net") || isTailscaleIpv4(hostname);
|
|
32
|
+
}
|
|
33
|
+
function isRecord(value) {
|
|
34
|
+
return typeof value === "object" && value !== null;
|
|
35
|
+
}
|
|
36
|
+
function buildErrorBody(code, message) {
|
|
37
|
+
return {
|
|
38
|
+
ok: false,
|
|
39
|
+
error: {
|
|
40
|
+
code,
|
|
41
|
+
message
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
async function readReadableStreamBody(stream, maxBytes) {
|
|
46
|
+
const reader = stream.getReader();
|
|
47
|
+
const chunks = [];
|
|
48
|
+
let totalBytes = 0;
|
|
49
|
+
try {
|
|
50
|
+
while (true) {
|
|
51
|
+
const { done, value } = await reader.read();
|
|
52
|
+
if (done) {
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
if (!value) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
totalBytes += value.byteLength;
|
|
59
|
+
if (totalBytes > maxBytes) {
|
|
60
|
+
throw new ForgePluginError(502, "forge_plugin_response_too_large", `Forge response exceeded ${maxBytes} bytes`);
|
|
61
|
+
}
|
|
62
|
+
chunks.push(value);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
reader.releaseLock();
|
|
67
|
+
}
|
|
68
|
+
const merged = new Uint8Array(totalBytes);
|
|
69
|
+
let offset = 0;
|
|
70
|
+
for (const chunk of chunks) {
|
|
71
|
+
merged.set(chunk, offset);
|
|
72
|
+
offset += chunk.byteLength;
|
|
73
|
+
}
|
|
74
|
+
return new TextDecoder().decode(merged);
|
|
75
|
+
}
|
|
76
|
+
async function readResponseBody(response, maxBytes = DEFAULT_RESPONSE_BODY_LIMIT) {
|
|
77
|
+
if (!response.body) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
const text = await readReadableStreamBody(response.body, maxBytes);
|
|
81
|
+
if (!text) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(text);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
error: {
|
|
91
|
+
code: "forge_upstream_invalid_json",
|
|
92
|
+
message: text
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function buildRequestHeaders(args) {
|
|
98
|
+
const headers = {
|
|
99
|
+
accept: "application/json",
|
|
100
|
+
"x-forge-source": "openclaw",
|
|
101
|
+
"x-forge-plugin-version": packageJson.version
|
|
102
|
+
};
|
|
103
|
+
if (args.actorLabel) {
|
|
104
|
+
headers["x-forge-actor"] = args.actorLabel;
|
|
105
|
+
}
|
|
106
|
+
if (args.apiToken) {
|
|
107
|
+
headers.authorization = `Bearer ${args.apiToken}`;
|
|
108
|
+
}
|
|
109
|
+
if (args.idempotencyKey) {
|
|
110
|
+
headers["idempotency-key"] = args.idempotencyKey;
|
|
111
|
+
}
|
|
112
|
+
if (args.body !== undefined) {
|
|
113
|
+
headers["content-type"] = "application/json";
|
|
114
|
+
}
|
|
115
|
+
for (const [name, value] of Object.entries(args.extraHeaders ?? {})) {
|
|
116
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
117
|
+
headers[name] = value;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return headers;
|
|
121
|
+
}
|
|
122
|
+
async function ensureOperatorSessionCookie(baseUrl, timeoutMs) {
|
|
123
|
+
const origin = normalizeOrigin(baseUrl);
|
|
124
|
+
const cached = operatorSessionCookies.get(origin);
|
|
125
|
+
if (cached) {
|
|
126
|
+
return cached;
|
|
127
|
+
}
|
|
128
|
+
const controller = new AbortController();
|
|
129
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
130
|
+
try {
|
|
131
|
+
const response = await fetch(new URL("/api/v1/auth/operator-session", normalizeBaseUrl(baseUrl)), {
|
|
132
|
+
method: "GET",
|
|
133
|
+
headers: {
|
|
134
|
+
accept: "application/json",
|
|
135
|
+
"x-forge-source": "openclaw"
|
|
136
|
+
},
|
|
137
|
+
signal: controller.signal
|
|
138
|
+
});
|
|
139
|
+
const setCookie = response.headers.get("set-cookie");
|
|
140
|
+
if (!response.ok || !setCookie) {
|
|
141
|
+
throw new ForgePluginError(401, "forge_plugin_session_bootstrap_failed", "Forge did not issue an operator session. Add a token or use a trusted local/Tailscale Forge URL.");
|
|
142
|
+
}
|
|
143
|
+
const cookie = setCookie.split(";")[0]?.trim();
|
|
144
|
+
if (!cookie) {
|
|
145
|
+
throw new ForgePluginError(401, "forge_plugin_session_bootstrap_failed", "Forge issued an unusable operator session cookie.");
|
|
146
|
+
}
|
|
147
|
+
operatorSessionCookies.set(origin, cookie);
|
|
148
|
+
return cookie;
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
if (error instanceof ForgePluginError) {
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
const message = error instanceof Error && error.name === "AbortError"
|
|
155
|
+
? `Forge operator-session bootstrap timed out after ${timeoutMs}ms`
|
|
156
|
+
: error instanceof Error
|
|
157
|
+
? error.message
|
|
158
|
+
: String(error);
|
|
159
|
+
throw new ForgePluginError(502, "forge_plugin_session_bootstrap_failed", message);
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
clearTimeout(timeout);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
export async function callForgeApi(args) {
|
|
166
|
+
return callForgeApiInternal(args, false);
|
|
167
|
+
}
|
|
168
|
+
async function callForgeApiInternal(args, retriedWithFreshSession) {
|
|
169
|
+
const controller = new AbortController();
|
|
170
|
+
const timeoutMs = Math.max(1000, args.timeoutMs ?? 15_000);
|
|
171
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
172
|
+
const sessionCookie = !args.apiToken && canBootstrapOperatorSession(args.baseUrl)
|
|
173
|
+
? await ensureOperatorSessionCookie(args.baseUrl, timeoutMs)
|
|
174
|
+
: null;
|
|
175
|
+
try {
|
|
176
|
+
const response = await fetch(new URL(args.path, normalizeBaseUrl(args.baseUrl)), {
|
|
177
|
+
method: args.method,
|
|
178
|
+
headers: {
|
|
179
|
+
...buildRequestHeaders(args),
|
|
180
|
+
...(sessionCookie ? { cookie: sessionCookie } : {})
|
|
181
|
+
},
|
|
182
|
+
body: args.body === undefined ? undefined : JSON.stringify(args.body),
|
|
183
|
+
signal: controller.signal
|
|
184
|
+
});
|
|
185
|
+
if (!args.apiToken && sessionCookie && response.status === 401 && !retriedWithFreshSession) {
|
|
186
|
+
operatorSessionCookies.delete(normalizeOrigin(args.baseUrl));
|
|
187
|
+
return callForgeApiInternal(args, true);
|
|
188
|
+
}
|
|
189
|
+
const body = await readResponseBody(response);
|
|
190
|
+
return {
|
|
191
|
+
status: response.status,
|
|
192
|
+
body: body ?? (response.ok ? { ok: true } : buildErrorBody("forge_upstream_empty_response", `Forge API ${response.status} returned no body`))
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
if (error instanceof ForgePluginError) {
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
const message = error instanceof Error && error.name === "AbortError"
|
|
200
|
+
? `Forge API request timed out after ${timeoutMs}ms`
|
|
201
|
+
: error instanceof Error
|
|
202
|
+
? error.message
|
|
203
|
+
: String(error);
|
|
204
|
+
throw new ForgePluginError(502, "forge_plugin_upstream_unreachable", message);
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
clearTimeout(timeout);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
export async function readJsonRequestBody(request, options = {}) {
|
|
211
|
+
const maxBytes = options.maxBytes ?? DEFAULT_REQUEST_BODY_LIMIT;
|
|
212
|
+
const chunks = [];
|
|
213
|
+
let totalBytes = 0;
|
|
214
|
+
for await (const chunk of request) {
|
|
215
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
216
|
+
totalBytes += buffer.byteLength;
|
|
217
|
+
if (totalBytes > maxBytes) {
|
|
218
|
+
throw new ForgePluginError(413, "forge_plugin_body_too_large", `Request body exceeded ${maxBytes} bytes`);
|
|
219
|
+
}
|
|
220
|
+
chunks.push(buffer);
|
|
221
|
+
}
|
|
222
|
+
if (chunks.length === 0) {
|
|
223
|
+
return options.emptyObject ? {} : undefined;
|
|
224
|
+
}
|
|
225
|
+
const raw = Buffer.concat(chunks).toString("utf8").trim();
|
|
226
|
+
if (!raw) {
|
|
227
|
+
return options.emptyObject ? {} : undefined;
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
return JSON.parse(raw);
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
throw new ForgePluginError(400, "forge_plugin_invalid_json", error instanceof Error ? error.message : "Request body must be valid JSON");
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
export function readSingleHeaderValue(headers, name) {
|
|
237
|
+
const raw = headers[name.toLowerCase()];
|
|
238
|
+
if (Array.isArray(raw)) {
|
|
239
|
+
return raw[0] ?? null;
|
|
240
|
+
}
|
|
241
|
+
return typeof raw === "string" ? raw : null;
|
|
242
|
+
}
|
|
243
|
+
export function requireApiToken(config) {
|
|
244
|
+
if (config.apiToken.trim().length === 0 && !canBootstrapOperatorSession(config.baseUrl)) {
|
|
245
|
+
throw new ForgePluginError(401, "forge_plugin_token_required", "Forge apiToken is required for remote mutating plugin routes that cannot use local or Tailscale operator-session bootstrap");
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
export function writeJsonResponse(response, status, body) {
|
|
249
|
+
response.statusCode = status;
|
|
250
|
+
response.setHeader("content-type", "application/json; charset=utf-8");
|
|
251
|
+
response.end(JSON.stringify(body));
|
|
252
|
+
}
|
|
253
|
+
export function writeForgeProxyResponse(response, result) {
|
|
254
|
+
writeJsonResponse(response, result.status, result.body);
|
|
255
|
+
}
|
|
256
|
+
export function writePluginError(response, error) {
|
|
257
|
+
if (error instanceof ForgePluginError) {
|
|
258
|
+
writeJsonResponse(response, error.status, buildErrorBody(error.code, error.message));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
writeJsonResponse(response, 500, buildErrorBody("forge_plugin_internal_error", error instanceof Error ? error.message : String(error)));
|
|
262
|
+
}
|
|
263
|
+
export function expectForgeSuccess(result) {
|
|
264
|
+
if (result.status >= 400) {
|
|
265
|
+
const message = isRecord(result.body) &&
|
|
266
|
+
isRecord(result.body.error) &&
|
|
267
|
+
typeof result.body.error.message === "string"
|
|
268
|
+
? result.body.error.message
|
|
269
|
+
: `Forge API returned ${result.status}`;
|
|
270
|
+
throw new ForgePluginError(result.status, "forge_plugin_upstream_error", message);
|
|
271
|
+
}
|
|
272
|
+
return result.body;
|
|
273
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { forgePluginConfigSchema, registerForgePlugin, resolveForgePluginConfig } from "./plugin-entry-shared.js";
|
|
2
|
+
declare const pluginEntry: {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
description: string;
|
|
6
|
+
configSchema: import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginConfigSchema;
|
|
7
|
+
register: NonNullable<import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginDefinition["register"]>;
|
|
8
|
+
} & Pick<import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginDefinition, "kind">;
|
|
9
|
+
export default pluginEntry;
|
|
10
|
+
export { forgePluginConfigSchema, registerForgePlugin, resolveForgePluginConfig };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
|
2
|
+
import { FORGE_PLUGIN_DESCRIPTION, FORGE_PLUGIN_ID, FORGE_PLUGIN_NAME, forgePluginConfigSchema, registerForgePlugin, resolveForgePluginConfig } from "./plugin-entry-shared.js";
|
|
3
|
+
const pluginEntry = definePluginEntry({
|
|
4
|
+
id: FORGE_PLUGIN_ID,
|
|
5
|
+
name: FORGE_PLUGIN_NAME,
|
|
6
|
+
description: FORGE_PLUGIN_DESCRIPTION,
|
|
7
|
+
configSchema: forgePluginConfigSchema,
|
|
8
|
+
register: registerForgePlugin
|
|
9
|
+
});
|
|
10
|
+
export default pluginEntry;
|
|
11
|
+
export { forgePluginConfigSchema, registerForgePlugin, resolveForgePluginConfig };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type ApiRouteKey = `${Uppercase<string>} ${string}`;
|
|
2
|
+
export type ForgePluginRouteExclusion = {
|
|
3
|
+
method: Uppercase<string>;
|
|
4
|
+
path: string;
|
|
5
|
+
reason: "browser-session-telemetry" | "legacy-alias" | "operator-token-bootstrap" | "sse-forwarding";
|
|
6
|
+
};
|
|
7
|
+
export declare const FORGE_PLUGIN_ROUTE_EXCLUSIONS: ForgePluginRouteExclusion[];
|
|
8
|
+
export declare function makeApiRouteKey(method: string, path: string): ApiRouteKey;
|
|
9
|
+
export declare function collectExcludedApiRouteKeys(): Set<`${Uppercase<string>} ${string}`>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export const FORGE_PLUGIN_ROUTE_EXCLUSIONS = [
|
|
2
|
+
{
|
|
3
|
+
method: "GET",
|
|
4
|
+
path: "/api/v1/campaigns",
|
|
5
|
+
reason: "legacy-alias"
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
method: "POST",
|
|
9
|
+
path: "/api/v1/settings/tokens",
|
|
10
|
+
reason: "operator-token-bootstrap"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
method: "POST",
|
|
14
|
+
path: "/api/v1/settings/tokens/:id/rotate",
|
|
15
|
+
reason: "operator-token-bootstrap"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
method: "POST",
|
|
19
|
+
path: "/api/v1/settings/tokens/:id/revoke",
|
|
20
|
+
reason: "operator-token-bootstrap"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
method: "POST",
|
|
24
|
+
path: "/api/v1/session-events",
|
|
25
|
+
reason: "browser-session-telemetry"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
method: "GET",
|
|
29
|
+
path: "/api/v1/events/stream",
|
|
30
|
+
reason: "sse-forwarding"
|
|
31
|
+
}
|
|
32
|
+
];
|
|
33
|
+
export function makeApiRouteKey(method, path) {
|
|
34
|
+
const normalizedPath = path.replaceAll(/\{([^}]+)\}/g, ":$1");
|
|
35
|
+
return `${method.toUpperCase()} ${normalizedPath}`;
|
|
36
|
+
}
|
|
37
|
+
export function collectExcludedApiRouteKeys() {
|
|
38
|
+
return new Set(FORGE_PLUGIN_ROUTE_EXCLUSIONS.map((route) => makeApiRouteKey(route.method, route.path)));
|
|
39
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ForgePluginConfig } from "./api-client.js";
|
|
2
|
+
import type { ForgePluginConfigSchema, ForgePluginRegistrationApi } from "./plugin-sdk-types.js";
|
|
3
|
+
export declare const FORGE_PLUGIN_ID = "forge";
|
|
4
|
+
export declare const FORGE_PLUGIN_NAME = "Forge";
|
|
5
|
+
export declare const FORGE_PLUGIN_DESCRIPTION = "Thin OpenClaw adapter for the live Forge /api/v1 collaboration API.";
|
|
6
|
+
export declare function resolveForgePluginConfig(pluginConfig: unknown): ForgePluginConfig;
|
|
7
|
+
export declare const forgePluginConfigSchema: ForgePluginConfigSchema;
|
|
8
|
+
export declare function registerForgePlugin(api: ForgePluginRegistrationApi): void;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { registerForgePluginCli, registerForgePluginRoutes } from "./routes.js";
|
|
2
|
+
import { registerForgePluginTools } from "./tools.js";
|
|
3
|
+
export const FORGE_PLUGIN_ID = "forge";
|
|
4
|
+
export const FORGE_PLUGIN_NAME = "Forge";
|
|
5
|
+
export const FORGE_PLUGIN_DESCRIPTION = "Thin OpenClaw adapter for the live Forge /api/v1 collaboration API.";
|
|
6
|
+
function normalizeString(value, fallback) {
|
|
7
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback;
|
|
8
|
+
}
|
|
9
|
+
function normalizeTimeout(value, fallback) {
|
|
10
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
11
|
+
return fallback;
|
|
12
|
+
}
|
|
13
|
+
return Math.min(120_000, Math.max(1000, Math.round(value)));
|
|
14
|
+
}
|
|
15
|
+
export function resolveForgePluginConfig(pluginConfig) {
|
|
16
|
+
const raw = (pluginConfig ?? {});
|
|
17
|
+
return {
|
|
18
|
+
baseUrl: normalizeString(raw.baseUrl, "http://127.0.0.1:3017"),
|
|
19
|
+
apiToken: typeof raw.apiToken === "string" ? raw.apiToken.trim() : "",
|
|
20
|
+
actorLabel: normalizeString(raw.actorLabel, "aurel"),
|
|
21
|
+
timeoutMs: normalizeTimeout(raw.timeoutMs, 15_000)
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export const forgePluginConfigSchema = {
|
|
25
|
+
parse(value) {
|
|
26
|
+
return resolveForgePluginConfig(value);
|
|
27
|
+
},
|
|
28
|
+
jsonSchema: {
|
|
29
|
+
type: "object",
|
|
30
|
+
additionalProperties: false,
|
|
31
|
+
properties: {
|
|
32
|
+
baseUrl: {
|
|
33
|
+
type: "string",
|
|
34
|
+
default: "http://127.0.0.1:3017",
|
|
35
|
+
description: "Base URL of the live Forge API bridge."
|
|
36
|
+
},
|
|
37
|
+
apiToken: {
|
|
38
|
+
type: "string",
|
|
39
|
+
default: "",
|
|
40
|
+
description: "Optional bearer token for remote or explicit scoped access. Localhost and Tailscale Forge instances can bootstrap an operator session automatically."
|
|
41
|
+
},
|
|
42
|
+
actorLabel: {
|
|
43
|
+
type: "string",
|
|
44
|
+
default: "aurel",
|
|
45
|
+
description: "Actor label recorded in Forge provenance headers."
|
|
46
|
+
},
|
|
47
|
+
timeoutMs: {
|
|
48
|
+
type: "integer",
|
|
49
|
+
default: 15000,
|
|
50
|
+
minimum: 1000,
|
|
51
|
+
maximum: 120000,
|
|
52
|
+
description: "Timeout for proxy calls from the OpenClaw plugin to Forge."
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
uiHints: {
|
|
57
|
+
baseUrl: {
|
|
58
|
+
label: "Forge Base URL",
|
|
59
|
+
help: "Base URL of the live Forge API bridge.",
|
|
60
|
+
placeholder: "http://127.0.0.1:3017"
|
|
61
|
+
},
|
|
62
|
+
apiToken: {
|
|
63
|
+
label: "Forge API Token",
|
|
64
|
+
help: "Optional bearer token. Leave blank for one-step localhost or Tailscale operator-session bootstrap.",
|
|
65
|
+
sensitive: true,
|
|
66
|
+
placeholder: "fg_live_..."
|
|
67
|
+
},
|
|
68
|
+
actorLabel: {
|
|
69
|
+
label: "Actor Label",
|
|
70
|
+
help: "Recorded in Forge provenance headers for plugin-originated writes.",
|
|
71
|
+
placeholder: "aurel"
|
|
72
|
+
},
|
|
73
|
+
timeoutMs: {
|
|
74
|
+
label: "Request Timeout (ms)",
|
|
75
|
+
help: "Maximum time to wait before the plugin aborts an upstream Forge request.",
|
|
76
|
+
advanced: true
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
export function registerForgePlugin(api) {
|
|
81
|
+
const config = resolveForgePluginConfig(api.pluginConfig);
|
|
82
|
+
registerForgePluginRoutes(api, config);
|
|
83
|
+
registerForgePluginCli(api, config);
|
|
84
|
+
registerForgePluginTools(api, config);
|
|
85
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { TSchema } from "@sinclair/typebox";
|
|
3
|
+
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
|
4
|
+
export type ForgePluginConfigSchema = {
|
|
5
|
+
parse(value: unknown): unknown;
|
|
6
|
+
jsonSchema: Record<string, unknown>;
|
|
7
|
+
uiHints?: Record<string, ForgePluginConfigUiHint>;
|
|
8
|
+
};
|
|
9
|
+
export type ForgePluginConfigUiHint = {
|
|
10
|
+
label?: string;
|
|
11
|
+
help?: string;
|
|
12
|
+
placeholder?: string;
|
|
13
|
+
sensitive?: boolean;
|
|
14
|
+
advanced?: boolean;
|
|
15
|
+
};
|
|
16
|
+
export type ForgeRegisteredHttpRoute = {
|
|
17
|
+
path: string;
|
|
18
|
+
auth: "plugin" | "gateway";
|
|
19
|
+
match?: "exact" | "prefix";
|
|
20
|
+
handler: (request: IncomingMessage, response: ServerResponse) => Promise<boolean | void> | boolean | void;
|
|
21
|
+
};
|
|
22
|
+
export type ForgeRegisteredTool = {
|
|
23
|
+
name: string;
|
|
24
|
+
label: string;
|
|
25
|
+
description: string;
|
|
26
|
+
parameters: TSchema;
|
|
27
|
+
execute: (toolCallId: string, params: unknown) => Promise<AgentToolResult<unknown>>;
|
|
28
|
+
};
|
|
29
|
+
export type ForgeCliProgram = {
|
|
30
|
+
command(name: string): ForgeCliProgram;
|
|
31
|
+
description(text: string): ForgeCliProgram;
|
|
32
|
+
action(handler: () => Promise<void> | void): ForgeCliProgram;
|
|
33
|
+
};
|
|
34
|
+
export type ForgeCliRegistrarContext = {
|
|
35
|
+
program: ForgeCliProgram;
|
|
36
|
+
config: unknown;
|
|
37
|
+
logger: {
|
|
38
|
+
info?(message: string): void;
|
|
39
|
+
warn?(message: string): void;
|
|
40
|
+
error?(message: string): void;
|
|
41
|
+
debug?(message: string): void;
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
export type ForgePluginRegistrationApi = {
|
|
45
|
+
pluginConfig?: unknown;
|
|
46
|
+
registerHttpRoute(route: ForgeRegisteredHttpRoute): void;
|
|
47
|
+
registerTool(tool: ForgeRegisteredTool, options?: {
|
|
48
|
+
optional?: boolean;
|
|
49
|
+
}): void;
|
|
50
|
+
registerCli?(registrar: (context: ForgeCliRegistrarContext) => void, options?: {
|
|
51
|
+
commands?: string[];
|
|
52
|
+
}): void;
|
|
53
|
+
};
|
|
54
|
+
export type ForgePluginRouteApi = Pick<ForgePluginRegistrationApi, "registerHttpRoute">;
|
|
55
|
+
export type ForgePluginToolApi = Pick<ForgePluginRegistrationApi, "registerTool">;
|
|
56
|
+
export type ForgePluginCliApi = Pick<ForgePluginRegistrationApi, "registerCli">;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type ForgeHttpMethod, type ForgePluginConfig } from "./api-client.js";
|
|
2
|
+
import { type ApiRouteKey } from "./parity.js";
|
|
3
|
+
import type { ForgePluginCliApi, ForgePluginRouteApi, ForgeRegisteredHttpRoute } from "./plugin-sdk-types.js";
|
|
4
|
+
type PluginRouteMatch = NonNullable<ForgeRegisteredHttpRoute["match"]>;
|
|
5
|
+
type RouteOperation = {
|
|
6
|
+
method: ForgeHttpMethod;
|
|
7
|
+
pattern: RegExp;
|
|
8
|
+
upstreamPath: string;
|
|
9
|
+
target: (match: RegExpMatchArray, url: URL) => string;
|
|
10
|
+
requiresToken?: boolean;
|
|
11
|
+
requestBody?: "json";
|
|
12
|
+
};
|
|
13
|
+
type RouteGroup = {
|
|
14
|
+
path: string;
|
|
15
|
+
match: PluginRouteMatch;
|
|
16
|
+
operations: RouteOperation[];
|
|
17
|
+
};
|
|
18
|
+
export declare const FORGE_PLUGIN_ROUTE_GROUPS: RouteGroup[];
|
|
19
|
+
export declare function collectMirroredApiRouteKeys(): Set<ApiRouteKey>;
|
|
20
|
+
export declare function buildRouteParityReport(pathMap: Record<string, Record<string, unknown>>): {
|
|
21
|
+
mirrored: `${Uppercase<string>} ${string}`[];
|
|
22
|
+
excluded: `${Uppercase<string>} ${string}`[];
|
|
23
|
+
uncovered: `${Uppercase<string>} ${string}`[];
|
|
24
|
+
};
|
|
25
|
+
export declare function registerForgePluginRoutes(api: ForgePluginRouteApi, config: ForgePluginConfig): void;
|
|
26
|
+
export declare function registerForgePluginCli(api: ForgePluginCliApi, config: ForgePluginConfig): void;
|
|
27
|
+
export {};
|