diff --git a/.gitignore b/.gitignore index b9b2a37..2fc5a90 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ mcp-publisher.exe # DevFlow engine .progress/ +.npm-cache-review/ # Agent registry & profiles .agents/ diff --git a/MCP-README.md b/MCP-README.md index 675b199..ad34a7f 100644 --- a/MCP-README.md +++ b/MCP-README.md @@ -107,9 +107,9 @@ XMemo MCP 服务器提供以下 20 个工具,每个工具都有清晰的名称 获取 Token:访问 [https://xmemo.dev](https://xmemo.dev) 注册并获取 API Key。 -### 方式二:OAuth 客户端(部分客户端支持) +### 方式二:MCP OAuth 客户端(仅部分客户端) -Cursor、Gemini CLI、Antigravity、OpenCode 等客户端支持 MCP OAuth 流程,无需手动配置 `XMEMO_KEY`: +当前 CLI 将 Gemini CLI、Antigravity 系列、OpenCode 和 Qwen 生成为 MCP OAuth 配置;这些客户端无需在 MCP 配置中手动配置 `XMEMO_KEY`: ```json { @@ -138,7 +138,7 @@ xmemo-mcp `xmemo-mcp` 是专用的 stdio MCP 入口;`xmemo mcp serve` 与它等价。 能力发现(Tools、Prompts、Resources)不需要 Token,实际工具执行仍需认证。 -支持的客户端:`codex`、`cursor`、`copilot`、`gemini`、`antigravity`、`grok`、`kiro`、`claude-desktop`、`windsurf`、`cline`、`kimi`、`qwen`、`trae` 等。 +支持的 `xmemo setup` 客户端包括:`codex`、`cursor`、`copilot`、`gemini`、`antigravity`、`grok`、`kiro`、`claude-desktop`、`windsurf`、`cline`、`kimi-code`、`qwen`、`trae`、`zed` 和 `opencode`。底层 `xmemo mcp add` 使用注册表 ID(例如 `gemini-cli`、`copilot-cli`);Copilot CLI 的推荐入口仍是 `xmemo setup copilot`。 --- @@ -187,8 +187,8 @@ xmemo-mcp ```json { "tool": "create_memory_todo", - "title": "重构 auth 模块:将 JWT 改为 Session + Redis", - "due": "next-week" + "content": "重构 auth 模块:将 JWT 改为 Session + Redis", + "due_at": "" } ``` @@ -210,18 +210,20 @@ xmemo-mcp | 客户端 | 支持方式 | 配置命令 | |--------|----------|----------| -| **Kimi Code** | Streamable HTTP + Bearer Token | `xmemo setup kiro` | -| **Claude Desktop** | Streamable HTTP + OAuth | `xmemo setup claude-desktop` | -| **Cursor** | Streamable HTTP + OAuth | `xmemo setup cursor` | +| **Kimi Code** | Streamable HTTP + Bearer Token(`XMEMO_KEY`) | `xmemo setup kimi-code` | +| **Kiro** | `mcp-remote` + Bearer Token(`XMEMO_KEY`) | `xmemo setup kiro` | +| **Claude Desktop** | `mcp-remote` + Bearer Token(`XMEMO_KEY`) | `xmemo setup claude-desktop` | +| **Cursor** | Streamable HTTP + Bearer Token(`XMEMO_KEY`) | `xmemo setup cursor` | | **Copilot CLI** | Local Proxy + Bearer Token | `xmemo setup copilot` | -| **Gemini CLI** | Streamable HTTP + OAuth | `xmemo setup gemini` | +| **Gemini CLI** | Streamable HTTP + MCP OAuth | `xmemo setup gemini` | | **Grok (xAI)** | Streamable HTTP + Bearer Token | `xmemo setup grok` | -| **Antigravity** | Streamable HTTP + OAuth | `xmemo setup antigravity` | +| **Antigravity 系列** | Streamable HTTP + MCP OAuth | `xmemo setup antigravity` | | **Windsurf** | Streamable HTTP + Bearer Token | `xmemo setup windsurf` | | **Cline** | Streamable HTTP + Bearer Token | `xmemo setup cline` | -| **Trae** | Streamable HTTP + Bearer Token | `xmemo setup trae` | -| **Qwen CLI** | Streamable HTTP + OAuth | `xmemo setup qwen` | -| **Zed** | Streamable HTTP + Bearer Token | `xmemo setup zed` | +| **Trae / Trae Solo** | `mcp-remote` + Bearer Token(`XMEMO_KEY`) | `xmemo setup trae` | +| **Qwen CLI** | Streamable HTTP + MCP OAuth | `xmemo setup qwen` | +| **Zed** | `mcp-remote` + Bearer Token(`XMEMO_KEY`) | `xmemo setup zed` | +| **OpenCode** | Remote MCP + MCP OAuth | `xmemo setup opencode` | --- @@ -229,7 +231,7 @@ xmemo-mcp - **无遥测**:CLI 和 MCP 服务均不发送任何遥测或分析数据 - **Token 安全**:生成的配置文件仅引用环境变量(如 `${XMEMO_KEY}`),从不嵌入真实 token 值 -- **OAuth 优先**:支持的客户端优先使用 OAuth 流程,避免手动管理密钥 +- **认证方式以表格为准**:只有标记为 MCP OAuth 的客户端走 OAuth;其余远程客户端从 `XMEMO_KEY` 环境变量读取 Bearer Token - **设备级标识**:`XMEMO_AGENT_INSTANCE_ID` 为设备级非敏感标识符,用于归因分析,不暴露个人信息 - **数据归属**:用户完全拥有记忆数据,支持随时导出、删除或脱敏 diff --git a/MCP-SETUP-GUIDE.md b/MCP-SETUP-GUIDE.md index 3a0aa07..d95ffc5 100644 --- a/MCP-SETUP-GUIDE.md +++ b/MCP-SETUP-GUIDE.md @@ -40,19 +40,18 @@ set XMEMO_AGENT_INSTANCE_ID=random-guid-here ## 各客户端配置详情 -### Kimi Code / Kiro +### Kimi Code -配置文件:`~/.kiro/settings/mcp.json` +配置文件:`~/.kimi-code/mcp.json` ```json { "mcpServers": { "XMemo": { - "type": "streamable-http", "url": "https://xmemo.dev/mcp", + "bearerTokenEnvVar": "XMEMO_KEY", "headers": { - "Authorization": "Bearer ${XMEMO_KEY}", - "X-Memory-OS-Agent-ID": "${XMEMO_AGENT_ID}", + "X-Memory-OS-Agent-ID": "kimi-code", "X-Memory-OS-Agent-Instance-ID": "${XMEMO_AGENT_INSTANCE_ID}" } } @@ -60,26 +59,54 @@ set XMEMO_AGENT_INSTANCE_ID=random-guid-here } ``` -> ⚠️ **重要**:Kimi Code 通过 `bearerTokenEnvVar` 读取环境变量。确保 `XMEMO_KEY` 在启动 Kimi Code 的**同一环境**中已导出。 +> ⚠️ **重要**:Kimi Code 通过 `bearerTokenEnvVar` 读取环境变量。确保 `XMEMO_KEY` 在启动 Kimi Code 的**同一环境**中已导出。推荐直接运行 `xmemo setup kimi-code`。 + +--- + +### Kiro + +配置文件:`~/.kiro/settings/mcp.json` + +Kiro 使用 `mcp-remote` 连接 Hosted MCP,并从 `XMEMO_KEY` 读取 Bearer Token。推荐运行: + +```bash +xmemo setup kiro +``` + +不要把 Kiro 与 MCP OAuth 客户端混为一谈:`xmemo login` 可以通过浏览器获取 CLI 凭据,但 Kiro 的 MCP 请求仍由环境变量认证。 --- ### Claude Desktop -配置文件:`%APPDATA%\Claude\settings.json` (Windows) 或 `~/Library/Application Support/Claude/settings.json` (macOS) +配置文件:`%APPDATA%\Claude\claude_desktop_config.json` (Windows) 或 `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) ```json { "mcpServers": { "XMemo": { - "type": "streamable-http", - "url": "https://xmemo.dev/mcp" + "command": "npx", + "args": [ + "-y", + "mcp-remote", + "https://xmemo.dev/mcp", + "--header", + "Authorization:Bearer ${XMEMO_KEY}", + "--header", + "X-Memory-OS-Agent-ID:claude-desktop", + "--header", + "X-Memory-OS-Agent-Instance-ID:${XMEMO_AGENT_INSTANCE_ID}" + ], + "env": { + "XMEMO_KEY": "${env:XMEMO_KEY}", + "XMEMO_AGENT_INSTANCE_ID": "${XMEMO_AGENT_INSTANCE_ID}" + } } } } ``` -Claude Desktop 支持 MCP OAuth,首次使用 XMemo 工具时会自动弹出浏览器授权窗口。 +Claude Desktop 的 CLI 配置路径使用 `mcp-remote` + `XMEMO_KEY`;它不属于本仓库 CLI 标记的 MCP OAuth 客户端。推荐运行 `xmemo setup claude-desktop` 生成配置。 --- @@ -91,14 +118,18 @@ Claude Desktop 支持 MCP OAuth,首次使用 XMemo 工具时会自动弹出浏 { "mcpServers": { "XMemo": { - "type": "streamable-http", - "url": "https://xmemo.dev/mcp" + "url": "https://xmemo.dev/mcp", + "headers": { + "Authorization": "Bearer ${env:XMEMO_KEY}", + "X-Memory-OS-Agent-ID": "cursor", + "X-Memory-OS-Agent-Instance-ID": "${XMEMO_AGENT_INSTANCE_ID}" + } } } } ``` -Cursor 同样支持 OAuth,无需手动配置 `Authorization`。 +Cursor 的 CLI 配置使用 `XMEMO_KEY` Bearer Token;只有 Cursor marketplace 插件是 OAuth-first,两者不要混用。推荐运行 `xmemo setup cursor` 生成配置。 --- @@ -130,8 +161,11 @@ xmemo mcp proxy { "mcpServers": { "XMemo": { - "type": "http", - "httpUrl": "https://xmemo.dev/mcp" + "httpUrl": "https://xmemo.dev/mcp", + "headers": { + "X-Memory-OS-Agent-ID": "gemini-cli", + "X-Memory-OS-Agent-Instance-ID": "${XMEMO_AGENT_INSTANCE_ID}" + } } } } @@ -165,8 +199,11 @@ bearer_token_env_var = "XMEMO_KEY" { "mcpServers": { "XMemo": { - "type": "http", - "url": "https://xmemo.dev/mcp" + "serverUrl": "https://xmemo.dev/mcp", + "headers": { + "X-Memory-OS-Agent-ID": "antigravity", + "X-Memory-OS-Agent-Instance-ID": "${XMEMO_AGENT_INSTANCE_ID}" + } } } } @@ -176,9 +213,9 @@ Antigravity 2.0 支持 OAuth,首次使用时会自动打开浏览器完成授 --- -### Windsurf / Cline / Trae / Zed / Qwen +### Windsurf / Cline -这些客户端通常使用标准的 `mcp.json` 格式: +这些客户端使用 Bearer Token。不同版本的配置键可能不同,推荐用对应的 CLI ID 生成配置:`xmemo setup windsurf` 或 `xmemo setup cline`。 ```json { @@ -187,13 +224,39 @@ Antigravity 2.0 支持 OAuth,首次使用时会自动打开浏览器完成授 "type": "streamable-http", "url": "https://xmemo.dev/mcp", "headers": { - "Authorization": "Bearer ${XMEMO_KEY}" + "Authorization": "Bearer ${env:XMEMO_KEY}", + "X-Memory-OS-Agent-ID": "your-client-id", + "X-Memory-OS-Agent-Instance-ID": "${XMEMO_AGENT_INSTANCE_ID}" } } } } ``` +### Trae / Trae Solo / Zed + +这些客户端由 CLI 配置为 `mcp-remote` + `XMEMO_KEY`,不要复制上面的直连 HTTP 示例。运行 `xmemo setup trae`、`xmemo setup trae-solo` 或 `xmemo setup zed`,并在启动客户端的同一环境中设置 `XMEMO_KEY`。 + +### Qwen + +Qwen 使用 MCP OAuth,无需在 MCP 配置中写入 `XMEMO_KEY`: + +```json +{ + "mcpServers": { + "XMemo": { + "httpUrl": "https://xmemo.dev/mcp", + "headers": { + "X-Memory-OS-Agent-ID": "qwen", + "X-Memory-OS-Agent-Instance-ID": "${XMEMO_AGENT_INSTANCE_ID}" + } + } + } +} +``` + +首次连接时按客户端提示完成浏览器授权。 + --- ## 验证配置 @@ -217,11 +280,18 @@ xmemo smoke --client | 问题 | 可能原因 | 解决方案 | |------|----------|----------| -| "无法连接到 XMemo" | Token 未设置 | 确认 `XMEMO_KEY` 环境变量已导出 | -| "401 Unauthorized" | Token 无效或过期 | 访问 xmemo.dev 重新获取 token | -| "OAuth 窗口未弹出" | 客户端不支持 MCP OAuth | 改用 Bearer Token 方式 | -| "工具未显示" | 客户端未重新加载 MCP 配置 | 重启客户端或执行 `/mcp reload` | -| "代理连接失败" | Copilot CLI 代理未运行 | 保持 `xmemo mcp proxy` 运行 | +| "无法连接到 XMemo" | 网络、地址或客户端传输配置错误 | 运行 `xmemo doctor`,确认地址为 `https://xmemo.dev/mcp`,再检查客户端日志 | +| "401 Unauthorized" | Token 缺失、无效或过期 | 运行 `xmemo auth status --verify`;按输出重新登录或更新 `XMEMO_KEY` | +| "403 Forbidden" | Token 有效,但缺少所需 scope 或当前资源不在授权范围 | 重新授权包含所需 scope 的正式凭据,并确认使用的是已授权的项目/团队范围;不要仅为绕过错误而扩大 scope | +| "OAuth 窗口未弹出" | 当前客户端不是 CLI 标记的 MCP OAuth 客户端,或客户端未重载配置 | 先运行 `xmemo mcp add --write` 并重启客户端;对于 Bearer 客户端改为在同一启动环境设置 `XMEMO_KEY` | +| "工具未显示" | MCP 配置未加载、服务名重复或客户端缓存旧配置 | 检查生成配置中的 `XMemo`、重启/Reload MCP,再运行 `xmemo doctor` | +| `XMEMO_KEY` 未检测到 | 环境变量没有传给启动客户端的那个进程 | 在启动客户端的同一终端运行 `xmemo auth status` 或 `xmemo token status --verify`,设置变量后重新启动客户端;不要把 token 写入项目文件 | +| 召回结果为空 | 查询词、path、scope 或项目范围不匹配 | 先确认认证成功,再使用明确的查询词和正确的授权 scope;项目上下文必须传入准确的 `project_id`,不能只传项目名 | +| 项目范围错误 | 使用了错误的 scope、team 或 project ID | 使用当前账号已授权的精确 `project_id`;不要通过扩大范围来掩盖 ID 错误 | +| `XMEMO_AGENT_INSTANCE_ID` 每次变化 | 每次启动都重新生成实例 ID | 使用稳定的用户环境变量,或运行 `xmemo mcp add --write` 让 CLI 保存用户级实例 ID;不要将其提交到 git | +| 出现重复记忆 | 同一事实被重复保存,或重复安装了 Native 与 MCP 两个集成 | 保存前先 `recall`;OpenClaw/Hermes 使用 Native 集成时不要再安装同一能力的 MCP fallback,除非明确需要 | +| forget/delete 不生效 | 误用了不存在的 `delete` 工具、目标不是精确 ID,或客户端没有刷新 | MCP 使用 `forget` 并传入 `current` 或精确 memory ID;删除后刷新并用 `recall` 验证,必要时用 `restore_memory` 恢复可恢复删除 | +| "代理连接失败" | Copilot CLI 本地代理未运行 | 保持 `xmemo mcp proxy` 运行,并检查代理端口配置 | --- diff --git a/README.md b/README.md index 3436973..c7b93d6 100644 --- a/README.md +++ b/README.md @@ -108,13 +108,13 @@ xmemo setup cursor --dry-run | Client | Recommended command | Connection | | --- | --- | --- | | **Codex** | `xmemo setup codex` | Hosted MCP + behavior profile | -| **Cursor** | `xmemo setup cursor` | Hosted MCP + behavior profile | +| **Cursor** | `xmemo setup cursor` | Hosted MCP + Bearer Token + behavior profile | | **Copilot CLI** | `xmemo setup copilot` | Local authenticated proxy | | **Gemini CLI** | `xmemo setup gemini` | Hosted MCP + OAuth | | **Antigravity** | `xmemo setup antigravity` | Hosted MCP + OAuth | | **OpenClaw** | `xmemo setup openclaw` | Native memory plugin + Skill | | **Hermes** | `xmemo setup hermes` | Native memory provider | -| **Kiro** | `xmemo setup kiro` | Hosted MCP | +| **Kiro** | `xmemo setup kiro` | Hosted MCP + Bearer Token | | **Grok** | `xmemo setup grok` | Hosted MCP | | **Other MCP clients** | `xmemo mcp config --client generic` | Generated template | @@ -299,6 +299,58 @@ xmemo setup hermes [--with-mcp|--mcp-only] +
+Direct XMemo service client + +```bash +xmemo memory add --content "Remember this" --path notes/example --json +xmemo memory search "example" --json +xmemo context recall "resume this task" --include-knowledge --json +xmemo state save --current-task "ship the client" --next-action "run tests" --json +xmemo state restore --json +xmemo restart snapshot --json +xmemo restart restore --snapshot-id --json + +xmemo knowledge add --base --file ./guide.pdf --title "Guide" --json +xmemo knowledge search "setup" --base --json +xmemo knowledge read --json > knowledge-view.json +xmemo knowledge update --text "Updated" --from knowledge-view.json --publish --yes --json + +xmemo dream preview --wait --json +xmemo dream show --json > dream-view.json +xmemo dream apply --item --from dream-view.json --yes --json + +xmemo cloud-skill list --json +xmemo cloud-skill add --file ./SKILL.md --json +xmemo cloud-skill show --json > skill-view.json +xmemo cloud-skill update --from skill-view.json --file ./SKILL.md --json +xmemo cloud-skill run --input ./args.json --from skill-view.json --yes --json +``` + +All direct service commands support a single machine-readable JSON envelope. +Knowledge update, Dream apply, and Cloud Skill run use the `readReceipt` from a +saved read/show result so the CLI never silently substitutes a newer revision. +Set `XMEMO_KNOWLEDGE_BASE_ID` for a non-interactive default knowledge base. +For a long knowledge item, continue the same fixed revision with +`xmemo knowledge read --from knowledge-view.json --offset `. +Run `xmemo doctor --services --json` for read-only Knowledge, Dream, and Cloud +Skill diagnostics; it deliberately does not claim write or production readiness. + +Cloud Skill add/update already target the safe create-only and content-CAS +contracts. They fail with `SERVER_CONTRACT_REQUIRED` on older services and do +not fall back to legacy upsert routes. Binary Knowledge item updates similarly +require a new version of the same server Document; use `--document` and +`--document-version` after that version has been uploaded. + +The normal login scopes remain unchanged. Request additional service scopes +explicitly when needed, for example: + +```bash +xmemo login --scopes memory:read,memory:write,memory:restore,knowledge:read,knowledge:write +``` + +
+
MCP and behavior profiles diff --git a/bin/memory-os.js b/bin/memory-os.js index 535bddd..f7dbab3 100755 --- a/bin/memory-os.js +++ b/bin/memory-os.js @@ -1,12 +1,19 @@ #!/usr/bin/env node import { run } from '../src/cli.js'; +const interruptController = new AbortController(); +const serviceCommand = ['memory', 'context', 'state', 'restart', 'knowledge', 'dream', 'cloud-skill'].includes(process.argv[2]) || (process.argv[2] === 'doctor' && process.argv.includes('--services')); +const interrupt = () => interruptController.abort(); +if (serviceCommand) process.once('SIGINT', interrupt); + const exitCode = await run(process.argv.slice(2), { env: process.env, stdin: process.stdin, stdout: process.stdout, stderr: process.stderr, - fetch: globalThis.fetch + fetch: globalThis.fetch, + signal: interruptController.signal }); process.exitCode = exitCode; +if (serviceCommand) process.removeListener('SIGINT', interrupt); diff --git a/docs/design/MOS_01_CLOUD_SKILL_SAFE_WRITE_HANDOFF.md b/docs/design/MOS_01_CLOUD_SKILL_SAFE_WRITE_HANDOFF.md new file mode 100644 index 0000000..fb0decf --- /dev/null +++ b/docs/design/MOS_01_CLOUD_SKILL_SAFE_WRITE_HANDOFF.md @@ -0,0 +1,60 @@ +# MOS-01: proposed Cloud Skill safe write contract handoff + +Owner: authorized `memory-os` project AI +Consumer: `@xmemo/client` Cloud Skill add/update adapters +Status: required proposal; absent from MemoryOS source/OpenAPI at the clean +`origin/master` snapshot `18d7a652de48181a43b10b8f771d3a8df5933884`. This is not an implemented, +reviewed, frozen, or deployed server contract. The CLI fails closed until the +service publishes compatible OpenAPI and response fixtures. + +## Required endpoints + +### `POST /v1/skills/create` + +Request fields: `markdown_content`, `sub_files`, optional `name`, optional +`slug`, `publish` (default false), and optional `team_id`. + +The operation must be atomic create-only. A duplicate slug in the effective +personal/team scope returns 409 and does not append a revision or move any +latest/published pointer. + +### `PUT /v1/skills/{skill_id}/content` + +Request fields: required `expected_revision_id`, optional `markdown_content`, +optional `sub_files`, `publish` (default false), and optional `team_id`. +Omitting content is allowed only to publish the reviewed revision. + +The operation must atomically compare the current maintenance head, merge the +provided files while preserving omitted files, append at most one revision, +and update latest/published or team-proposed state. A stale expected revision +returns 409 with no resource or pointer change. + +## Compatibility and errors + +- Existing upsert/import/component endpoints keep their current behavior. +- The new paths must not delegate to a saving importer before uniqueness/CAS + checks are acquired in the real storage transaction or lock. +- Distinguish duplicate slug, stale revision, missing skill, permission denial, + parse failure, and unsupported publication state. +- Preserve current memory read/write scopes, ownership, team role/approval, + audit, and sandbox policies. Do not invent `skills:*` scopes in this task. +- 404/405 on these paths is interpreted by the CLI as + `SERVER_CONTRACT_REQUIRED`; the CLI does not fall back. + +## Required service tests + +1. Two independent repository/service instances race the same scoped slug: + exactly one create succeeds and one receives 409. +2. Two independent instances update the same expected revision: exactly one + succeeds and one receives 409. +3. Reopen persisted storage and prove resource count, components, latest head, + and published/proposed head match the winning transaction. +4. Prove a failed parse, permission check, stale CAS, or duplicate create leaves + no orphan revision and changes no pointer. +5. Prove omitted sub-files survive update, explicit replacements change only + named paths, and path/case normalization is deterministic. +6. Exercise personal publish and team proposal/approval policy separately. + +After source review, record the deployed service SHA and run the CLI contract +fixtures against that deployment before declaring Cloud Skill add/update +available to users. diff --git a/docs/design/XMEMO_CLI_SERVICE_CLIENT_BASELINE_2026-09-03.md b/docs/design/XMEMO_CLI_SERVICE_CLIENT_BASELINE_2026-09-03.md new file mode 100644 index 0000000..3575078 --- /dev/null +++ b/docs/design/XMEMO_CLI_SERVICE_CLIENT_BASELINE_2026-09-03.md @@ -0,0 +1,167 @@ +# XMemo CLI service-client baseline (CLI-00) + +计划:`XMEMO-CLI-CLIENT-V1` +核验日期:2026-09-03 +状态:历史基线,已由 `XMEMO_CLI_SERVICE_CLIENT_IMPLEMENTATION_STATUS_2026-09-04.md` 取代。本文保留 2026-09-03 的契约快照,不代表当前实现、审核或部署状态。 + +## Scope and repository evidence + +| Item | Evidence | +| --- | --- | +| CLI worktree | Isolated worktree used for historical local validation | +| CLI baseline | detached `HEAD` `6a82011d202316997220e9183c2b309a7c5df61d` | +| CLI package | `@xmemo/client` `0.4.181` | +| CLI main checkout | Not modified by the isolated validation worktree | +| Service source checked | 历史值 `11fd8652a528b95de33310f20b2b6b70323b4f90`;当前值见实施状态文档 | +| Service ref checked | 历史值 `1fa16bfdb9c7c6db063e100d3ddbc00ec704a9f4`;不要用于当前判断 | +| Service deployment | Not checked; source/OpenAPI evidence is not deployment evidence | +| Independent Skill | `skills/xmemo/scripts/xmemo-skill.mjs`; no CLI import or shell-out added | + +The service checkout was already three commits behind its `origin/master` ref +when checked. The local source and checked OpenAPI file are the contract +evidence for this baseline; the remote ref must be refreshed before any +target-environment claim. + +## Frozen command registry + +The source of truth is +`src/api/contracts/command-registry.js`. It contains the seven basic calls and +exactly the twelve planned domain entries. Existing setup, authentication, +diagnostic, MCP, profile, update, privacy, and uninstall commands remain +outside this new registry and are not removed. + +| CLI entry | Service route(s) | Scope | Side effect | Status | +| --- | --- | --- | --- | --- | +| `memory add` | `POST /api/v1/remember` | `memory:write` | yes | current | +| `memory search` | `GET /api/v1/recall` | `memory:read` | no | current | +| `context recall` | `POST /api/v1/recall/context` | `memory:read` | no | current, read-only POST | +| `state save` | `POST /api/v1/update_state` | `memory:write` | yes | current | +| `state restore` | `POST /api/v1/skill/operations`, `operation=state-restore` | `memory:read` | no | current, read-only dispatcher operation | +| `restart snapshot` | `POST /api/v1/restart/snapshot` | `memory:write` | yes | current | +| `restart restore` | `POST /api/v1/restart/restore` | `memory:read`, `memory:restore` | yes | current; may restore remote state | +| `knowledge add` | base/document/item or item-from-document sequence | `knowledge:write` | yes | current | +| `knowledge search` | `POST /api/v1/knowledge/search` | `knowledge:read` | no | current, read-only POST | +| `knowledge read` | item metadata plus fixed revision read | `knowledge:read` | no | current | +| `knowledge update` | content, content-from-document, and metadata paths | `knowledge:write` | yes | current; CAS fields required by adapter | +| `dream preview` | settings read plus `POST /api/v1/me/dream/runs` | memory read/write | yes (run creation) | current | +| `dream show` | `GET /api/v1/me/dream/runs/{run_id}` | `memory:read` | no | current | +| `dream apply` | `POST /api/v1/me/dream/runs/{run_id}/confirm` | `memory:write` | yes | current | +| `cloud-skill add` | proposed `POST /v1/skills/create` | memory write | yes | contract-required | +| `cloud-skill list` | `GET /v1/skills` | memory read | no | current | +| `cloud-skill show` | skill detail plus components | memory read | no | current | +| `cloud-skill update` | proposed `PUT /v1/skills/{skill_id}/content` | memory write | yes | contract-required | +| `cloud-skill run` | `POST /v1/skills/{skill_id}/execute` | memory write | yes | current, no retry | + +The registry records route intent only. Implementations use the checked +OpenAPI/source facts and fail closed when a required contract is absent; they +do not infer a full schema from MCP tool descriptions. + +## Service contract facts + +The checked service source exposes both `/api/v1/...` and compatibility +`/v1/...` aliases for the memory, knowledge, document, Dream, and restart +routes. The published OpenAPI uses `/api/v1/...` for those domains and `/v1` +for Cloud Skill routes. The selected defaults therefore preserve the documented +OpenAPI route rather than relying on an undocumented alias. + +Relevant request facts from the checked OpenAPI/source: + +- `MemoryRememberRequest` requires `content` and `path`. +- `RecallContextRequest` requires `query`; `include_knowledge` defaults false. +- `MemoryStateUpdateRequest` accepts `state_key`, structured state fields, + `bucket`, `scope`, and bounded `ttl_seconds`. +- Restart snapshot/restore accept `state_key`, session selectors, restore + controls, and bounded TTL. Restore can return a `state_update`, so the CLI + treats it as a remote write even though it does not write local project files. +- Knowledge search accepts `query`, optional base, `limit` (1..100), and an + opaque `cursor`; results carry `next_cursor` in the page response. +- Knowledge content update requires `expected_current_revision_id`; document + update additionally requires `document_id` and `expected_document_version`. +- Document upload accepts `filename` and base64 content, requires the memory + data-plane write permission as well as document runtime availability, and the + checked route reports a 4 MiB raw-file limit. +- Dream preview uses `window_days` and optional `idempotency_key`; confirmation + requires `item_id`, `expected_run_version`, and + `expected_settings_version`. +- Cloud Skill execution accepts `script_path`, optional `revision_id` and + `input_args`, and `timeout_seconds` bounded to 1..60 seconds. + +The device-login implementation accepts the explicit scopes +`memory:read`, `memory:write`, `memory:restore`, `ledger:read`, +`ledger:write`, `knowledge:read`, and `knowledge:write`; when scopes are not +requested the service defaults to memory read/write. Dream checks the generic +REST read/write scope plus owner/entitlement rules. Cloud Skill currently uses +the generic memory read/write scope and owner/team checks; no new `dream:*` or +`skills:*` scope is introduced by this work. + +## Cloud Skill safety blocker + +The current source exposes: + +- `POST /v1/skills` and `POST /v1/skills/import`, both capable of saving into + an existing scoped slug; +- `PUT /v1/skills/{skill_id}/components`, which does not accept the planned + expected-revision CAS field; and +- detail, revision, component, publish, execute, render, and export routes. + +The planned safe paths `POST /v1/skills/create` and +`PUT /v1/skills/{skill_id}/content` are absent from the checked source and +OpenAPI. CLI-00 therefore marks only `cloud-skill.add` and +`cloud-skill.update` as `contract-required`. Future adapters must return a +stable server-contract error on 404/405 or incompatible response and must not +send an unsafe fallback request. MOS-01 remains a separate handoff to the +authorized `memory-os` project owner; no service files were modified here. + +## Frozen implementation constraints + +- New business commands will use the `schemaVersion/ok/command/data/meta/error` + envelope and single-object JSON stdout under `--json`; progress goes to + stderr. +- `Authorization` is attached only to the selected HTTPS service origin (with + explicit loopback development exception), never followed across origins. +- Writes are not retried unless a verified idempotency/CAS contract says so; + unknown outcomes remain unknown and are not replayed. +- `--from` read receipts bind update/apply/run to the resource, origin, scope, + and viewed revision/settings version. Dream receipts use + `confirmation_version` and candidate IDs; Cloud Skill receipts record + published/draft status and default execution accepts published receipts + only. A receipt is not an authorization token or approval proof. +- Credential-file tokens are origin-bound to login metadata. Legacy files with + no origin metadata are rejected unless the caller explicitly opts into the + default-service-only migration path with `--allow-legacy-credential`. +- Local file/input validation precedes Knowledge base creation. Document upload + extraction is bounded and multi-step failures return resumable partial + results; content update followed by publish uses the authoritative + post-update version. +- Read-only POST requests may use bounded retry; side-effect requests never + auto-replay. Response bodies are bounded before decoding completes, and new + service commands reject unknown options/input fields. +- `skills/xmemo` remains runtime- and credential-independent from the CLI. + +## Remediation evidence and review state + +Changed files in this worktree include: + +- `src/api/contracts/command-registry.js` — frozen registry and availability + metadata. +- `test/command-registry.test.js` — count, route, scope, and safety assertions. +- `src/api/*.js` — shared service transport, errors, envelopes, inputs, receipts, + upload safety, and command help schemas. +- `src/commands/service.js`, `knowledge.js`, `dream.js`, `cloud-skill.js` — + command adapters and safety gates. +- `test/service-client.test.js`, `test/service-command.test.js` — regression + coverage for the first review findings and command contracts. +- `docs/design/XMEMO_CLI_SERVICE_CLIENT_BASELINE_2026-09-03.md` — this audit + record. + +Historical validation at that checkpoint: `npm test` 166 passed / 0 failed, +`npm run lint` passed, `git diff --check` passed, and `npm pack --dry-run` +passed using a worktree-local npm cache. These counts do not describe the final +scope. The prior review request was +`824402e1-b80e-4012-85c2-891b2b006ce1` and was changes-requested; a new request +must include the immutable scope snapshot supplied by the current DevFlow +review helper. + +Next authorized unit: CLI-01, implementing the shared REST transport, +validated inputs, error/envelope mapping, read receipts, and help schema while +preserving the existing CLI command behavior. diff --git a/docs/design/XMEMO_CLI_SERVICE_CLIENT_IMPLEMENTATION_STATUS_2026-09-04.md b/docs/design/XMEMO_CLI_SERVICE_CLIENT_IMPLEMENTATION_STATUS_2026-09-04.md new file mode 100644 index 0000000..602112a --- /dev/null +++ b/docs/design/XMEMO_CLI_SERVICE_CLIENT_IMPLEMENTATION_STATUS_2026-09-04.md @@ -0,0 +1,99 @@ +# XMemo CLI service-client implementation status + +Plan: `XMEMO-CLI-CLIENT-V1` +Audit date: 2026-09-04 +CLI base: `origin/main` at `49a959f5f3c1cd72b4d449209054e91bd158afe4` + +## Outcome + +The planned CLI code is implemented for the seven basic service calls and the +safe, currently available Knowledge, Dream, and Cloud Skill contracts. It is +not an "all features available in production" claim. The +two Cloud Skill write adapters are implemented but intentionally remain marked +`contract-required` until MemoryOS provides and deploys MOS-01. A new binary +file cannot yet replace the source of an existing file-backed Knowledge item, +because the checked service has no API that creates a new version under the +same Document ID; the CLI supports the safe existing-Document update route and +rejects unsupported source conversion. + +No merge, version bump, tag, package publish, service deployment, or production +write was performed. + +## Local validation + +- The targeted service suite passes. It includes child-process calls + through a real loopback HTTP server for all 19 frozen commands, delayed-body + timeouts, unknown writes, redaction, HTML 405 contract rejection, and a real + npm archive executed outside the repository. +- Package smoke: the archive was unpacked to a temporary directory; a CLI-only + copy called the HTTP fixture without `skills/`, while a Skill-only copy called + the fixture without CLI `src/`. +- Full-suite, lint, diff, and formal-review results are recorded only after they + actually run; this document does not treat the fixture as production E2E. +- Full repository suite, lint, package, and diff checks are rerun against the + final candidate before review; formal review is tracked outside this document. +- Server source rechecked at clean local/origin + `18d7a652de48181a43b10b8f771d3a8df5933884`; + both proposed MOS-01 paths remain absent. No deployed service was checked. + +## Work-unit coverage + +| Work unit | Status | Evidence / remaining condition | +| --- | --- | --- | +| CLI-00 | implemented | Registry freezes seven basic calls and exactly twelve domain entries. | +| CLI-01 | implemented locally | HTTPS/loopback origin gate, whole-response timeout/interruption, retry policy, envelopes, typed receipts/input, and command-specific JSON schema/examples. | +| CLI-02 | implemented locally | Optional explicit service scopes without changing default login scopes; origin-bound credentials; legacy migration gate; recursive error redaction; read-only `doctor --services`. | +| CLI-03 | implemented | Memory/context/state/restart adapters; full restart request models accepted through `--input`. | +| CLI-04 | implemented locally | Knowledge search/read, explicit first-page cursor, `--from` fixed-revision continuation, cursor propagation, unmodified citations/index state, and receipts. | +| CLI-05 | implemented locally; one server limitation | Text/document add, prevalidated base/Document, bounded upload/extraction, provenance-preserving CAS update, publish-only, partial/unknown outcomes. New binary replacement requires a same-Document version-upload server contract. | +| CLI-06 | implemented | Settings/entitlement preflight, stable idempotency key, apply availability, bounded wait, terminal failures, receipts, candidate validation, and single-item apply. | +| CLI-07 | implemented locally | Cloud Skill list/show/run, draft/published distinction, script selection, HTTP budget covering 1..60-second execution, business failure, and unknown outcome handling. | +| CLI-08 | adapter complete; service blocked | File/directory safety, create-only request, content CAS, publish confirmation, and no legacy fallback are implemented. MOS-01 must exist and be deployed. | +| CLI-09 | partial | Windows loopback HTTP and real-package isolation are covered. Unix execution and authorized test-tenant E2E remain pending. | +| CLI-10 | release-gated | Final immutable-scope review and release authorization remain external gates rather than implementation claims. | + +## Product boundary + +The public command surface remains limited to: + +- Basic calls: `memory add/search`, `context recall`, `state save/restore`, and + `restart snapshot/restore`. +- Knowledge: `add/search/read/update`. +- Dream: `preview/show/apply`. +- Cloud Skill: `add/list/show/update/run`. + +No revision, diff, export, rollback, schedule-management, batch, generic API, +or background-daemon command was added. + +## Safety and agent-use behavior + +- Every new business command supports `--json`; successful and failed machine + output is one envelope on stdout. +- Every business command accepts JSON input where the command contract permits + it. Cloud Skill run reserves `--input` for `input_args`, as frozen in the plan. +- Duplicate flag/JSON fields are rejected. High-impact actions require an + interactive confirmation or an explicit `--yes`; missing confirmation exits + with code 10. +- Read/show receipts bind origin, scope, resource, and viewed versions. Writes + never silently refresh a stale receipt. +- Side-effect requests are not automatically replayed. Disconnect or timeout + after a write is sent exits 11; known multi-step partial completion exits 12. +- Local SIGINT exits 130 for read/wait operations; interruption during an + already-sent write remains an unknown outcome. +- Human output includes the actual bounded service result instead of only an + acknowledgement line. +- Stored credentials are origin-bound. CLI and `skills/xmemo` retain separate + runtime code and credential stores; neither imports or shells out to the other. + +## Remaining external verification + +1. Implement, review, and deploy MOS-01 in `memory-os`, then run create/create + and update/update multi-instance races plus persisted reopen checks. +2. If direct replacement of a file-backed Knowledge source is required, add an + authorized same-Document version-upload contract in `memory-os`; the existing + `content/from-document` route already enforces that the Document ID matches. +3. Run authorized test-tenant E2E for one success and key failure path in each + domain, and verify the deployed service SHA, feature flags, extraction queue, + Dream entitlement, and Cloud Skill sandbox. +4. Repeat the package/HTTP checks on a Unix host. Do not infer production + readiness from the successful Windows fixture. diff --git a/package.json b/package.json index 3c66445..215b912 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@xmemo/client", "version": "0.4.181", - "description": "Privacy-first CLI and MCP setup helper for XMemo.", + "description": "Privacy-first CLI client and MCP setup tool for XMemo.", "mcpName": "io.github.yonro/xmemo", "type": "module", "bin": { diff --git a/plugins/kiro/POWER.md b/plugins/kiro/POWER.md index 84d4b52..58840bc 100644 --- a/plugins/kiro/POWER.md +++ b/plugins/kiro/POWER.md @@ -22,14 +22,14 @@ xmemo setup kiro This command: 1. Merges XMemo MCP server configuration into `~/.kiro/settings/mcp.json` -2. Sets up OAuth or environment-variable-based authentication +2. Configures Kiro to read Bearer authentication from the `XMEMO_KEY` environment variable 3. Adds agent identity headers for attribution ## Authentication ### Environment Variable (Required) -Kiro requires the `XMEMO_KEY` environment variable to be set for authentication. +Kiro requires the `XMEMO_KEY` environment variable to be set for MCP authentication. First, authenticate with XMemo: @@ -55,7 +55,7 @@ export XMEMO_KEY=$(xmemo token show --format raw) ### Why Environment Variable? -Kiro currently has a known issue with OAuth token persistence. Using an environment variable ensures reliable authentication across all sessions. +Kiro currently has a known issue with MCP OAuth token persistence. `xmemo login` may use a browser to obtain the CLI credential, but Kiro itself still reads `XMEMO_KEY`; using the environment variable ensures reliable authentication across sessions. ## Usage diff --git a/plugins/kiro/SETUP.md b/plugins/kiro/SETUP.md index 3b72bc2..7939089 100644 --- a/plugins/kiro/SETUP.md +++ b/plugins/kiro/SETUP.md @@ -29,13 +29,13 @@ npm install -g @xmemo/client Kiro requires the `XMEMO_KEY` environment variable to be set. Choose one of these methods: -#### Option A: OAuth Login (Recommended) +#### Option A: Browser Login for the CLI Credential (Recommended) ```bash xmemo login ``` -This opens your browser for secure OAuth authentication and automatically stores your token. +This opens your browser for secure device login and stores the CLI credential. It does not change Kiro's MCP authentication mode: Kiro still reads `XMEMO_KEY` from its launch environment. #### Option B: Direct Token diff --git a/src/api/client.js b/src/api/client.js new file mode 100644 index 0000000..6df9021 --- /dev/null +++ b/src/api/client.js @@ -0,0 +1,215 @@ +import { CLI_VERSION, COMMAND_NAME } from '../core/constants.js'; +import { UsageError } from '../core/errors.js'; +import { endpointUrl, normalizeBaseUrl } from '../network/http.js'; +import { ContractRequiredError, InterruptedError, ServiceClientError, UnknownOutcomeError, classifyHttpFailure } from './errors.js'; + +const DEFAULT_TIMEOUT_MS = 15000; +const DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024; +const LOOPBACK_HOSTS = new Set(['127.0.0.1', '[::1]', 'localhost']); + +export function assertServiceOrigin(baseUrl) { + const normalized = normalizeBaseUrl(baseUrl); + const supplied = new URL(baseUrl); + const parsed = new URL(normalized); + if (supplied.username || supplied.password || supplied.search || supplied.hash) throw new UsageError('Service URL must not contain credentials, query parameters, or fragments.'); + if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && LOOPBACK_HOSTS.has(parsed.hostname))) { + throw new UsageError('Service URL must use HTTPS; HTTP is allowed only for loopback development URLs.'); + } + return normalized; +} + +export function createServiceClient({ + baseUrl, + token, + io, + timeoutMs = DEFAULT_TIMEOUT_MS, + maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, + agentId, + agentInstanceId +}) { + const serviceBaseUrl = assertServiceOrigin(baseUrl); + if (!token || typeof token !== 'string') { + throw new ServiceClientError('Authentication is required for this service request.', { + code: 'AUTH_REQUIRED', + httpStatus: 401, + nextAction: `Run \`${COMMAND_NAME} login\` or provide ${'XMEMO_KEY'}.` + }); + } + if (typeof io?.fetch !== 'function') { + throw new UsageError('This Node runtime does not provide fetch; use Node.js 20 or newer.'); + } + if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) { + throw new UsageError('timeoutMs must be a positive integer.'); + } + + async function request({ method, path, query, body, sideEffect = false, retry = 'none', operation, timeoutMs: requestTimeoutMs = timeoutMs }) { + if (!Number.isSafeInteger(requestTimeoutMs) || requestTimeoutMs <= 0) throw new UsageError('Request timeout must be a positive integer.'); + if (io.signal?.aborted) throw new InterruptedError('Local request interrupted before transmission.'); + const url = buildUrl(serviceBaseUrl, path, query); + const headers = { + accept: 'application/json', + authorization: `Bearer ${token}`, + 'user-agent': `XMemo-CLI/${CLI_VERSION}` + }; + if (agentId) headers['X-Memory-OS-Agent-ID'] = agentId; + if (agentInstanceId) headers['X-Memory-OS-Agent-Instance-ID'] = agentInstanceId; + const init = { + method, + headers, + redirect: 'error' + }; + if (body !== undefined) { + headers['content-type'] = 'application/json'; + init.body = JSON.stringify(body); + } + + const attempts = retry === 'bounded' && !sideEffect ? 2 : 1; + let lastError; + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + const { response, payload } = await fetchWithTimeout(url, init, requestTimeoutMs, io, async (response) => { + let payload; + try { payload = await readJsonResponse(response, maxResponseBytes); } + catch (error) { + if (response.ok || !(error instanceof ServiceClientError)) throw error; + payload = null; + } + return { response, payload }; + }); + if (!response.ok) { + const safePayload = safeErrorData(payload, token); + const details = classifyHttpFailure(response.status, safePayload); + if (details.retryable && attempt + 1 < attempts) continue; + if ((details.httpStatus === 404 || details.httpStatus === 405) && operation?.contractRequired) { + throw new ContractRequiredError(`Server contract is unavailable for ${operation.name ?? path}.`, details); + } + if (sideEffect && (details.httpStatus === 408 || details.httpStatus >= 500)) { + throw new UnknownOutcomeError(`Service may have processed the write before returning HTTP ${details.httpStatus}: ${method} ${path}.`, { + ...details, + data: safePayload, + nextAction: '核对服务端资源状态;不要自动重试该写入。' + }); + } + throw new ServiceClientError(details.message, { ...details, data: safePayload }); + } + return { status: response.status, headers: response.headers, data: payload }; + } catch (error) { + if (error instanceof ContractRequiredError || (error instanceof ServiceClientError && error.httpStatus)) throw error; + if (sideEffect) throw new UnknownOutcomeError(`Service request outcome is unknown: ${method} ${path}.`, { cause: error, nextAction: '核对服务端资源状态;不要自动重试该写入。' }); + if (io.signal?.aborted) throw new InterruptedError('Local request interrupted.'); + if (error instanceof ServiceClientError) throw error; + lastError = error; + if (attempt + 1 >= attempts) { + const reason = error?.name === 'AbortError' ? `timeout after ${requestTimeoutMs}ms` : 'network transport error'; + throw new ServiceClientError(`Service request failed: ${method} ${path} (${reason}).`, { + code: error?.name === 'AbortError' ? 'REQUEST_TIMEOUT' : 'NETWORK_ERROR', + retryable: false, + outcome: 'known-failure', + cause: error + }); + } + } + } + throw lastError; + } + + return Object.freeze({ baseUrl: serviceBaseUrl, request }); +} + +function buildUrl(baseUrl, path, query) { + const url = new URL(endpointUrl(baseUrl, path)); + if (query && typeof query === 'object') { + for (const [key, value] of Object.entries(query)) { + if (value !== undefined && value !== null) url.searchParams.set(key, String(value)); + } + } + if (url.origin !== new URL(baseUrl).origin) { + throw new UsageError('Service request path must remain on the configured service origin.'); + } + return url; +} + +async function fetchWithTimeout(url, init, timeoutMs, io, consume) { + const controller = new AbortController(); + let rejectAbort; + const aborted = new Promise((_, reject) => { rejectAbort = reject; }); + const onAbort = () => rejectAbort(Object.assign(new Error('Request aborted.'), { name: 'AbortError' })); + controller.signal.addEventListener('abort', onAbort, { once: true }); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + const interrupt = () => controller.abort(); + if (io.signal?.aborted) controller.abort(); + else io.signal?.addEventListener?.('abort', interrupt, { once: true }); + try { + return await Promise.race([ + Promise.resolve().then(() => io.fetch(url.toString(), { ...init, signal: controller.signal })).then(consume), + aborted + ]); + } finally { + clearTimeout(timeout); + io.signal?.removeEventListener?.('abort', interrupt); + controller.signal.removeEventListener('abort', onAbort); + } +} + +async function readJsonResponse(response, maxBytes) { + const declaredLength = response.headers?.get?.('content-length'); + if (declaredLength && Number.isFinite(Number(declaredLength)) && Number(declaredLength) > maxBytes) { + await response.body?.cancel?.(); + throw new ServiceClientError(`Service response exceeded the ${maxBytes}-byte limit.`, { code: 'RESPONSE_TOO_LARGE' }); + } + let text = ''; + try { + if (response.body?.getReader) { + const reader = response.body.getReader(); + const decoder = new TextDecoder('utf-8', { fatal: true }); + let bytes = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength; + if (bytes > maxBytes) { + await reader.cancel(); + throw new ServiceClientError(`Service response exceeded the ${maxBytes}-byte limit.`, { code: 'RESPONSE_TOO_LARGE' }); + } + text += decoder.decode(value, { stream: true }); + } + text += decoder.decode(); + } finally { + reader.releaseLock(); + } + } else { + text = await response.text(); + } + } catch (error) { + if (error instanceof ServiceClientError) throw error; + throw new ServiceClientError('Could not read service response.', { code: 'INVALID_RESPONSE', cause: error }); + } + if (Buffer.byteLength(text, 'utf8') > maxBytes) { + throw new ServiceClientError(`Service response exceeded the ${maxBytes}-byte limit.`, { code: 'RESPONSE_TOO_LARGE' }); + } + if (!text.trim()) return null; + try { + return JSON.parse(text); + } catch (error) { + throw new ServiceClientError('Service returned invalid JSON.', { code: 'INVALID_RESPONSE', cause: error }); + } +} + +function safeErrorData(payload, token) { + return redact(payload, new WeakSet(), token); +} + +function redact(value, seen, token) { + if (typeof value === 'string') return value.split(token).join('[REDACTED]').replace(/Bearer\s+[^\s"']+/gi, 'Bearer [REDACTED]'); + if (value === null || typeof value !== 'object') return value; + if (seen.has(value)) return '[Circular]'; + seen.add(value); + if (Array.isArray(value)) return value.map((entry) => redact(entry, seen, token)); + const copy = {}; + for (const [key, entry] of Object.entries(value)) { + if (/token|cookie|secret|authorization|oauth|private.?key/i.test(key)) continue; + copy[key] = redact(entry, seen, token); + } + return copy; +} diff --git a/src/api/confirmation.js b/src/api/confirmation.js new file mode 100644 index 0000000..d66f6e4 --- /dev/null +++ b/src/api/confirmation.js @@ -0,0 +1,22 @@ +import { createInterface } from 'node:readline/promises'; + +import { hasFlag } from '../core/args.js'; +import { ConfirmationRequiredError } from './errors.js'; + +export async function confirmRemoteAction(args, io, message) { + if (hasFlag(args, '--yes')) return; + if (hasFlag(args, '--json') || !io.stdin?.isTTY) throw new ConfirmationRequiredError(message); + let accepted; + if (typeof io.confirm === 'function') { + accepted = await io.confirm(`${message} [y/N] `); + } else { + const prompt = createInterface({ input: io.stdin, output: io.stderr }); + try { + const answer = await prompt.question(`${message} [y/N] `); + accepted = /^(y|yes)$/i.test(answer.trim()); + } finally { + prompt.close(); + } + } + if (!accepted) throw new ConfirmationRequiredError('Operation cancelled before any remote write.'); +} diff --git a/src/api/contracts/command-registry.js b/src/api/contracts/command-registry.js new file mode 100644 index 0000000..b6f7ef5 --- /dev/null +++ b/src/api/contracts/command-registry.js @@ -0,0 +1,215 @@ +/** + * The deliberately small REST surface for XMemo CLI service commands. + * + * This registry is source-level scope, not a promise that every entry is + * implemented. `availability` records whether the current service contract + * can support the command safely; commands marked contract-required must not + * fall back to an older upsert endpoint. + */ + +const read = Object.freeze({ sideEffect: false, retry: 'bounded' }); +const readPost = Object.freeze({ sideEffect: false, retry: 'bounded' }); +const write = Object.freeze({ sideEffect: true, retry: 'none' }); + +export const SERVICE_CONTRACT_VERSION = 'memory-os-openapi@2026-09-03'; + +export const COMMAND_REGISTRY = Object.freeze([ + { + command: 'memory.add', + domain: 'memory', + method: 'POST', + path: '/api/v1/remember', + scopes: ['memory:write'], + availability: 'current', + ...write + }, + { + command: 'memory.search', + domain: 'memory', + method: 'GET', + path: '/api/v1/recall', + scopes: ['memory:read'], + availability: 'current', + ...read + }, + { + command: 'context.recall', + domain: 'context', + method: 'POST', + path: '/api/v1/recall/context', + scopes: ['memory:read'], + availability: 'current', + ...readPost + }, + { + command: 'state.save', + domain: 'state', + method: 'POST', + path: '/api/v1/update_state', + scopes: ['memory:write'], + availability: 'current', + ...write + }, + { + command: 'state.restore', + domain: 'state', + method: 'POST', + path: '/api/v1/skill/operations', + operation: 'state-restore', + scopes: ['memory:read'], + availability: 'current', + ...readPost + }, + { + command: 'restart.snapshot', + domain: 'restart', + method: 'POST', + path: '/api/v1/restart/snapshot', + scopes: ['memory:write'], + availability: 'current', + ...write + }, + { + command: 'restart.restore', + domain: 'restart', + method: 'POST', + path: '/api/v1/restart/restore', + scopes: ['memory:read', 'memory:restore'], + availability: 'current', + ...write + }, + { + command: 'knowledge.add', + domain: 'knowledge', + method: 'POST', + path: [ + '/api/v1/knowledge-bases', + '/api/v1/documents', + '/api/v1/knowledge-bases/{knowledge_base_id}/items', + '/api/v1/knowledge-bases/{knowledge_base_id}/items/from-document' + ], + scopes: ['knowledge:write'], + conditionalScopes: ['memory:write', 'memory:read', 'knowledge:read'], + scopeNotes: ['Document upload requires memory:write; extraction and existing-Document checks require memory:read. Base selection/default validation requires knowledge:read.'], + availability: 'current', + ...write + }, + { + command: 'knowledge.search', + domain: 'knowledge', + method: 'POST', + path: '/api/v1/knowledge/search', + scopes: ['knowledge:read'], + availability: 'current', + ...readPost + }, + { + command: 'knowledge.read', + domain: 'knowledge', + method: 'GET', + path: [ + '/api/v1/knowledge-items/{knowledge_item_id}', + '/api/v1/knowledge-items/{knowledge_item_id}/revisions/{knowledge_revision_id}' + ], + scopes: ['knowledge:read'], + availability: 'current', + ...read + }, + { + command: 'knowledge.update', + domain: 'knowledge', + method: 'PUT/PATCH', + path: [ + '/api/v1/knowledge-items/{knowledge_item_id}/content', + '/api/v1/knowledge-items/{knowledge_item_id}/content/from-document', + '/api/v1/knowledge-items/{knowledge_item_id}' + ], + scopes: ['knowledge:write'], + availability: 'current', + ...write + }, + { + command: 'dream.preview', + domain: 'dream', + method: 'GET/POST', + path: ['/api/v1/me/dream/settings', '/api/v1/me/dream/runs'], + scopes: ['memory:read', 'memory:write'], + availability: 'current', + ...write + }, + { + command: 'dream.show', + domain: 'dream', + method: 'GET', + path: '/api/v1/me/dream/runs/{run_id}', + scopes: ['memory:read'], + availability: 'current', + ...read + }, + { + command: 'dream.apply', + domain: 'dream', + method: 'POST', + path: '/api/v1/me/dream/runs/{run_id}/confirm', + scopes: ['memory:write'], + availability: 'current', + ...write + }, + { + command: 'cloud-skill.add', + domain: 'cloud-skill', + method: 'POST', + path: '/v1/skills/create', + scopes: ['memory:write'], + availability: 'contract-required', + ...write + }, + { + command: 'cloud-skill.list', + domain: 'cloud-skill', + method: 'GET', + path: '/v1/skills', + scopes: ['memory:read'], + availability: 'current', + ...read + }, + { + command: 'cloud-skill.show', + domain: 'cloud-skill', + method: 'GET', + path: ['/v1/skills/{skill_id}', '/v1/skills/{skill_id}/components'], + scopes: ['memory:read'], + availability: 'current', + ...read + }, + { + command: 'cloud-skill.update', + domain: 'cloud-skill', + method: 'PUT', + path: '/v1/skills/{skill_id}/content', + scopes: ['memory:write'], + availability: 'contract-required', + ...write + }, + { + command: 'cloud-skill.run', + domain: 'cloud-skill', + method: 'POST', + path: '/v1/skills/{skill_id}/execute', + scopes: ['memory:write'], + availability: 'current', + ...write + } +]); + +export function commandSpec(command) { + return COMMAND_REGISTRY.find((entry) => entry.command === command) ?? null; +} + +export function commandsForDomain(domain) { + return COMMAND_REGISTRY.filter((entry) => entry.domain === domain); +} + +export function unavailableCommands() { + return COMMAND_REGISTRY.filter((entry) => entry.availability !== 'current'); +} diff --git a/src/api/contracts/help-schema.js b/src/api/contracts/help-schema.js new file mode 100644 index 0000000..1972f17 --- /dev/null +++ b/src/api/contracts/help-schema.js @@ -0,0 +1,88 @@ +import { commandSpec } from './command-registry.js'; +import { writeLine } from '../../core/io.js'; +import { commandInputSchema } from './input-schema.js'; + +const common = Object.freeze({ + '--json': { type: 'boolean', description: '输出单个 JSON envelope。' }, + '--base-url': { type: 'https-url', description: '目标 XMemo 服务地址。' }, + '--timeout-ms': { type: 'integer>0', description: '单次 HTTP 请求超时。' }, + '--allow-legacy-credential': { type: 'boolean', description: '仅允许无 origin 元数据的旧凭证连接默认服务;推荐重新登录迁移。' } +}); + +const INPUT_COMMANDS = new Set([ + 'memory.add', 'memory.search', 'context.recall', 'state.save', 'state.restore', + 'restart.snapshot', 'restart.restore', 'knowledge.add', 'knowledge.search', + 'knowledge.read', 'knowledge.update', 'dream.preview', 'dream.show', 'dream.apply', + 'cloud-skill.add', 'cloud-skill.list', 'cloud-skill.show', 'cloud-skill.update', 'cloud-skill.run' +]); + +const option = (type, description) => ({ type, description }); +const COMMAND_OPTIONS = Object.freeze({ + 'memory.add': { '--content': option('string', '记忆正文。'), '--path': option('string', '记忆路径。'), '--bucket': option('string', '数据桶。'), '--scope': option('string', '空间。'), '--team': option('id', '团队 ID。') }, + 'memory.search': { '': option('string', '检索文本。'), '--limit': option('integer>0', '结果上限。'), '--team': option('id', '团队 ID。'), '--bucket': option('string', '数据桶。'), '--path': option('string', '路径过滤。'), '--prefer-working': option('boolean', '优先 working 记忆。') }, + 'context.recall': { '': option('string', '召回目标。'), '--include-knowledge': option('boolean', '包含知识库结果。'), '--team': option('id', '团队 ID。') }, + 'state.save': { '--state-key': option('string', '状态槽。'), '--content': option('string', '状态正文。'), '--current-task': option('string', '当前任务。'), '--next-action': option('string', '下一动作。'), '--blocked-reason': option('string', '阻塞原因。'), '--ttl-seconds': option('integer>=0', '存活时间。') }, + 'state.restore': { '--state-key': option('string', '状态槽。'), '--bucket': option('string', '数据桶。'), '--scope': option('string', '空间。') }, + 'restart.snapshot': { '--state-key': option('string', '状态槽。'), '--bucket': option('string', '数据桶。'), '--scope': option('string', '空间。') }, + 'restart.restore': { '--snapshot-id': option('id', '快照 ID。'), '--state-key': option('string', '状态槽。'), '--bucket': option('string', '数据桶。'), '--scope': option('string', '空间。') }, + 'knowledge.add': { '--base': option('id', '知识库 ID。'), '--create-base': option('string', '显式新建知识库。'), '--title': option('string', '条目标题。'), '--text': option('string', '文本内容。'), '--file': option('path', '文本或文档文件。'), '--document': option('id', '已有 Document ID。'), '--document-version': option('integer>0', 'Document 版本。'), '--publish': option('boolean', '创建为发布状态。'), '--yes': option('boolean', '确认发布。'), '--team': option('id', '团队 ID。') }, + 'knowledge.search': { '': option('string', '检索文本。'), '--base': option('id', '知识库 ID。'), '--limit': option('integer>0', '结果上限。'), '--cursor': option('string', '服务端游标。'), '--team': option('id', '团队 ID。') }, + 'knowledge.read': { '': option('id', '知识条目 ID。'), '--offset': option('integer>=0', '正文偏移。'), '--limit-chars': option('integer>0', '本页字符数。'), '--team': option('id', '团队 ID。') }, + 'knowledge.update': { '': option('id', '知识条目 ID。'), '--from': option('path', 'knowledge read JSON。'), '--text': option('string', '新文本。'), '--file': option('path', '新文本文件。'), '--document': option('id', '同一来源 Document ID。'), '--document-version': option('integer>0', 'Document 版本。'), '--publish': option('boolean', '修改线上内容或发布草稿。'), '--yes': option('boolean', '确认发布。'), '--team': option('id', '团队 ID。') }, + 'dream.preview': { '--window-days': option('1..365', '回看天数。'), '--wait': option('boolean', '本地等待完成。'), '--wait-timeout': option('integer>0', '本地等待上限。'), '--idempotency-key': option('string', '复用同一预览意图。'), '--team': option('id', '团队 ID。') }, + 'dream.show': { '': option('id', 'Dream run ID。'), '--wait': option('boolean', '本地等待完成。'), '--wait-timeout': option('integer>0', '本地等待上限。'), '--team': option('id', '团队 ID。') }, + 'dream.apply': { '': option('id', 'Dream run ID。'), '--item': option('id', '单个候选 ID。'), '--from': option('path', 'dream show JSON。'), '--yes': option('boolean', '确认写入。'), '--team': option('id', '团队 ID。') }, + 'cloud-skill.add': { '--file': option('SKILL.md', '单文件技能。'), '--dir': option('directory', '技能目录。'), '--name': option('string', '显示名。'), '--slug': option('string', '唯一 slug。'), '--publish': option('boolean', '请求发布。'), '--yes': option('boolean', '确认发布。'), '--team': option('id', '团队 ID。') }, + 'cloud-skill.list': { '--team': option('id', '团队 ID。') }, + 'cloud-skill.show': { '': option('id', '技能 ID。'), '--draft': option('boolean', '读取最新维护版本。'), '--team': option('id', '团队 ID。') }, + 'cloud-skill.update': { '': option('id', '技能 ID。'), '--file': option('SKILL.md', '单文件技能。'), '--dir': option('directory', '技能目录。'), '--from': option('path', 'cloud-skill show JSON。'), '--publish': option('boolean', '请求发布。'), '--yes': option('boolean', '确认发布。'), '--team': option('id', '团队 ID。') }, + 'cloud-skill.run': { '': option('id', '技能 ID。'), '--script': option('logical-path', '明确脚本入口。'), '--input': option('path', '包含 input_args 的 JSON。'), '--from': option('path', 'published show JSON。'), '--yes': option('boolean', '确认远端执行。'), '--timeout-seconds': option('1..60', '脚本运行上限。'), '--team': option('id', '团队 ID。') } +}); + +const CONFIRMATION = Object.freeze({ + 'knowledge.add': { when: 'publish=true', flag: '--yes' }, + 'knowledge.update': { when: 'publish=true', flag: '--yes' }, + 'dream.apply': { when: 'always', flag: '--yes' }, + 'cloud-skill.add': { when: 'publish=true', flag: '--yes' }, + 'cloud-skill.update': { when: 'publish=true', flag: '--yes' }, + 'cloud-skill.run': { when: 'always', flag: '--yes' } +}); + +export function serviceHelpSchema(command) { + const spec = commandSpec(command); + if (!spec) return null; + return { + schemaVersion: '1', + command, + method: spec.method, + path: spec.path, + scopes: spec.scopes, + ...(spec.conditionalScopes ? { conditionalScopes: spec.conditionalScopes } : {}), + ...(spec.scopeNotes ? { scopeNotes: spec.scopeNotes } : {}), + sideEffect: spec.sideEffect, + availability: spec.availability, + inputSchema: commandInputSchema(command), + examples: [{ input: commandInputSchema(command).examples[0], invocation: `xmemo ${command.replace('.', ' ')} --input params.json --json`, additionalFlags: command.startsWith('cloud-skill.') && ['cloud-skill.add', 'cloud-skill.update'].includes(command) ? ['--file SKILL.md (or --dir folder)'] : command === 'cloud-skill.run' ? ['', '--from skill-view.json', '--yes'] : CONFIRMATION[command]?.when === 'always' ? ['--yes'] : [] }], + options: { + ...common, + ...(INPUT_COMMANDS.has(command) ? { '--input': option('path|-', '从 JSON 文件或 stdin 读取命令参数。') } : {}), + ...(COMMAND_OPTIONS[command] ?? {}), + ...(command === 'knowledge.read' ? { '--from': option('path', '后续分页使用前页 JSON,固定原修订。') } : {}), + ...(command === 'knowledge.add' ? { '--wait-timeout': option('integer>0', '文档抽取的本地等待上限。') } : {}), + ...(command === 'state.save' ? { '--bucket': option('string', '数据桶。'), '--scope': option('string', '空间。') } : {}), + ...(command === 'cloud-skill.run' ? { '--input': option('path|-', '包含 input_args 的 JSON 文件或 stdin。') } : {}) + }, + confirmation: CONFIRMATION[command] ?? null, + notes: [ + 'flags 与 --input 中的同名字段不能重复。', + '写入请求默认不自动重试;未知结果不会被当作失败重放。' + ] + }; +} + +export function writeServiceHelpSchema(io, command) { + const schema = serviceHelpSchema(command); + if (!schema) return false; + writeLine(io.stdout, JSON.stringify(schema)); + return true; +} diff --git a/src/api/contracts/input-schema.js b/src/api/contracts/input-schema.js new file mode 100644 index 0000000..7bc27a9 --- /dev/null +++ b/src/api/contracts/input-schema.js @@ -0,0 +1,33 @@ +const definitions = { + 'memory.add': ['content path bucket scope team_id metadata:object memory_type', { content: 'Synthetic memory', path: 'examples/cli' }], + 'memory.search': ['query limit:integer team_id bucket path prefer_working:boolean', { query: 'Synthetic', limit: 5 }], + 'context.recall': ['query include_knowledge:boolean team_id scope limit:integer max_items:integer max_tokens:integer path bucket memory_type status threshold:number prefer_working:boolean', { query: 'Synthetic', max_items: 5, include_knowledge: false }], + 'state.save': ['state_key content current_task next_action blocked_reason metadata:object source bucket scope path ttl_seconds:integer', { state_key: 'active_task', current_task: 'Review changes', next_action: 'Run checks' }], + 'state.restore': ['state_key bucket scope', { state_key: 'active_task' }], + 'restart.snapshot': ['session_id state_key timeline_limit:integer reminder_limit:integer decision_limit:integer metadata:object source bucket scope path ttl_seconds:integer', { state_key: 'active_task', timeline_limit: 5 }], + 'restart.restore': ['snapshot_id source_session_id target_session_id state_key restore_state:boolean record_restore_event:boolean ttl_seconds:integer source bucket scope', { snapshot_id: 'snapshot-id', restore_state: false, record_restore_event: false }], + 'knowledge.add': ['knowledge_base_id title content document_id document_version:integer team_id publish:boolean', { knowledge_base_id: 'base-id', title: 'Example', content: 'Synthetic knowledge', publish: false }], + 'knowledge.search': ['query knowledge_base_id limit:integer cursor mode alpha:number query_embedding:array k:integer team_id scope', { query: 'Synthetic', limit: 5 }], + 'knowledge.read': ['item_id from team_id offset:integer limit_chars:integer', { item_id: 'item-id', limit_chars: 10000 }], + 'knowledge.update': ['item_id from content document_id document_version:integer team_id publish:boolean', { item_id: 'item-id', from: 'knowledge-view.json', content: 'Updated text', publish: false }], + 'dream.preview': ['window_days:integer idempotency_key wait:boolean wait_timeout:integer team_id', { window_days: 30, wait: true, wait_timeout: 120000 }], + 'dream.show': ['run_id wait:boolean wait_timeout:integer team_id', { run_id: 'run-id', wait: false }], + 'dream.apply': ['run_id item_id from team_id', { run_id: 'run-id', item_id: 'candidate-id', from: 'dream-view.json' }], + 'cloud-skill.add': ['name slug team_id publish:boolean', { name: 'Example', slug: 'example', publish: false }], + 'cloud-skill.list': ['team_id', {}], + 'cloud-skill.show': ['skill_id draft:boolean team_id', { skill_id: 'skill-id', draft: false }], + 'cloud-skill.update': ['skill_id from team_id publish:boolean', { skill_id: 'skill-id', from: 'skill-view.json', publish: false }], + 'cloud-skill.run': ['input_args:object script_path', { input_args: { text: 'Synthetic input' } }] +}; + +export function commandInputSchema(command) { + const [fields, example] = definitions[command]; + return { + type: 'object', additionalProperties: false, + properties: Object.fromEntries(fields.split(' ').map((field) => { + const [name, type = 'string'] = field.split(':'); + return [name, { type }]; + })), + examples: [example] + }; +} diff --git a/src/api/envelope.js b/src/api/envelope.js new file mode 100644 index 0000000..6d34d79 --- /dev/null +++ b/src/api/envelope.js @@ -0,0 +1,34 @@ +import { writeLine } from '../core/io.js'; +import { errorEnvelope } from './errors.js'; + +export function successEnvelope(command, data, meta = {}) { + return { + schemaVersion: '1', + ok: true, + command, + data, + meta: { + readReceipt: meta.readReceipt ?? null, + warnings: Array.isArray(meta.warnings) ? meta.warnings : [], + nextCursor: meta.nextCursor ?? null, + ...(meta.uploadManifest ? { uploadManifest: meta.uploadManifest } : {}) + }, + error: null + }; +} + +export function writeEnvelope(io, envelope) { + writeLine(io.stdout, JSON.stringify(envelope)); +} + +export function writeSuccess(io, command, data, meta) { + const envelope = successEnvelope(command, data, meta); + writeEnvelope(io, envelope); + return envelope; +} + +export function writeFailure(io, command, error) { + const envelope = errorEnvelope(error, command); + writeEnvelope(io, envelope); + return envelope; +} diff --git a/src/api/errors.js b/src/api/errors.js new file mode 100644 index 0000000..53cf8a8 --- /dev/null +++ b/src/api/errors.js @@ -0,0 +1,190 @@ +export const EXIT_CODES = Object.freeze({ + SUCCESS: 0, + INTERNAL: 1, + INPUT: 2, + UNAUTHENTICATED: 3, + FORBIDDEN: 4, + NOT_FOUND: 5, + CONFLICT: 6, + SERVICE: 7, + REMOTE_FAILURE: 8, + CONFIRMATION_REQUIRED: 10, + UNKNOWN_OUTCOME: 11, + PARTIAL: 12, + LOCAL_TIMEOUT: 124, + INTERRUPTED: 130 +}); + +export class ServiceClientError extends Error { + constructor(message, details = {}) { + super(message); + this.name = 'ServiceClientError'; + this.code = details.code ?? 'SERVICE_ERROR'; + this.httpStatus = details.httpStatus ?? null; + this.serviceCode = details.serviceCode ?? null; + this.retryable = details.retryable === true; + this.outcome = details.outcome ?? 'known-failure'; + this.nextAction = details.nextAction ?? null; + this.cause = details.cause; + this.data = details.data; + } +} + +export class UnknownOutcomeError extends ServiceClientError { + constructor(message, details = {}) { + super(message, { + ...details, + code: details.code ?? 'REQUEST_OUTCOME_UNKNOWN', + outcome: 'unknown', + retryable: false, + nextAction: details.nextAction ?? '核对服务端状态后再决定是否继续。' + }); + this.name = 'UnknownOutcomeError'; + } +} + +export class ContractRequiredError extends ServiceClientError { + constructor(message, details = {}) { + super(message, { + ...details, + code: 'SERVER_CONTRACT_REQUIRED', + outcome: 'not-sent', + retryable: false, + nextAction: details.contractNextAction ?? '升级服务端契约后重试;CLI 不会回退到旧写接口。' + }); + this.name = 'ContractRequiredError'; + } +} + +export class PartialCompletionError extends ServiceClientError { + constructor(message, details = {}) { + super(message, { + ...details, + code: details.code ?? 'PARTIAL_COMPLETION', + outcome: 'partial', + retryable: false, + nextAction: details.nextAction ?? '使用返回的资源 ID 继续核对或完成后续步骤。' + }); + this.name = 'PartialCompletionError'; + } +} + +export class InterruptedError extends ServiceClientError { + constructor(message = 'Local operation interrupted.', details = {}) { + super(message, { + ...details, + code: 'INTERRUPTED', + outcome: 'known-failure', + retryable: false, + nextAction: details.nextAction ?? null + }); + this.name = 'InterruptedError'; + } +} + +export class ConfirmationRequiredError extends ServiceClientError { + constructor(message, details = {}) { + super(message, { + ...details, + code: 'CONFIRMATION_REQUIRED', + outcome: 'not-sent', + retryable: false, + nextAction: details.nextAction ?? 'Review the operation and rerun with --yes in non-interactive mode.' + }); + this.name = 'ConfirmationRequiredError'; + } +} + +export class PrerequisiteRequiredError extends ServiceClientError { + constructor(message, details = {}) { + super(message, { + ...details, + code: details.code ?? 'PREREQUISITE_REQUIRED', + outcome: 'not-sent', + retryable: false, + nextAction: details.nextAction ?? null + }); + this.name = 'PrerequisiteRequiredError'; + } +} + +export function classifyHttpFailure(status, payload) { + const serviceError = payload?.error; + const detail = payload?.detail; + const serviceCode = typeof serviceError === 'object' + ? serviceError.code ?? serviceError.error_code ?? null + : typeof detail === 'object' + ? detail.code ?? detail.error_code ?? null + : typeof payload?.code === 'string' ? payload.code : null; + const message = typeof serviceError === 'string' + ? serviceError + : serviceError?.message + ?? payload?.detail?.message + ?? payload?.detail + ?? payload?.message + ?? `HTTP ${status}`; + const scopeText = `${serviceCode ?? ''} ${message}`.toLowerCase(); + const code = status === 401 ? 'AUTH_REQUIRED' + : status === 403 ? 'PERMISSION_DENIED' + : status === 404 ? 'NOT_FOUND' + : status === 409 ? 'CONFLICT' + : status === 422 ? 'VALIDATION_ERROR' + : status === 429 ? 'RATE_LIMITED' + : status >= 500 ? 'SERVICE_UNAVAILABLE' + : 'SERVICE_ERROR'; + return { + code, + message: String(message), + httpStatus: status, + serviceCode, + retryable: status === 408 || status === 425 || status === 429 || status >= 500, + outcome: 'known-failure', + nextAction: status === 401 ? '登录或重新授权后重试。' + : status === 403 && /document|memory.*scope|scope.*memory/.test(scopeText) ? '重新授权:xmemo login --scopes knowledge:write,memory:write。' + : status === 403 ? '检查授权 scope、账号角色和团队空间。' + : status === 404 ? '检查资源 ID 与服务契约。' + : status === 409 ? '重新读取最新资源后再提交。' + : status === 429 || status >= 500 ? '稍后重试;写入请求不会自动重放。' + : '修正请求后重试。' + }; +} + +export function errorToExitCode(error) { + if (error?.name === 'UsageError') return EXIT_CODES.INPUT; + if (error?.code === 'INTERRUPTED') return EXIT_CODES.INTERRUPTED; + if (error?.code === 'CONFIRMATION_REQUIRED' || error?.code === 'PREREQUISITE_REQUIRED') return EXIT_CODES.CONFIRMATION_REQUIRED; + if (error?.code === 'LOCAL_WAIT_TIMEOUT') return EXIT_CODES.LOCAL_TIMEOUT; + if (error instanceof UnknownOutcomeError) return EXIT_CODES.UNKNOWN_OUTCOME; + if (error instanceof ContractRequiredError) return EXIT_CODES.SERVICE; + if (error instanceof PartialCompletionError) return EXIT_CODES.PARTIAL; + if (error instanceof ServiceClientError) { + if (error.httpStatus === 401) return EXIT_CODES.UNAUTHENTICATED; + if (error.httpStatus === 403) return EXIT_CODES.FORBIDDEN; + if (error.httpStatus === 404) return EXIT_CODES.NOT_FOUND; + if (error.httpStatus === 409) return EXIT_CODES.CONFLICT; + if (error.code === 'SKILL_EXECUTION_FAILED' || error.code === 'DREAM_RUN_FAILED') return EXIT_CODES.REMOTE_FAILURE; + if (error.outcome === 'partial') return EXIT_CODES.PARTIAL; + return EXIT_CODES.SERVICE; + } + return EXIT_CODES.INTERNAL; +} + +export function errorEnvelope(error, command) { + return { + schemaVersion: '1', + ok: false, + command, + data: null, + meta: { readReceipt: null, warnings: [], nextCursor: null }, + error: { + code: error?.code ?? (error?.name === 'UsageError' ? 'INPUT_ERROR' : 'INTERNAL_ERROR'), + message: error?.message ?? 'Unexpected error.', + httpStatus: error?.httpStatus ?? null, + serviceCode: error?.serviceCode ?? null, + retryable: error?.retryable === true, + outcome: error?.outcome ?? 'known-failure', + nextAction: error?.nextAction ?? null, + ...(error?.data ? { data: error.data } : {}) + } + }; +} diff --git a/src/api/input.js b/src/api/input.js new file mode 100644 index 0000000..20d098f --- /dev/null +++ b/src/api/input.js @@ -0,0 +1,68 @@ +import { optionValue } from '../core/args.js'; +import { UsageError } from '../core/errors.js'; +import { readTextFileBounded, readTextStreamBounded } from './text-input.js'; + +export async function readJsonInput(args, io) { + const inputPath = optionValue(args, '--input'); + if (!inputPath) return null; + const normalized = inputPath === '-' + ? await readTextStreamBounded(io.stdin, 'JSON input stdin') + : await readTextFileBounded(inputPath, 'JSON input'); + try { + const value = JSON.parse(normalized); + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('top-level value must be a JSON object'); + } + return value; + } catch (error) { + throw new UsageError(`Invalid JSON input ${inputPath}: ${error.message}`); + } +} + +export function rejectInputFlagConflicts(input, flagEntries, args = null) { + if (!input) return; + for (const [flag, key] of flagEntries) { + if (args && !args.includes(flag)) continue; + if (input[key] !== undefined) { + throw new UsageError(`Input field '${key}' conflicts with command option ${flag}; provide it in one place only.`); + } + } +} + +export function assertNoUnknownInputFields(input, allowed) { + if (!input) return; + const allowedSet = new Set(allowed); + const unknown = Object.keys(input).filter((key) => !allowedSet.has(key)); + if (unknown.length > 0) throw new UsageError(`Unsupported input field(s): ${unknown.join(', ')}.`); +} + +export function booleanInput(input, key) { + const value = input?.[key]; + if (value === undefined) return false; + if (typeof value !== 'boolean') throw new UsageError(`Input field '${key}' must be a boolean.`); + return value; +} + +export function optionalBooleanInput(input, key) { + const value = input?.[key]; + if (value === undefined) return undefined; + if (typeof value !== 'boolean') throw new UsageError(`Input field '${key}' must be a boolean.`); + return value; +} + +export function assertKnownOptions(args, allowed) { + const allowedSet = new Set(allowed); + const optionsWithValue = new Set(allowed.filter((option) => !['--services', '--json', '--yes', '--wait', '--publish', '--draft', '--include-knowledge', '--prefer-working', '--allow-legacy-credential'].includes(option))); + const seen = new Set(); + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (!token.startsWith('--')) continue; + if (!allowedSet.has(token)) throw new UsageError(`Unsupported option: ${token}.`); + if (seen.has(token)) throw new UsageError(`Duplicate option: ${token}.`); + seen.add(token); + if (optionsWithValue.has(token)) { + optionValue(args, token); + index += 1; + } + } +} diff --git a/src/api/read-receipt.js b/src/api/read-receipt.js new file mode 100644 index 0000000..054d095 --- /dev/null +++ b/src/api/read-receipt.js @@ -0,0 +1,52 @@ +import crypto from 'node:crypto'; + +import { UsageError } from '../core/errors.js'; +import { readTextFileBounded } from './text-input.js'; + +export function createReadReceipt({ baseUrl, resource, scope, revision, latestRevision, version, itemStatus, settingsVersion, content, revisionStatus, revisionKind, candidateItemIds, sourceType, sourceRef }) { + const receipt = { + schemaVersion: '1', + serviceOrigin: new URL(baseUrl).origin, + resource, + scope: scope ?? null, + displayedRevision: revision ?? null, + latestRevision: latestRevision ?? revision ?? null, + version: version ?? null, + sourceType: sourceType ?? null, + sourceRef: sourceRef ?? null, + itemStatus: itemStatus ?? null, + revisionStatus: revisionStatus ?? null, + revisionKind: revisionKind ?? null, + settingsVersion: settingsVersion ?? null, + candidateItemIds: Array.isArray(candidateItemIds) ? candidateItemIds.map(String) : null, + contentSha256: typeof content === 'string' + ? crypto.createHash('sha256').update(content, 'utf8').digest('hex') + : null + }; + return Object.freeze(receipt); +} + +export async function readAndValidateReceipt(filePath, { baseUrl, resource, scope } = {}) { + const normalized = await readTextFileBounded(filePath, 'receipt file'); + let receipt; + try { + const parsed = JSON.parse(normalized); + receipt = parsed?.meta?.readReceipt ?? parsed; + } catch (error) { + throw new UsageError(`Invalid read receipt JSON in ${filePath}: ${error.message}`); + } + if (!receipt || typeof receipt !== 'object' || receipt.schemaVersion !== '1') { + throw new UsageError(`Invalid read receipt in ${filePath}: schemaVersion 1 is required.`); + } + if (baseUrl && receipt.serviceOrigin !== new URL(baseUrl).origin) { + throw new UsageError('Read receipt belongs to a different service origin.'); + } + if (resource && receipt.resource !== resource) { + throw new UsageError('Read receipt belongs to a different resource.'); + } + if (scope !== undefined && receipt.scope !== scope) { + throw new UsageError('Read receipt belongs to a different scope.'); + } + if (typeof receipt.displayedRevision !== 'string' || !receipt.displayedRevision.trim()) throw new UsageError('Read receipt has no valid displayed revision; read the resource again.'); + return receipt; +} diff --git a/src/api/service-context.js b/src/api/service-context.js new file mode 100644 index 0000000..2c38bf2 --- /dev/null +++ b/src/api/service-context.js @@ -0,0 +1,54 @@ +import { hasFlag, optionValue, parseIntegerInRange } from '../core/args.js'; +import { DEFAULT_SERVICE_URL, TOKEN_ENV_VAR, LEGACY_TOKEN_ENV_VAR, AGENT_ID_ENV_VAR, AGENT_INSTANCE_ENV_VAR } from '../core/constants.js'; +import { readStoredCredential, resolveCredentialToken } from '../network/auth.js'; +import { baseUrlOption } from '../network/base-url.js'; +import { assertServiceOrigin, createServiceClient } from './client.js'; +import { ServiceClientError } from './errors.js'; + +export async function serviceContext(args, io) { + const baseUrl = assertServiceOrigin(baseUrlOption(args, io.env)); + const environmentToken = io.env[TOKEN_ENV_VAR] || io.env[LEGACY_TOKEN_ENV_VAR] || null; + const credential = environmentToken ? null : await readStoredCredential(io.env); + const token = environmentToken ?? credential?.token ?? await resolveCredentialToken(io.env); + if (!environmentToken && token) { + const storedOrigin = credential?.metadata?.baseUrl ?? credential?.metadata?.origin; + if (!storedOrigin) { + const isDefaultOrigin = new URL(baseUrl).origin === new URL(DEFAULT_SERVICE_URL).origin; + if (!hasFlag(args, '--allow-legacy-credential') || !isDefaultOrigin) { + throw new ServiceClientError('Stored credential has no service origin binding.', { + code: 'CREDENTIAL_ORIGIN_REQUIRED', + httpStatus: 401, + nextAction: `Run \`${'xmemo'} login --base-url ${DEFAULT_SERVICE_URL}\` to migrate this credential, or explicitly use --allow-legacy-credential only with the default service.` + }); + } + } else { + let normalizedStoredOrigin; + try { + normalizedStoredOrigin = assertServiceOrigin(storedOrigin); + } catch (error) { + throw new ServiceClientError('Stored credential has an invalid service origin binding.', { + code: 'CREDENTIAL_ORIGIN_INVALID', + httpStatus: 401, + cause: error, + nextAction: '重新登录以替换无效的凭据 origin;CLI 不会发送该凭据。' + }); + } + if (new URL(normalizedStoredOrigin).origin !== new URL(baseUrl).origin) { + throw new ServiceClientError('Stored credential is bound to a different service origin.', { + code: 'CREDENTIAL_ORIGIN_MISMATCH', + httpStatus: 401, + nextAction: '登录到目标服务 origin 后再重试;CLI 不会跨 origin 发送凭证。' + }); + } + } + } + const client = createServiceClient({ + baseUrl, + token, + io, + timeoutMs: parseIntegerInRange(optionValue(args, '--timeout-ms') ?? '15000', '--timeout-ms', { min: 1, max: 2_147_483_647 }), + agentId: io.env[AGENT_ID_ENV_VAR] ?? 'xmemo-cli', + agentInstanceId: io.env[AGENT_INSTANCE_ENV_VAR] + }); + return { client, baseUrl: client.baseUrl, tokenSource: environmentToken ? 'environment' : 'credential-file', signal: io.signal }; +} diff --git a/src/api/service-output.js b/src/api/service-output.js new file mode 100644 index 0000000..0177917 --- /dev/null +++ b/src/api/service-output.js @@ -0,0 +1,10 @@ +import { writeLine } from '../core/io.js'; + +export function writeHumanServiceResult(io, command, data, meta = {}) { + writeLine(io.stdout, `${command} completed.`); + if (data !== undefined) writeLine(io.stdout, JSON.stringify(data, null, 2)); + if (meta.nextCursor) writeLine(io.stdout, `Next cursor: ${meta.nextCursor}`); + if (Array.isArray(meta.warnings)) { + for (const warning of meta.warnings) writeLine(io.stderr, `Warning: ${warning}`); + } +} diff --git a/src/api/text-input.js b/src/api/text-input.js new file mode 100644 index 0000000..075ebb6 --- /dev/null +++ b/src/api/text-input.js @@ -0,0 +1,76 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { UsageError } from '../core/errors.js'; + +export const MAX_JSON_INPUT_BYTES = 2 * 1024 * 1024; + +export async function readTextFileBounded(filePath, label, maxBytes = MAX_JSON_INPUT_BYTES) { + try { + const resolved = path.resolve(filePath); + const stat = await fs.lstat(resolved); + if (!stat.isFile() || stat.isSymbolicLink()) throw new UsageError(`${label} must be a regular non-symlink file.`); + if (stat.size > maxBytes) throw new UsageError(`${label} exceeds the ${maxBytes}-byte limit.`); + const handle = await fs.open(resolved, 'r'); + try { + const openedStat = await handle.stat(); + if (!openedStat.isFile() || !sameFileIdentity(stat, openedStat)) { + throw new UsageError(`${label} changed after validation; retry with a stable regular file.`); + } + const buffer = Buffer.alloc(maxBytes + 1); + let offset = 0; + while (offset < buffer.length) { + const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, null); + if (!bytesRead) break; + offset += bytesRead; + } + if (offset > maxBytes) throw new UsageError(`${label} exceeds the ${maxBytes}-byte limit.`); + const finalStat = await handle.stat(); + if (finalStat.size !== openedStat.size || finalStat.mtimeMs !== openedStat.mtimeMs) { + throw new UsageError(`${label} changed while it was being read; retry with a stable file.`); + } + return decodeTextBytes(buffer.subarray(0, offset), label, maxBytes); + } finally { + await handle.close(); + } + } catch (error) { + throw new UsageError(`Could not read ${label} ${filePath}: ${error.message}`); + } +} + +function sameFileIdentity(pathStat, handleStat) { + if (pathStat.ino !== handleStat.ino) return false; + return process.platform === 'win32' || pathStat.dev === handleStat.dev; +} + +export async function readTextStreamBounded(stream, label, maxBytes = MAX_JSON_INPUT_BYTES) { + const chunks = []; + let total = 0; + try { + for await (const chunk of stream) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8'); + total += bytes.length; + if (total > maxBytes) throw new UsageError(`${label} exceeds the ${maxBytes}-byte limit.`); + chunks.push(bytes); + } + } catch (error) { + if (error instanceof UsageError) throw error; + throw new UsageError(`Could not read ${label}: ${error.message}`); + } + return decodeTextBytes(Buffer.concat(chunks), label, maxBytes); +} + +export function decodeTextBytes(value, label, maxBytes = MAX_JSON_INPUT_BYTES) { + const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value); + if (bytes.length > maxBytes) throw new UsageError(`${label} exceeds the ${maxBytes}-byte limit.`); + if (bytes.length >= 2 && ((bytes[0] === 0xff && bytes[1] === 0xfe) || (bytes[0] === 0xfe && bytes[1] === 0xff))) { + try { return new TextDecoder(bytes[0] === 0xff ? 'utf-16le' : 'utf-16be', { fatal: true }).decode(bytes.subarray(2)); } + catch { throw new UsageError(`${label} has invalid UTF-16 encoding.`); } + } + const offset = bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf ? 3 : 0; + try { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(offset)); + } catch (error) { + throw new UsageError(`${label} must be UTF-8 or BOM-marked UTF-16 text: ${error.message}`); + } +} diff --git a/src/api/upload-input.js b/src/api/upload-input.js new file mode 100644 index 0000000..bc99119 --- /dev/null +++ b/src/api/upload-input.js @@ -0,0 +1,186 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { UsageError } from '../core/errors.js'; + +export const MAX_DOCUMENT_BYTES = 4 * 1024 * 1024; +const MAX_SKILL_FILES = 100; +const MAX_SKILL_FILE_BYTES = 1024 * 1024; +const MAX_SKILL_TOTAL_BYTES = 10 * 1024 * 1024; +const EXCLUDED_DIRECTORY_NAMES = /^(\.git|node_modules|__pycache__|\.venv|venv|dist|build|coverage)$/i; +const SENSITIVE_NAMES = /(^\.env(?:\..*)?$|^\.npmrc$|^\.pypirc$|^\.netrc$|^id_(?:rsa|dsa|ecdsa|ed25519)$|secret|token|credential|private.?key|\.(?:pem|p12|pfx|key)$)/i; + +export async function readDocumentInput(filePath) { + const resolved = path.resolve(filePath); + let stat; + try { + stat = await fs.lstat(resolved); + } catch (error) { + throw new UsageError(`Could not read document input ${filePath}: ${error.message}`); + } + if (!stat.isFile() || stat.isSymbolicLink()) throw new UsageError(`Document input must be a regular non-symlink file: ${filePath}`); + if (stat.size > MAX_DOCUMENT_BYTES) throw new UsageError(`Document input exceeds the ${MAX_DOCUMENT_BYTES}-byte limit.`); + let bytes; + try { + bytes = await readBoundedBytes(resolved, MAX_DOCUMENT_BYTES, stat); + } catch (error) { + throw new UsageError(`Could not read document input ${filePath}: ${error.message}`); + } + return { + filename: path.basename(resolved), + byteSize: bytes.length, + sha256: crypto.createHash('sha256').update(bytes).digest('hex'), + contentBase64: bytes.toString('base64') + }; +} + +export async function collectSkillFiles(directory) { + const root = path.resolve(directory); + let rootStat; + try { + rootStat = await fs.lstat(root); + } catch (error) { + throw new UsageError(`Could not read skill input ${directory}: ${error.message}`); + } + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new UsageError(`Skill input must be a regular directory: ${directory}`); + const files = []; + const state = { bytes: 0, entries: 0, excluded: [], paths: new Set() }; + const realRoot = await fs.realpath(root); + await visit(realRoot, realRoot, files, state); + if (!files.some((file) => file.logicPath === 'SKILL.md')) throw new UsageError('Skill directory must contain a root SKILL.md file.'); + if (files.length > MAX_SKILL_FILES) throw new UsageError(`Skill directory contains more than ${MAX_SKILL_FILES} eligible files.`); + const lowerPaths = new Set(); + let totalBytes = 0; + const subFiles = Object.create(null); + for (const file of files) { + const key = file.logicPath.toLowerCase(); + if (lowerPaths.has(key)) throw new UsageError(`Skill directory has a case-colliding path: ${file.logicPath}`); + lowerPaths.add(key); + totalBytes += file.bytes.length; + if (totalBytes > MAX_SKILL_TOTAL_BYTES) throw new UsageError(`Skill directory exceeds the ${MAX_SKILL_TOTAL_BYTES}-byte total limit.`); + if (file.logicPath !== 'SKILL.md') subFiles[file.logicPath] = decodeUtf8(file.bytes, file.logicPath); + } + const rootFile = files.find((file) => file.logicPath === 'SKILL.md'); + return { + rootMarkdown: decodeUtf8(rootFile.bytes, rootFile.logicPath), + subFiles, + included: files.map((file) => ({ path: file.logicPath, bytes: file.bytes.length, sha256: file.sha256 })), + excluded: state.excluded, + totalBytes + }; +} + +export async function readSkillFile(filePath) { + const resolved = path.resolve(filePath); + if (path.basename(resolved).toLowerCase() !== 'skill.md') throw new UsageError('Cloud Skill --file must point to SKILL.md.'); + let stat; + try { + stat = await fs.lstat(resolved); + } catch (error) { + throw new UsageError(`Could not read skill input ${filePath}: ${error.message}`); + } + if (!stat.isFile() || stat.isSymbolicLink()) throw new UsageError(`Skill input must be a regular non-symlink file: ${filePath}`); + if (stat.size > MAX_SKILL_FILE_BYTES) throw new UsageError(`Skill file exceeds the ${MAX_SKILL_FILE_BYTES}-byte limit: ${filePath}`); + let bytes; + try { + bytes = await readBoundedBytes(resolved, MAX_SKILL_FILE_BYTES, stat); + } catch (error) { + throw new UsageError(`Could not read skill input ${filePath}: ${error.message}`); + } + return { + rootMarkdown: decodeUtf8(bytes, path.basename(resolved)), + subFiles: {}, + included: [{ path: 'SKILL.md', bytes: bytes.length, sha256: crypto.createHash('sha256').update(bytes).digest('hex') }], + totalBytes: bytes.length + }; +} + +async function visit(root, current, files, state, depth = 0) { + if (depth > 16) throw new UsageError('Skill directory nesting exceeds 16 levels.'); + let entries; + try { + entries = await fs.opendir(current); + } catch (error) { + throw new UsageError(`Could not read skill input directory ${current}: ${error.message}`); + } + for await (const entry of entries) { + if (++state.entries > 1000) throw new UsageError('Skill directory contains more than 1000 filesystem entries.'); + if (entry.isDirectory() && EXCLUDED_DIRECTORY_NAMES.test(entry.name)) { + state.excluded.push({ path: path.relative(root, path.join(current, entry.name)).split(path.sep).join('/'), reason: 'dependency or build directory' }); + continue; + } + if (SENSITIVE_NAMES.test(entry.name)) throw new UsageError(`Refusing sensitive-looking skill input path: ${entry.name}`); + const absolute = path.join(current, entry.name); + const logicPath = path.relative(root, absolute).split(path.sep).join('/'); + if (!logicPath || logicPath.split('/').includes('..') || path.isAbsolute(logicPath)) throw new UsageError(`Unsafe skill input path: ${logicPath}`); + if (entry.isSymbolicLink()) throw new UsageError(`Refusing symbolic-link skill input: ${logicPath}`); + const real = await fs.realpath(absolute); + const relativeReal = path.relative(root, real); + if (relativeReal === '..' || relativeReal.startsWith(`..${path.sep}`) || path.isAbsolute(relativeReal)) throw new UsageError(`Skill input escapes its root: ${logicPath}`); + const key = logicPath.toLowerCase(); + if (state.paths.has(key)) throw new UsageError(`Skill directory has a case-colliding path: ${logicPath}`); + state.paths.add(key); + if (entry.isDirectory()) { + await visit(root, absolute, files, state, depth + 1); + continue; + } + if (!entry.isFile()) throw new UsageError(`Unsupported skill input filesystem entry: ${logicPath}`); + let stat; + try { + stat = await fs.lstat(absolute); + } catch (error) { + throw new UsageError(`Could not inspect skill input file ${logicPath}: ${error.message}`); + } + if (stat.size > MAX_SKILL_FILE_BYTES) throw new UsageError(`Skill file exceeds the ${MAX_SKILL_FILE_BYTES}-byte limit: ${logicPath}`); + if (files.length >= MAX_SKILL_FILES) throw new UsageError(`Skill directory contains more than ${MAX_SKILL_FILES} eligible files.`); + if (state.bytes + stat.size > MAX_SKILL_TOTAL_BYTES) throw new UsageError(`Skill directory exceeds the ${MAX_SKILL_TOTAL_BYTES}-byte total limit.`); + let bytes; + try { + bytes = await readBoundedBytes(absolute, Math.min(MAX_SKILL_FILE_BYTES, MAX_SKILL_TOTAL_BYTES - state.bytes), stat); + } catch (error) { + throw new UsageError(`Could not read skill input file ${logicPath}: ${error.message}`); + } + state.bytes += bytes.length; + files.push({ logicPath, bytes, sha256: crypto.createHash('sha256').update(bytes).digest('hex') }); + } +} + +function decodeUtf8(bytes, name) { + try { + const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes).replace(/^\ufeff/, ''); + if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(text)) throw new Error('binary control characters'); + return text; + } catch (error) { + throw new UsageError(`Skill file must be valid UTF-8 text: ${name} (${error.message})`); + } +} + +async function readBoundedBytes(filePath, maxBytes, expectedStat) { + const handle = await fs.open(filePath, 'r'); + try { + const stat = await handle.stat(); + if (!stat.isFile() || stat.size > maxBytes) throw new UsageError(`Input must be a regular file no larger than ${maxBytes} bytes.`); + if (expectedStat && !sameFileIdentity(expectedStat, stat)) { + throw new UsageError('Input file changed after validation; retry with a stable regular file.'); + } + const buffer = Buffer.alloc(maxBytes + 1); + let offset = 0; + while (offset < buffer.length) { + const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, null); + if (!bytesRead) break; + offset += bytesRead; + } + if (offset > maxBytes) throw new UsageError(`Input exceeds the ${maxBytes}-byte limit.`); + const finalStat = await handle.stat(); + if (finalStat.size !== stat.size || finalStat.mtimeMs !== stat.mtimeMs) { + throw new UsageError('Input file changed while it was being read; retry with a stable file.'); + } + return buffer.subarray(0, offset); + } finally { await handle.close(); } +} + +function sameFileIdentity(pathStat, handleStat) { + if (pathStat.ino !== handleStat.ino) return false; + return process.platform === 'win32' || pathStat.dev === handleStat.dev; +} diff --git a/src/cli.js b/src/cli.js index 755fea3..00d4385 100644 --- a/src/cli.js +++ b/src/cli.js @@ -22,6 +22,13 @@ import { envCommand, writePrivacy } from './config/env.js'; import { UsageError } from './core/errors.js'; import { writeHelp } from './ui/help.js'; import { defaultIo, writeLine } from './core/io.js'; +import { contextCommand, memoryCommand, restartCommand, stateCommand } from './commands/service.js'; +import { knowledgeCommand } from './commands/knowledge.js'; +import { dreamCommand } from './commands/dream.js'; +import { cloudSkillCommand } from './commands/cloud-skill.js'; +import { hasFlag } from './core/args.js'; +import { errorToExitCode } from './api/errors.js'; +import { writeFailure } from './api/envelope.js'; export async function run(args, io = defaultIo()) { try { @@ -98,8 +105,41 @@ export async function run(args, io = defaultIo()) { return 0; } + if (command === 'memory') { + return await memoryCommand(args.slice(1), io); + } + + if (command === 'context') { + return await contextCommand(args.slice(1), io); + } + + if (command === 'state') { + return await stateCommand(args.slice(1), io); + } + + if (command === 'restart') { + return await restartCommand(args.slice(1), io); + } + + if (command === 'knowledge') { + return await knowledgeCommand(args.slice(1), io); + } + + if (command === 'dream') { + return await dreamCommand(args.slice(1), io); + } + + if (command === 'cloud-skill') { + return await cloudSkillCommand(args.slice(1), io); + } + throw new UsageError(`Unknown command: ${command}`); } catch (error) { + if (hasFlag(args, '--json') && ['memory', 'context', 'state', 'restart', 'knowledge', 'dream', 'cloud-skill'].includes(args[0])) { + const command = [args[0] ?? 'help', args[1]].filter(Boolean).join('.'); + writeFailure(io, command, error); + return errorToExitCode(error); + } if (error instanceof UsageError) { writeLine(io.stderr, `Error: ${error.message}`); writeLine(io.stderr, `Run \`${COMMAND_NAME} help\` for usage.`); diff --git a/src/commands/auth.js b/src/commands/auth.js index 7eaedf0..8461393 100644 --- a/src/commands/auth.js +++ b/src/commands/auth.js @@ -8,7 +8,9 @@ import { storeTokenFromStdin, storeTokenValue, validateToken, - credentialsPath + credentialsPath, + parseRequestedScopes, + DEVICE_LOGIN_SCOPES } from '../network/auth.js'; import { baseUrlOption } from '../network/base-url.js'; import { @@ -18,7 +20,8 @@ import { TOKEN_ENV_VAR } from '../core/constants.js'; import { UsageError } from '../core/errors.js'; -import { normalizeBaseUrl, verifyTokenWithMcp } from '../network/http.js'; +import { verifyTokenWithMcp } from '../network/http.js'; +import { assertServiceOrigin } from '../api/client.js'; import { writeLine } from '../core/io.js'; import { readAll } from '../core/runtime.js'; import { createInterface } from 'node:readline/promises'; @@ -30,10 +33,11 @@ export async function loginCommand(args, io) { } const outputJson = hasFlag(args, '--json'); const fromStdin = hasFlag(args, '--from-stdin') || hasFlag(args, '--token-stdin'); - const baseUrl = normalizeBaseUrl(baseUrlOption(args, io.env)); + const baseUrl = assertServiceOrigin(baseUrlOption(args, io.env)); const httpTimeoutMs = parsePositiveInteger(optionValue(args, '--http-timeout-ms') ?? '30000', '--http-timeout-ms'); const loginTimeoutOption = optionValue(args, '--timeout-ms'); const pollOnce = hasFlag(args, '--poll-once'); + const requestedScopes = parseRequestedScopes(optionValue(args, '--scopes')); if (fromStdin) { const consented = await authorizePlaintextStorage(args, io, { @@ -43,7 +47,7 @@ export async function loginCommand(args, io) { if (!consented) { return 0; } - const result = await storeTokenFromStdin(io, { source: 'stdin' }, { allowPlaintext: true }); + const result = await storeTokenFromStdin(io, { source: 'stdin', baseUrl }, { allowPlaintext: true }); if (outputJson) { writeLine(io.stdout, JSON.stringify(result, null, 2)); } else { @@ -63,7 +67,7 @@ export async function loginCommand(args, io) { return 0; } - const start = await startDeviceLogin(baseUrl, httpTimeoutMs, io); + const start = await startDeviceLogin(baseUrl, httpTimeoutMs, io, requestedScopes); const loginTimeoutMs = loginTimeoutOption ? parsePositiveInteger(loginTimeoutOption, '--timeout-ms') : Math.max(1000, start.expiresIn * 1000); @@ -79,7 +83,7 @@ export async function loginCommand(args, io) { const token = await pollDeviceLogin(baseUrl, start, loginTimeoutMs, httpTimeoutMs, io, { pollOnce }); const result = await storeTokenValue( token.accessToken, - { source: 'device-login', account: token.account }, + { source: 'device-login', account: token.account, baseUrl, scopes: requestedScopes ?? start.scopes }, io.env, { allowPlaintext: true } ); @@ -87,6 +91,7 @@ export async function loginCommand(args, io) { ...result, baseUrl, verificationUri: start.verificationUri, + scopes: requestedScopes ?? start.scopes ?? null, account: token.account, deviceLogin: true }; @@ -129,8 +134,8 @@ export async function tokenCommand(args, io) { if (subcommand === 'help' || subcommand === '--help' || subcommand === '-h') { writeLine(io.stdout, 'Token commands:'); writeLine(io.stdout, ` ${COMMAND_NAME} token status [--verify]`); - writeLine(io.stdout, ` ${COMMAND_NAME} token add --from-stdin --allow-plaintext`); - writeLine(io.stdout, ` ${COMMAND_NAME} token set --from-stdin [--allow-plaintext]`); + writeLine(io.stdout, ` ${COMMAND_NAME} token add --from-stdin --allow-plaintext [--base-url ]`); + writeLine(io.stdout, ` ${COMMAND_NAME} token set --from-stdin [--allow-plaintext] [--base-url ]`); writeLine(io.stdout, ''); writeLine(io.stdout, `${COMMAND_NAME} login is the recommended personal-user path.`); writeLine(io.stdout, `${COMMAND_NAME} token add --from-stdin requires explicit consent to unencrypted user-file storage.`); @@ -149,7 +154,8 @@ export async function tokenCommand(args, io) { action: 'Adding an existing token', interactive: false }); - const result = await storeTokenFromStdin(io, { source: 'token-add' }, { allowPlaintext: true }); + const baseUrl = assertServiceOrigin(baseUrlOption(args, io.env)); + const result = await storeTokenFromStdin(io, { source: 'token-add', baseUrl }, { allowPlaintext: true }); if (hasFlag(args, '--json')) { writeLine(io.stdout, JSON.stringify(result, null, 2)); } else { @@ -168,9 +174,10 @@ export async function tokenCommand(args, io) { action: 'Setting a token', interactive: false }); + const baseUrl = assertServiceOrigin(baseUrlOption(args, io.env)); const token = (await readAll(io.stdin)).trim(); validateToken(token); - const result = await storeTokenValue(token, { source: 'token-set' }, io.env, { allowPlaintext: true }); + const result = await storeTokenValue(token, { source: 'token-set', baseUrl }, io.env, { allowPlaintext: true }); writeLine(io.stdout, `Credential stored in the approved user file: ${result.credentialPath}`); writeLine(io.stdout, 'Storage: unencrypted; file access is restricted to the current OS user where supported.'); writeLine(io.stdout, 'Token value was not printed. Do not commit this file.'); @@ -184,7 +191,7 @@ async function credentialStatusCommand(args, io, { mode }) { const outputJson = hasFlag(args, '--json'); const verify = hasFlag(args, '--verify'); const credential = await readStoredCredential(io.env); - const environmentToken = io.env[TOKEN_ENV_VAR] ?? io.env[LEGACY_TOKEN_ENV_VAR] ?? ''; + const environmentToken = io.env[TOKEN_ENV_VAR] || io.env[LEGACY_TOKEN_ENV_VAR] || ''; const hasEnvironmentToken = Boolean(environmentToken); const hasUserCredential = Boolean(credential.token); const tokenSource = hasEnvironmentToken ? 'environment' : hasUserCredential ? 'user-credential-file' : 'missing'; @@ -203,6 +210,7 @@ async function credentialStatusCommand(args, io, { mode }) { plaintextStorageConsent: credential.plaintextStorageConsent ?? false }, account: credential.account ?? null, + credentialMetadata: safeCredentialMetadata(credential.metadata), privacy: { tokenPrinted: false, projectFilesModified: false @@ -220,7 +228,8 @@ async function credentialStatusCommand(args, io, { mode }) { } return 1; } - const baseUrl = normalizeBaseUrl(baseUrlOption(args, io.env)); + const baseUrl = assertServiceOrigin(baseUrlOption(args, io.env)); + if (!hasEnvironmentToken) assertCredentialVerificationOrigin(credential, baseUrl); const timeoutMs = parsePositiveInteger(optionValue(args, '--timeout-ms') ?? '10000', '--timeout-ms'); const verification = await verifyTokenWithMcp(baseUrl, token, timeoutMs, io); report.verification = verification; @@ -241,6 +250,36 @@ async function credentialStatusCommand(args, io, { mode }) { return report.loggedIn ? 0 : 1; } +function safeCredentialMetadata(metadata) { + if (!metadata || typeof metadata !== 'object') return {}; + let baseUrl; + try { + if (typeof metadata.baseUrl === 'string') baseUrl = assertServiceOrigin(metadata.baseUrl); + } catch { + baseUrl = undefined; + } + return { + ...(baseUrl ? { baseUrl } : {}), + ...(Array.isArray(metadata.scopes) ? { scopes: metadata.scopes.filter((scope) => DEVICE_LOGIN_SCOPES.includes(scope)) } : {}) + }; +} + +function assertCredentialVerificationOrigin(credential, baseUrl) { + const storedOrigin = credential?.metadata?.baseUrl ?? credential?.metadata?.origin; + if (!storedOrigin) { + throw new UsageError(`Stored credential has no service origin binding. Run \`${COMMAND_NAME} login --base-url ${baseUrl}\` to migrate it.`); + } + let normalizedStoredOrigin; + try { + normalizedStoredOrigin = assertServiceOrigin(storedOrigin); + } catch { + throw new UsageError(`Stored credential has an invalid service origin binding. Run \`${COMMAND_NAME} login --base-url ${baseUrl}\` again.`); + } + if (new URL(normalizedStoredOrigin).origin !== new URL(baseUrl).origin) { + throw new UsageError('Stored credential is bound to a different service origin; verification was not sent.'); + } +} + function writeCredentialStatus(report, io, { mode }) { if (mode === 'auth') { writeLine(io.stdout, `${PRODUCT_NAME} auth status`); @@ -269,11 +308,12 @@ function hasHelpFlag(args) { function writeLoginHelp(io) { writeLine(io.stdout, 'Login command:'); - writeLine(io.stdout, ` ${COMMAND_NAME} login [--base-url ] [--allow-plaintext]`); + writeLine(io.stdout, ` ${COMMAND_NAME} login [--base-url ] [--scopes ] [--allow-plaintext]`); writeLine(io.stdout, ` ${COMMAND_NAME} login --from-stdin --allow-plaintext [--json]`); writeLine(io.stdout, ''); writeLine(io.stdout, 'Interactive browser login asks once before storing the issued token unencrypted.'); writeLine(io.stdout, 'Use --allow-plaintext to record that consent non-interactively. XMEMO_KEY remains preferred for managed environments.'); + writeLine(io.stdout, 'Supported scopes: memory:read, memory:write, memory:restore, ledger:read, ledger:write, knowledge:read, knowledge:write.'); } async function authorizePlaintextStorage(args, io, { action, interactive }) { diff --git a/src/commands/cloud-skill.js b/src/commands/cloud-skill.js new file mode 100644 index 0000000..1025cfd --- /dev/null +++ b/src/commands/cloud-skill.js @@ -0,0 +1,222 @@ +import { hasFlag, optionValue, parseIntegerInRange } from '../core/args.js'; +import { UsageError } from '../core/errors.js'; +import { writeLine } from '../core/io.js'; +import { readAndValidateReceipt, createReadReceipt } from '../api/read-receipt.js'; +import { assertKnownOptions, assertNoUnknownInputFields, booleanInput, readJsonInput, rejectInputFlagConflicts } from '../api/input.js'; +import { ServiceClientError, UnknownOutcomeError, errorToExitCode } from '../api/errors.js'; +import { writeFailure, writeSuccess } from '../api/envelope.js'; +import { serviceContext } from '../api/service-context.js'; +import { writeServiceHelpSchema } from '../api/contracts/help-schema.js'; +import { collectSkillFiles, readSkillFile } from '../api/upload-input.js'; +import { writeHumanServiceResult } from '../api/service-output.js'; +import { confirmRemoteAction } from '../api/confirmation.js'; + +export async function cloudSkillCommand(args, io) { + const subcommand = args[0] ?? 'help'; + if (subcommand === 'help' || hasFlag(args, '--help')) { + if (subcommand !== 'help' && hasFlag(args, '--json') && writeServiceHelpSchema(io, `cloud-skill.${subcommand}`)) return 0; + writeLine(io.stdout, 'Cloud Skill commands:'); + writeLine(io.stdout, ' xmemo cloud-skill add --file SKILL.md|--dir [--publish --yes] [--json]'); + writeLine(io.stdout, ' xmemo cloud-skill list [--team ] [--json]'); + writeLine(io.stdout, ' xmemo cloud-skill show [--draft] [--json]'); + writeLine(io.stdout, ' xmemo cloud-skill update --from --file|--dir ... [--json]'); + writeLine(io.stdout, ' xmemo cloud-skill run [--script ] --input --from --yes [--json]'); + return 0; + } + if (subcommand === 'add') return await run('cloud-skill.add', args.slice(1), io, addSkill); + if (subcommand === 'list') return await run('cloud-skill.list', args.slice(1), io, listSkills); + if (subcommand === 'show') return await run('cloud-skill.show', args.slice(1), io, showSkill); + if (subcommand === 'update') return await run('cloud-skill.update', args.slice(1), io, updateSkill); + if (subcommand === 'run') return await run('cloud-skill.run', args.slice(1), io, runSkill); + throw new UsageError(`Unknown cloud-skill command: ${subcommand}`); +} + +async function addSkill(args, io, context) { + assertKnownOptions(args, ['--file', '--dir', '--name', '--slug', '--publish', '--yes', '--team', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); + const input = await readJsonInput(args, io); + assertNoUnknownInputFields(input, ['name', 'slug', 'team_id', 'publish']); + rejectInputFlagConflicts(input, [['--name', 'name'], ['--slug', 'slug'], ['--team', 'team_id'], ['--publish', 'publish']], args); + const publish = hasFlag(args, '--publish') || booleanInput(input, 'publish'); + const files = await readSkillSource(args); + showUploadManifest(io, files); + const body = compact({ + markdown_content: files.rootMarkdown, + sub_files: files.subFiles, + name: optionValue(args, '--name') ?? input?.name, + slug: optionValue(args, '--slug') ?? input?.slug, + publish, + team_id: optionValue(args, '--team') ?? input?.team_id + }); + if (publish) await confirmRemoteAction(args, io, 'Publish this Cloud Skill?'); + const response = await context.client.request({ + method: 'POST', path: '/v1/skills/create', body, sideEffect: true, + operation: { contractRequired: true, name: 'Cloud Skill create-only' } + }); + assertWriteReceipt(response.data); + return { data: response.data, meta: { uploadManifest: manifest(files), warnings: [`Uploaded ${files.included.length} validated file(s); omitted paths were not sent.`] } }; +} + +async function updateSkill(args, io, context) { + assertKnownOptions(args, ['--file', '--dir', '--from', '--publish', '--yes', '--team', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); + const input = await readJsonInput(args, io); + assertNoUnknownInputFields(input, ['skill_id', 'from', 'team_id', 'publish']); + rejectInputFlagConflicts(input, [['--from', 'from'], ['--team', 'team_id'], ['--publish', 'publish']], args); + const positionalSkillId = positional(args); + if (positionalSkillId && input?.skill_id !== undefined) throw new UsageError('Cloud Skill ID cannot be supplied both positionally and in --input.'); + const skillId = positionalSkillId ?? input?.skill_id; + const fromPath = optionValue(args, '--from') ?? input?.from; + if (!skillId || !fromPath) throw new UsageError('cloud-skill update requires and --from .'); + const publish = hasFlag(args, '--publish') || booleanInput(input, 'publish'); + const teamId = optionValue(args, '--team') ?? input?.team_id; + const receipt = await readAndValidateReceipt(fromPath, { baseUrl: context.baseUrl, resource: `cloud-skill:${skillId}`, scope: teamId ?? 'personal' }); + if (!receipt.displayedRevision) throw new UsageError('Cloud Skill read receipt lacks a revision; run cloud-skill show again.'); + if (receipt.latestRevision !== receipt.displayedRevision) { + throw new UsageError('The viewed Cloud Skill draft is no longer the latest revision; run cloud-skill show --draft again.'); + } + const hasSource = Boolean(optionValue(args, '--file') || optionValue(args, '--dir')); + if (!hasSource && !publish) throw new UsageError('cloud-skill update requires --file/--dir, or --publish to publish the reviewed revision.'); + const files = hasSource ? await readSkillSource(args) : null; + if (files) showUploadManifest(io, files); + const body = compact({ + expected_revision_id: receipt.displayedRevision, + markdown_content: files?.rootMarkdown, + sub_files: files?.subFiles, + publish, + team_id: teamId + }); + if (publish) await confirmRemoteAction(args, io, 'Publish these Cloud Skill changes?'); + const response = await context.client.request({ + method: 'PUT', path: `/v1/skills/${encodeURIComponent(skillId)}/content`, body, sideEffect: true, + operation: { contractRequired: true, name: 'Cloud Skill content-CAS update' } + }); + assertWriteReceipt(response.data); + return { data: response.data, meta: { uploadManifest: files ? manifest(files) : null, warnings: files ? [`Uploaded ${files.included.length} validated file(s); preservation of omitted paths requires the MOS-01 server contract.`] : [] } }; +} + +async function listSkills(args, io, context) { + assertKnownOptions(args, ['--team', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); + const input = await readJsonInput(args, io); + assertNoUnknownInputFields(input, ['team_id']); + rejectInputFlagConflicts(input, [['--team', 'team_id']], args); + const response = await context.client.request({ method: 'GET', path: '/v1/skills', query: compact({ team_id: optionValue(args, '--team') ?? input?.team_id }), retry: 'bounded' }); + return response; +} + +async function showSkill(args, io, context) { + assertKnownOptions(args, ['--draft', '--team', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); + const input = await readJsonInput(args, io); + assertNoUnknownInputFields(input, ['skill_id', 'draft', 'team_id']); + rejectInputFlagConflicts(input, [['--draft', 'draft'], ['--team', 'team_id']], args); + const positionalSkillId = positional(args); + if (positionalSkillId && input?.skill_id !== undefined) throw new UsageError('Cloud Skill ID cannot be supplied both positionally and in --input.'); + const skillId = positionalSkillId ?? input?.skill_id; + if (!skillId) throw new UsageError('cloud-skill show requires a skill ID.'); + const teamId = optionValue(args, '--team') ?? input?.team_id; + let showDraft = hasFlag(args, '--draft') || booleanInput(input, 'draft'); + const detail = await context.client.request({ method: 'GET', path: `/v1/skills/${encodeURIComponent(skillId)}`, query: compact({ team_id: teamId }), retry: 'bounded' }); + const publishedRevision = detail.data?.published_revision; + const latestRevision = detail.data?.latest_revision; + if (!showDraft && !publishedRevision?.revision_id) { + showDraft = true; + } + const targetRevision = showDraft ? latestRevision?.revision_id : publishedRevision?.revision_id; + if (!targetRevision) throw new ServiceClientError('Cloud Skill has no readable revision.', { code: 'INVALID_RESPONSE' }); + const components = await context.client.request({ method: 'GET', path: `/v1/skills/${encodeURIComponent(skillId)}/components`, query: compact({ team_id: teamId, revision_id: targetRevision }), retry: 'bounded' }); + const displayedRevision = targetRevision ?? null; + const displayedRevisionData = showDraft ? latestRevision : publishedRevision; + const receipt = createReadReceipt({ baseUrl: context.baseUrl, resource: `cloud-skill:${skillId}`, scope: teamId ?? 'personal', revision: displayedRevision, latestRevision: latestRevision?.revision_id ?? detail.data?.skill?.latest_revision_id, revisionStatus: String(displayedRevisionData?.status ?? (showDraft ? 'draft' : 'published')).toLowerCase(), revisionKind: showDraft ? 'draft' : 'published' }); + return { data: { skill: detail.data?.skill, displayed_revision: displayedRevisionData, components: components.data, executable: !showDraft && scriptCandidates(components.data).length > 0 }, meta: { readReceipt: receipt, warnings: showDraft ? ['This is a maintenance view; run requires a published view.'] : latestRevision?.revision_id !== targetRevision ? ['A newer maintenance revision exists; use show --draft before update.'] : [] } }; +} + +async function runSkill(args, io, context) { + assertKnownOptions(args, ['--script', '--input', '--from', '--yes', '--team', '--timeout-seconds', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); + const skillId = positional(args); + const fromPath = optionValue(args, '--from'); + const inputPath = optionValue(args, '--input'); + if (!skillId || !fromPath || !inputPath) throw new UsageError('cloud-skill run requires , --input , and --from .'); + const teamId = optionValue(args, '--team'); + const receipt = await readAndValidateReceipt(fromPath, { baseUrl: context.baseUrl, resource: `cloud-skill:${skillId}`, scope: teamId ?? 'personal' }); + if (!receipt.displayedRevision) throw new UsageError('Cloud Skill read receipt lacks a published revision; run cloud-skill show again.'); + if (receipt.revisionStatus !== 'published' || receipt.revisionKind !== 'published') throw new UsageError('Cloud Skill execution requires a published read receipt; run cloud-skill show without --draft.'); + const input = await readJsonInput(args, io); + assertNoUnknownInputFields(input, ['input_args', 'script_path']); + if (!input.input_args || typeof input.input_args !== 'object' || Array.isArray(input.input_args)) throw new UsageError('Cloud Skill input JSON must contain an object field named input_args.'); + rejectInputFlagConflicts(input, [['--script', 'script_path']], args); + const timeoutSeconds = parseIntegerInRange(optionValue(args, '--timeout-seconds') ?? 30, '--timeout-seconds', { min: 1, max: 60 }); + const requestTimeoutMs = parseIntegerInRange(optionValue(args, '--timeout-ms') ?? timeoutSeconds * 1000 + 5000, '--timeout-ms', { min: 1, max: 2_147_483_647 }); + if (requestTimeoutMs < timeoutSeconds * 1000 + 5000) throw new UsageError('--timeout-ms must cover --timeout-seconds plus 5000ms transport overhead.'); + const componentsResponse = await context.client.request({ method: 'GET', path: `/v1/skills/${encodeURIComponent(skillId)}/components`, query: compact({ team_id: teamId, revision_id: receipt.displayedRevision }), retry: 'bounded' }); + const reviewedScripts = scriptCandidates(componentsResponse.data); + const requestedScript = optionValue(args, '--script') ?? input.script_path; + if (requestedScript && !reviewedScripts.includes(requestedScript)) throw new UsageError('Requested Cloud Skill script is not present in the reviewed published revision.'); + const scriptPath = requestedScript ?? (reviewedScripts.length === 1 ? reviewedScripts[0] : null); + if (!scriptPath) throw new UsageError(reviewedScripts.length > 1 ? 'Cloud Skill has multiple scripts; select one with --script.' : 'Cloud Skill has no executable script; use cloud-skill show to read its instructions.'); + await confirmRemoteAction(args, io, `Run Cloud Skill ${skillId}, revision ${receipt.displayedRevision}, script ${scriptPath}, with the supplied input_args?`); + let response; + try { + response = await context.client.request({ method: 'POST', path: `/v1/skills/${encodeURIComponent(skillId)}/execute`, query: compact({ team_id: teamId }), body: { script_path: scriptPath, revision_id: receipt.displayedRevision, input_args: input.input_args, timeout_seconds: timeoutSeconds }, timeoutMs: requestTimeoutMs, sideEffect: true }); + } catch (error) { + if (error instanceof UnknownOutcomeError) error.data = { skill_id: skillId, revision_id: receipt.displayedRevision, script_path: scriptPath }; + throw error; + } + const data = response.data; + if (String(data?.status ?? '').toLowerCase() !== 'success' || data?.exit_code !== 0) throw new ServiceClientError('Cloud Skill execution returned a business failure.', { code: 'SKILL_EXECUTION_FAILED', httpStatus: 200, data }); + return response; +} + +function scriptCandidates(components) { + const scripts = Array.isArray(components) + ? components.filter((component) => String(component?.type ?? '').toLowerCase() === 'script') + : []; + return [...new Set(scripts.map((component) => String(component?.logical_path ?? '').trim()).filter(Boolean))]; +} + +async function run(command, args, io, handler) { + const outputJson = hasFlag(args, '--json'); + try { + const context = await serviceContext(args, io); + const response = await handler(args, io, context); + const data = Object.hasOwn(response, 'data') ? response.data : response; + const meta = { ...response?.meta, nextCursor: response?.meta?.nextCursor ?? data?.next_cursor ?? null }; + if (outputJson) writeSuccess(io, command, data, meta); + else writeHumanServiceResult(io, command, data, meta); + return 0; + } catch (error) { + if (outputJson) writeFailure(io, command, error); + else writeLine(io.stderr, `Error: ${error.message}`); + return errorToExitCode(error); + } +} + +function positional(args) { + const optionsWithValue = new Set(['--team', '--script', '--input', '--from', '--file', '--dir', '--name', '--slug', '--timeout-seconds', '--timeout-ms', '--base-url', '--url']); + for (let index = 0; index < args.length; index += 1) { + if (!args[index].startsWith('--')) return args[index]; + if (optionsWithValue.has(args[index])) index += 1; + } + return null; +} + +async function readSkillSource(args) { + const filePath = optionValue(args, '--file'); + const directory = optionValue(args, '--dir'); + if (Boolean(filePath) === Boolean(directory)) throw new UsageError('Provide exactly one of --file SKILL.md or --dir .'); + return filePath ? await readSkillFile(filePath) : await collectSkillFiles(directory); +} + +function manifest(files) { + return { included: files.included, excluded: files.excluded ?? [], totalBytes: files.totalBytes }; +} + +function assertWriteReceipt(data) { + const revisionId = data?.revision_id ?? data?.revision?.revision_id ?? data?.latest_revision?.revision_id; + if (typeof revisionId !== 'string' || !revisionId) throw new UnknownOutcomeError('Cloud Skill write returned no revision receipt. Verify the MOS-01 response contract and remote state; no fallback or retry was attempted.'); +} + +function showUploadManifest(io, files) { + writeLine(io.stderr, `Cloud Skill upload: ${JSON.stringify(manifest(files))}`); +} + +function compact(value) { + return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== null)); +} diff --git a/src/commands/diagnostics.js b/src/commands/diagnostics.js index a3180ba..d0a3608 100644 --- a/src/commands/diagnostics.js +++ b/src/commands/diagnostics.js @@ -29,8 +29,13 @@ import { import { writeLine } from '../core/io.js'; import { codexSmokeReport } from '../mcp/formats/toml.js'; import { defaultCodexConfigPath } from '../config/paths.js'; +import { serviceContext } from '../api/service-context.js'; +import { assertKnownOptions } from '../api/input.js'; +import { ServiceClientError, errorToExitCode } from '../api/errors.js'; +import { writeFailure, writeSuccess } from '../api/envelope.js'; export async function doctorCommand(args, io) { + if (hasFlag(args, '--services')) return await serviceDoctor(args, io); const baseUrl = normalizeBaseUrl(baseUrlOption(args, io.env)); const outputJson = hasFlag(args, '--json'); const timeoutMs = parsePositiveInteger(optionValue(args, '--timeout-ms') ?? '5000', '--timeout-ms'); @@ -90,6 +95,37 @@ export async function doctorCommand(args, io) { return report.ok ? 0 : 1; } +async function serviceDoctor(args, io) { + try { + assertKnownOptions(args, ['--services', '--team', '--json', '--base-url', '--url', '--timeout-ms', '--allow-legacy-credential']); + const context = await serviceContext(args, io); + const teamId = optionValue(args, '--team'); + const checks = []; + for (const [name, endpoint, query] of [ + ['knowledge', '/api/v1/knowledge-bases', { limit: 1, include_archived: false }], + ['dream', '/api/v1/me/dream/settings', {}], + ['cloud-skill', '/v1/skills', {}] + ]) { + try { + const response = await context.client.request({ method: 'GET', path: endpoint, query: { ...query, team_id: teamId }, retry: 'bounded' }); + checks.push({ name, readable: true, ...(name === 'dream' ? { enabled: response.data?.enabled ?? null, mode: response.data?.mode ?? null, canPreview: response.data?.entitlement?.can_preview ?? null, canApply: response.data?.entitlement?.can_apply ?? null } : {}) }); + } catch (error) { + checks.push({ name, readable: false, code: error.code, httpStatus: error.httpStatus, nextAction: error.nextAction, exitCode: errorToExitCode(error) }); + } + } + const report = { baseUrl: context.baseUrl, checks, writeReadiness: 'not-tested', cloudSkillWriteContract: 'MOS-01 deployment not verified', notes: ['Read-only checks do not prove write permission, queue health, sandbox readiness, or production availability.'] }; + const failed = checks.find((check) => !check.readable); + if (failed) throw new ServiceClientError('One or more service read checks failed.', { code: failed.code, httpStatus: failed.httpStatus, data: report, nextAction: failed.nextAction }); + if (hasFlag(args, '--json')) writeSuccess(io, 'doctor.services', report); + else writeLine(io.stdout, JSON.stringify(report, null, 2)); + return 0; + } catch (error) { + if (hasFlag(args, '--json')) writeFailure(io, 'doctor.services', error); + else writeLine(io.stderr, `Error: ${error.message}`); + return errorToExitCode(error); + } +} + export async function discoveryCommand(args, io) { const subcommand = args[0] ?? 'help'; if (subcommand === 'help' || subcommand === '--help' || subcommand === '-h') { diff --git a/src/commands/dream.js b/src/commands/dream.js new file mode 100644 index 0000000..c8a30bc --- /dev/null +++ b/src/commands/dream.js @@ -0,0 +1,173 @@ +import { hasFlag, optionValue, parseIntegerInRange, parsePositiveInteger } from '../core/args.js'; +import { randomUUID } from 'node:crypto'; +import { UsageError } from '../core/errors.js'; +import { writeLine } from '../core/io.js'; +import { sleep } from '../core/runtime.js'; +import { assertKnownOptions, assertNoUnknownInputFields, booleanInput, readJsonInput, rejectInputFlagConflicts } from '../api/input.js'; +import { createReadReceipt, readAndValidateReceipt } from '../api/read-receipt.js'; +import { InterruptedError, PrerequisiteRequiredError, ServiceClientError, UnknownOutcomeError, errorToExitCode } from '../api/errors.js'; +import { writeFailure, writeSuccess } from '../api/envelope.js'; +import { serviceContext } from '../api/service-context.js'; +import { writeServiceHelpSchema } from '../api/contracts/help-schema.js'; +import { writeHumanServiceResult } from '../api/service-output.js'; +import { confirmRemoteAction } from '../api/confirmation.js'; + +export async function dreamCommand(args, io) { + const subcommand = args[0] ?? 'help'; + if (subcommand === 'help' || hasFlag(args, '--help')) { + if (subcommand !== 'help' && hasFlag(args, '--json') && writeServiceHelpSchema(io, `dream.${subcommand}`)) return 0; + writeLine(io.stdout, 'Dream commands:'); + writeLine(io.stdout, ' xmemo dream preview [--window-days ] [--wait] [--json]'); + writeLine(io.stdout, ' xmemo dream show [--wait] [--json]'); + writeLine(io.stdout, ' xmemo dream apply --item --from --yes [--json]'); + return 0; + } + if (subcommand === 'preview') return await run('dream.preview', args.slice(1), io, previewDream); + if (subcommand === 'show') return await run('dream.show', args.slice(1), io, showDream); + if (subcommand === 'apply') return await run('dream.apply', args.slice(1), io, applyDream); + throw new UsageError(`Unknown dream command: ${subcommand}`); +} + +async function previewDream(args, io, context) { + assertKnownOptions(args, ['--window-days', '--wait', '--wait-timeout', '--idempotency-key', '--team', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); + const input = await readJsonInput(args, io); + assertNoUnknownInputFields(input, ['window_days', 'idempotency_key', 'wait', 'wait_timeout', 'team_id']); + rejectInputFlagConflicts(input, [['--window-days', 'window_days'], ['--idempotency-key', 'idempotency_key'], ['--wait', 'wait'], ['--wait-timeout', 'wait_timeout'], ['--team', 'team_id']], args); + const teamId = optionValue(args, '--team') ?? input?.team_id; + const wait = hasFlag(args, '--wait') || booleanInput(input, 'wait'); + const waitTimeout = parseIntegerInRange(optionValue(args, '--wait-timeout') ?? input?.wait_timeout ?? 120000, '--wait-timeout', { min: 1, max: 2_147_483_647 }); + const idempotencyKey = optionValue(args, '--idempotency-key') ?? input?.idempotency_key ?? randomUUID(); + if (typeof idempotencyKey !== 'string' || !idempotencyKey.trim() || idempotencyKey.length > 256) throw new UsageError('idempotency_key must be a non-empty string of at most 256 characters.'); + const rawWindowDays = optionValue(args, '--window-days') ?? input?.window_days; + const body = compact({ window_days: rawWindowDays === undefined ? undefined : parseIntegerInRange(rawWindowDays, '--window-days', { min: 1, max: 365 }), idempotency_key: idempotencyKey }); + const settingsResponse = await context.client.request({ method: 'GET', path: '/api/v1/me/dream/settings', query: compact({ team_id: teamId }), retry: 'bounded' }); + const settings = settingsResponse.data; + if (settings?.enabled === false || settings?.mode === 'off' || settings?.entitlement?.can_preview === false) { + throw new PrerequisiteRequiredError('Dream preview is disabled; CLI will not change Dream settings.', { data: { settings }, nextAction: 'Enable Dream preview in the management UI, then retry.' }); + } + let response; + try { + response = await context.client.request({ method: 'POST', path: '/api/v1/me/dream/runs', query: compact({ team_id: teamId }), body, sideEffect: true }); + } catch (error) { + if (error instanceof UnknownOutcomeError) { + error.data = { ...(error.data ?? {}), idempotency_key: idempotencyKey }; + } + throw error; + } + const entitlementAllowsApply = settings?.entitlement?.can_apply !== false; + const applyAvailable = settings?.enabled !== false && settings?.mode === 'confirm_each_run' && entitlementAllowsApply; + const applyUnavailableReason = applyAvailable + ? null + : settings?.mode !== 'confirm_each_run' + ? 'Dream settings mode must be confirm_each_run before a preview can be applied.' + : 'The current entitlement does not allow Dream apply.'; + let result = response.data; + if (!result?.run?.run_id && !result?.run?.id) throw new UnknownOutcomeError('Dream preview returned no run ID; verify the same idempotency key before retrying.', { data: { idempotency_key: idempotencyKey } }); + if (wait) result = await waitForDream(context, result?.run?.run_id ?? result?.run?.id, args, null, waitTimeout, teamId); + result = { ...result, applyAvailable, applyUnavailableReason }; + return { data: result }; +} + +async function showDream(args, io, context) { + assertKnownOptions(args, ['--wait', '--wait-timeout', '--team', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); + const input = await readJsonInput(args, io); + assertNoUnknownInputFields(input, ['run_id', 'wait', 'wait_timeout', 'team_id']); + rejectInputFlagConflicts(input, [['--wait', 'wait'], ['--wait-timeout', 'wait_timeout'], ['--team', 'team_id']], args); + const positionalRunId = positional(args); + if (positionalRunId && input?.run_id !== undefined) throw new UsageError('Dream run ID cannot be supplied both positionally and in --input.'); + const runId = positionalRunId ?? input?.run_id; + if (!runId) throw new UsageError('dream show requires a run ID.'); + const wait = hasFlag(args, '--wait') || booleanInput(input, 'wait'); + const waitTimeout = parseIntegerInRange(optionValue(args, '--wait-timeout') ?? input?.wait_timeout ?? 120000, '--wait-timeout', { min: 1, max: 2_147_483_647 }); + const teamId = optionValue(args, '--team') ?? input?.team_id; + let response = await context.client.request({ method: 'GET', path: `/api/v1/me/dream/runs/${encodeURIComponent(runId)}`, query: compact({ team_id: teamId }), retry: 'bounded' }); + if (wait) response = { ...response, data: await waitForDream(context, runId, args, response.data, waitTimeout, teamId) }; + const run = response.data?.run; + const candidateItemIds = Array.isArray(response.data?.items) ? response.data.items.map((item) => item?.id).filter(Boolean) : []; + const receipt = createReadReceipt({ baseUrl: context.baseUrl, resource: `dream-run:${runId}`, scope: teamId ?? 'personal', revision: run?.confirmation_version, settingsVersion: run?.settings_version ?? response.data?.settings_version, candidateItemIds }); + return { data: response.data, meta: { readReceipt: receipt } }; +} + +async function applyDream(args, io, context) { + assertKnownOptions(args, ['--item', '--from', '--yes', '--team', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); + const input = await readJsonInput(args, io); + assertNoUnknownInputFields(input, ['run_id', 'item_id', 'from', 'team_id']); + rejectInputFlagConflicts(input, [['--item', 'item_id'], ['--from', 'from'], ['--team', 'team_id']], args); + const positionalRunId = positional(args); + if (positionalRunId && input?.run_id !== undefined) throw new UsageError('Dream run ID cannot be supplied both positionally and in --input.'); + const runId = positionalRunId ?? input?.run_id; + const itemId = optionValue(args, '--item') ?? input?.item_id; + const fromPath = optionValue(args, '--from') ?? input?.from; + if (!runId || !itemId || !fromPath) throw new UsageError('dream apply requires , --item, and --from .'); + const teamId = optionValue(args, '--team') ?? input?.team_id; + const receipt = await readAndValidateReceipt(fromPath, { baseUrl: context.baseUrl, resource: `dream-run:${runId}`, scope: teamId ?? 'personal' }); + if (!receipt.displayedRevision || !receipt.settingsVersion) throw new UsageError('Dream read receipt lacks run/settings versions; run dream show again.'); + if (!Array.isArray(receipt.candidateItemIds) || !receipt.candidateItemIds.includes(String(itemId))) { + throw new UsageError('The selected Dream item was not present in the reviewed run; run dream show again.'); + } + await confirmRemoteAction(args, io, 'Apply this Dream candidate to memory?'); + const response = await context.client.request({ + method: 'POST', + path: `/api/v1/me/dream/runs/${encodeURIComponent(runId)}/confirm`, + query: compact({ team_id: teamId }), + body: { item_id: itemId, expected_run_version: String(receipt.displayedRevision), expected_settings_version: Number(receipt.settingsVersion) }, + sideEffect: true + }); + return response; +} + +async function waitForDream(context, runId, args, initial = null, timeoutOverride = null, teamIdOverride = null) { + if (!runId) throw new ServiceClientError('Dream preview did not return a run ID.', { code: 'INVALID_RESPONSE' }); + const timeoutMs = parsePositiveInteger(optionValue(args, '--wait-timeout') ?? timeoutOverride ?? '120000', '--wait-timeout'); + const deadline = Date.now() + timeoutMs; + let data = initial; + while (Date.now() <= deadline) { + if (context.signal?.aborted) throw new InterruptedError(`Dream wait interrupted; run ${runId} remains available for show.`, { data: { run_id: runId }, nextAction: `Run \`xmemo dream show ${runId}\` to continue checking.` }); + if (!data) { + try { + data = (await context.client.request({ method: 'GET', path: `/api/v1/me/dream/runs/${encodeURIComponent(runId)}`, query: compact({ team_id: teamIdOverride ?? optionValue(args, '--team') }), retry: 'bounded', timeoutMs: Math.max(1, deadline - Date.now()) })).data; + } catch (error) { + error.data = { ...(error.data ?? {}), run_id: runId }; + error.nextAction = `Run \`xmemo dream show ${runId}\` to continue checking.`; + if (error.code === 'REQUEST_TIMEOUT' && Date.now() >= deadline) error.code = 'LOCAL_WAIT_TIMEOUT'; + throw error; + } + } + const status = String(data?.run?.status ?? '').toLowerCase(); + if (status === 'succeeded') return data; + if (['failed', 'dead_letter', 'cancelled'].includes(status)) { + throw new ServiceClientError(`Dream run ${runId} ended with status ${status}.`, { code: 'DREAM_RUN_FAILED', httpStatus: 200, data }); + } + await sleep(250); + data = null; + } + throw new ServiceClientError(`Dream wait timed out; run ${runId} remains available for show.`, { code: 'LOCAL_WAIT_TIMEOUT', outcome: 'known-failure', data: { run_id: runId }, nextAction: `Run \`xmemo dream show ${runId}\` to continue checking.` }); +} + +async function run(command, args, io, handler) { + const outputJson = hasFlag(args, '--json'); + try { + const context = await serviceContext(args, io); + const response = await handler(args, io, context); + if (outputJson) writeSuccess(io, command, response?.data ?? response, response?.meta ?? {}); + else writeHumanServiceResult(io, command, response?.data ?? response, response?.meta ?? {}); + return 0; + } catch (error) { + if (outputJson) writeFailure(io, command, error); + else writeLine(io.stderr, `Error: ${error.message}`); + return errorToExitCode(error); + } +} + +function positional(args) { + const optionsWithValue = new Set(['--input', '--window-days', '--idempotency-key', '--wait-timeout', '--team', '--from', '--item', '--timeout-ms', '--base-url', '--url']); + for (let index = 0; index < args.length; index += 1) { + if (!args[index].startsWith('--')) return args[index]; + if (optionsWithValue.has(args[index])) index += 1; + } + return null; +} + +function compact(value) { + return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== null)); +} diff --git a/src/commands/hermes.js b/src/commands/hermes.js index fa2a1af..3665041 100644 --- a/src/commands/hermes.js +++ b/src/commands/hermes.js @@ -195,7 +195,7 @@ export async function hermesSetupPlan({ setupPlan, optionArgs, io, dryRun, ident if (shouldBackfillSharedCredential && !mcpOnly) { await storeTokenValue( credential.token, - { source: 'hermes-env-sync', existingPlaintextSource: hermesEnvPath }, + { source: 'hermes-env-sync', existingPlaintextSource: hermesEnvPath, baseUrl: setupPlan.baseUrl }, io.env, { allowPlaintext: true } ); diff --git a/src/commands/knowledge.js b/src/commands/knowledge.js new file mode 100644 index 0000000..ec7254c --- /dev/null +++ b/src/commands/knowledge.js @@ -0,0 +1,324 @@ +import path from 'node:path'; +import { createInterface } from 'node:readline/promises'; + +import { hasFlag, optionValue, parseIntegerInRange, parsePositiveInteger } from '../core/args.js'; +import { UsageError } from '../core/errors.js'; +import { writeLine } from '../core/io.js'; +import { booleanInput, readJsonInput, rejectInputFlagConflicts } from '../api/input.js'; +import { createReadReceipt, readAndValidateReceipt } from '../api/read-receipt.js'; +import { InterruptedError, PartialCompletionError, ServiceClientError, UnknownOutcomeError, errorToExitCode } from '../api/errors.js'; +import { writeFailure, writeSuccess } from '../api/envelope.js'; +import { serviceContext } from '../api/service-context.js'; +import { readDocumentInput } from '../api/upload-input.js'; +import { readTextFileBounded } from '../api/text-input.js'; +import { writeServiceHelpSchema } from '../api/contracts/help-schema.js'; +import { sleep } from '../core/runtime.js'; +import { assertKnownOptions, assertNoUnknownInputFields } from '../api/input.js'; +import { writeHumanServiceResult } from '../api/service-output.js'; +import { confirmRemoteAction } from '../api/confirmation.js'; + +export async function knowledgeCommand(args, io) { + const subcommand = args[0] ?? 'help'; + if (subcommand === 'help' || hasFlag(args, '--help')) { + if (subcommand !== 'help' && hasFlag(args, '--json') && writeServiceHelpSchema(io, `knowledge.${subcommand}`)) return 0; + writeLine(io.stdout, 'Knowledge commands:'); + writeLine(io.stdout, ' xmemo knowledge add --base (--text |--file |--document ) [--publish --yes] [--json]'); + writeLine(io.stdout, ' xmemo knowledge search [--base ] [--cursor ] [--json]'); + writeLine(io.stdout, ' xmemo knowledge read [--offset ] [--limit-chars ] [--json]'); + writeLine(io.stdout, ' xmemo knowledge update --from [--text |--file |--document --document-version ] [--publish --yes] [--json]'); + return 0; + } + if (subcommand === 'add') return await run('knowledge.add', args.slice(1), io, addKnowledge); + if (subcommand === 'search') return await run('knowledge.search', args.slice(1), io, searchKnowledge); + if (subcommand === 'read') return await run('knowledge.read', args.slice(1), io, readKnowledge); + if (subcommand === 'update') return await run('knowledge.update', args.slice(1), io, updateKnowledge); + throw new UsageError(`Unknown knowledge command: ${subcommand}`); +} + +async function addKnowledge(args, io, context) { + assertKnownOptions(args, ['--base', '--create-base', '--title', '--text', '--file', '--document', '--document-version', '--team', '--publish', '--yes', '--wait-timeout', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); + const input = await readJsonInput(args, io); + assertNoUnknownInputFields(input, ['knowledge_base_id', 'title', 'content', 'document_id', 'document_version', 'team_id', 'publish']); + rejectInputFlagConflicts(input, [['--base', 'knowledge_base_id'], ['--title', 'title'], ['--text', 'content'], ['--document', 'document_id'], ['--document-version', 'document_version'], ['--team', 'team_id'], ['--publish', 'publish']], args); + const explicitBaseId = optionValue(args, '--base') ?? input?.knowledge_base_id; + const createBaseName = optionValue(args, '--create-base'); + if (explicitBaseId && createBaseName) throw new UsageError('--base and --create-base are mutually exclusive.'); + let knowledgeBaseId = explicitBaseId ?? (!createBaseName ? io.env.XMEMO_KNOWLEDGE_BASE_ID : null); + let createdBase = null; + const title = optionValue(args, '--title') ?? input?.title ?? 'Untitled knowledge item'; + const teamId = optionValue(args, '--team') ?? input?.team_id; + const publish = hasFlag(args, '--publish') || booleanInput(input, 'publish'); + const text = optionValue(args, '--text') ?? input?.content; + const filePath = optionValue(args, '--file'); + const documentId = optionValue(args, '--document') ?? input?.document_id; + parseIntegerInRange(optionValue(args, '--wait-timeout') ?? 120000, '--wait-timeout', { min: 1, max: 2_147_483_647 }); + if ([Boolean(text), Boolean(filePath), Boolean(documentId)].filter(Boolean).length !== 1) { + throw new UsageError('knowledge add requires exactly one of --text, --file, or --document.'); + } + let preparedDocument = null; + let preparedText = text; + if (filePath) { + if (isTextFile(filePath)) { + preparedText = await readTextFile(filePath); + } else { + preparedDocument = await readDocumentInput(filePath); + } + } + let documentVersion = null; + if (documentId) { + documentVersion = parseIntegerInRange(input?.document_version ?? optionValue(args, '--document-version'), '--document-version', { min: 1, max: Number.MAX_SAFE_INTEGER }); + } + if (typeof title !== 'string' || !title.trim() || title.length > 500) throw new UsageError('knowledge add requires a title of 1..500 characters.'); + if (preparedText !== undefined && preparedText !== null && (typeof preparedText !== 'string' || !preparedText.trim() || preparedText.length > 500000)) throw new UsageError('knowledge add content must contain 1..500000 characters.'); + if (!knowledgeBaseId && !createBaseName) { + knowledgeBaseId = await selectKnowledgeBase(context, io, teamId, hasFlag(args, '--json')); + } + if (knowledgeBaseId && (preparedDocument || (!explicitBaseId && io.env.XMEMO_KNOWLEDGE_BASE_ID))) { + await context.client.request({ method: 'GET', path: `/api/v1/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}`, query: compact({ team_id: teamId }), retry: 'bounded' }); + } + if (documentId) { + const document = (await context.client.request({ method: 'GET', path: `/api/v1/documents/${encodeURIComponent(documentId)}`, query: compact({ bucket: 'private', team_id: teamId }), retry: 'bounded' })).data; + if (document?.extraction_status !== 'succeeded' || document.version !== documentVersion) throw new UsageError('Document must have completed extraction at the requested version before creating a knowledge snapshot.'); + } + if (publish) await confirmRemoteAction(args, io, 'Publish this knowledge item?'); + if (!knowledgeBaseId && createBaseName) { + const baseResponse = await context.client.request({ method: 'POST', path: '/api/v1/knowledge-bases', body: { name: createBaseName, team_id: optionValue(args, '--team') ?? input?.team_id }, sideEffect: true }); + createdBase = baseResponse.data; + knowledgeBaseId = createdBase?.knowledge_base_id ?? createdBase?.id; + if (!knowledgeBaseId) throw new PartialCompletionError('Knowledge base was created but its ID was not returned.', { data: { createdBase } }); + } + let data; + let uploadedDocument = null; + try { + if (documentId) { + data = await createFromDocument(context, knowledgeBaseId, documentId, documentVersion, title, publish, teamId); + } else if (filePath && !isTextFile(filePath)) { + uploadedDocument = await uploadDocument(context, preparedDocument, teamId); + const extracted = await waitForDocumentExtraction(context, uploadedDocument, args, teamId); + const finalDocument = extracted ?? uploadedDocument; + if (finalDocument.extraction_status !== 'succeeded') throw new PartialCompletionError('Document upload completed but extraction did not succeed.', { data: { document: finalDocument } }); + uploadedDocument = finalDocument; + if (!Number.isSafeInteger(finalDocument.version) || finalDocument.version < 1) throw new PartialCompletionError('Extracted Document did not return its version; snapshot creation was not attempted.', { data: { document: finalDocument } }); + data = await createFromDocument(context, knowledgeBaseId, finalDocument.document_id, finalDocument.version, title, publish, teamId); + } else { + const response = await context.client.request({ method: 'POST', path: `/api/v1/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/items`, body: compact({ title, content: preparedText, status: publish ? 'published' : 'draft', team_id: teamId }), sideEffect: true }); + data = response.data; + } + } catch (error) { + if (createdBase || uploadedDocument) { + if (error instanceof UnknownOutcomeError || error?.code === 'LOCAL_WAIT_TIMEOUT' || error?.code === 'INTERRUPTED') { + error.data = { base: createdBase, document: uploadedDocument, ...(error.data ?? {}) }; + throw error; + } + throw new PartialCompletionError('Knowledge add completed an earlier remote step but did not create the final item.', { cause: error, data: { base: createdBase, document: uploadedDocument, error: { code: error.code, message: error.message } } }); + } + throw error; + } + return { data: compact({ base: createdBase, document: uploadedDocument, item: data }) }; +} + +async function searchKnowledge(args, io, context) { + assertKnownOptions(args, ['--base', '--team', '--limit', '--cursor', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); + const input = await readJsonInput(args, io); + assertNoUnknownInputFields(input, ['query', 'knowledge_base_id', 'limit', 'cursor', 'mode', 'alpha', 'query_embedding', 'k', 'team_id', 'scope']); + rejectInputFlagConflicts(input, [['--base', 'knowledge_base_id'], ['--limit', 'limit'], ['--cursor', 'cursor'], ['--team', 'team_id']], args); + const query = positional(args) ?? input?.query; + if (positional(args) && input?.query !== undefined) throw new UsageError('Search query cannot be supplied both positionally and in --input.'); + if (!query) throw new UsageError('knowledge search requires a query.'); + const rawLimit = optionValue(args, '--limit') ?? input?.limit; + const response = await context.client.request({ method: 'POST', path: '/api/v1/knowledge/search', body: compact({ ...input, query, knowledge_base_id: optionValue(args, '--base') ?? input?.knowledge_base_id, limit: rawLimit === undefined || rawLimit === null ? undefined : parseIntegerInRange(rawLimit, '--limit', { min: 1, max: 100 }), cursor: optionValue(args, '--cursor') ?? input?.cursor ?? '', team_id: optionValue(args, '--team') ?? input?.team_id }), sideEffect: false, retry: 'bounded' }); + return response; +} + +async function readKnowledge(args, io, context) { + assertKnownOptions(args, ['--from', '--team', '--offset', '--limit-chars', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); + const input = await readJsonInput(args, io); + assertNoUnknownInputFields(input, ['item_id', 'from', 'team_id', 'offset', 'limit_chars']); + rejectInputFlagConflicts(input, [['--from', 'from'], ['--team', 'team_id'], ['--offset', 'offset'], ['--limit-chars', 'limit_chars']], args); + const positionalItemId = positional(args); + if (positionalItemId && input?.item_id !== undefined) throw new UsageError('Knowledge item ID cannot be supplied both positionally and in --input.'); + const itemId = positionalItemId ?? input?.item_id; + if (!itemId) throw new UsageError('knowledge read requires an item ID.'); + const teamId = optionValue(args, '--team') ?? input?.team_id; + const offset = parseIntegerInRange(optionValue(args, '--offset') ?? input?.offset ?? 0, '--offset', { min: 0, max: 2_147_483_647 }); + const limitChars = parseIntegerInRange(optionValue(args, '--limit-chars') ?? input?.limit_chars ?? 100000, '--limit-chars', { min: 1, max: 100000 }); + const fromPath = optionValue(args, '--from') ?? input?.from; + if (offset > 0 && !fromPath) throw new UsageError('Continuation pages require --from to pin the viewed revision.'); + const previous = fromPath ? await readAndValidateReceipt(fromPath, { baseUrl: context.baseUrl, resource: `knowledge-item:${itemId}`, scope: teamId ?? 'personal' }) : null; + const item = previous ? { item_id: itemId, current_revision_id: previous.displayedRevision, version: previous.version, status: previous.itemStatus, source_type: previous.sourceType } : (await context.client.request({ method: 'GET', path: `/api/v1/knowledge-items/${encodeURIComponent(itemId)}`, query: compact({ team_id: teamId }), retry: 'bounded' })).data; + const revisionId = previous?.displayedRevision ?? item?.current_revision_id; + if (!revisionId) throw new ServiceClientError('Knowledge item did not return current_revision_id.', { code: 'INVALID_RESPONSE' }); + const revisionResponse = await context.client.request({ method: 'GET', path: `/api/v1/knowledge-items/${encodeURIComponent(itemId)}/revisions/${encodeURIComponent(revisionId)}`, query: compact({ offset, limit_chars: limitChars, team_id: teamId }), retry: 'bounded' }); + const revision = revisionResponse.data; + const receipt = createReadReceipt({ baseUrl: context.baseUrl, resource: `knowledge-item:${itemId}`, scope: teamId ?? 'personal', revision: revisionId, version: item.version, itemStatus: item.status, content: revision?.canonical_content, sourceType: item.source_type, sourceRef: revision?.source_ref ?? previous?.sourceRef }); + return { data: { item, revision }, meta: { readReceipt: receipt } }; +} + +async function updateKnowledge(args, io, context) { + assertKnownOptions(args, ['--from', '--text', '--file', '--document', '--document-version', '--publish', '--yes', '--team', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); + const input = await readJsonInput(args, io); + assertNoUnknownInputFields(input, ['item_id', 'from', 'content', 'document_id', 'document_version', 'team_id', 'publish']); + rejectInputFlagConflicts(input, [['--from', 'from'], ['--text', 'content'], ['--document', 'document_id'], ['--document-version', 'document_version'], ['--team', 'team_id'], ['--publish', 'publish']], args); + if (positional(args) && input?.item_id !== undefined) throw new UsageError('Knowledge item ID cannot be supplied both positionally and in --input.'); + const itemId = positional(args) ?? input?.item_id; + if (!itemId) throw new UsageError('knowledge update requires an item ID.'); + const fromPath = optionValue(args, '--from') ?? input?.from; + if (!fromPath) throw new UsageError('knowledge update requires --from .'); + const teamId = optionValue(args, '--team') ?? input?.team_id; + const receipt = await readAndValidateReceipt(fromPath, { baseUrl: context.baseUrl, resource: `knowledge-item:${itemId}`, scope: teamId ?? 'personal' }); + if (receipt.latestRevision && receipt.displayedRevision !== receipt.latestRevision) throw new UsageError('The viewed knowledge revision is not the latest maintenance revision; run knowledge read again.'); + const publish = hasFlag(args, '--publish') || booleanInput(input, 'publish'); + if (receipt.itemStatus === 'published' && !publish) throw new UsageError('Updating a published knowledge item requires explicit --publish --yes; the service has no independent draft path.'); + const text = optionValue(args, '--text') ?? input?.content; + const filePath = optionValue(args, '--file'); + const documentId = optionValue(args, '--document') ?? input?.document_id; + const sourceCount = [Boolean(text), Boolean(filePath), Boolean(documentId)].filter(Boolean).length; + if (sourceCount > 1 || (sourceCount === 0 && !publish)) { + throw new UsageError('knowledge update requires one of --text, --file, or --document, unless --publish alone is publishing an existing draft.'); + } + if (filePath && !isTextFile(filePath)) { + throw new UsageError('Binary replacement requires a new version of the same Document; this server contract is not yet available. Use --document --document-version only if that version already exists.'); + } + if (sourceCount && !receipt.sourceType) throw new UsageError('Read receipt lacks source metadata; run knowledge read again before updating content.'); + if (documentId && (receipt.sourceType !== 'file' || receipt.sourceRef !== `document:${documentId}`)) throw new UsageError('Document updates must preserve the reviewed file source and Document ID.'); + if (!documentId && sourceCount && receipt.sourceType === 'file') throw new UsageError('File-backed knowledge cannot be replaced with text; use the same source Document and its version.'); + if (!documentId && (optionValue(args, '--document-version') || input?.document_version !== undefined)) throw new UsageError('--document-version requires --document.'); + if (sourceCount === 0 && receipt.itemStatus === 'published') throw new UsageError('The reviewed knowledge item is already published; provide new content to update it.'); + if (publish) await confirmRemoteAction(args, io, 'Publish these knowledge changes?'); + let data = {}; + if (sourceCount === 1) { + let contentResponse; + if (documentId) { + const documentVersion = parseIntegerInRange(optionValue(args, '--document-version') ?? input?.document_version, '--document-version', { min: 1, max: Number.MAX_SAFE_INTEGER }); + contentResponse = await context.client.request({ + method: 'PUT', + path: `/api/v1/knowledge-items/${encodeURIComponent(itemId)}/content/from-document`, + body: { document_id: documentId, expected_document_version: documentVersion, expected_current_revision_id: receipt.displayedRevision, team_id: teamId }, + sideEffect: true + }); + } else { + const content = text ?? await readTextFile(filePath); + if (typeof content !== 'string' || !content.trim() || content.length > 500000) throw new UsageError('knowledge update content must contain 1..500000 characters.'); + contentResponse = await context.client.request({ method: 'PUT', path: `/api/v1/knowledge-items/${encodeURIComponent(itemId)}/content`, body: { expected_current_revision_id: receipt.displayedRevision, canonical_content: content, team_id: teamId }, sideEffect: true }); + } + data.content = contentResponse.data; + } + if (publish && receipt.itemStatus !== 'published') { + const updatedVersion = sourceCount ? (data.content?.version ?? data.content?.item?.version) : receipt.version; + if (!Number.isInteger(Number(updatedVersion)) || Number(updatedVersion) < 1) { + if (!sourceCount) throw new UsageError('Knowledge read receipt has no item version; run knowledge read again.'); + throw new PartialCompletionError('Knowledge content was updated but the new item version was not returned; publish was not attempted.', { data }); + } + try { + const statusResponse = await context.client.request({ method: 'PATCH', path: `/api/v1/knowledge-items/${encodeURIComponent(itemId)}`, body: { expected_version: Number(updatedVersion), status: 'published', team_id: teamId }, sideEffect: true }); + data.status = statusResponse.data; + } catch (error) { + if (!sourceCount) throw error; + if (error instanceof UnknownOutcomeError) { + error.data = { ...(error.data ?? {}), ...data }; + throw error; + } + throw new PartialCompletionError('Knowledge content was updated but publishing failed.', { cause: error, data: { ...data, publishError: { code: error.code, message: error.message } } }); + } + } + return { data }; +} + +async function readTextFile(filePath) { + const content = await readTextFileBounded(filePath, 'knowledge content'); + if (content.length > 500000) throw new UsageError('Knowledge content exceeds the 500000-character limit.'); + return content; +} + +async function createFromDocument(context, baseId, documentId, documentVersion, title, publish, teamId) { + const response = await context.client.request({ method: 'POST', path: `/api/v1/knowledge-bases/${encodeURIComponent(baseId)}/items/from-document`, body: { document_id: documentId, expected_document_version: documentVersion, title, status: publish ? 'published' : 'draft', team_id: teamId }, sideEffect: true }); + return response.data; +} + +async function uploadDocument(context, document, teamId) { + const response = await context.client.request({ method: 'POST', path: '/api/v1/documents', body: { filename: document.filename, content_base64: document.contentBase64, bucket: 'private', team_id: teamId }, sideEffect: true }); + return response.data; +} + +async function waitForDocumentExtraction(context, document, args, teamId) { + if (!document?.document_id) throw new PartialCompletionError('Document upload returned no document ID.', { data: { document } }); + let current = document; + const status = () => String(current?.extraction_status ?? '').toLowerCase(); + if (status() === 'succeeded' || status() === 'failed' || status() === 'dead_letter') return current; + const timeoutMs = parsePositiveInteger(optionValue(args, '--wait-timeout') ?? '120000', '--wait-timeout'); + const deadline = Date.now() + timeoutMs; + while (Date.now() <= deadline) { + if (context.signal?.aborted) throw new InterruptedError('Local extraction wait interrupted; the uploaded Document remains available.', { data: { document: current }, nextAction: `Run knowledge add again with --document ${document.document_id} --document-version ${current?.version ?? document.version ?? 1} after extraction completes.` }); + await sleep(250); + try { + current = (await context.client.request({ method: 'GET', path: `/api/v1/documents/${encodeURIComponent(document.document_id)}`, query: compact({ bucket: 'private', team_id: teamId }), retry: 'bounded', timeoutMs: Math.max(1, deadline - Date.now()) })).data; + } catch (error) { + error.data = { document: current, ...(error.data ?? {}) }; + if (error.code === 'REQUEST_TIMEOUT' && Date.now() >= deadline) error.code = 'LOCAL_WAIT_TIMEOUT'; + throw error; + } + if (['succeeded', 'failed', 'dead_letter'].includes(status())) return current; + } + throw new ServiceClientError('Local extraction wait timed out; the uploaded Document remains available.', { code: 'LOCAL_WAIT_TIMEOUT', outcome: 'known-failure', data: { document: current }, nextAction: `Run knowledge add again with --document ${document.document_id} --document-version ${current?.version ?? document.version ?? 1} after extraction completes.` }); +} + +async function run(command, args, io, handler) { + const outputJson = hasFlag(args, '--json'); + try { + const context = await serviceContext(args, io); + const response = await handler(args, io, context); + const data = Object.hasOwn(response, 'data') ? response.data : response; + const meta = { ...response?.meta, nextCursor: response?.meta?.nextCursor ?? data?.next_cursor ?? null }; + if (outputJson) writeSuccess(io, command, data, meta); + else writeHumanServiceResult(io, command, data, meta); + return 0; + } catch (error) { + if (outputJson) writeFailure(io, command, error); + else writeLine(io.stderr, `Error: ${error.message}`); + return errorToExitCode(error); + } +} + +function positional(args) { + const optionsWithValue = new Set(['--input', '--base', '--title', '--text', '--file', '--document', '--document-version', '--team', '--limit', '--cursor', '--offset', '--limit-chars', '--from', '--timeout-ms', '--base-url', '--url']); + for (let index = 0; index < args.length; index += 1) { + if (!args[index].startsWith('--')) return args[index]; + if (optionsWithValue.has(args[index])) index += 1; + } + return null; +} + +async function selectKnowledgeBase(context, io, teamId, outputJson) { + if (outputJson || (!io.stdin?.isTTY && typeof io.selectKnowledgeBase !== 'function')) { + throw new UsageError('knowledge add requires --base, --create-base, or XMEMO_KNOWLEDGE_BASE_ID in non-interactive mode.'); + } + const response = await context.client.request({ method: 'GET', path: '/api/v1/knowledge-bases', query: compact({ team_id: teamId, include_archived: false, limit: 100, cursor: '' }), retry: 'bounded' }); + if (response.data?.next_cursor) writeLine(io.stderr, 'Showing the first 100 bases. For another base, rerun with its explicit --base ID.'); + const bases = Array.isArray(response.data) ? response.data : response.data?.items; + if (!Array.isArray(bases) || bases.length === 0) throw new UsageError('No active knowledge base is available. Use --create-base .'); + bases.forEach((base, index) => writeLine(io.stderr, ` ${index + 1}. ${base.name ?? 'Unnamed'} (${base.knowledge_base_id ?? base.id})`)); + let selected; + if (typeof io.selectKnowledgeBase === 'function') { + selected = await io.selectKnowledgeBase(bases); + } else { + const prompt = createInterface({ input: io.stdin, output: io.stderr }); + try { + selected = await prompt.question('Select a knowledge base number: '); + } finally { + prompt.close(); + } + } + const index = parseIntegerInRange(String(selected).trim(), 'knowledge base selection', { min: 1, max: bases.length }) - 1; + const base = bases[index]; + const baseId = base?.knowledge_base_id ?? base?.id; + if (!Number.isInteger(index) || index < 0 || index >= bases.length || !baseId) throw new UsageError('Invalid knowledge base selection.'); + return String(baseId); +} + +function isTextFile(filePath) { + return /\.(?:md|markdown|txt)$/i.test(filePath); +} + +function compact(value) { + return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== null)); +} diff --git a/src/commands/service.js b/src/commands/service.js new file mode 100644 index 0000000..0a0ea10 --- /dev/null +++ b/src/commands/service.js @@ -0,0 +1,215 @@ +import { hasFlag, optionValue, parseIntegerInRange } from '../core/args.js'; +import { UsageError } from '../core/errors.js'; +import { writeLine } from '../core/io.js'; +import { assertKnownOptions, assertNoUnknownInputFields, optionalBooleanInput, readJsonInput, rejectInputFlagConflicts } from '../api/input.js'; +import { errorToExitCode } from '../api/errors.js'; +import { writeFailure, writeSuccess } from '../api/envelope.js'; +import { serviceContext } from '../api/service-context.js'; +import { writeServiceHelpSchema } from '../api/contracts/help-schema.js'; +import { writeHumanServiceResult } from '../api/service-output.js'; + +export async function memoryCommand(args, io) { + const subcommand = args[0] ?? 'help'; + if (subcommand === 'help' || hasFlag(args, '--help')) { + if (subcommand !== 'help' && hasFlag(args, '--json') && writeServiceHelpSchema(io, `memory.${subcommand}`)) return 0; + writeLine(io.stdout, 'Memory commands:'); + writeLine(io.stdout, ' xmemo memory add --content --path [--bucket ] [--json]'); + writeLine(io.stdout, ' xmemo memory search [--limit ] [--team ] [--json]'); + return 0; + } + if (subcommand === 'add') return await runServiceCommand('memory.add', args.slice(1), io, memoryAdd); + if (subcommand === 'search') return await runServiceCommand('memory.search', args.slice(1), io, memorySearch); + throw new UsageError(`Unknown memory command: ${subcommand}`); +} + +export async function contextCommand(args, io) { + const subcommand = args[0] ?? 'help'; + if (subcommand === 'help' || hasFlag(args, '--help')) { + if (subcommand !== 'help' && hasFlag(args, '--json') && writeServiceHelpSchema(io, `context.${subcommand}`)) return 0; + writeLine(io.stdout, 'Context commands:'); + writeLine(io.stdout, ' xmemo context recall [--include-knowledge] [--json]'); + return 0; + } + if (subcommand === 'recall') return await runServiceCommand('context.recall', args.slice(1), io, contextRecall); + throw new UsageError(`Unknown context command: ${subcommand}`); +} + +export async function stateCommand(args, io) { + const subcommand = args[0] ?? 'help'; + if (subcommand === 'help' || hasFlag(args, '--help')) { + if (subcommand !== 'help' && hasFlag(args, '--json') && writeServiceHelpSchema(io, `state.${subcommand}`)) return 0; + writeLine(io.stdout, 'State commands:'); + writeLine(io.stdout, ' xmemo state save [--content ] [--state-key ] [--json]'); + writeLine(io.stdout, ' xmemo state restore [--state-key ] [--json]'); + return 0; + } + if (subcommand === 'save') return await runServiceCommand('state.save', args.slice(1), io, stateSave); + if (subcommand === 'restore') return await runServiceCommand('state.restore', args.slice(1), io, stateRestore); + throw new UsageError(`Unknown state command: ${subcommand}`); +} + +export async function restartCommand(args, io) { + const subcommand = args[0] ?? 'help'; + if (subcommand === 'help' || hasFlag(args, '--help')) { + if (subcommand !== 'help' && hasFlag(args, '--json') && writeServiceHelpSchema(io, `restart.${subcommand}`)) return 0; + writeLine(io.stdout, 'Restart commands:'); + writeLine(io.stdout, ' xmemo restart snapshot [--state-key ] [--json]'); + writeLine(io.stdout, ' xmemo restart restore [--snapshot-id ] [--json]'); + return 0; + } + if (subcommand === 'snapshot') return await runServiceCommand('restart.snapshot', args.slice(1), io, restartSnapshot); + if (subcommand === 'restore') return await runServiceCommand('restart.restore', args.slice(1), io, restartRestore); + throw new UsageError(`Unknown restart command: ${subcommand}`); +} + +async function memoryAdd(args, io, context) { + assertKnownOptions(args, ['--content', '--path', '--bucket', '--scope', '--team', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); + const input = await readJsonInput(args, io); + assertNoUnknownInputFields(input, ['content', 'path', 'bucket', 'scope', 'team_id', 'metadata', 'memory_type']); + rejectInputFlagConflicts(input, [['--content', 'content'], ['--path', 'path'], ['--bucket', 'bucket'], ['--scope', 'scope'], ['--team', 'team_id']], args); + const body = { + content: optionValue(args, '--content') ?? input?.content, + path: optionValue(args, '--path') ?? input?.path, + bucket: optionValue(args, '--bucket') ?? input?.bucket, + scope: optionValue(args, '--scope') ?? input?.scope, + team_id: optionValue(args, '--team') ?? input?.team_id, + metadata: input?.metadata, + memory_type: input?.memory_type + }; + if (typeof body.content !== 'string' || !body.content.trim()) throw new UsageError('memory add requires non-empty --content or input.content.'); + if (typeof body.path !== 'string' || !body.path.trim()) throw new UsageError('memory add requires non-empty --path or input.path.'); + return await context.client.request({ method: 'POST', path: '/api/v1/remember', body: compact(body), sideEffect: true }); +} + +async function memorySearch(args, io, context) { + assertKnownOptions(args, ['--limit', '--team', '--bucket', '--path', '--prefer-working', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); + const input = await readJsonInput(args, io); + assertNoUnknownInputFields(input, ['query', 'limit', 'team_id', 'bucket', 'path', 'prefer_working']); + rejectInputFlagConflicts(input, [['--limit', 'limit'], ['--team', 'team_id'], ['--bucket', 'bucket'], ['--path', 'path'], ['--prefer-working', 'prefer_working']], args); + const query = positional(args) ?? input?.query; + if (positional(args) && input?.query !== undefined) throw new UsageError('Search query cannot be supplied both positionally and in --input.'); + if (typeof query !== 'string' || !query.trim()) throw new UsageError('memory search requires a query.'); + const rawLimit = optionValue(args, '--limit') ?? input?.limit; + const data = await context.client.request({ + method: 'GET', path: '/api/v1/recall', retry: 'bounded', sideEffect: false, + query: compact({ query, limit: rawLimit === undefined || rawLimit === null ? undefined : parseIntegerInRange(rawLimit, '--limit', { min: 1, max: 5000 }), team_id: optionValue(args, '--team') ?? input?.team_id, bucket: optionValue(args, '--bucket') ?? input?.bucket, path: optionValue(args, '--path') ?? input?.path, prefer_working: hasFlag(args, '--prefer-working') ? true : optionalBooleanInput(input, 'prefer_working') }) + }); + return data; +} + +async function contextRecall(args, io, context) { + assertKnownOptions(args, ['--include-knowledge', '--team', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); + const input = await readJsonInput(args, io); + assertNoUnknownInputFields(input, ['query', 'include_knowledge', 'team_id', 'scope', 'limit', 'max_items', 'max_tokens', 'path', 'bucket', 'memory_type', 'status', 'threshold', 'prefer_working']); + rejectInputFlagConflicts(input, [['--team', 'team_id'], ['--include-knowledge', 'include_knowledge']], args); + const query = positional(args) ?? input?.query; + if (positional(args) && input?.query !== undefined) throw new UsageError('Context query cannot be supplied both positionally and in --input.'); + if (typeof query !== 'string' || !query.trim()) throw new UsageError('context recall requires a query.'); + const body = compact({ + ...input, + query, + include_knowledge: hasFlag(args, '--include-knowledge') ? true : optionalBooleanInput(input, 'include_knowledge'), + prefer_working: optionalBooleanInput(input, 'prefer_working'), + team_id: optionValue(args, '--team') ?? input?.team_id + }); + return await context.client.request({ method: 'POST', path: '/api/v1/recall/context', body, sideEffect: false, retry: 'bounded' }); +} + +async function stateSave(args, io, context) { + assertKnownOptions(args, ['--state-key', '--content', '--current-task', '--next-action', '--blocked-reason', '--bucket', '--scope', '--ttl-seconds', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); + const input = await readJsonInput(args, io); + assertNoUnknownInputFields(input, ['state_key', 'content', 'current_task', 'next_action', 'blocked_reason', 'metadata', 'source', 'bucket', 'scope', 'path', 'ttl_seconds']); + rejectInputFlagConflicts(input, [['--state-key', 'state_key'], ['--content', 'content'], ['--current-task', 'current_task'], ['--next-action', 'next_action'], ['--blocked-reason', 'blocked_reason'], ['--bucket', 'bucket'], ['--scope', 'scope'], ['--ttl-seconds', 'ttl_seconds']], args); + const ttl = optionValue(args, '--ttl-seconds') ?? input?.ttl_seconds; + const body = compact({ + ...input, + state_key: optionValue(args, '--state-key') ?? input?.state_key ?? 'active_task', + content: optionValue(args, '--content') ?? input?.content, + current_task: optionValue(args, '--current-task') ?? input?.current_task, + next_action: optionValue(args, '--next-action') ?? input?.next_action, + blocked_reason: optionValue(args, '--blocked-reason') ?? input?.blocked_reason, + bucket: optionValue(args, '--bucket') ?? input?.bucket ?? 'work', + scope: optionValue(args, '--scope') ?? input?.scope, + ttl_seconds: ttl === undefined || ttl === null ? undefined : parseIntegerInRange(ttl, '--ttl-seconds', { min: 0, max: 604800 }) + }); + if (!body.content && !body.current_task && !body.next_action && !body.blocked_reason) throw new UsageError('state save requires content or a structured state field.'); + return await context.client.request({ method: 'POST', path: '/api/v1/update_state', body, sideEffect: true }); +} + +async function stateRestore(args, io, context) { + assertKnownOptions(args, ['--state-key', '--bucket', '--scope', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); + const input = await readJsonInput(args, io); + assertNoUnknownInputFields(input, ['state_key', 'bucket', 'scope']); + rejectInputFlagConflicts(input, [['--state-key', 'state_key'], ['--bucket', 'bucket'], ['--scope', 'scope']], args); + const body = compact({ operation: 'state-restore', arguments: compact({ + state_key: optionValue(args, '--state-key') ?? input?.state_key ?? 'active_task', + bucket: optionValue(args, '--bucket') ?? input?.bucket ?? 'work', + scope: optionValue(args, '--scope') ?? input?.scope + }) }); + return await context.client.request({ method: 'POST', path: '/api/v1/skill/operations', body, sideEffect: false, retry: 'bounded' }); +} + +async function restartSnapshot(args, io, context) { + assertKnownOptions(args, ['--state-key', '--bucket', '--scope', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); + const input = await readJsonInput(args, io); + assertNoUnknownInputFields(input, ['session_id', 'state_key', 'timeline_limit', 'reminder_limit', 'decision_limit', 'metadata', 'source', 'bucket', 'scope', 'path', 'ttl_seconds']); + rejectInputFlagConflicts(input, [['--state-key', 'state_key'], ['--bucket', 'bucket'], ['--scope', 'scope']], args); + const body = compact({ + ...input, + state_key: optionValue(args, '--state-key') ?? input?.state_key ?? 'active_task', + bucket: optionValue(args, '--bucket') ?? input?.bucket ?? 'work', + scope: optionValue(args, '--scope') ?? input?.scope, + timeline_limit: optionalRange(input?.timeline_limit, 'timeline_limit', 0, 100), + reminder_limit: optionalRange(input?.reminder_limit, 'reminder_limit', 0, 100), + decision_limit: optionalRange(input?.decision_limit, 'decision_limit', 0, 100), + ttl_seconds: optionalRange(input?.ttl_seconds, 'ttl_seconds', 0, 604800) + }); + return await context.client.request({ method: 'POST', path: '/api/v1/restart/snapshot', body, sideEffect: true }); +} + +async function restartRestore(args, io, context) { + assertKnownOptions(args, ['--snapshot-id', '--state-key', '--bucket', '--scope', '--input', '--timeout-ms', '--base-url', '--url', '--allow-legacy-credential', '--json']); + const input = await readJsonInput(args, io); + assertNoUnknownInputFields(input, ['snapshot_id', 'source_session_id', 'target_session_id', 'state_key', 'restore_state', 'record_restore_event', 'ttl_seconds', 'source', 'bucket', 'scope']); + rejectInputFlagConflicts(input, [['--snapshot-id', 'snapshot_id'], ['--state-key', 'state_key'], ['--bucket', 'bucket'], ['--scope', 'scope']], args); + const body = compact({ ...input, snapshot_id: optionValue(args, '--snapshot-id') ?? input?.snapshot_id, state_key: optionValue(args, '--state-key') ?? input?.state_key, bucket: optionValue(args, '--bucket') ?? input?.bucket, scope: optionValue(args, '--scope') ?? input?.scope, restore_state: optionalBooleanInput(input, 'restore_state'), record_restore_event: optionalBooleanInput(input, 'record_restore_event'), ttl_seconds: optionalRange(input?.ttl_seconds, 'ttl_seconds', 0, 604800) }); + if (!body.snapshot_id && !body.source_session_id && !body.state_key) throw new UsageError('restart restore requires --snapshot-id, source_session_id, or state_key.'); + return await context.client.request({ method: 'POST', path: '/api/v1/restart/restore', body, sideEffect: true }); +} + +async function runServiceCommand(command, args, io, handler) { + const outputJson = hasFlag(args, '--json'); + try { + const context = await serviceContext(args, io); + const response = await handler(args, io, context); + const data = response?.data ?? response; + const meta = { + ...(response?.meta ?? {}), + nextCursor: response?.meta?.nextCursor ?? data?.next_cursor ?? data?.nextCursor ?? null + }; + if (outputJson) writeSuccess(io, command, data, meta); + else writeHumanServiceResult(io, command, data, meta); + return 0; + } catch (error) { + if (outputJson) writeFailure(io, command, error); + else writeLine(io.stderr, `Error: ${error.message}`); + return errorToExitCode(error); + } +} + +function positional(args) { + const optionsWithValue = new Set(['--input', '--content', '--path', '--bucket', '--scope', '--team', '--limit', '--state-key', '--current-task', '--next-action', '--blocked-reason', '--ttl-seconds', '--snapshot-id', '--base-url', '--url', '--timeout-ms']); + for (let index = 0; index < args.length; index += 1) { + if (!args[index].startsWith('--') && args[index] !== '-') return args[index]; + if (optionsWithValue.has(args[index])) index += 1; + } + return null; +} + +function compact(value) { + return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== null)); +} + +function optionalRange(value, name, min, max) { + return value === undefined || value === null ? undefined : parseIntegerInRange(value, name, { min, max }); +} diff --git a/src/core/args.js b/src/core/args.js index a8fe412..c411944 100644 --- a/src/core/args.js +++ b/src/core/args.js @@ -58,6 +58,14 @@ export function parsePositiveInteger(value, name) { return parsed; } +export function parseIntegerInRange(value, name, { min, max }) { + const parsed = typeof value === 'number' ? value : typeof value === 'string' && /^-?\d+$/.test(value) ? Number(value) : NaN; + if (!Number.isSafeInteger(parsed) || parsed < min || parsed > max) { + throw new UsageError(`${name} must be an integer from ${min} to ${max}.`); + } + return parsed; +} + function isPlainObject(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } diff --git a/src/core/io.js b/src/core/io.js index cc45ac5..e572ff8 100644 --- a/src/core/io.js +++ b/src/core/io.js @@ -7,6 +7,7 @@ export function defaultIo() { stdout: process.stdout, stderr: process.stderr, fetch: globalThis.fetch, + signal: undefined, spawn }; } diff --git a/src/network/auth.js b/src/network/auth.js index 1896576..576afb1 100644 --- a/src/network/auth.js +++ b/src/network/auth.js @@ -21,18 +21,41 @@ import { sleep } from '../core/runtime.js'; -export async function startDeviceLogin(baseUrl, timeoutMs, io) { +export const DEVICE_LOGIN_SCOPES = Object.freeze([ + 'memory:read', + 'memory:write', + 'memory:restore', + 'ledger:write', + 'ledger:read', + 'knowledge:read', + 'knowledge:write' +]); +export const DEFAULT_DEVICE_LOGIN_SCOPES = Object.freeze([ + 'memory:read', 'memory:write', 'memory:restore', 'ledger:write', 'ledger:read' +]); + +export function parseRequestedScopes(value) { + if (!value) return null; + const scopes = String(value) + .split(/[\s,]+/) + .map((scope) => scope.trim()) + .filter(Boolean); + const unique = [...new Set(scopes)]; + const unsupported = unique.filter((scope) => !DEVICE_LOGIN_SCOPES.includes(scope)); + if (unsupported.length > 0) { + throw new UsageError(`Unsupported login scope(s): ${unsupported.join(', ')}. Supported scopes: ${DEVICE_LOGIN_SCOPES.join(', ')}.`); + } + if (unique.length === 0) throw new UsageError('At least one login scope is required when --scopes is provided.'); + return unique; +} + +export async function startDeviceLogin(baseUrl, timeoutMs, io, requestedScopes = null) { + const scopes = requestedScopes ?? DEFAULT_DEVICE_LOGIN_SCOPES; const payload = await postJson(endpointUrl(baseUrl, DEVICE_LOGIN_START_PATH), { client_id: PACKAGE_NAME, cli_version: CLI_VERSION, token_type: 'mcp_token', - scopes: [ - 'memory:read', - 'memory:write', - 'memory:restore', - 'ledger:write', - 'ledger:read' - ] + scopes }, timeoutMs, io); const deviceCode = stringValue(payload, ['device_code']); @@ -46,6 +69,7 @@ export async function startDeviceLogin(baseUrl, timeoutMs, io) { userCode: stringValue(payload, ['user_code']), verificationUri, verificationUriComplete: stringValue(payload, ['verification_uri_complete']), + scopes, expiresIn: Number.isFinite(Number(payload.expires_in)) ? Number(payload.expires_in) : 600, interval: Number.isFinite(Number(payload.interval)) ? Math.max(1, Number(payload.interval)) : 5 }; @@ -122,7 +146,8 @@ export async function readStoredCredential(env) { storage: stringValue(parsed, ['storage']), encryption: stringValue(parsed, ['encryption']), plaintextStorageConsent: parsed.plaintextStorageConsent === true, - account: accountFromPayload(parsed.metadata) + account: accountFromPayload(parsed.metadata), + metadata: parsed.metadata && typeof parsed.metadata === 'object' ? parsed.metadata : {} }; } diff --git a/src/network/http.js b/src/network/http.js index ce82aa9..b4e62d8 100644 --- a/src/network/http.js +++ b/src/network/http.js @@ -27,6 +27,7 @@ export async function verifyTokenWithMcp(baseUrl, token, timeoutMs, io) { clientInfo: { name: COMMAND_NAME, version: CLI_VERSION } } }), + redirect: 'error', signal: controller.signal }); return { @@ -54,6 +55,7 @@ export async function probe(url, timeoutMs, io) { try { const response = await io.fetch(url, { headers: { accept: 'application/json' }, + redirect: 'error', signal: controller.signal }); return { url, ok: response.ok, status: response.status }; @@ -79,6 +81,7 @@ export async function fetchJson(url, timeoutMs, io) { try { const response = await io.fetch(url, { headers: { accept: 'application/json' }, + redirect: 'error', signal: controller.signal }); if (!response.ok) { @@ -112,6 +115,7 @@ export async function postJson(url, payload, timeoutMs, io, options = {}) { 'content-type': 'application/json' }, body: JSON.stringify(payload), + redirect: 'error', signal: controller.signal }); const responsePayload = await response.json(); diff --git a/src/ui/help.js b/src/ui/help.js index b9b6ce4..0709cee 100644 --- a/src/ui/help.js +++ b/src/ui/help.js @@ -26,13 +26,13 @@ export function writeHelp(io) { writeLine(io.stdout, ' Install or update the native Hermes plugin and shared credential.'); writeLine(io.stdout, ''); writeLine(io.stdout, 'Authentication'); - writeLine(io.stdout, ` ${COMMAND_NAME} login [--base-url ] [--allow-plaintext]`); + writeLine(io.stdout, ` ${COMMAND_NAME} login [--base-url ] [--scopes ] [--allow-plaintext]`); writeLine(io.stdout, ' Start browser login; interactive use asks before unencrypted storage.'); writeLine(io.stdout, ` ${COMMAND_NAME} auth status [--verify]`); writeLine(io.stdout, ' Check login state and optionally verify the active credential.'); writeLine(io.stdout, ` ${COMMAND_NAME} token status [--verify]`); writeLine(io.stdout, ' Check the local credential without printing secrets.'); - writeLine(io.stdout, ` ${COMMAND_NAME} token add --from-stdin --allow-plaintext`); + writeLine(io.stdout, ` ${COMMAND_NAME} token add --from-stdin --allow-plaintext [--base-url ]`); writeLine(io.stdout, ' Store an existing token after explicit consent to unencrypted storage.'); writeLine(io.stdout, ''); writeLine(io.stdout, 'Operations'); @@ -42,6 +42,20 @@ export function writeHelp(io) { writeLine(io.stdout, ' Probe hosted service endpoints and readiness.'); writeLine(io.stdout, ` ${COMMAND_NAME} update [--dry-run]`); writeLine(io.stdout, ' Check or apply the latest npm package update.'); + writeLine(io.stdout, ` ${COMMAND_NAME} memory add|search ... [--json]`); + writeLine(io.stdout, ' Add or search memory through the REST service.'); + writeLine(io.stdout, ` ${COMMAND_NAME} context recall [--json]`); + writeLine(io.stdout, ' Recall a bounded agent context package.'); + writeLine(io.stdout, ` ${COMMAND_NAME} state save|restore ... [--json]`); + writeLine(io.stdout, ' Save or restore one working state slot.'); + writeLine(io.stdout, ` ${COMMAND_NAME} restart snapshot|restore ... [--json]`); + writeLine(io.stdout, ' Create a restart package or explicitly restore its working state.'); + writeLine(io.stdout, ` ${COMMAND_NAME} knowledge add|search|read|update ... [--json]`); + writeLine(io.stdout, ' Add, search, read, or safely update knowledge items.'); + writeLine(io.stdout, ` ${COMMAND_NAME} dream preview|show|apply ... [--json]`); + writeLine(io.stdout, ' Preview, inspect, or confirm one Dream candidate.'); + writeLine(io.stdout, ` ${COMMAND_NAME} cloud-skill add|list|show|update|run ... [--json]`); + writeLine(io.stdout, ' Inspect and execute Cloud Skills; safe writes require the server CAS contract.'); writeLine(io.stdout, ''); writeLine(io.stdout, 'MCP And Profiles'); writeLine(io.stdout, ` ${COMMAND_NAME} mcp list`); diff --git a/test/cli.test.js b/test/cli.test.js index f6ba623..a5033bf 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -131,6 +131,7 @@ test('login from stdin stores token in user credential file without printing it' assert.equal(credential.encryption, 'none'); assert.equal(credential.plaintextStorageConsent, true); assert.equal(payload.encryption, 'none'); + assert.equal(credential.metadata.baseUrl, 'https://xmemo.dev'); }); test('login from stdin rejects unencrypted storage without explicit consent', async () => { @@ -164,6 +165,17 @@ test('token add from stdin stores token and status sees user credential', async assert.doesNotMatch(status.stdout, new RegExp(token)); }); +test('token set from stdin binds the credential to the selected service origin', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-os-token-set-origin-')); + const result = await invoke(['token', 'set', '--from-stdin', '--allow-plaintext', '--base-url', 'https://api.example.test'], { + env: { MEMORY_OS_CONFIG_HOME: tempDir }, + stdin: 'mem_os_test_token_1234567890' + }); + assert.equal(result.code, 0); + const credential = JSON.parse(await fs.readFile(path.join(tempDir, 'credentials.json'), 'utf8')); + assert.equal(credential.metadata.baseUrl, 'https://api.example.test'); +}); + test('auth status reports login state without printing tokens', async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-os-auth-status-')); const token = 'mem_os_test_token_1234567890'; @@ -186,6 +198,25 @@ test('auth status reports login state without printing tokens', async () => { assert.equal(payload.privacy.projectFilesModified, false); }); +test('auth status exposes only allowlisted credential metadata', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-os-auth-metadata-')); + const credentialPath = path.join(tempDir, 'credentials.json'); + await fs.writeFile(credentialPath, JSON.stringify({ + token: 'mem_os_test_token_1234567890', + metadata: { + baseUrl: 'https://xmemo.dev', + scopes: ['memory:read', 7], + existingPlaintextSource: 'C:\\private\\hermes.env', + nestedSecret: { token: 'must-not-leak' } + } + })); + const status = await invoke(['auth', 'status', '--json'], { env: { MEMORY_OS_CONFIG_HOME: tempDir } }); + assert.equal(status.code, 0); + const payload = JSON.parse(status.stdout); + assert.deepEqual(payload.credentialMetadata, { baseUrl: 'https://xmemo.dev', scopes: ['memory:read'] }); + assert.doesNotMatch(status.stdout, /hermes\.env|must-not-leak/); +}); + test('auth status shows stored device-login account without token warning noise', async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-os-auth-account-')); const token = 'mem_os_test_token_1234567890'; @@ -218,7 +249,7 @@ test('auth status shows stored device-login account without token warning noise' test('token status verify uses stored credential without printing it', async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-os-token-verify-')); const token = 'mem_os_test_token_1234567890'; - await invoke(['token', 'add', '--from-stdin', '--allow-plaintext'], { + await invoke(['token', 'add', '--from-stdin', '--allow-plaintext', '--base-url', 'https://api.example.test'], { env: { MEMORY_OS_CONFIG_HOME: tempDir }, stdin: token }); @@ -235,9 +266,28 @@ test('token status verify uses stored credential without printing it', async () assert.equal(requests.length, 1); assert.equal(requests[0].url, 'https://api.example.test/mcp'); assert.equal(requests[0].init.headers.authorization, `Bearer ${token}`); + assert.equal(requests[0].init.redirect, 'error'); assert.doesNotMatch(status.stdout, new RegExp(token)); }); +test('token status verify refuses to send a stored credential to another origin', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-os-token-origin-refusal-')); + const token = 'mem_os_test_token_1234567890'; + await invoke(['token', 'add', '--from-stdin', '--allow-plaintext'], { + env: { MEMORY_OS_CONFIG_HOME: tempDir }, + stdin: token + }); + let calls = 0; + const status = await invoke(['token', 'status', '--verify', '--base-url', 'https://attacker.example.test'], { + env: { MEMORY_OS_CONFIG_HOME: tempDir }, + fetch: async () => { calls += 1; return { ok: true, status: 200 }; } + }); + assert.equal(status.code, 2); + assert.equal(calls, 0); + assert.match(status.stderr, /different service origin/); + assert.doesNotMatch(status.stdout + status.stderr, new RegExp(token)); +}); + test('device login stores issued token without printing it', async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-os-device-login-')); const token = 'mem_os_device_token_1234567890'; @@ -290,6 +340,25 @@ test('device login stores issued token without printing it', async () => { assert.deepEqual(credential.metadata.account, payload.account); }); +test('device login expands service scopes only when explicitly requested', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-os-device-scopes-')); + let requested; + const result = await invoke(['login', '--scopes', 'memory:read,knowledge:read,knowledge:write', '--allow-plaintext', '--json'], { + env: { MEMORY_OS_CONFIG_HOME: tempDir }, + fetch: async (url, init) => { + if (url.endsWith('/api/v1/auth/device/start')) { + requested = JSON.parse(init.body).scopes; + return jsonResponse({ device_code: 'device-code', user_code: 'CODE', verification_uri: 'https://xmemo.dev/device', interval: 1, expires_in: 600 }); + } + return jsonResponse({ access_token: 'mem_os_scoped_token_1234567890' }); + } + }); + assert.equal(result.code, 0); + assert.deepEqual(requested, ['memory:read', 'knowledge:read', 'knowledge:write']); + const credential = JSON.parse(await fs.readFile(path.join(tempDir, 'credentials.json'), 'utf8')); + assert.deepEqual(credential.metadata.scopes, requested); +}); + test('device login waits for the service approval window by default', async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-os-device-login-window-')); const token = 'mem_os_device_token_1234567890'; @@ -989,6 +1058,7 @@ test('setup hermes backfills shared credential from existing Hermes env', async const credential = JSON.parse(await fs.readFile(path.join(tempDir, 'credentials.json'), 'utf8')); assert.equal(credential.token, token); assert.equal(credential.metadata.source, 'hermes-env-sync'); + assert.equal(credential.metadata.baseUrl, 'https://api.example.test'); }); test('setup discovers hosted service without sending token values', async () => { diff --git a/test/command-registry.test.js b/test/command-registry.test.js new file mode 100644 index 0000000..acc435e --- /dev/null +++ b/test/command-registry.test.js @@ -0,0 +1,34 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + COMMAND_REGISTRY, + SERVICE_CONTRACT_VERSION, + commandSpec, + unavailableCommands +} from '../src/api/contracts/command-registry.js'; + +test('CLI-00 freezes exactly the 12 planned domain entries', () => { + const planned = COMMAND_REGISTRY.filter((entry) => entry.domain === 'knowledge' || entry.domain === 'dream' || entry.domain === 'cloud-skill'); + assert.equal(planned.length, 12); + assert.deepEqual(planned.map((entry) => entry.command), [ + 'knowledge.add', 'knowledge.search', 'knowledge.read', 'knowledge.update', + 'dream.preview', 'dream.show', 'dream.apply', + 'cloud-skill.add', 'cloud-skill.list', 'cloud-skill.show', 'cloud-skill.update', 'cloud-skill.run' + ]); +}); + +test('CLI-00 marks unsafe Cloud Skill writes as contract-required', () => { + assert.equal(commandSpec('cloud-skill.add').availability, 'contract-required'); + assert.equal(commandSpec('cloud-skill.update').availability, 'contract-required'); + assert.deepEqual(unavailableCommands().map((entry) => entry.command), ['cloud-skill.add', 'cloud-skill.update']); +}); + +test('CLI-00 records current routes and side-effect policy', () => { + assert.equal(SERVICE_CONTRACT_VERSION, 'memory-os-openapi@2026-09-03'); + assert.equal(commandSpec('memory.search').path, '/api/v1/recall'); + assert.equal(commandSpec('context.recall').sideEffect, false); + assert.equal(commandSpec('context.recall').method, 'POST'); + assert.equal(commandSpec('restart.restore').sideEffect, true); + assert.equal(commandSpec('cloud-skill.run').retry, 'none'); +}); diff --git a/test/service-client.test.js b/test/service-client.test.js new file mode 100644 index 0000000..5712cfc --- /dev/null +++ b/test/service-client.test.js @@ -0,0 +1,233 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { createServiceClient, assertServiceOrigin } from '../src/api/client.js'; +import { ContractRequiredError, InterruptedError, UnknownOutcomeError } from '../src/api/errors.js'; +import { rejectInputFlagConflicts, readJsonInput } from '../src/api/input.js'; +import { createReadReceipt, readAndValidateReceipt } from '../src/api/read-receipt.js'; +import { serviceHelpSchema } from '../src/api/contracts/help-schema.js'; +import { serviceContext } from '../src/api/service-context.js'; +import { collectSkillFiles } from '../src/api/upload-input.js'; + +function ioWith(fetch) { + return { fetch, env: {}, stdin: { async *[Symbol.asyncIterator]() {} } }; +} + +test('CLI-01 accepts HTTPS and loopback HTTP only', () => { + assert.equal(assertServiceOrigin('https://api.example.test/'), 'https://api.example.test'); + assert.equal(assertServiceOrigin('http://127.0.0.1:8080/'), 'http://127.0.0.1:8080'); + assert.throws(() => assertServiceOrigin('http://api.example.test'), /HTTPS/); + assert.throws(() => assertServiceOrigin('https://api.example.test/?target=other'), /query parameters/); + assert.throws(() => assertServiceOrigin('https://api.example.test/#fragment'), /fragments/); +}); + +test('CLI-01 sends auth only to the configured origin and parses one JSON response', async () => { + const requests = []; + const client = createServiceClient({ + baseUrl: 'https://api.example.test', + token: 'synthetic-token-value', + io: ioWith(async (url, init) => { + requests.push({ url, init }); + return new Response(JSON.stringify({ memory_id: 'mem-1', content: 'synthetic' }), { status: 201 }); + }) + }); + const result = await client.request({ method: 'POST', path: '/api/v1/remember', body: { content: 'synthetic', path: 'test' }, sideEffect: true }); + assert.equal(result.status, 201); + assert.equal(result.data.memory_id, 'mem-1'); + assert.equal(new URL(requests[0].url).origin, 'https://api.example.test'); + assert.equal(requests[0].init.headers.authorization, 'Bearer synthetic-token-value'); + assert.equal(requests[0].init.redirect, 'error'); +}); + +test('CLI-01 retries bounded read-only POST requests but never side effects', async () => { + let calls = 0; + const client = createServiceClient({ + baseUrl: 'https://api.example.test', + token: 'synthetic-token-value', + io: ioWith(async () => { + calls += 1; + return calls === 1 + ? new Response(JSON.stringify({ detail: 'busy' }), { status: 503 }) + : new Response(JSON.stringify({ results: [] }), { status: 200 }); + }) + }); + const result = await client.request({ method: 'POST', path: '/api/v1/knowledge/search', body: { query: 'test' }, sideEffect: false, retry: 'bounded' }); + assert.deepEqual(result.data, { results: [] }); + assert.equal(calls, 2); +}); + +test('CLI-02 rejects credential-file origin mismatch and provides migration boundary', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xmemo-cli-origin-')); + await fs.writeFile(path.join(tempDir, 'credentials.json'), JSON.stringify({ + token: 'synthetic-token-value', + metadata: { baseUrl: 'https://trusted.example.test' } + })); + await assert.rejects( + () => serviceContext(['--base-url', 'https://attacker.example.test'], { + env: { XMEMO_CONFIG_HOME: tempDir }, + fetch: async () => new Response('{}') + }), + (error) => error.code === 'CREDENTIAL_ORIGIN_MISMATCH' && error.httpStatus === 401 + ); +}); + +test('CLI-01 does not retry a side-effect request and reports unknown outcome on timeout', async () => { + let calls = 0; + const client = createServiceClient({ + baseUrl: 'https://api.example.test', + token: 'synthetic-token-value', + timeoutMs: 5, + io: ioWith(async (_url, init) => { + calls += 1; + await new Promise((_resolve, reject) => init.signal.addEventListener('abort', () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' })))); + }) + }); + await assert.rejects( + client.request({ method: 'POST', path: '/api/v1/remember', body: {}, sideEffect: true }), + (error) => error instanceof UnknownOutcomeError && error.outcome === 'unknown' + ); + assert.equal(calls, 1); +}); + +test('CLI-01 treats server errors after a side-effect request as an unknown outcome', async () => { + let calls = 0; + const client = createServiceClient({ + baseUrl: 'https://api.example.test', + token: 'synthetic-token-value', + io: ioWith(async () => { + calls += 1; + return new Response(JSON.stringify({ detail: 'upstream failed after dispatch' }), { status: 503 }); + }) + }); + await assert.rejects( + client.request({ method: 'POST', path: '/api/v1/remember', body: {}, sideEffect: true, retry: 'bounded' }), + (error) => error instanceof UnknownOutcomeError && error.httpStatus === 503 && error.outcome === 'unknown' + ); + assert.equal(calls, 1); +}); + +test('CLI-02 rejects malformed stored credential origins without sending a token', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xmemo-cli-invalid-origin-')); + await fs.writeFile(path.join(tempDir, 'credentials.json'), JSON.stringify({ + token: 'synthetic-token-value', + metadata: { baseUrl: 'not-a-service-url' } + })); + let calls = 0; + await assert.rejects( + () => serviceContext([], { + env: { XMEMO_CONFIG_HOME: tempDir }, + fetch: async () => { calls += 1; return new Response('{}'); } + }), + (error) => error.code === 'CREDENTIAL_ORIGIN_INVALID' && error.httpStatus === 401 + ); + assert.equal(calls, 0); +}); + +test('CLI-01 distinguishes a local read interruption from an unknown write outcome', async () => { + const controller = new AbortController(); + const client = createServiceClient({ + baseUrl: 'https://api.example.test', + token: 'synthetic-token-value', + io: { ...ioWith(async (_url, init) => { + await new Promise((_resolve, reject) => init.signal.addEventListener('abort', () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' })))); + }), signal: controller.signal } + }); + const request = client.request({ method: 'GET', path: '/api/v1/recall', sideEffect: false }); + controller.abort(); + await assert.rejects(request, (error) => error instanceof InterruptedError && error.code === 'INTERRUPTED'); +}); + +test('CLI-01 marks a missing contract without falling back to legacy write routes', async () => { + const client = createServiceClient({ + baseUrl: 'https://api.example.test', + token: 'synthetic-token-value', + io: ioWith(async () => new Response(JSON.stringify({ detail: 'not found' }), { status: 404 })) + }); + await assert.rejects( + client.request({ method: 'POST', path: '/v1/skills/create', body: {}, sideEffect: true, operation: { contractRequired: true, name: 'cloud-skill.add' } }), + (error) => error instanceof ContractRequiredError && error.code === 'SERVER_CONTRACT_REQUIRED' + ); +}); + +test('CLI-01 recursively redacts sensitive fields in service error data', async () => { + const client = createServiceClient({ + baseUrl: 'https://api.example.test', + token: 'synthetic-token-value', + io: ioWith(async () => new Response(JSON.stringify({ detail: { message: 'denied', nested: { access_token: 'do-not-leak', safe: 'keep' } }, items: [{ secret: 'also-hide', id: 'item-1' }] }), { status: 403 })) + }); + await assert.rejects( + client.request({ method: 'GET', path: '/api/v1/recall' }), + (error) => error.data.detail.nested.access_token === undefined + && error.data.detail.nested.safe === 'keep' + && error.data.items[0].secret === undefined + ); +}); + +test('CLI-01 gives document scope failures a precise reauthorization action', async () => { + const client = createServiceClient({ + baseUrl: 'https://api.example.test', + token: 'synthetic-token-value', + io: ioWith(async () => new Response(JSON.stringify({ detail: { code: 'memory_write_scope_required', message: 'document upload requires memory:write' } }), { status: 403 })) + }); + await assert.rejects( + client.request({ method: 'POST', path: '/api/v1/documents', body: {}, sideEffect: true }), + (error) => error.nextAction === '重新授权:xmemo login --scopes knowledge:write,memory:write。' + ); +}); + +test('CLI-01 rejects duplicate flag and JSON input fields', () => { + assert.throws(() => rejectInputFlagConflicts({ title: 'from-json' }, [['--title', 'title']]), /conflicts/); +}); + +test('CLI-01 accepts UTF-8 BOM JSON input', async () => { + const io = { stdin: { async *[Symbol.asyncIterator]() { yield '\ufeff{"query":"中文"}'; } } }; + assert.deepEqual(await readJsonInput(['--input', '-'], io), { query: '中文' }); +}); + +test('CLI-01 accepts Windows PowerShell UTF-16LE JSON files and receipts', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xmemo-cli-utf16-')); + const inputPath = path.join(tempDir, 'input.json'); + const receiptPath = path.join(tempDir, 'view.json'); + await fs.writeFile(inputPath, Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from('{"query":"中文"}', 'utf16le')])); + await fs.writeFile(receiptPath, Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(JSON.stringify({ schemaVersion: '1', serviceOrigin: 'https://api.example.test', resource: 'item-1', scope: 'personal', displayedRevision: 'rev-1' }), 'utf16le')])); + assert.deepEqual(await readJsonInput(['--input', inputPath], { stdin: null }), { query: '中文' }); + const receipt = await readAndValidateReceipt(receiptPath, { baseUrl: 'https://api.example.test', resource: 'item-1', scope: 'personal' }); + assert.equal(receipt.schemaVersion, '1'); +}); + +test('CLI-07 Cloud Skill directory input rejects secret-looking and binary files', async () => { + const secretDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xmemo-cli-secret-skill-')); + await fs.writeFile(path.join(secretDir, 'SKILL.md'), '# Safe root\n'); + await fs.writeFile(path.join(secretDir, '.env'), 'TOKEN=must-not-leave-disk\n'); + await assert.rejects(() => collectSkillFiles(secretDir), /sensitive-looking/); + + const binaryDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xmemo-cli-binary-skill-')); + await fs.writeFile(path.join(binaryDir, 'SKILL.md'), '# Safe root\n'); + await fs.writeFile(path.join(binaryDir, 'asset.bin'), Buffer.from([0xff, 0xfe, 0xfd])); + await assert.rejects(() => collectSkillFiles(binaryDir), /valid UTF-8/); +}); + +test('CLI-01 receipt binds origin and resource without credentials', async () => { + const receipt = createReadReceipt({ baseUrl: 'https://api.example.test', resource: 'item-1', scope: 'personal', revision: 'rev-1', content: 'synthetic' }); + assert.equal(receipt.serviceOrigin, 'https://api.example.test'); + assert.equal(receipt.resource, 'item-1'); + assert.equal(Object.hasOwn(receipt, 'token'), false); + await assert.rejects(() => readAndValidateReceipt('missing-receipt.json', { baseUrl: 'https://api.example.test' }), /Could not read receipt/); +}); + +test('CLI-01 help schema exposes registry metadata without advanced command menus', () => { + const schema = serviceHelpSchema('knowledge.search'); + assert.equal(schema.sideEffect, false); + assert.equal(schema.path, '/api/v1/knowledge/search'); + assert.equal(schema.options['--json'].type, 'boolean'); + assert.equal(serviceHelpSchema('revision.list'), null); +}); + +test('CLI-01 knowledge document help declares its conditional memory scope', () => { + const schema = serviceHelpSchema('knowledge.add'); + assert.deepEqual(schema.conditionalScopes, ['memory:write', 'memory:read', 'knowledge:read']); + assert.match(schema.scopeNotes[0], /Document upload/); +}); diff --git a/test/service-command.test.js b/test/service-command.test.js new file mode 100644 index 0000000..6899d32 --- /dev/null +++ b/test/service-command.test.js @@ -0,0 +1,512 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { run } from '../src/cli.js'; + +class Stream { + constructor() { this.value = ''; } + write(value) { this.value += String(value); } +} + +function makeIo(fetch, env = {}) { + return { + env: { XMEMO_KEY: 'synthetic-token-value', XMEMO_BASE_URL: 'https://api.example.test', ...env }, + fetch, + stdout: new Stream(), + stderr: new Stream(), + stdin: { async *[Symbol.asyncIterator]() {} } + }; +} + +test('CLI-03 memory search emits one parseable envelope and correct GET query', async () => { + const calls = []; + const io = makeIo(async (url, init) => { + calls.push({ url, init }); + return new Response(JSON.stringify([{ memory_id: 'mem-1', content: 'synthetic' }]), { status: 200 }); + }); + const code = await run(['memory', 'search', '中文 query', '--limit', '2', '--json'], io); + assert.equal(code, 0); + assert.equal(io.stderr.value, ''); + const envelope = JSON.parse(io.stdout.value); + assert.equal(envelope.ok, true); + assert.equal(envelope.command, 'memory.search'); + assert.equal(envelope.data[0].memory_id, 'mem-1'); + assert.equal(new URL(calls[0].url).pathname, '/api/v1/recall'); + assert.equal(new URL(calls[0].url).searchParams.get('query'), '中文 query'); + assert.equal(new URL(calls[0].url).searchParams.get('limit'), '2'); +}); + +test('CLI-03 unauthenticated JSON request returns exit 3 without network access', async () => { + let calls = 0; + const io = makeIo(async () => { calls += 1; return new Response('{}'); }, { XMEMO_KEY: '' }); + const code = await run(['memory', 'search', 'query', '--json'], io); + const envelope = JSON.parse(io.stdout.value); + assert.equal(code, 3); + assert.equal(calls, 0); + assert.equal(envelope.error.code, 'AUTH_REQUIRED'); +}); + +test('CLI-01 service subcommand help emits its executable contract schema as JSON', async () => { + const io = makeIo(async () => new Response('{}')); + const code = await run(['knowledge', 'read', '--help', '--json'], io); + assert.equal(code, 0); + const schema = JSON.parse(io.stdout.value); + assert.equal(schema.command, 'knowledge.read'); + assert.equal(schema.availability, 'current'); + assert.ok(schema.path.some((path) => path.includes('knowledge-items'))); +}); + +test('CLI-04 knowledge read fixes receipt to the returned current revision', async () => { + const responses = [ + { knowledge_item_id: 'item-1', current_revision_id: 'rev-2', version: 3, status: 'draft' }, + { knowledge_revision_id: 'rev-2', canonical_content: 'synthetic knowledge' } + ]; + const io = makeIo(async (_url, _init) => new Response(JSON.stringify(responses.shift()), { status: 200 })); + const code = await run(['knowledge', 'read', 'item-1', '--json'], io); + assert.equal(code, 0); + const envelope = JSON.parse(io.stdout.value); + assert.equal(envelope.data.revision.knowledge_revision_id, 'rev-2'); + assert.equal(envelope.meta.readReceipt.displayedRevision, 'rev-2'); + assert.equal(envelope.meta.readReceipt.itemStatus, 'draft'); +}); + +test('CLI-06 dream show binds confirmation_version and candidate identity', async () => { + const io = makeIo(async () => new Response(JSON.stringify({ + run: { id: 'run-1', status: 'succeeded', confirmation_version: 'sha256:run-version', settings_version: 4 }, + items: [{ id: 'item-1' }] + }), { status: 200 })); + const code = await run(['dream', 'show', 'run-1', '--json'], io); + assert.equal(code, 0); + const envelope = JSON.parse(io.stdout.value); + assert.equal(envelope.meta.readReceipt.displayedRevision, 'sha256:run-version'); + assert.deepEqual(envelope.meta.readReceipt.candidateItemIds, ['item-1']); +}); + +test('CLI-06 dream wait returns remote failure and local timeout exit codes', async () => { + const failedIo = makeIo(async () => new Response(JSON.stringify({ + run: { id: 'run-1', status: 'failed', confirmation_version: 'sha256:run-version', settings_version: 4 }, + items: [] + }), { status: 200 })); + const failedCode = await run(['dream', 'show', 'run-1', '--wait', '--json'], failedIo); + assert.equal(failedCode, 8); + assert.equal(JSON.parse(failedIo.stdout.value).error.code, 'DREAM_RUN_FAILED'); +}); + +test('CLI-08 cloud skill run uses the reviewed published revision and only input_args', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xmemo-cli-skill-')); + const viewPath = path.join(tempDir, 'view.json'); + const inputPath = path.join(tempDir, 'input.json'); + await fs.writeFile(viewPath, JSON.stringify({ meta: { readReceipt: { + schemaVersion: '1', serviceOrigin: 'https://api.example.test', resource: 'cloud-skill:skill-1', scope: 'personal', displayedRevision: 'rev-1', revisionStatus: 'published', revisionKind: 'published' + } } })); + await fs.writeFile(inputPath, JSON.stringify({ input_args: { query: 'hello' } })); + const requests = []; + const io = makeIo(async (url, init) => { + requests.push({ url, init }); + if (new URL(url).pathname.endsWith('/components')) return new Response(JSON.stringify([{ type: 'script', logical_path: 'scripts/run.py', component_id: 'component-1' }]), { status: 200 }); + return new Response(JSON.stringify({ status: 'success', exit_code: 0 }), { status: 200 }); + }); + const code = await run(['cloud-skill', 'run', 'skill-1', '--input', inputPath, '--from', viewPath, '--yes', '--json'], io); + assert.equal(code, 0); + const execute = requests.find((request) => new URL(request.url).pathname.endsWith('/execute')); + const body = JSON.parse(execute.init.body); + assert.deepEqual(body.input_args, { query: 'hello' }); + assert.equal(body.script_path, 'scripts/run.py'); + assert.equal(body.revision_id, 'rev-1'); +}); + +test('CLI-04 knowledge add validates local content before creating a base', async () => { + let calls = 0; + const io = makeIo(async () => { calls += 1; return new Response('{}'); }); + const code = await run(['knowledge', 'add', '--create-base', 'new-base', '--json'], io); + assert.equal(code, 2); + assert.equal(calls, 0); +}); + +test('CLI-04 knowledge update publishes with the post-content version', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xmemo-cli-knowledge-')); + const viewPath = path.join(tempDir, 'view.json'); + await fs.writeFile(viewPath, JSON.stringify({ meta: { readReceipt: { + schemaVersion: '1', serviceOrigin: 'https://api.example.test', resource: 'knowledge-item:item-1', scope: 'personal', displayedRevision: 'rev-1', latestRevision: 'rev-1', version: 2, itemStatus: 'draft', sourceType: 'text' + } } })); + const requests = []; + const io = makeIo(async (url, init) => { + requests.push({ url, init }); + return new Response(JSON.stringify(new URL(url).pathname.endsWith('/content') ? { version: 3, current_revision_id: 'rev-2' } : { version: 4, status: 'published' }), { status: 200 }); + }); + const code = await run(['knowledge', 'update', 'item-1', '--from', viewPath, '--text', 'new content', '--publish', '--yes', '--json'], io); + assert.equal(code, 0); + const publish = requests.find((request) => request.init.method === 'PATCH'); + assert.equal(JSON.parse(publish.init.body).expected_version, 3); +}); + +test('CLI-04 knowledge add preserves unknown outcome and created base evidence', async () => { + const io = makeIo(async (url, init) => { + if (new URL(url).pathname.endsWith('/knowledge-bases')) { + return new Response(JSON.stringify({ id: 'base-1' }), { status: 201 }); + } + await new Promise((_resolve, reject) => init.signal.addEventListener('abort', () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' })))); + }); + const code = await run(['knowledge', 'add', '--create-base', 'new-base', '--text', 'content', '--timeout-ms', '5', '--json'], io); + const envelope = JSON.parse(io.stdout.value); + assert.equal(code, 11); + assert.equal(envelope.error.outcome, 'unknown'); + assert.equal(envelope.error.data.base.id, 'base-1'); +}); + +test('CLI-04 knowledge update preserves unknown publish outcome and content evidence', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xmemo-cli-knowledge-unknown-')); + const viewPath = path.join(tempDir, 'view.json'); + await fs.writeFile(viewPath, JSON.stringify({ meta: { readReceipt: { + schemaVersion: '1', serviceOrigin: 'https://api.example.test', resource: 'knowledge-item:item-1', scope: 'personal', displayedRevision: 'rev-1', latestRevision: 'rev-1', version: 2, itemStatus: 'draft', sourceType: 'text' + } } })); + let calls = 0; + const io = makeIo(async (url, init) => { + calls += 1; + if (calls === 1) return new Response(JSON.stringify({ version: 3, current_revision_id: 'rev-2' }), { status: 200 }); + await new Promise((_resolve, reject) => init.signal.addEventListener('abort', () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' })))); + }); + const code = await run(['knowledge', 'update', 'item-1', '--from', viewPath, '--text', 'new content', '--publish', '--yes', '--timeout-ms', '5', '--json'], io); + const envelope = JSON.parse(io.stdout.value); + assert.equal(code, 11); + assert.equal(envelope.error.outcome, 'unknown'); + assert.equal(envelope.error.data.content.version, 3); +}); + +test('CLI-06 dream preview always sends one idempotency key', async () => { + const bodies = []; + const io = makeIo(async (_url, init) => { + if (init.method === 'POST') bodies.push(JSON.parse(init.body)); + return new Response(JSON.stringify(init.method === 'POST' ? { run: { id: 'run-1' } } : { enabled: true }), { status: 200 }); + }); + const generatedCode = await run(['dream', 'preview', '--json'], io); + assert.equal(generatedCode, 0); + assert.match(bodies[0].idempotency_key, /^[0-9a-f-]{36}$/); + + const suppliedBodies = []; + const suppliedIo = makeIo(async (_url, init) => { + if (init.method === 'POST') suppliedBodies.push(JSON.parse(init.body)); + return new Response(JSON.stringify(init.method === 'POST' ? { run: { id: 'run-2' } } : { enabled: true }), { status: 200 }); + }); + const suppliedCode = await run(['dream', 'preview', '--idempotency-key', 'fixed-key', '--json'], suppliedIo); + assert.equal(suppliedCode, 0); + assert.equal(suppliedBodies[0].idempotency_key, 'fixed-key'); + + const unknownIo = makeIo(async (_url, init) => { + if (init.method === 'GET') return new Response(JSON.stringify({ enabled: true }), { status: 200 }); + await new Promise((_resolve, reject) => init.signal.addEventListener('abort', () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' })))); + }); + const unknownCode = await run(['dream', 'preview', '--timeout-ms', '5', '--json'], unknownIo); + const unknownEnvelope = JSON.parse(unknownIo.stdout.value); + assert.equal(unknownCode, 11); + assert.match(unknownEnvelope.error.data.idempotency_key, /^[0-9a-f-]{36}$/); +}); + +test('CLI-06 dream wait treats every non-success terminal state as remote failure', async () => { + for (const status of ['failed', 'dead_letter', 'cancelled']) { + const io = makeIo(async () => new Response(JSON.stringify({ run: { id: 'run-1', status }, items: [] }), { status: 200 })); + const code = await run(['dream', 'show', 'run-1', '--wait', '--json'], io); + assert.equal(code, 8); + assert.equal(JSON.parse(io.stdout.value).error.code, 'DREAM_RUN_FAILED'); + } +}); + +test('CLI-03 JSON-only memory search fields reach the GET request and conflicts are rejected', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xmemo-cli-search-input-')); + const inputPath = path.join(tempDir, 'input.json'); + await fs.writeFile(inputPath, JSON.stringify({ query: 'from-json', limit: 3, team_id: 'team-1', bucket: 'work', path: 'notes', prefer_working: true })); + let requestedUrl; + const io = makeIo(async (url) => { requestedUrl = url; return new Response('[]', { status: 200 }); }); + const code = await run(['memory', 'search', '--input', inputPath, '--json'], io); + assert.equal(code, 0); + const parsed = new URL(requestedUrl); + assert.equal(parsed.searchParams.get('limit'), '3'); + assert.equal(parsed.searchParams.get('team_id'), 'team-1'); + assert.equal(parsed.searchParams.get('prefer_working'), 'true'); + + const conflictIo = makeIo(async () => new Response('[]', { status: 200 })); + const conflictCode = await run(['state', 'save', '--input', inputPath, '--current-task', 'flag', '--json'], conflictIo); + assert.equal(conflictCode, 2); +}); + +test('CLI-03 missing JSON input is a typed input error', async () => { + const io = makeIo(async () => new Response('[]', { status: 200 })); + const code = await run(['memory', 'search', 'query', '--input', 'missing-input.json', '--json'], io); + const envelope = JSON.parse(io.stdout.value); + assert.equal(code, 2); + assert.equal(envelope.error.outcome, 'known-failure'); + assert.equal(envelope.error.code, 'INPUT_ERROR'); +}); + +test('CLI-04 knowledge command flags reject duplicate JSON fields before transmission', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xmemo-cli-knowledge-conflict-')); + const inputPath = path.join(tempDir, 'input.json'); + await fs.writeFile(inputPath, JSON.stringify({ document_id: 'doc-1', document_version: 2, title: 'from-json' })); + let calls = 0; + const io = makeIo(async () => { calls += 1; return new Response('{}', { status: 200 }); }); + const code = await run(['knowledge', 'add', '--base', 'base-1', '--document', 'doc-2', '--input', inputPath, '--json'], io); + assert.equal(code, 2); + assert.equal(calls, 0); +}); + +test('CLI-08 Cloud Skill run requires an explicit reviewed script when multiple scripts exist', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xmemo-cli-skill-multi-')); + const viewPath = path.join(tempDir, 'view.json'); + const inputPath = path.join(tempDir, 'input.json'); + await fs.writeFile(viewPath, JSON.stringify({ meta: { readReceipt: { + schemaVersion: '1', serviceOrigin: 'https://api.example.test', resource: 'cloud-skill:skill-1', scope: 'personal', displayedRevision: 'rev-1', revisionStatus: 'published', revisionKind: 'published' + } } })); + await fs.writeFile(inputPath, JSON.stringify({ input_args: {} })); + let executeCalls = 0; + const io = makeIo(async (url) => { + if (new URL(url).pathname.endsWith('/components')) return new Response(JSON.stringify([ + { type: 'script', logical_path: 'scripts/one.py', component_id: 'component-1' }, + { type: 'script', logical_path: 'scripts/two.py', component_id: 'component-2' } + ]), { status: 200 }); + executeCalls += 1; + return new Response(JSON.stringify({ status: 'success', exit_code: 0 }), { status: 200 }); + }); + const code = await run(['cloud-skill', 'run', 'skill-1', '--input', inputPath, '--from', viewPath, '--yes', '--json'], io); + assert.equal(code, 2); + assert.equal(executeCalls, 0); + assert.match(JSON.parse(io.stdout.value).error.message, /multiple scripts.*--script/); + + const selectedRequests = []; + const selectedIo = makeIo(async (url, init) => { + selectedRequests.push({ url, init }); + if (new URL(url).pathname.endsWith('/components')) return new Response(JSON.stringify([ + { type: 'script', logical_path: 'scripts/one.py', component_id: 'component-1' }, + { type: 'script', logical_path: 'scripts/two.py', component_id: 'component-2' } + ]), { status: 200 }); + return new Response(JSON.stringify({ status: 'success', exit_code: 0 }), { status: 200 }); + }); + const selectedCode = await run(['cloud-skill', 'run', 'skill-1', '--input', inputPath, '--from', viewPath, '--script', 'scripts/two.py', '--yes', '--json'], selectedIo); + assert.equal(selectedCode, 0); + assert.equal(JSON.parse(selectedRequests.find(({ url }) => new URL(url).pathname.endsWith('/execute')).init.body).script_path, 'scripts/two.py'); +}); + +test('CLI-08 Cloud Skill add probes only the create-only contract and never falls back', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xmemo-cli-skill-create-')); + const skillPath = path.join(tempDir, 'SKILL.md'); + await fs.writeFile(skillPath, '# Synthetic skill\n'); + const calls = []; + const io = makeIo(async (url, init) => { + calls.push({ url, init }); + return new Response(JSON.stringify({ detail: 'not found' }), { status: 404, headers: { 'content-type': 'application/json' } }); + }); + const code = await run(['cloud-skill', 'add', '--file', skillPath, '--json'], io); + const envelope = JSON.parse(io.stdout.value); + assert.equal(code, 7); + assert.equal(calls.length, 1); + assert.equal(new URL(calls[0].url).pathname, '/v1/skills/create'); + assert.equal(envelope.error.code, 'SERVER_CONTRACT_REQUIRED'); + assert.match(envelope.error.nextAction, /不.*回退/); +}); + +test('CLI-08 Cloud Skill update sends reviewed CAS revision and preserves omitted files by contract', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xmemo-cli-skill-update-')); + const skillDir = path.join(tempDir, 'skill'); + await fs.mkdir(path.join(skillDir, 'scripts'), { recursive: true }); + await fs.writeFile(path.join(skillDir, 'SKILL.md'), '# Synthetic skill\n'); + await fs.writeFile(path.join(skillDir, 'scripts', 'run.py'), 'print("ok")\n'); + const viewPath = path.join(tempDir, 'view.json'); + await fs.writeFile(viewPath, JSON.stringify({ meta: { readReceipt: { + schemaVersion: '1', serviceOrigin: 'https://api.example.test', resource: 'cloud-skill:skill-1', scope: 'personal', displayedRevision: 'rev-2', latestRevision: 'rev-2', revisionStatus: 'draft', revisionKind: 'draft' + } } })); + let request; + const io = makeIo(async (url, init) => { + request = { url, init }; + return new Response(JSON.stringify({ revision_id: 'rev-3', status: 'draft' }), { status: 200 }); + }); + const code = await run(['cloud-skill', 'update', 'skill-1', '--dir', skillDir, '--from', viewPath, '--json'], io); + assert.equal(code, 0); + assert.equal(new URL(request.url).pathname, '/v1/skills/skill-1/content'); + const body = JSON.parse(request.init.body); + assert.equal(body.expected_revision_id, 'rev-2'); + assert.equal(body.markdown_content, '# Synthetic skill\n'); + assert.equal(body.sub_files['scripts/run.py'], 'print("ok")\n'); + assert.equal(body.publish, false); +}); + +test('CLI-05 knowledge update supports publish-only and document snapshot CAS', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xmemo-cli-knowledge-update-')); + const viewPath = path.join(tempDir, 'view.json'); + await fs.writeFile(viewPath, JSON.stringify({ meta: { readReceipt: { + schemaVersion: '1', serviceOrigin: 'https://api.example.test', resource: 'knowledge-item:item-1', scope: 'personal', displayedRevision: 'rev-4', latestRevision: 'rev-4', version: 7, itemStatus: 'draft', sourceType: 'file', sourceRef: 'document:doc-1' + } } })); + const publishRequests = []; + const publishIo = makeIo(async (url, init) => { + publishRequests.push({ url, init }); + return new Response(JSON.stringify({ version: 8, status: 'published' }), { status: 200 }); + }); + assert.equal(await run(['knowledge', 'update', 'item-1', '--from', viewPath, '--publish', '--yes', '--json'], publishIo), 0); + assert.equal(publishRequests.length, 1); + assert.equal(publishRequests[0].init.method, 'PATCH'); + assert.equal(JSON.parse(publishRequests[0].init.body).expected_version, 7); + + const documentRequests = []; + const documentIo = makeIo(async (url, init) => { + documentRequests.push({ url, init }); + return new Response(JSON.stringify({ version: 8, current_revision_id: 'rev-5' }), { status: 200 }); + }); + assert.equal(await run(['knowledge', 'update', 'item-1', '--from', viewPath, '--document', 'doc-1', '--document-version', '3', '--json'], documentIo), 0); + assert.equal(new URL(documentRequests[0].url).pathname, '/api/v1/knowledge-items/item-1/content/from-document'); + assert.deepEqual(JSON.parse(documentRequests[0].init.body), { document_id: 'doc-1', expected_document_version: 3, expected_current_revision_id: 'rev-4' }); +}); + +test('CLI-05 knowledge add uses the explicit default base only as a fallback', async () => { + let request; + const io = makeIo(async (url, init) => { + request = { url, init }; + return new Response(JSON.stringify({ knowledge_item_id: 'item-1', status: 'draft' }), { status: 201 }); + }, { XMEMO_KNOWLEDGE_BASE_ID: 'base-default' }); + const code = await run(['knowledge', 'add', '--text', 'synthetic', '--json'], io); + assert.equal(code, 0); + assert.equal(new URL(request.url).pathname, '/api/v1/knowledge-bases/base-default/items'); +}); + +test('CLI-06 Dream preview reports whether apply is available without changing settings', async () => { + const requests = []; + const io = makeIo(async (url, init) => { + requests.push({ url, init }); + if (init.method === 'GET') return new Response(JSON.stringify({ enabled: true, mode: 'preview_only', entitlement: { can_apply: true } }), { status: 200 }); + return new Response(JSON.stringify({ run: { id: 'run-1', status: 'queued' } }), { status: 202 }); + }); + const code = await run(['dream', 'preview', '--json'], io); + const envelope = JSON.parse(io.stdout.value); + assert.equal(code, 0); + assert.equal(envelope.data.applyAvailable, false); + assert.match(envelope.data.applyUnavailableReason, /confirm_each_run/); + assert.deepEqual(requests.map(({ init }) => init.method), ['GET', 'POST']); +}); + +test('CLI-03 restart JSON input preserves the full server request model', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xmemo-cli-restart-input-')); + const inputPath = path.join(tempDir, 'snapshot.json'); + await fs.writeFile(inputPath, JSON.stringify({ session_id: 'session-1', state_key: 'active_task', timeline_limit: 5, reminder_limit: 6, decision_limit: 7, metadata: { source: 'test' }, source: 'cli', bucket: 'work', scope: 'private', path: 'restart', ttl_seconds: 3600 })); + let body; + const io = makeIo(async (_url, init) => { + body = JSON.parse(init.body); + return new Response(JSON.stringify({ id: 'snapshot-1' }), { status: 201 }); + }); + assert.equal(await run(['restart', 'snapshot', '--input', inputPath, '--json'], io), 0); + assert.equal(body.session_id, 'session-1'); + assert.equal(body.timeline_limit, 5); + assert.deepEqual(body.metadata, { source: 'test' }); + assert.equal(body.ttl_seconds, 3600); +}); + +test('CLI-01 high-impact non-interactive commands use confirmation exit 10 before writing', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xmemo-cli-confirm-')); + const viewPath = path.join(tempDir, 'dream-view.json'); + await fs.writeFile(viewPath, JSON.stringify({ meta: { readReceipt: { + schemaVersion: '1', serviceOrigin: 'https://api.example.test', resource: 'dream-run:run-1', scope: 'personal', displayedRevision: 'sha256:run-version', settingsVersion: 2, candidateItemIds: ['candidate-1'] + } } })); + let calls = 0; + const io = makeIo(async () => { calls += 1; return new Response('{}', { status: 200 }); }); + const code = await run(['dream', 'apply', 'run-1', '--item', 'candidate-1', '--from', viewPath, '--json'], io); + assert.equal(code, 10); + assert.equal(calls, 0); + assert.equal(JSON.parse(io.stdout.value).error.code, 'CONFIRMATION_REQUIRED'); +}); + +test('CLI-04 knowledge read accepts its resource selector through JSON input', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xmemo-cli-read-input-')); + const inputPath = path.join(tempDir, 'read.json'); + await fs.writeFile(inputPath, JSON.stringify({ item_id: 'item-json', offset: 0, limit_chars: 50 })); + const urls = []; + const io = makeIo(async (url) => { + urls.push(url); + if (urls.length === 1) return new Response(JSON.stringify({ current_revision_id: 'rev-json', version: 1, status: 'draft' }), { status: 200 }); + return new Response(JSON.stringify({ canonical_content: 'content' }), { status: 200 }); + }); + assert.equal(await run(['knowledge', 'read', '--input', inputPath, '--json'], io), 0); + assert.equal(new URL(urls[0]).pathname, '/api/v1/knowledge-items/item-json'); + assert.equal(new URL(urls[1]).searchParams.get('offset'), '0'); +}); + +test('CLI-03 basic REST calls cover memory, context, state, and restart routes', async () => { + const calls = []; + const io = makeIo(async (url, init) => { + calls.push({ url, init }); + return new Response(JSON.stringify({ ok: true, id: `result-${calls.length}` }), { status: init.method === 'POST' ? 201 : 200 }); + }); + const commands = [ + ['memory', 'add', '--content', 'synthetic', '--path', 'tests/basic', '--json'], + ['memory', 'search', 'synthetic', '--json'], + ['context', 'recall', 'synthetic', '--include-knowledge', '--json'], + ['state', 'save', '--current-task', 'testing', '--json'], + ['state', 'restore', '--json'], + ['restart', 'snapshot', '--json'], + ['restart', 'restore', '--snapshot-id', 'snapshot-1', '--json'] + ]; + for (const args of commands) { + io.stdout.value = ''; + assert.equal(await run(args, io), 0); + assert.equal(JSON.parse(io.stdout.value).ok, true); + } + assert.deepEqual(calls.map(({ url }) => new URL(url).pathname), [ + '/api/v1/remember', '/api/v1/recall', '/api/v1/recall/context', + '/api/v1/update_state', '/api/v1/skill/operations', + '/api/v1/restart/snapshot', '/api/v1/restart/restore' + ]); + assert.equal(JSON.parse(calls[4].init.body).operation, 'state-restore'); +}); + +test('CLI-05 document knowledge waits for extraction and pins the returned version', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xmemo-cli-document-')); + const documentPath = path.join(tempDir, 'guide.pdf'); + await fs.writeFile(documentPath, Buffer.from('%PDF synthetic')); + const calls = []; + const io = makeIo(async (url, init) => { + calls.push({ url, init }); + const pathname = new URL(url).pathname; + if (pathname === '/api/v1/documents') return new Response(JSON.stringify({ document_id: 'doc-1', version: 2, extraction_status: 'queued' }), { status: 201 }); + if (pathname === '/api/v1/documents/doc-1') return new Response(JSON.stringify({ document_id: 'doc-1', version: 2, extraction_status: 'succeeded' }), { status: 200 }); + return new Response(JSON.stringify({ knowledge_item_id: 'item-1', status: 'draft' }), { status: 201 }); + }); + const code = await run(['knowledge', 'add', '--base', 'base-1', '--file', documentPath, '--title', 'Guide', '--json'], io); + assert.equal(code, 0); + assert.deepEqual(calls.map(({ url }) => new URL(url).pathname), [ + '/api/v1/knowledge-bases/base-1', '/api/v1/documents', '/api/v1/documents/doc-1', '/api/v1/knowledge-bases/base-1/items/from-document' + ]); + const snapshot = JSON.parse(calls[3].init.body); + assert.equal(snapshot.document_id, 'doc-1'); + assert.equal(snapshot.expected_document_version, 2); + assert.equal(snapshot.status, 'draft'); +}); + +test('CLI-06 Dream apply sends exactly one reviewed candidate and versions', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xmemo-cli-dream-apply-')); + const viewPath = path.join(tempDir, 'view.json'); + await fs.writeFile(viewPath, JSON.stringify({ meta: { readReceipt: { + schemaVersion: '1', serviceOrigin: 'https://api.example.test', resource: 'dream-run:run-1', scope: 'personal', displayedRevision: 'sha256:run-version', settingsVersion: 4, candidateItemIds: ['item-1'] + } } })); + let request; + const io = makeIo(async (url, init) => { + request = { url, init }; + return new Response(JSON.stringify({ created: true, memory_id: 'memory-1' }), { status: 200 }); + }); + const code = await run(['dream', 'apply', 'run-1', '--item', 'item-1', '--from', viewPath, '--yes', '--json'], io); + assert.equal(code, 0); + assert.equal(new URL(request.url).pathname, '/api/v1/me/dream/runs/run-1/confirm'); + assert.deepEqual(JSON.parse(request.init.body), { item_id: 'item-1', expected_run_version: 'sha256:run-version', expected_settings_version: 4 }); +}); + +test('CLI-08 Cloud Skill run rejects a view from another origin before execution', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xmemo-cli-view-')); + const viewPath = path.join(tempDir, 'view.json'); + const inputPath = path.join(tempDir, 'input.json'); + await fs.writeFile(viewPath, JSON.stringify({ meta: { readReceipt: { schemaVersion: '1', serviceOrigin: 'https://other.example.test', resource: 'cloud-skill:skill-1', displayedRevision: 'rev-1' } } })); + await fs.writeFile(inputPath, '{}'); + let calls = 0; + const io = makeIo(async () => { calls += 1; return new Response('{}'); }); + const code = await run(['cloud-skill', 'run', 'skill-1', '--input', inputPath, '--from', viewPath, '--yes', '--json'], io); + const envelope = JSON.parse(io.stdout.value); + assert.equal(code, 2); + assert.equal(calls, 0); + assert.match(envelope.error.message, /origin/); +}); diff --git a/test/service-http.test.js b/test/service-http.test.js new file mode 100644 index 0000000..d443051 --- /dev/null +++ b/test/service-http.test.js @@ -0,0 +1,204 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawn } from 'node:child_process'; +import { createServiceClient } from '../src/api/client.js'; +import { COMMAND_REGISTRY } from '../src/api/contracts/command-registry.js'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const binary = path.join(root, 'bin/memory-os.js'); +const syntheticToken = 'synthetic-http-fixture-token'; + +async function fixture(t, handler) { + const errors = []; + const requests = []; + const server = http.createServer(async (req, res) => { + try { + assert.equal(req.headers.authorization, `Bearer ${syntheticToken}`); + let text = ''; + for await (const chunk of req) text += chunk; + const record = { method: req.method, url: new URL(req.url, 'http://fixture'), body: text ? JSON.parse(text) : null }; + requests.push(record); + await handler(record, res); + } catch (error) { + errors.push(error.message); + if (!res.headersSent) res.writeHead(500); + res.end('{}'); + } + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(async () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + assert.deepEqual(errors, []); + }); + return { origin: `http://127.0.0.1:${server.address().port}`, requests }; +} + +function respond(res, data, status = 200) { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(data)); +} + +async function temp(t) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'xmemo-service-http-')); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + return directory; +} + +function child(executable, args, { cwd, env, input = '' }) { + return new Promise((resolve, reject) => { + const proc = spawn(executable, args, { cwd, env, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); + let stdout = '', stderr = ''; + const timer = setTimeout(() => { proc.kill(); reject(new Error('Fixture child exceeded 30s.')); }, 30000); + proc.on('error', (error) => { clearTimeout(timer); reject(error); }); + proc.stdout.on('data', (data) => { stdout += data; }); + proc.stderr.on('data', (data) => { stderr += data; }); + proc.stdin.on('error', () => {}); + proc.on('close', (code, signal) => { clearTimeout(timer); resolve({ code, signal, stdout, stderr }); }); + proc.stdin.end(input); + }); +} + +function environment(directory, origin) { + const env = { ...process.env }; + for (const key of Object.keys(env)) if (/^(XMEMO|MEMORY_OS)_/.test(key)) delete env[key]; + return { ...env, XMEMO_KEY: syntheticToken, XMEMO_BASE_URL: origin, XMEMO_CONFIG_HOME: path.join(directory, 'config'), XMEMO_SKILL_CONFIG_HOME: path.join(directory, 'skill-config') }; +} + +test('CLI-09 actual HTTP + child CLI exercise all 19 frozen commands and pinned workflows', async (t) => { + const directory = await temp(t); + let knowledgeRevision = 'k1', knowledgeVersion = 1; + const api = await fixture(t, (r, res) => { + const p = r.url.pathname, b = r.body; + if (p === '/api/v1/remember') { assert.equal(b.content, '中文 synthetic'); return respond(res, { memory_id: 'm1' }, 201); } + if (p === '/api/v1/recall') return respond(res, [{ memory_id: 'm1', content: '中文 synthetic' }]); + if (p === '/api/v1/recall/context') { assert.equal(b.max_items, 3); assert.equal(b.memory_limit, undefined); return respond(res, { items: [{ memory_id: 'm1' }] }); } + if (p === '/api/v1/update_state') return respond(res, { state_key: b.state_key, content: b.content }); + if (p === '/api/v1/skill/operations') { assert.equal(b.operation, 'state-restore'); return respond(res, { state_key: 'active_task' }); } + if (p === '/api/v1/restart/snapshot') return respond(res, { snapshot_id: 'snap1' }); + if (p === '/api/v1/restart/restore') return respond(res, { snapshot_id: b.snapshot_id }); + if (p === '/api/v1/knowledge-bases/b1/items') { assert.equal(b.status, 'draft'); return respond(res, { knowledge_item_id: 'i1', version: 1, status: 'draft' }, 201); } + if (p === '/api/v1/knowledge/search') { assert.equal(b.cursor, ''); return respond(res, { items: [{ citation: { revision_id: 'k1' }, indexing_pending: false }], next_cursor: 'cursor-2' }); } + if (p === '/api/v1/knowledge-items/i1' && r.method === 'GET') return respond(res, { knowledge_item_id: 'i1', current_revision_id: knowledgeRevision, version: knowledgeVersion, status: 'draft', source_type: 'text' }); + if (p.includes('/revisions/')) { assert.ok(p.endsWith('/k1')); return respond(res, { knowledge_revision_id: 'k1', canonical_content: r.url.searchParams.get('offset') === '2' ? 'second page' : 'first page', source_ref: 'manual' }); } + if (p.endsWith('/i1/content')) { assert.equal(b.expected_current_revision_id, 'k1'); knowledgeRevision = 'k2'; knowledgeVersion = 2; return respond(res, { version: 2, current_revision_id: 'k2' }); } + if (p === '/api/v1/knowledge-items/i1' && r.method === 'PATCH') { assert.equal(b.expected_version, 2); return respond(res, { version: 3, status: 'published' }); } + if (p.endsWith('/dream/settings')) return respond(res, { enabled: true, mode: 'confirm_each_run', entitlement: { can_preview: true, can_apply: true } }); + if (p.endsWith('/dream/runs')) { assert.deepEqual(Object.keys(b).sort(), ['idempotency_key', 'window_days']); assert.equal(r.url.searchParams.get('team_id'), 't1'); return respond(res, { run: { id: 'r1', status: 'queued' } }, 201); } + if (p.endsWith('/dream/runs/r1')) return respond(res, { run: { id: 'r1', status: 'succeeded', confirmation_version: 'sha256:run1', settings_version: 3 }, items: [{ id: 'd1', generated_content: 'synthetic' }] }); + if (p.endsWith('/dream/runs/r1/confirm')) { assert.equal(b.expected_run_version, 'sha256:run1'); assert.equal(b.item_id, 'd1'); return respond(res, { created: true, memory_id: 'm2' }); } + if (p === '/v1/skills/create') { assert.equal(b.publish, false); return respond(res, { skill_id: 's1', revision_id: 'srev1', status: 'draft' }, 201); } + if (p === '/v1/skills') return respond(res, [{ skill_id: 's1' }]); + if (p === '/v1/skills/s1') return respond(res, { skill: { skill_id: 's1' }, latest_revision: { revision_id: 'srev1', status: 'published' }, published_revision: { revision_id: 'srev1', status: 'published' } }); + if (p === '/v1/skills/s1/components') { assert.equal(r.url.searchParams.get('revision_id'), 'srev1'); return respond(res, [{ type: 'script', logical_path: 'scripts/run.py' }]); } + if (p === '/v1/skills/s1/content') { assert.equal(b.expected_revision_id, 'srev1'); return respond(res, { revision_id: 'srev2', status: 'draft' }); } + if (p === '/v1/skills/s1/execute') { assert.equal(b.revision_id, 'srev1'); assert.equal(b.timeout_seconds, 30); assert.deepEqual(b.input_args, { text: 'synthetic' }); return respond(res, { status: 'success', exit_code: 0, execution_id: 'e1', stdout: 'ok', stderr: '' }); } + throw new Error(`Unexpected fixture request: ${r.method} ${p}`); + }); + const env = environment(directory, api.origin), completed = new Set(); + async function call(args, input, expected = 0) { + const result = await child(process.execPath, [binary, ...args, ...(input === undefined ? [] : ['--input', '-']), '--json'], { cwd: directory, env, input: input === undefined ? '' : JSON.stringify(input) }); + assert.equal(result.code, expected, result.stderr + result.stdout); + const output = JSON.parse(result.stdout); + assert.equal(output.ok, expected === 0); + assert.ok(!result.stdout.includes(syntheticToken)); + if (expected === 0) completed.add(output.command); + return output; + } + await call(['memory', 'add'], { content: '中文 synthetic', path: 'fixture/test' }); + await call(['memory', 'search', '中文']); + await call(['context', 'recall'], { query: 'synthetic', max_items: 3 }); + await call(['state', 'save'], { state_key: 'active_task', content: 'synthetic' }); + await call(['state', 'restore']); + await call(['restart', 'snapshot']); + await call(['restart', 'restore'], { snapshot_id: 'snap1', restore_state: false, record_restore_event: false }); + await call(['knowledge', 'add', '--base', 'b1', '--text', 'synthetic']); + assert.equal((await call(['knowledge', 'search', 'synthetic'])).meta.nextCursor, 'cursor-2'); + const knowledgeView = path.join(directory, 'knowledge view 中文.json'); + await fs.writeFile(knowledgeView, JSON.stringify(await call(['knowledge', 'read', 'i1']))); + await call(['knowledge', 'update', 'i1', '--from', knowledgeView, '--text', 'new', '--publish', '--yes']); + const pinnedPage = await call(['knowledge', 'read', 'i1', '--from', knowledgeView, '--offset', '2']); + assert.equal(pinnedPage.meta.readReceipt.displayedRevision, 'k1'); + assert.equal(api.requests.filter((r) => r.method === 'GET' && r.url.pathname === '/api/v1/knowledge-items/i1').length, 1); + await call(['dream', 'preview'], { window_days: 7, wait: true, wait_timeout: 3000, team_id: 't1' }); + const dreamView = path.join(directory, 'dream.json'); + await fs.writeFile(dreamView, JSON.stringify(await call(['dream', 'show', 'r1']))); + await call(['dream', 'apply', 'r1', '--item', 'd1', '--from', dreamView, '--yes']); + const source = path.join(directory, 'SKILL.md'); + await fs.writeFile(source, '# Synthetic skill\n'); + const created = await call(['cloud-skill', 'add', '--file', source]); + assert.equal(created.meta.uploadManifest.included[0].path, 'SKILL.md'); + assert.equal(created.meta.uploadManifest.included[0].sha256.length, 64); + await call(['cloud-skill', 'list']); + const skillView = path.join(directory, 'skill.json'); + await fs.writeFile(skillView, JSON.stringify(await call(['cloud-skill', 'show', 's1']))); + await call(['cloud-skill', 'update', 's1', '--from', skillView, '--file', source]); + await call(['cloud-skill', 'run', 's1', '--from', skillView, '--yes'], { input_args: { text: 'synthetic' } }); + assert.deepEqual([...completed].sort(), COMMAND_REGISTRY.map((r) => r.command).sort()); + const count = api.requests.length; + await call(['dream', 'preview'], { wait: 'not-boolean' }, 2); + await call(['dream', 'preview', '--wait-timeout', 'invalid'], undefined, 2); + await call(['knowledge', 'read', 'i1', '--offset', '2'], undefined, 2); + await call(['memory', 'search', 'q', '--limit', '2', '--limit', '3'], undefined, 2); + assert.equal(api.requests.length, count, 'invalid local input must not send HTTP requests'); + for (const spec of COMMAND_REGISTRY) { + const help = await child(process.execPath, [binary, ...spec.command.split('.'), '--help', '--json'], { cwd: directory, env }); + assert.equal(help.code, 0); + const schema = JSON.parse(help.stdout); + assert.equal(schema.command, spec.command); + assert.ok(schema.inputSchema.examples.length); + const failure = await child(process.execPath, [binary, ...spec.command.split('.'), '--json'], { cwd: directory, env: { ...env, XMEMO_KEY: '' } }); + assert.equal(failure.code, 3, failure.stdout); + assert.equal(JSON.parse(failure.stdout).ok, false); + } + assert.equal(api.requests.length, count, 'help and missing credentials must not send HTTP requests'); +}); + +test('CLI-09 real HTTP covers delayed body, unknown writes, redaction and missing HTML contract', async (t) => { + const api = await fixture(t, (r, res) => { + if (r.url.pathname === '/slow') { res.writeHead(200, { 'content-type': 'application/json' }); res.write('{'); return; } + if (r.url.pathname === '/invalid') { res.writeHead(200); return res.end('broken JSON'); } + if (r.url.pathname === '/secret') return respond(res, { detail: `denied Bearer ${syntheticToken}`, access_token: syntheticToken }, 403); + res.writeHead(405, { 'content-type': 'text/html' }); res.end('Method not allowed'); + }); + const client = createServiceClient({ baseUrl: api.origin, token: syntheticToken, io: { fetch }, timeoutMs: 150 }); + await assert.rejects(client.request({ method: 'GET', path: '/slow' }), (e) => e.code === 'REQUEST_TIMEOUT'); + for (const route of ['/slow', '/invalid']) await assert.rejects(client.request({ method: 'POST', path: route, sideEffect: true, retry: 'bounded' }), (e) => e.outcome === 'unknown'); + assert.equal(api.requests.filter((r) => r.method === 'POST').length, 2); + await assert.rejects(client.request({ method: 'GET', path: '/secret' }), (e) => !JSON.stringify({ message: e.message, data: e.data }).includes(syntheticToken)); + await assert.rejects(client.request({ method: 'POST', path: '/v1/skills/create', sideEffect: true, operation: { contractRequired: true } }), (e) => e.code === 'SERVER_CONTRACT_REQUIRED'); +}); + +test('CLI-09 actual npm archive runs outside repo with CLI and independent Skill separated', async (t) => { + const directory = await temp(t); + const env = environment(directory, 'http://127.0.0.1:1'); + const npm = process.env.npm_execpath ?? path.join(path.dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js'); + const packed = await child(process.execPath, [npm, 'pack', '--json', '--ignore-scripts', '--offline', '--pack-destination', directory, '--cache', path.join(directory, 'npm-cache')], { cwd: root, env }); + assert.equal(packed.code, 0, packed.stderr); + const info = JSON.parse(packed.stdout)[0]; + assert.ok(info.files.some((f) => f.path === 'src/api/client.js')); + assert.ok(!info.files.some((f) => /credentials\.json|\.env$|npm-cache-review|\.progress/.test(f.path))); + const unpacked = path.join(directory, 'unpacked'); + await fs.mkdir(unpacked); + const extraction = await child('tar', ['-xf', path.join(directory, info.filename), '-C', unpacked], { cwd: directory, env }); + assert.equal(extraction.code, 0, extraction.stderr); + const cliOnly = path.join(directory, 'cli only'), skillOnly = path.join(directory, 'skill only'); + await fs.mkdir(cliOnly); + for (const entry of ['bin', 'src', 'package.json']) await fs.cp(path.join(unpacked, 'package', entry), path.join(cliOnly, entry), { recursive: true }); + await fs.cp(path.join(unpacked, 'package/skills/xmemo'), skillOnly, { recursive: true }); + await assert.rejects(fs.access(path.join(cliOnly, 'skills'))); + await assert.rejects(fs.access(path.join(skillOnly, 'src'))); + const api = await fixture(t, (_r, res) => respond(res, [{ memory_id: 'packaged', content: 'synthetic' }])); + const isolatedEnv = environment(directory, api.origin); + const cli = await child(process.execPath, [path.join(cliOnly, 'bin/memory-os.js'), 'memory', 'search', 'synthetic', '--json'], { cwd: cliOnly, env: isolatedEnv }); + assert.equal(cli.code, 0, cli.stderr + cli.stdout); + assert.equal(JSON.parse(cli.stdout).data[0].memory_id, 'packaged'); + const skill = await child(process.execPath, [path.join(skillOnly, 'scripts/xmemo-skill.mjs'), 'recall', '--query', 'synthetic', '--json'], { cwd: skillOnly, env: isolatedEnv }); + assert.equal(skill.code, 0, skill.stderr + skill.stdout); + assert.ok(JSON.parse(skill.stdout)); +}); diff --git a/test/service-safety.test.js b/test/service-safety.test.js new file mode 100644 index 0000000..c5d886a --- /dev/null +++ b/test/service-safety.test.js @@ -0,0 +1,84 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { run } from '../src/cli.js'; + +function io(fetch, extra = {}) { + const stream = () => ({ value: '', write(value) { this.value += value; } }); + return { env: { XMEMO_KEY: 'synthetic-safety-token', XMEMO_BASE_URL: 'https://api.example.test' }, fetch, stdout: stream(), stderr: stream(), stdin: { async *[Symbol.asyncIterator]() {} }, ...extra }; +} + +async function view(t, overrides = {}) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'xmemo-safety-')); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const file = path.join(directory, 'view.json'); + await fs.writeFile(file, JSON.stringify({ meta: { readReceipt: { schemaVersion: '1', serviceOrigin: 'https://api.example.test', resource: 'knowledge-item:i1', scope: 'personal', displayedRevision: 'r1', latestRevision: 'r1', version: 1, itemStatus: 'draft', sourceType: 'user_created', ...overrides } } })); + return file; +} + +test('K03 rejects text/source conversion and another Document without network access', async (t) => { + const from = await view(t, { sourceType: 'file', sourceRef: 'document:doc1' }); + let calls = 0; + for (const source of [['--text', 'overwrite'], ['--document', 'doc2', '--document-version', '2']]) { + const streams = io(async () => { calls++; return new Response('{}'); }); + assert.equal(await run(['knowledge', 'update', 'i1', '--from', from, ...source, '--json'], streams), 2); + } + assert.equal(calls, 0); +}); + +test('K04 missing post-content version is partial and never falls back to the old item version', async (t) => { + const from = await view(t); + const methods = []; + const streams = io(async (_url, init) => { methods.push(init.method); return new Response(JSON.stringify({ current_revision_id: 'r2' })); }); + assert.equal(await run(['knowledge', 'update', 'i1', '--from', from, '--text', 'new', '--publish', '--yes', '--json'], streams), 12); + assert.deepEqual(methods, ['PUT']); + assert.equal(JSON.parse(streams.stdout.value).error.data.content.current_revision_id, 'r2'); +}); + +test('K03 publish-only conflict is not falsely reported as partial completion', async (t) => { + const from = await view(t); + const streams = io(async () => new Response(JSON.stringify({ detail: 'stale' }), { status: 409 })); + assert.equal(await run(['knowledge', 'update', 'i1', '--from', from, '--publish', '--yes', '--json'], streams), 6); + assert.equal(JSON.parse(streams.stdout.value).error.code, 'CONFLICT'); +}); + +test('C02 published view cannot overwrite a newer maintenance draft', async (t) => { + const from = await view(t, { resource: 'cloud-skill:s1', latestRevision: 'r2', revisionKind: 'published', revisionStatus: 'published' }); + let calls = 0; + const streams = io(async () => { calls++; return new Response('{}'); }); + assert.equal(await run(['cloud-skill', 'update', 's1', '--from', from, '--publish', '--yes', '--json'], streams), 2); + assert.equal(calls, 0); +}); + +test('C05 insufficient execution HTTP budget fails before any request', async (t) => { + const from = await view(t, { resource: 'cloud-skill:s1', revisionKind: 'published', revisionStatus: 'published' }); + let calls = 0; + const streams = io(async () => { calls++; return new Response('{}'); }, { stdin: { async *[Symbol.asyncIterator]() { yield '{"input_args":{}}'; } } }); + assert.equal(await run(['cloud-skill', 'run', 's1', '--from', from, '--input', '-', '--timeout-ms', '1000', '--yes', '--json'], streams), 2); + assert.equal(calls, 0); +}); + +test('D03 interruption during preview polling retains its resumable run ID', async () => { + const controller = new AbortController(); + const streams = io(async (url) => { + if (url.endsWith('/settings')) return new Response(JSON.stringify({ enabled: true, mode: 'preview_only' })); + if (url.endsWith('/runs')) return new Response(JSON.stringify({ run: { id: 'run-recover' } })); + controller.abort(); + throw Object.assign(new Error('interrupted'), { name: 'AbortError' }); + }, { signal: controller.signal }); + assert.equal(await run(['dream', 'preview', '--wait', '--json'], streams), 130); + assert.equal(JSON.parse(streams.stdout.value).error.data.run_id, 'run-recover'); +}); + +test('CLI-02 explicit domain doctor only reads and never claims write readiness', async () => { + const methods = []; + const streams = io(async (url, init) => { methods.push(init.method); return new Response(JSON.stringify(url.endsWith('/settings') ? { enabled: true, mode: 'preview_only', entitlement: { can_apply: false } } : [])); }); + assert.equal(await run(['doctor', '--services', '--json'], streams), 0); + assert.deepEqual(methods, ['GET', 'GET', 'GET']); + assert.equal(JSON.parse(streams.stdout.value).data.writeReadiness, 'not-tested'); + const denied = io(async () => new Response('{"detail":"scope missing"}', { status: 403 })); + assert.equal(await run(['doctor', '--services', '--json'], denied), 4); + assert.equal(JSON.parse(denied.stdout.value).ok, false); +});