diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..e873e66 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "tencentdb-agent-memory-local", + "interface": { + "displayName": "TencentDB Agent Memory Local" + }, + "plugins": [ + { + "name": "memory-tencentdb-codex", + "source": { + "source": "local", + "path": "./codex-plugin" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f2d346..268b7d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ --- +## [Unreleased] + +### ✨ 新功能 + +- **Codex adapter**:新增独立 `codex-plugin/` 适配层,覆盖 Codex CLI 与 Codex App 的生命周期 hook、MCP 检索工具、历史 JSONL 导入、工具输出 offload 与本地 Gateway 自动启动,并包含 Codex App 的额外适配与验证。 + +### 🔒 安全增强 + +- Gateway 默认要求 tokenized POST;无 token 时仅保留 loopback GET 探活,loopback tokenless POST 必须显式启用开发开关。 +- Codex adapter 默认拒绝非 loopback Gateway URL,MCP 默认不暴露跨项目检索或完整 offload 内容。 +- Gateway token 文件改为私有权限、owner 校验、atomic create;并发 autostart 不再可能生成互相覆盖的 token。 +- Codex hook 诊断写入私有 `hook.log`,日志内容先经过敏感字段 redaction。 + +### 🐛 修复 + +- scoped memory/conversation search 会扩展候选窗口到 store 记录总数,避免当前项目结果被其他项目的前 500 个候选挤掉。 +- `prepack` 不再因为已缺失的历史可选 bin-script 源目录而失败;存在对应 `tsconfig.json` 时仍会构建这些脚本。 + ## [0.3.4] - 2026-05-12 ### 🐛 修复 diff --git a/codex-plugin/.codex-plugin/plugin.json b/codex-plugin/.codex-plugin/plugin.json new file mode 100644 index 0000000..799a3a6 --- /dev/null +++ b/codex-plugin/.codex-plugin/plugin.json @@ -0,0 +1,30 @@ +{ + "name": "memory-tencentdb-codex", + "version": "0.1.0", + "description": "Codex adapter for TencentDB Agent Memory: auto-recall, auto-capture, hook context injection, MCP search/offload tools, compaction flush, and seed through the TDAI Gateway.", + "author": { + "name": "TencentDB Agent Memory Codex adapter" + }, + "license": "MIT", + "homepage": "https://github.com/Tencent/TencentDB-Agent-Memory", + "repository": "https://github.com/Tencent/TencentDB-Agent-Memory", + "interface": { + "displayName": "TencentDB Agent Memory", + "shortDescription": "Automatic recall, capture, flush, offload, and MCP search for Codex sessions through TencentDB Agent Memory.", + "longDescription": "Adds Codex hooks that recall relevant memory before each prompt, inject model-visible memory hints, capture completed turns, track tool and permission activity, offload large tool results into JSONL/ref/Mermaid artifacts, flush session memory after compaction or every N turns, expose memory and offload lookup as MCP tools, seed historical conversations, and manage a local TencentDB Agent Memory Gateway. The adapter supports Codex CLI and Codex App, with additional Codex App adaptation and validation.", + "developerName": "TencentDB Agent Memory Codex adapter", + "category": "Coding", + "capabilities": [ + "Interactive", + "Read", + "Write" + ], + "defaultPrompt": [ + "Use TencentDB Agent Memory to recall prior context before answering and capture important session details after the turn." + ], + "brandColor": "#2563EB" + }, + "skills": "./skills/", + "mcpServers": "./.mcp.json", + "hooks": "./hooks/hooks.codex.json" +} diff --git a/codex-plugin/.mcp.json b/codex-plugin/.mcp.json new file mode 100644 index 0000000..963f09a --- /dev/null +++ b/codex-plugin/.mcp.json @@ -0,0 +1,15 @@ +{ + "mcpServers": { + "tdai-memory": { + "command": "node", + "args": [ + "${PLUGIN_ROOT}/scripts/mcp-server.mjs" + ], + "env": { + "TDAI_CODEX_AUTOSTART": "true" + }, + "startup_timeout_sec": 20, + "tool_timeout_sec": 60 + } + } +} diff --git a/codex-plugin/README.md b/codex-plugin/README.md new file mode 100644 index 0000000..1b9bdcd --- /dev/null +++ b/codex-plugin/README.md @@ -0,0 +1,354 @@ +# TencentDB Agent Memory Codex Adapter + +Codex adapter for the **memory-tencentdb** four-layer memory system +(L0 conversation capture -> L1 episodic extraction -> L2 scene blocks -> L3 +persona synthesis). + +The heavy lifting runs in the Node.js **Gateway** sidecar used by the other +TencentDB Agent Memory integrations. This adapter is a thin Codex plugin layer: +it translates Codex hooks and MCP tool calls into the Gateway API, keeps +Codex-specific state under the adapter data directory, and leaves the OpenClaw +and Hermes paths unchanged. + +The adapter targets Codex as a host, including Codex CLI and Codex App. +It also includes extra Codex App adaptation and validation for App session +history, archived JSONL import, plugin-cache loading, and App-observed hook +behavior. + +## Architecture + +```text +Codex (CLI and App) + +-- Hooks + | +-- SessionStart -> scripts/session-start.mjs + | +-- UserPromptSubmit -> scripts/user-prompt-submit.mjs + | +-- PreToolUse -> scripts/pre-tool-use.mjs + | +-- PermissionRequest -> scripts/permission-request.mjs + | +-- PostToolUse -> scripts/post-tool-use.mjs + | +-- PreCompact -> scripts/pre-compact.mjs + | +-- PostCompact -> scripts/post-compact.mjs + | +-- Stop -> scripts/stop.mjs + +-- MCP + +-- scripts/mcp-server.mjs + +-- tdai_memory_search + +-- tdai_conversation_search + +-- tdai_offload_lookup + | + v HTTP (127.0.0.1:8420 by default) + memory-tencentdb Gateway + +-- POST /recall + +-- POST /capture + +-- POST /search/memories + +-- POST /search/conversations + +-- POST /session/end + +-- POST /seed +``` + +The Codex-specific integration lives in this directory. The shared changes +outside `codex-plugin/` are limited to host-neutral Gateway and seed support +used by sidecar clients: a lightweight root metadata endpoint, optional +`started_at` capture metadata, and opt-in full-pipeline waiting for `/seed`. + +## Lifecycle Mapping + +| Codex surface | Gateway or local path | Behavior | +| --- | --- | --- | +| `SessionStart` | `/recall`, `/search/memories`, selective `/search/conversations` | Restores project/session context and returns Codex `additionalContext` when useful context exists. | +| `UserPromptSubmit` | Local turn state, `/recall`, `/search/memories`, selective `/search/conversations`, local L0 JSONL fallback | Starts a pending turn, recalls relevant memory, and injects bounded context; if Gateway recall/search has no useful context, scans project-scoped local L0 JSONL as a last resort. | +| `PreToolUse` | Local turn state | Records tool intent and returns a compact memory/offload hint. | +| `PermissionRequest` | Local turn state | Records permission activity for the current turn. | +| `PostToolUse` | Local turn state, context-offload files | Records tool results and can replace large tool output with compact hook feedback plus a lookup reference. | +| `PreCompact` | `/capture` | Captures pending turn state before compaction. | +| `PostCompact` | `/session/end` | Flushes pending Gateway pipeline work after compaction. | +| `Stop` | `/capture`, periodic `/session/end` | Captures the completed Codex turn and flushes every `TDAI_CODEX_FLUSH_EVERY_N_TURNS` captured turns. | +| MCP `tdai_memory_search` | `/search/memories` | Searches L1 structured memory. | +| MCP `tdai_conversation_search` | `/search/conversations` | Searches L0 raw conversation history. | +| MCP `tdai_offload_lookup` | Local context-offload index | Retrieves exact redacted tool results by `node_id`, `tool_call_id`, or query. | + +## Reliability Features + +- **Gateway supervision** - the adapter can auto-discover and start the Gateway + from a local TencentDB Agent Memory checkout, then poll `/health` before use. +- **Circuit breaker** - repeated Gateway failures pause calls for a short + cooldown instead of slowing every hook invocation. +- **Bounded prompt injection** - empty Gateway search responses are not injected, + recall output is capped by `TDAI_CODEX_CONTEXT_MAX_CHARS`, and tool hints are + intentionally compact. +- **Injected-context cleanup** - adapter-controlled capture, import, transcript, + and Gateway L0/L1 write paths strip TencentDB/Codex injected blocks before + persistence to avoid recall feedback loops. +- **Local L0 fallback** - when Gateway recall/search is unavailable or empty, + automatic prompt recall can stream recent local L0 JSONL and filter by the + current Codex project session-key prefix. +- **Short-term offload lookup** - large `PostToolUse` output can be stored under + local JSONL/ref/Mermaid artifacts and retrieved later even if the Gateway is + temporarily unavailable. + +## Installation Location + +This directory (`codex-plugin/`) is the source of truth for the Codex adapter. +Codex loads it as a local plugin or from a local marketplace/cache copy. + +The plugin manifest is: + +```text +codex-plugin/.codex-plugin/plugin.json +``` + +It declares the Codex skill, bundled hook config, and bundled MCP server config. +The adapter also ships a machine-readable reuse contract: + +```text +codex-plugin/adapter-profile.json +codex-plugin/hooks/hooks.codex.json +codex-plugin/.mcp.json +``` + +Codex can load these hooks as plugin-bundled hooks when `plugin_hooks` is +enabled, or as user-level hooks from `~/.codex/hooks.json`. When mirroring the +hook file into a user-level config, replace the plugin-root variable with the +installed adapter path because user-level hooks do not receive plugin-specific +environment variables. The bundled MCP config exposes memory search and offload +lookup tools; the manual `codex mcp add` command below is a fallback for local +development or older Codex builds. + +## Reuse Contract + +The adapter is intended to be installable from a copied plugin directory, a +Codex plugin cache, a package release, or a forked source checkout without +editing script files. The stable contract is: + +- `adapter-profile.json` describes the adapter ID, host, entrypoints, + runtime requirements, environment variables, and extension points. +- Hook and MCP configs refer to the plugin root through Codex-provided root + variables instead of machine-specific absolute paths. +- Per-user state lives under `TDAI_CODEX_DATA_DIR` or the default + `~/.memory-tencentdb/codex-memory-tdai`; copied adapters do not share state + unless that directory is explicitly shared. +- Gateway autostart uses the package binary by default, so a copied adapter can + run without importing dependencies from the plugin directory. +- Source-tree development is still supported by setting `TDAI_CODEX_TDAI_ROOT` + to a local checkout. +- Fork, release, and tarball validation can override + `TDAI_CODEX_GATEWAY_PACKAGE` without changing the adapter scripts. + +Run the doctor before publishing, copying, or handing the adapter to another +Codex environment: + +```bash +node codex-plugin/scripts/doctor.mjs +node codex-plugin/scripts/doctor.mjs --start --require-healthy --strict +node codex-plugin/scripts/query.mjs doctor --json +``` + +The doctor checks that manifest entrypoints exist, hook/MCP configs are +portable, adapter state is writable with private adapter-owned subdirectories, +the Gateway URL is loopback unless explicitly allowed, and the Gateway can be +launched from either a source checkout or package binary. + +## Setup + +From the TencentDB-Agent-Memory repository root: + +```bash +npm install +``` + +Optional Codex adapter environment: + +```bash +export TDAI_CODEX_TDAI_ROOT="/path/to/TencentDB-Agent-Memory" +export TDAI_CODEX_DATA_DIR="$HOME/.memory-tencentdb/codex-memory-tdai" +export TDAI_CODEX_GATEWAY_URL="http://127.0.0.1:8420" +export TDAI_CODEX_AUTOSTART=true +export TDAI_CODEX_FLUSH_EVERY_N_TURNS=5 +# Tool-output offload is enabled by default; uncomment to disable it. +# export TDAI_CODEX_TOOL_OFFLOAD=false +``` + +When the adapter autostarts the Gateway it keeps the service on loopback by +default, creates a private bearer token under +`$TDAI_CODEX_DATA_DIR/codex-adapter/gateway-token`, and sends that token on +Gateway requests. The token is passed to the daemon through `TDAI_TOKEN_PATH` +instead of a generated token environment variable. Set `TDAI_CODEX_GATEWAY_TOKEN` +or `TDAI_TOKEN_PATH` if you want to manage the token yourself. Autostart refuses +non-loopback hosts unless `TDAI_CODEX_ALLOW_NON_LOOPBACK=true` is set explicitly. + +By default autostart uses the package bin +(`npx --yes --ignore-scripts --package @tencentdb-agent-memory/memory-tencentdb tdai-memory-gateway`), +so the copied Codex plugin does not need to import package dependencies from the +plugin directory and daemon launch does not run npm lifecycle scripts. For +source-tree development, set `TDAI_CODEX_TDAI_ROOT` to use +`npx tsx src/gateway/server.ts` from a local checkout, or set +`TDAI_CODEX_GATEWAY_PACKAGE` to override the package spec, including a pinned +version or tarball during release validation. Package-bin launch does not +hydrate additional shell-only LLM secrets unless +`TDAI_CODEX_HYDRATE_ENV_FOR_PACKAGE_GATEWAY=true` is set explicitly. + +The Gateway also rejects non-loopback browser origins by default and blocks +credential-bearing `/seed config_override` keys, so imported Codex history cannot +redirect configured LLM, embedding, TCVDB, or backend credentials to a different +network endpoint. + +When no Gateway token is configured, unauthenticated loopback access is limited +to `GET` routes such as `/health`. Tokenless `POST` routes require the explicit +loopback-only development flag `TDAI_GATEWAY_AUTH_DISABLED=true`; non-loopback +tokenless access is always rejected. + +Adapter requests also refuse non-loopback `TDAI_CODEX_GATEWAY_URL` values unless +`TDAI_CODEX_ALLOW_NON_LOOPBACK=true` is set explicitly. This prevents hooks from +sending local bearer tokens or captured memory to an unexpected remote URL. + +For L1/L2/L3 extraction, configure an OpenAI-compatible LLM for the Gateway: + +```bash +export TDAI_LLM_BASE_URL="https://api.openai.com/v1" +export TDAI_LLM_API_KEY="..." +export TDAI_LLM_MODEL="gpt-4o-mini" +``` + +The example Gateway config is `tdai-gateway.example.json`. Copy it to: + +```bash +$TDAI_CODEX_DATA_DIR/tdai-gateway.json +``` + +or use environment variables only. During autostart the adapter sets +`TDAI_GATEWAY_CONFIG=$TDAI_CODEX_DATA_DIR/tdai-gateway.json`, because the Gateway +normally discovers config files from the current working directory or its +default data directory unless this variable is explicit. + +## Register MCP Tools + +The plugin bundles `codex-plugin/.mcp.json`, so normal Codex plugin installation +can register the MCP server from the plugin manifest. For local development, +or if a Codex build does not load plugin-bundled MCP config, register it +manually: + +```bash +codex mcp add tdai-memory \ + --env TDAI_CODEX_TDAI_ROOT="/path/to/TencentDB-Agent-Memory" \ + --env TDAI_CODEX_DATA_DIR="$HOME/.memory-tencentdb/codex-memory-tdai" \ + --env TDAI_CODEX_GATEWAY_URL="http://127.0.0.1:8420" \ + --env TDAI_CODEX_AUTOSTART="true" \ + -- node "/path/to/TencentDB-Agent-Memory/codex-plugin/scripts/mcp-server.mjs" +``` + +MCP search tools are scoped to the current Codex project path by default. Pass +`all_projects: true` only when you intentionally want cross-project memory or +offload lookup. + +For model-facing MCP safety, cross-project search and exact offload content are +not exposed by default. To opt in from outside the model context, set: + +```bash +export TDAI_CODEX_MCP_ALLOW_ALL_PROJECTS=true +export TDAI_CODEX_MCP_ALLOW_OFFLOAD_CONTENT=true +``` + +## Diagnostics + +```bash +node codex-plugin/scripts/gateway.mjs status +node codex-plugin/scripts/gateway.mjs start +node codex-plugin/scripts/query.mjs status +node codex-plugin/scripts/query.mjs memory "previous decision" +node codex-plugin/scripts/query.mjs conversation "continue where we left off" +node codex-plugin/scripts/query.mjs remember "This project uses X as the source of truth." +node codex-plugin/scripts/query.mjs flush +node codex-plugin/scripts/query.mjs seed ./historical-conversations.json +node codex-plugin/scripts/query.mjs import-codex-history --dry-run --since 30d +node codex-plugin/scripts/query.mjs import-codex-history --yes --since 30d --cwd "/path/to/project" +node codex-plugin/scripts/query.mjs offload list --all --limit 10 +node codex-plugin/scripts/query.mjs offload node Cxxxxxx_N001 --content +node codex-plugin/scripts/query.mjs offload canvas +node codex-plugin/scripts/mcp-server.mjs +``` + +Logs: + +```text +$TDAI_CODEX_DATA_DIR/codex-adapter/logs/gateway.stdout.log +$TDAI_CODEX_DATA_DIR/codex-adapter/logs/gateway.stderr.log +$TDAI_CODEX_DATA_DIR/codex-adapter/logs/hook.log +``` + +## Import Existing Codex History + +The Gateway supports historical seeding through `POST /seed`. The Codex adapter +adds a host-specific importer that converts local Codex JSONL rollouts into +that seed format. + +By default it reads: + +```text +~/.codex/sessions/**/*.jsonl +~/.codex/archived_sessions/**/*.jsonl +``` + +The importer is opt-in and runs as a dry run unless `--yes` is provided: + +```bash +node codex-plugin/scripts/import-codex-history.mjs --dry-run --since 30d +node codex-plugin/scripts/import-codex-history.mjs --yes --since 30d --cwd "/path/to/project" +``` + +It skips Codex-generated context scaffolding such as `AGENTS.md` injections and +imports only paired user/assistant rounds. Use `--no-archived` to exclude +archived sessions, `--limit` for a small trial import, and `--out` to inspect +the generated `/seed` payload before writing. + +By default, a real import requests `wait_for_full_pipeline`, so Gateway `/seed` +records L0, waits for L1, flushes L2 scene extraction, and waits for L3 persona +generation before returning. Use `--no-full-pipeline` when the faster L0/L1-only +seed behavior is preferred. For large trusted local imports, `--l1-concurrency` +or `TDAI_CODEX_IMPORT_L1_CONCURRENCY` can raise bounded L1 extraction +parallelism without changing the live host default. The importer also sends +`l2_batch_size` by default, which lets Gateway coalesce many short historical +Codex sessions into larger L2 scene-extraction batches while keeping live +runtime L2 scheduling unchanged. + +## Short-Term Context Offload + +Codex does not expose OpenClaw's `slots.contextEngine`, so the adapter uses the +official Codex hook surface as the equivalent control point: + +1. `PostToolUse` evaluates tool-result size against mild, aggressive, and + emergency thresholds. +2. When offload is triggered, the full redacted result is written under + `$TDAI_CODEX_DATA_DIR/codex-adapter/context-offload//refs/`. +3. A structured `offload-.jsonl` entry is appended with `node_id`, + `tool_call_id`, summary, score, policy, and `result_ref`. +4. The deterministic L2 canvas at `mmds/001-codex-tool-offload.mmd` is rebuilt + and injected on later `SessionStart` / `UserPromptSubmit` hooks. +5. The model can drill down by calling `tdai_offload_lookup`; humans can use + `query.mjs offload node ... --content`. + +Thresholds are configurable: + +```bash +export TDAI_CODEX_TOOL_OFFLOAD_MIN_CHARS=20000 +export TDAI_CODEX_TOOL_OFFLOAD_AGGRESSIVE_MIN_CHARS=80000 +export TDAI_CODEX_TOOL_OFFLOAD_EMERGENCY_MIN_CHARS=250000 +export TDAI_CODEX_TOOL_OFFLOAD_PREVIEW_CHARS=2000 +export TDAI_CODEX_TOOL_OFFLOAD_AGGRESSIVE_PREVIEW_CHARS=800 +export TDAI_CODEX_TOOL_OFFLOAD_EMERGENCY_PREVIEW_CHARS=240 +``` + +## Codex Host Notes + +OpenClaw- or Claude Code-only interfaces such as host-specific slot APIs are not +applicable to Codex; this adapter uses Codex hook, MCP, JSONL history, and +context-injection surfaces instead. Codex can gate plugin-scoped hooks or omit +optional transcript fields in some builds; the adapter provides Codex-native +fallbacks through user-level hooks, local session state, tool-event summaries, +and history import. + +## Security Notes + +- Adapter-owned session state, gateway tokens, and offloaded tool-result files + are written with private owner-only permissions on POSIX filesystems. +- Tokenized Gateways require `Authorization: Bearer ...` for all routes. A + tokenless Gateway exposes only loopback `GET` probes by default; loopback + tokenless `POST` routes require explicit development opt-in, and non-loopback + tokenless access is rejected. diff --git a/codex-plugin/adapter-profile.json b/codex-plugin/adapter-profile.json new file mode 100644 index 0000000..172b673 --- /dev/null +++ b/codex-plugin/adapter-profile.json @@ -0,0 +1,69 @@ +{ + "schemaVersion": 1, + "adapterId": "memory-tencentdb-codex", + "displayName": "TencentDB Agent Memory Codex Adapter", + "host": "codex", + "packageName": "@tencentdb-agent-memory/memory-tencentdb", + "entrypoints": { + "pluginManifest": ".codex-plugin/plugin.json", + "hooks": "hooks/hooks.codex.json", + "mcp": ".mcp.json", + "skill": "skills/tdai-memory/SKILL.md", + "doctor": "scripts/doctor.mjs", + "gateway": "scripts/gateway.mjs", + "query": "scripts/query.mjs" + }, + "requiredRuntime": { + "node": ">=22.16.0", + "gatewayDefaultUrl": "http://127.0.0.1:8420" + }, + "installContract": { + "rootEnv": [ + "PLUGIN_ROOT", + "CLAUDE_PLUGIN_ROOT" + ], + "dataDirEnv": [ + "TDAI_CODEX_DATA_DIR", + "TDAI_DATA_DIR" + ], + "gatewayRootEnv": [ + "TDAI_CODEX_TDAI_ROOT", + "TDAI_INSTALL_DIR" + ], + "gatewayPackageEnv": "TDAI_CODEX_GATEWAY_PACKAGE", + "gatewayUrlEnv": "TDAI_CODEX_GATEWAY_URL", + "tokenEnv": [ + "TDAI_CODEX_GATEWAY_TOKEN", + "TDAI_GATEWAY_TOKEN", + "TDAI_TOKEN_PATH" + ], + "behaviorEnv": [ + "TDAI_CODEX_TOOL_OFFLOAD", + "TDAI_CODEX_AUTOSTART", + "TDAI_CODEX_CIRCUIT_BREAKER", + "TDAI_CODEX_ALLOW_NON_LOOPBACK", + "TDAI_CODEX_DEBUG" + ] + }, + "reuseContract": { + "noRepositoryAbsolutePathsRequired": true, + "safeDefaultGatewayScope": "loopback-only", + "authDefault": "private bearer token generated under the adapter data directory", + "stateIsolation": "per-user data lives under TDAI_CODEX_DATA_DIR or ~/.memory-tencentdb/codex-memory-tdai", + "sourceCheckoutOptional": "package-bin autostart is the default; TDAI_CODEX_TDAI_ROOT is only for source-tree development", + "extensionPoints": [ + "override TDAI_CODEX_GATEWAY_PACKAGE to validate a fork, version, or tarball", + "override TDAI_CODEX_TDAI_ROOT to run the Gateway from a local source checkout", + "override TDAI_CODEX_DATA_DIR to isolate state for another user, test, or product", + "extend hooks/hooks.codex.json with host-specific hooks while keeping shared scripts intact", + "extend .mcp.json with additional MCP tools while preserving tdai-memory tools" + ] + }, + "diagnosticChecks": [ + "manifest files exist relative to the plugin root", + "hook and MCP configs do not require machine-specific absolute paths", + "data directory is writable and private when created", + "Gateway URL is loopback unless explicitly overridden", + "Gateway can be launched from package-bin or an explicit source checkout" + ] +} diff --git a/codex-plugin/hooks/hooks.codex.json b/codex-plugin/hooks/hooks.codex.json new file mode 100644 index 0000000..1ae77b4 --- /dev/null +++ b/codex-plugin/hooks/hooks.codex.json @@ -0,0 +1,90 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|clear", + "hooks": [ + { + "type": "command", + "command": "node ${PLUGIN_ROOT}/scripts/session-start.mjs", + "statusMessage": "tdai-memory: loading Codex memory context" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "node ${PLUGIN_ROOT}/scripts/user-prompt-submit.mjs", + "statusMessage": "tdai-memory: recalling relevant memory" + } + ] + } + ], + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "node ${PLUGIN_ROOT}/scripts/pre-tool-use.mjs" + } + ] + } + ], + "PermissionRequest": [ + { + "hooks": [ + { + "type": "command", + "command": "node ${PLUGIN_ROOT}/scripts/permission-request.mjs" + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "node ${PLUGIN_ROOT}/scripts/post-tool-use.mjs" + } + ] + } + ], + "PreCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "node ${PLUGIN_ROOT}/scripts/pre-compact.mjs", + "statusMessage": "tdai-memory: preserving turn before compaction" + } + ] + } + ], + "PostCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "node ${PLUGIN_ROOT}/scripts/post-compact.mjs", + "statusMessage": "tdai-memory: flushing memory after compaction" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node ${PLUGIN_ROOT}/scripts/stop.mjs", + "statusMessage": "tdai-memory: capturing completed Codex turn" + } + ] + } + ] + } +} diff --git a/codex-plugin/scripts/cli-smoke.test.mjs b/codex-plugin/scripts/cli-smoke.test.mjs new file mode 100644 index 0000000..c47431b --- /dev/null +++ b/codex-plugin/scripts/cli-smoke.test.mjs @@ -0,0 +1,81 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, describe, expect, it } from "vitest"; + +const scriptsDir = path.dirname(fileURLToPath(import.meta.url)); +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tdai-codex-cli-")); + +describe("Codex adapter CLI entry scripts", () => { + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("imports Codex JSONL history in dry-run mode", async () => { + const sessionsDir = path.join(tmpDir, "sessions"); + fs.mkdirSync(sessionsDir, { recursive: true }); + const cwd = path.join(tmpDir, "project"); + fs.mkdirSync(cwd, { recursive: true }); + const sessionPath = path.join(sessionsDir, "sample.jsonl"); + fs.writeFileSync(sessionPath, [ + JSON.stringify({ type: "session_meta", timestamp: "2026-05-20T00:00:00.000Z", payload: { id: "smoke", cwd, source: "codex-cli" } }), + JSON.stringify({ type: "response_item", timestamp: "2026-05-20T00:00:01.000Z", payload: { type: "message", role: "user", content: [{ text: "What did we decide?" }] } }), + JSON.stringify({ type: "response_item", timestamp: "2026-05-20T00:00:02.000Z", payload: { type: "message", role: "assistant", content: [{ text: "We decided to keep the adapter portable." }] } }), + "", + ].join("\n")); + + const result = await runScript("import-codex-history.mjs", [ + "--sessions-dir", sessionsDir, + "--no-archived", + "--dry-run", + "--cwd", cwd, + "--limit", "1", + ]); + expect(result.stderr).toBe(""); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(expect.objectContaining({ + sessionsPrepared: 1, + roundsPrepared: 1, + messagesPrepared: 2, + skipped: expect.objectContaining({ parseError: 0 }), + })); + }); + + it("prints query status JSON without autostarting Gateway", async () => { + const result = await runScript("query.mjs", ["status"], { + CLAUDE_PROJECT_DIR: tmpDir, + TDAI_CODEX_AUTOSTART: "false", + TDAI_CODEX_GATEWAY_URL: "http://127.0.0.1:9", + TDAI_CODEX_DATA_DIR: tmpDir, + }); + expect(result.stderr).toBe(""); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(expect.objectContaining({ + healthy: false, + gatewayUrl: "http://127.0.0.1:9", + sessionKey: expect.stringContaining("codex:"), + })); + }); +}); + +function runScript(script, args = [], env = {}) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [path.join(scriptsDir, script), ...args], { + env: { + ...process.env, + TDAI_DATA_DIR: tmpDir, + TDAI_CODEX_AUTOSTART: "false", + ...env, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { stdout += chunk; }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + child.on("error", reject); + child.on("close", (code) => resolve({ code, stdout, stderr })); + }); +} diff --git a/codex-plugin/scripts/codex-security.test.mjs b/codex-plugin/scripts/codex-security.test.mjs new file mode 100644 index 0000000..179e3c9 --- /dev/null +++ b/codex-plugin/scripts/codex-security.test.mjs @@ -0,0 +1,509 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + beginTurn, + configuredGatewayTokenPath, + captureCurrentTurn, + debug, + ensureGatewayAuthToken, + healthCheck, + hookLogPath, + httpPost, + loadSessionState, + recallForPrompt, + readGatewayAuthToken, + promptFromPayload, + sanitizeMemoryText, + sessionKeyFromPayload, +} from "./lib.mjs"; +import { buildAdapterDoctorReport } from "./doctor.mjs"; +import { + lookupCodexOffload, + recordCodexToolOffload, +} from "./offload-store.mjs"; + +let tmpDir; +let originalDataDir; +let originalAutostart; +let originalGatewayUrl; +let originalAllowNonLoopback; +let originalCodexGatewayToken; +let originalGatewayToken; +let originalTokenPath; + +beforeEach(() => { + originalDataDir = process.env.TDAI_CODEX_DATA_DIR; + originalAutostart = process.env.TDAI_CODEX_AUTOSTART; + originalGatewayUrl = process.env.TDAI_CODEX_GATEWAY_URL; + originalAllowNonLoopback = process.env.TDAI_CODEX_ALLOW_NON_LOOPBACK; + originalCodexGatewayToken = process.env.TDAI_CODEX_GATEWAY_TOKEN; + originalGatewayToken = process.env.TDAI_GATEWAY_TOKEN; + originalTokenPath = process.env.TDAI_TOKEN_PATH; + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tdai-codex-security-")); + process.env.TDAI_CODEX_DATA_DIR = tmpDir; +}); + +afterEach(() => { + if (originalDataDir === undefined) { + delete process.env.TDAI_CODEX_DATA_DIR; + } else { + process.env.TDAI_CODEX_DATA_DIR = originalDataDir; + } + if (originalAutostart === undefined) { + delete process.env.TDAI_CODEX_AUTOSTART; + } else { + process.env.TDAI_CODEX_AUTOSTART = originalAutostart; + } + if (originalGatewayUrl === undefined) { + delete process.env.TDAI_CODEX_GATEWAY_URL; + } else { + process.env.TDAI_CODEX_GATEWAY_URL = originalGatewayUrl; + } + if (originalAllowNonLoopback === undefined) { + delete process.env.TDAI_CODEX_ALLOW_NON_LOOPBACK; + } else { + process.env.TDAI_CODEX_ALLOW_NON_LOOPBACK = originalAllowNonLoopback; + } + if (originalCodexGatewayToken === undefined) { + delete process.env.TDAI_CODEX_GATEWAY_TOKEN; + } else { + process.env.TDAI_CODEX_GATEWAY_TOKEN = originalCodexGatewayToken; + } + if (originalGatewayToken === undefined) { + delete process.env.TDAI_GATEWAY_TOKEN; + } else { + process.env.TDAI_GATEWAY_TOKEN = originalGatewayToken; + } + if (originalTokenPath === undefined) { + delete process.env.TDAI_TOKEN_PATH; + } else { + process.env.TDAI_TOKEN_PATH = originalTokenPath; + } + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +describe("Codex adapter security defaults", () => { + it("strips injected memory blocks and redacts common secrets", () => { + const githubToken = ["github", "pat", "1234567890abcdefghijklmnopqrstuvwxyz"].join("_"); + const awsAccessKey = `AKIA${"1234567890ABCDEF"}`; + const keyKind = "PRIVATE KEY"; + const privateKeyBlock = [ + `-----BEGIN ${keyKind}-----`, + "secret material", + `-----END ${keyKind}-----`, + ].join("\n"); + const cleaned = sanitizeMemoryText(` +keep this +private injected context +${githubToken} +${awsAccessKey} +${privateKeyBlock} +`); + + expect(cleaned).toContain("keep this"); + expect(cleaned).not.toContain("private injected context"); + expect(cleaned).not.toContain(githubToken); + expect(cleaned).not.toContain(awsAccessKey); + expect(cleaned).not.toContain("secret material"); + expect(cleaned).toContain("[REDACTED_GITHUB_TOKEN]"); + expect(cleaned).toContain("[REDACTED_AWS_ACCESS_KEY]"); + expect(cleaned).toContain("[REDACTED_PRIVATE_KEY]"); + }); + + it("redacts local Gateway token diagnostics", () => { + const token = "a".repeat(43); + const cleaned = sanitizeMemoryText(`gateway token: ${token}`); + + expect(cleaned).not.toContain(token); + expect(cleaned).toContain("[REDACTED"); + }); + + it("redacts JSON-style credential fields", () => { + const cleaned = sanitizeMemoryText(JSON.stringify({ + apiKey: "plain-secret-123", + password: "hunter2", + token: "abc123xyz", + authorization: "Basic abc123", + nested: { + clientSecret: "client-secret-value", + accessToken: "access-token-value", + }, + })); + + expect(cleaned).not.toContain("plain-secret-123"); + expect(cleaned).not.toContain("hunter2"); + expect(cleaned).not.toContain("abc123xyz"); + expect(cleaned).not.toContain("Basic abc123"); + expect(cleaned).not.toContain("client-secret-value"); + expect(cleaned).not.toContain("access-token-value"); + expect(cleaned.match(/\[REDACTED\]/g)?.length).toBeGreaterThanOrEqual(6); + }); + + it("redacts env-style credential fields with prefixes", () => { + const cleaned = sanitizeMemoryText([ + "CLIENT_SECRET=client-secret-value", + "ACCESS_TOKEN=access-token-value", + "DB_PASSWORD=hunter2", + ].join("\n")); + + expect(cleaned).not.toContain("client-secret-value"); + expect(cleaned).not.toContain("access-token-value"); + expect(cleaned).not.toContain("hunter2"); + expect(cleaned).toContain("CLIENT_SECRET=[REDACTED]"); + expect(cleaned).toContain("ACCESS_TOKEN=[REDACTED]"); + expect(cleaned).toContain("DB_PASSWORD=[REDACTED]"); + }); + + it("extracts Codex App prompts from user message content arrays", () => { + const payload = { + message: { + type: "message", + role: "user", + content: [ + { type: "input_text", text: "capture this Codex App prompt" }, + ], + }, + }; + + expect(promptFromPayload(payload)).toBe("capture this Codex App prompt"); + }); + + it("does not treat assistant messages as user prompts", () => { + const payload = { + message: { + type: "message", + role: "assistant", + content: [ + { type: "output_text", text: "assistant output should not be captured" }, + ], + }, + prompt: "", + }; + + expect(promptFromPayload(payload)).toBe(""); + }); + + it("falls back to the latest real user message in the Codex transcript", () => { + const transcriptPath = path.join(tmpDir, "rollout.jsonl"); + fs.writeFileSync(transcriptPath, [ + JSON.stringify({ + timestamp: "2026-05-20T05:00:00.000Z", + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "earlier real prompt" }], + }, + }), + JSON.stringify({ + timestamp: "2026-05-20T05:01:00.000Z", + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "\nsynthetic interruption" }], + }, + }), + JSON.stringify({ + timestamp: "2026-05-20T05:02:00.000Z", + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "assistant response" }], + }, + }), + JSON.stringify({ + timestamp: "2026-05-20T05:03:00.000Z", + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "latest real Codex App prompt" }], + }, + }), + ].join("\n") + "\n"); + + expect(promptFromPayload({ transcript_path: transcriptPath })).toBe("latest real Codex App prompt"); + }); + + it("stores transcript fallback text when beginning a turn", async () => { + const transcriptPath = path.join(tmpDir, "begin-turn-rollout.jsonl"); + fs.writeFileSync(transcriptPath, JSON.stringify({ + timestamp: "2026-05-20T05:10:00.000Z", + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "write this prompt into memory" }], + }, + }) + "\n"); + + const payload = { + cwd: process.cwd(), + session_id: "transcript-fallback", + transcript_path: transcriptPath, + }; + const sessionKey = sessionKeyFromPayload(payload); + + await beginTurn(payload); + const state = await loadSessionState(sessionKey); + + expect(state.currentTurn.userPrompt).toBe("write this prompt into memory"); + }); + + it("redacts full Authorization and Proxy-Authorization header values", () => { + const cleaned = sanitizeMemoryText([ + "Authorization: Basic dXNlcjpwYXNz", + "Proxy-Authorization: Token proxy-secret-value", + ].join("\n")); + + expect(cleaned).not.toContain("dXNlcjpwYXNz"); + expect(cleaned).not.toContain("proxy-secret-value"); + expect(cleaned).toContain("Authorization=[REDACTED]"); + expect(cleaned).toContain("Proxy-Authorization=[REDACTED]"); + }); + + it("writes redacted diagnostics to hook.log without throwing", () => { + debug("Gateway failed with Authorization: Bearer diagnostic-secret-value"); + + const log = fs.readFileSync(hookLogPath(), "utf-8"); + expect(log).toContain("Gateway failed"); + expect(log).toContain("Authorization=[REDACTED]"); + expect(log).not.toContain("diagnostic-secret-value"); + }); + + it("writes Codex state and offload files with private permissions", async () => { + const payload = { cwd: process.cwd(), session_id: "perm-test", prompt: "hello" }; + await beginTurn(payload); + await recordCodexToolOffload({ + sessionKey: sessionKeyFromPayload(payload), + sessionId: "perm-test", + cwd: process.cwd(), + toolName: "test-tool", + toolUseId: "tool-1", + inputSummary: "input", + redactedOutput: "output".repeat(100), + storedText: "stored output", + policy: { name: "mild", score: 8 }, + }); + + const sessionDir = path.join(tmpDir, "codex-adapter", "sessions"); + const sessionFile = path.join(sessionDir, fs.readdirSync(sessionDir)[0]); + const offloadBase = path.join(tmpDir, "codex-adapter", "context-offload"); + const offloadRoot = path.join(offloadBase, fs.readdirSync(offloadBase)[0]); + const refFile = path.join(offloadRoot, "refs", fs.readdirSync(path.join(offloadRoot, "refs"))[0]); + + expect(mode(sessionDir)).toBe("700"); + expect(mode(sessionFile)).toBe("600"); + expect(mode(offloadRoot)).toBe("700"); + expect(mode(refFile)).toBe("600"); + }); + + it("scopes offload lookup by project cwd unless explicitly omitted", async () => { + const cwdA = path.join(tmpDir, "project-a"); + const cwdB = path.join(tmpDir, "project-b"); + fs.mkdirSync(cwdA); + fs.mkdirSync(cwdB); + + await recordCodexToolOffload(offloadParams(cwdA, "session-a", "tool-a")); + await recordCodexToolOffload(offloadParams(cwdB, "session-b", "tool-b")); + + const scoped = await lookupCodexOffload({ cwd: cwdA, limit: 10 }); + expect(scoped.matches).toHaveLength(1); + expect(scoped.matches[0].tool_call_id).toBe("tool-a"); + + const all = await lookupCodexOffload({ limit: 10 }); + expect(all.matches.map((entry) => entry.tool_call_id).sort()).toEqual(["tool-a", "tool-b"]); + }); + + it("escapes Mermaid labels for offloaded tool results", async () => { + const cwd = path.join(tmpDir, "project-mermaid"); + fs.mkdirSync(cwd); + const result = await recordCodexToolOffload({ + ...offloadParams(cwd, "session-mermaid", "tool-mermaid"), + toolName: "tool\"] --> EVIL[\"x", + inputSummary: "payload [brackets]", + }); + + const canvas = fs.readFileSync(result.paths.canvasPath, "utf-8"); + expect(canvas).not.toContain("