diff --git a/README.md b/README.md index cbdf3b2a..5403d674 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,8 @@ The plugin stores its own auth and local state under: - `~/.config/useorgx/openclaw-plugin/installation.json` - `~/.config/useorgx/openclaw-plugin/snapshot.json` - `~/.openclaw/orgx-outbox/` +- `~/.config/useorgx/wizard/hooks/events.jsonl` for compact, redacted Work + Graph hook events when runtime hooks are installed If you explicitly enable MCP client auto-configuration, the plugin may update supported client config files and create timestamped backups before writing: @@ -177,6 +179,9 @@ Agent turns, terminal opens, and other `openclaw` CLI child-process actions are - It does not auto-install the managed OrgX agent suite unless you enable that behavior. - It does not patch Claude, Cursor, or Codex MCP config files unless you enable that behavior. - It does not send product telemetry unless you explicitly enable it. +- It does not send raw transcripts through runtime hooks. Hook scripts write + redacted event summaries locally so the OrgX wizard can reconcile Work Graph + fingerprints, missed OrgX writeback, and safe public readouts later. --- diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 8553726f..6276fbd6 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -1,7 +1,7 @@ { "id": "orgx", "name": "OrgX for OpenClaw", - "version": "0.7.33", + "version": "0.7.34", "description": "Persistent organizational memory and coordinated execution for OpenClaw agents", "entry": "./dist/index.js", "author": "OrgX Team", diff --git a/package-lock.json b/package-lock.json index 1947e16b..f16f4bc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@useorgx/openclaw-plugin", - "version": "0.7.33", + "version": "0.7.34", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@useorgx/openclaw-plugin", - "version": "0.7.33", + "version": "0.7.34", "license": "MIT", "dependencies": { "better-sqlite3": "^11.10.0" diff --git a/package.json b/package.json index 00d72a9a..6bef6b61 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@useorgx/openclaw-plugin", - "version": "0.7.33", + "version": "0.7.34", "description": "Persistent organizational memory and coordination for OpenClaw agents", "type": "module", "main": "./dist/index.js", diff --git a/templates/hooks/scripts/post-reporting-event.mjs b/templates/hooks/scripts/post-reporting-event.mjs index 53a4fafb..4861d823 100644 --- a/templates/hooks/scripts/post-reporting-event.mjs +++ b/templates/hooks/scripts/post-reporting-event.mjs @@ -1,5 +1,8 @@ #!/usr/bin/env node +import { appendFileSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; @@ -26,6 +29,37 @@ export function pickString(...values) { return undefined; } +async function readStdin() { + const chunks = []; + for await (const chunk of process.stdin) { + chunks.push(Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString("utf8"); +} + +export function parseJsonRecord(value) { + try { + const parsed = JSON.parse(value || "{}"); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed + : {}; + } catch { + return {}; + } +} + +export function sanitizeArgs(args) { + const redacted = {}; + for (const [key, value] of Object.entries(args ?? {})) { + if (/token|api[_-]?key|authorization|cookie|secret/i.test(key)) { + redacted[key] = "[redacted]"; + } else { + redacted[key] = value; + } + } + return redacted; +} + async function postJson(url, payload, headers, fetchImpl = fetch) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 8000); @@ -49,6 +83,72 @@ async function postJson(url, payload, headers, fetchImpl = fetch) { } } +export function buildWorkGraphHookRecord({ + args, + payload, + sourceClient, + event, + cwd = process.cwd(), + timestamp = new Date().toISOString(), +}) { + const toolName = pickString( + payload.tool_name, + payload.toolName, + payload.tool?.name, + payload.name + ); + const prompt = pickString(payload.prompt, payload.user_prompt, payload.userPrompt); + + return { + schema_version: "2026-05-07", + source: "orgx_openclaw_plugin_runtime_hook", + source_client: sourceClient, + event, + session_id: pickString( + payload.session_id, + payload.sessionId, + payload.conversation_id, + payload.conversationId, + args.session_id, + args.sessionId, + args.run_id + ), + turn_id: pickString(payload.turn_id, payload.turnId, args.turn_id, args.turnId), + cwd: pickString( + payload.cwd, + payload.working_directory, + payload.workspace, + args.cwd, + cwd + ), + transcript_path: pickString(payload.transcript_path, payload.transcriptPath), + timestamp, + summary: { + tool_name: toolName, + prompt_chars: prompt ? prompt.length : undefined, + payload_keys: Object.keys(payload).slice(0, 40), + initiative_id: pickString(args.initiative, args.initiative_id), + workstream_id: pickString(args.workstream_id), + task_id: pickString(args.task_id), + run_id: pickString(args.run_id), + correlation_id: pickString(args.correlation_id), + }, + }; +} + +export function appendWorkGraphHookRecord(record, outbox) { + try { + mkdirSync(dirname(outbox), { recursive: true, mode: 0o700 }); + appendFileSync(outbox, `${JSON.stringify(record)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + return true; + } catch { + return false; + } +} + export function buildRuntimePayload({ initiativeId, runId, @@ -79,7 +179,7 @@ export function buildRuntimePayload({ message, metadata: { source: "hook_runtime_relay", - raw_args: args, + raw_args: sanitizeArgs(args), }, timestamp: new Date().toISOString(), }; @@ -106,7 +206,7 @@ export function buildActivityPayload({ metadata: { source: "hook_backstop", hook_event: event, - raw_args: args, + raw_args: sanitizeArgs(args), }, }; } @@ -140,8 +240,11 @@ export async function main({ env = process.env, fetchImpl = fetch, now = () => Date.now(), + stdinText = "", + cwd = process.cwd(), } = {}) { const args = parseArgs(argv); + const stdinPayload = parseJsonRecord(stdinText); const runtimeHookUrl = pickString( args.runtime_hook_url, @@ -182,6 +285,22 @@ export async function main({ args.message, `Hook event: ${event}` ); + const outbox = pickString( + args.outbox, + env.ORGX_WIZARD_HOOK_OUTBOX, + join(homedir(), ".config", "useorgx", "wizard", "hooks", "events.jsonl") + ); + const workGraphSpooled = appendWorkGraphHookRecord( + buildWorkGraphHookRecord({ + args, + payload: stdinPayload, + sourceClient, + event, + cwd, + timestamp: new Date(now()).toISOString(), + }), + outbox + ); let runtimePosted = false; let runtimePostFailed = false; @@ -218,6 +337,7 @@ export async function main({ return { ok: true, runtime_posted: runtimePosted, + work_graph_spooled: workGraphSpooled, skipped: "missing_api_key", ...(runtimePostFailed ? { runtime_skipped: "runtime_post_failed" } : {}), }; @@ -226,6 +346,7 @@ export async function main({ return { ok: true, runtime_posted: runtimePosted, + work_graph_spooled: workGraphSpooled, skipped: "missing_initiative_id", ...(runtimePostFailed ? { runtime_skipped: "runtime_post_failed" } : {}), }; @@ -262,6 +383,7 @@ export async function main({ return { ok: true, runtime_posted: runtimePosted, + work_graph_spooled: workGraphSpooled, skipped: "activity_post_failed", }; } @@ -272,6 +394,7 @@ export async function main({ return { ok: true, runtime_posted: runtimePosted, + work_graph_spooled: workGraphSpooled, activity_posted: true, changeset_posted: false, }; @@ -297,6 +420,7 @@ export async function main({ return { ok: true, runtime_posted: runtimePosted, + work_graph_spooled: workGraphSpooled, activity_posted: true, changeset_posted: false, skipped: "changeset_post_failed", @@ -306,13 +430,15 @@ export async function main({ return { ok: true, runtime_posted: runtimePosted, + work_graph_spooled: workGraphSpooled, activity_posted: true, changeset_posted: true, }; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - main() + readStdin() + .then((stdinText) => main({ stdinText })) .then(() => { process.exit(0); }) diff --git a/tests/hooks/post-reporting-event.test.mjs b/tests/hooks/post-reporting-event.test.mjs index 983bed10..b6f4f4fb 100644 --- a/tests/hooks/post-reporting-event.test.mjs +++ b/tests/hooks/post-reporting-event.test.mjs @@ -1,14 +1,24 @@ import assert from "node:assert/strict"; +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import test from "node:test"; import { buildActivityPayload, buildCompletionChangesetPayload, buildRuntimePayload, + buildWorkGraphHookRecord, main, parseArgs, + sanitizeArgs, } from "../../templates/hooks/scripts/post-reporting-event.mjs"; +async function createOutboxPath(prefix = "orgx-openclaw-hook-") { + const dir = await mkdtemp(join(tmpdir(), prefix)); + return join(dir, "events.jsonl"); +} + test("parseArgs parses --key=value pairs", () => { const args = parseArgs([ "--event=session_stop", @@ -86,16 +96,78 @@ test("buildRuntimePayload emits runtime relay envelope", () => { assert.equal(payload.metadata.source, "hook_runtime_relay"); }); +test("sanitizeArgs redacts token-like hook arguments", () => { + const sanitized = sanitizeArgs({ + event: "session_stop", + hook_token: "secret-token", + runtime_hook_token: "secret-token", + api_key: "oxk_secret", + }); + assert.equal(sanitized.event, "session_stop"); + assert.equal(sanitized.hook_token, "[redacted]"); + assert.equal(sanitized.runtime_hook_token, "[redacted]"); + assert.equal(sanitized.api_key, "[redacted]"); +}); + +test("buildWorkGraphHookRecord emits redacted reconciliation metadata", () => { + const payload = buildWorkGraphHookRecord({ + args: { run_id: "run-1", task_id: "task-1" }, + payload: { + session_id: "session-1", + prompt: "do the work", + secret: "do-not-copy", + }, + sourceClient: "codex", + event: "Stop", + cwd: "/repo", + timestamp: "2026-05-07T00:00:00.000Z", + }); + + assert.equal(payload.source, "orgx_openclaw_plugin_runtime_hook"); + assert.equal(payload.source_client, "codex"); + assert.equal(payload.session_id, "session-1"); + assert.equal(payload.summary.prompt_chars, 11); + assert.equal(payload.summary.task_id, "task-1"); + assert.equal(JSON.stringify(payload).includes("do the work"), false); + assert.equal(JSON.stringify(payload).includes("do-not-copy"), false); +}); + test("main returns early when API key is missing", async () => { const result = await main({ - argv: [], + argv: [`--outbox=${await createOutboxPath()}`], + env: {}, + fetchImpl: async () => { + throw new Error("fetch should not be called"); + }, + }); + + assert.equal(result.skipped, "missing_api_key"); +}); + +test("main spools Work Graph event when API key is missing", async () => { + const outbox = await createOutboxPath(); + + const result = await main({ + argv: ["--event=session_stop", "--source_client=codex", `--outbox=${outbox}`], env: {}, + stdinText: JSON.stringify({ session_id: "session-1", prompt: "hello" }), fetchImpl: async () => { throw new Error("fetch should not be called"); }, + now: () => Date.parse("2026-05-07T00:00:00.000Z"), + cwd: "/repo", }); + assert.equal(result.ok, true); + assert.equal(result.work_graph_spooled, true); assert.equal(result.skipped, "missing_api_key"); + + const lines = (await readFile(outbox, "utf8")).trim().split("\n"); + assert.equal(lines.length, 1); + const event = JSON.parse(lines[0]); + assert.equal(event.source, "orgx_openclaw_plugin_runtime_hook"); + assert.equal(event.source_client, "codex"); + assert.equal(event.summary.prompt_chars, 5); }); test("main posts runtime relay when hook token is provided", async () => { @@ -121,6 +193,7 @@ test("main posts runtime relay when hook token is provided", async () => { "--event=session_start", "--phase=intent", "--source_client=codex", + `--outbox=${await createOutboxPath()}`, ], env: { ORGX_HOOK_TOKEN: "hook_test", @@ -153,7 +226,12 @@ test("main does not synthesize correlation ids when run id is missing", async () }; await main({ - argv: ["--event=progress", "--phase=execution", "--source_client=openclaw"], + argv: [ + "--event=progress", + "--phase=execution", + "--source_client=openclaw", + `--outbox=${await createOutboxPath()}`, + ], env: { ORGX_HOOK_TOKEN: "hook_test", ORGX_RUNTIME_HOOK_URL: "http://127.0.0.1:18789/orgx/api/hooks/runtime", @@ -189,6 +267,7 @@ test("main posts activity and optional completion changeset", async () => { "--phase=completed", "--apply_completion=true", "--task_id=15f34642-4fc5-47a0-b604-f0056c1958c6", + `--outbox=${await createOutboxPath()}`, ], env: { ORGX_API_KEY: "oxk_test",