Compare commits

..
Author SHA1 Message Date
alvinfeiandCursor 1821a31295 fix(core): drop native store write gate on HY search
MemHub expects concurrent native reads during precipitation. The
process-local read slot mostly existed for a Chroma/GIL fail-fast and
was never held by writers on main; activating it (as in #98) would only
push recall onto weaker fallbacks. Remove the lock module and the
hy_memory_store_busy path instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 12:07:37 +08:00
059e3a4903 fix(runtime): accept profile-scoped host ids in the host permission layer (#70)
* fix(runtime): accept profile-scoped host ids in the host permission layer

DeepSeek Harness configures recall per profile, so recall-hosts.ts reports
`deepseek-harness:<profile>` as the host id. The CLI accepts that shape and
prints it from `host list`, but the runtime rejected it twice:

  - HostPermissions.supported() tested `[a-z][a-z0-9-]*`, so preview/install
    answered host_permission_transport_unsupported for every Harness profile.
  - HostGrantStore.validHostId() applied the same restriction, so even a
    supported host could not create its installation or pass the load-time
    revalidation.

A host id is an opaque label: the installation directory is a digest of it,
so a colon never becomes a path component. Both checks now allow one optional
`:<profile>` segment, matching the CLI's existing HOST_ID.

Adds an integration test that previews a real `cordis.patch.yml` install for
`deepseek-harness:desktop` and a grant-store test that the colon survives a
reload, plus a regression for malformed ids.

* fix(runtime): share one host-id grammar across CLI, permissions and grants

Review follow-up: the three layers had three different host-id rules, so the
CLI could list an id the permission layer (or the grant store) then refused.
They now import `isHostId` from `@one-memory/contracts`, which is the only
definition:

  - host: 1..64 chars, alphanumeric first, lower-case letters/digits/hyphens
  - optional `:<profile>`: 1..32 chars, alphanumeric first
  - total <= 97, and the pattern asserts true end-of-input so a trailing
    newline cannot pass

Digit-leading ids stay accepted (the CLI always took them), and the profile
segment is the only colon allowed. Boundary cases live in
`packages/contracts/src/host-id.test.ts`; the grant-store and permission tests
cover the same grammar from their side.

Packaging: contracts is a source-only workspace package, so every bundle that
now imports it for a value inlines it (`noExternal`): host-bridge entries,
the built Claude Code hook assets, and the CLI. Without that the emitted CJS
asks Node for a `.js` file that only exists as `.ts` and the MCP entry fails
at startup.

---------

Co-authored-by: Vesper <226942762+VE5PER-17@users.noreply.github.com>
Co-authored-by: Vesper <hb.ran@foxmail.com>
2026-10-21 11:40:28 +08:00
0a9b10dbfc fix(auth): require the local token on reader internal endpoints (#82)
The /internal/semantic-ids, /internal/call-event and
/internal/graph-snapshot routes were registered directly on the app without
the local_api_guard dependency used by every other route. Any local process
could read memory graph content, probe semantic IDs, or forge call events
without authorization.

Mount the same guard on all three routes. The Reader already sends
Authorization: Bearer <core_api_token>, so its calls keep working. Add
regression tests for both the 401 and authorized paths.

Co-authored-by: qiye.sjh <shijiahao_0802@youzan.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-10-21 11:40:23 +08:00
3a74a33456 feat(memhub): pair unlisted harnesses with one-time codes (#124)
* feat: pair custom harnesses with one-time codes

* fix(memhub): satisfy biome format checks for custom harness pairing

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 11:36:55 +08:00
20 changed files with 312 additions and 137 deletions

No files matched your search

+12 -8
View File
@@ -4,7 +4,11 @@ import { createReadStream } from "node:fs";
import { access } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { HealthResponse, ImportJob } from "@one-memory/contracts";
import {
type HealthResponse,
type ImportJob,
isHostId,
} from "@one-memory/contracts";
import {
type AcquiredRuntimeDistribution,
acquireRuntimeDistribution,
@@ -5156,10 +5160,10 @@ type HostCommand =
args: unknown[];
};
// `deepseek-harness:web` is a real id — that host is configured per profile —
// and `host list` prints it. Rejecting it here would mean listing a host the
// CLI then refuses to act on.
const HOST_ID = /^[a-z0-9][a-z0-9-]{0,31}(:[a-z0-9][a-z0-9-]{0,31})?$/;
// Host ids are read with the same grammar as the permission layer and the
// grant store (`@one-memory/contracts`): `deepseek-harness:web` is a real id —
// that host is configured per profile — and `host list` prints it, so rejecting
// it here would mean listing a host the CLI then refuses to act on.
const FINGERPRINT = /^[0-9a-f]{64}$/;
/** Host subcommands.
@@ -5182,7 +5186,7 @@ function parseHostCommand(rest: readonly string[]): HostCommand | null {
const token = tokens[index];
if (token === "--host") {
const value = tokens[index + 1];
if (!value || !HOST_ID.test(value)) return null;
if (!value || !isHostId(value)) return null;
if (hostId !== undefined) return null;
hostId = value;
index += 1;
@@ -5215,7 +5219,7 @@ function parseHostCommand(rest: readonly string[]): HostCommand | null {
qualifier !== undefined &&
(verb === "status" || verb === "install" || verb === "uninstall")
) {
if (!HOST_ID.test(qualifier)) return null;
if (!isHostId(qualifier)) return null;
hostId = qualifier;
// Drop the consumed host positional so the verb/qualifier checks below see
// the same shape as the flag form.
@@ -5231,7 +5235,7 @@ function parseHostCommand(rest: readonly string[]): HostCommand | null {
positional[2] !== undefined
) {
const trailing = positional[2];
if (!HOST_ID.test(trailing)) return null;
if (!isHostId(trailing)) return null;
hostId = trailing;
positional.splice(2, 1);
verb = positional[0];
+3
View File
@@ -41,7 +41,10 @@ export default defineConfig({
// The shared operations package ships TypeScript sources, so it must be
// inlined into the CJS bundle the same way runtime-control is; otherwise the
// emitted bundle tries to `require` a `.js` file that only exists as `.ts`.
// `@one-memory/contracts` is the same kind of package and is imported for a
// value (the host-id grammar), so it has to be inlined too.
noExternal: [
"@one-memory/contracts",
"@one-memory/operations",
"@one-memory/runtime-control",
"axios",
+3 -3
View File
@@ -1685,7 +1685,7 @@ def create_app(
# --- Reader-only internal endpoints. Registered here, before the public
# routes below; no early return, so every route after this still registers.
@app.get("/internal/semantic-ids")
@app.get("/internal/semantic-ids", dependencies=protected)
async def internal_semantic_ids(
query: str = Query(min_length=1, max_length=2_000),
limit: int = Query(default=12, ge=1, le=20),
@@ -1697,7 +1697,7 @@ def create_app(
return {"ids": []}
return {"ids": [memory_id for memory_id, _score in hits]}
@app.post("/internal/call-event")
@app.post("/internal/call-event", dependencies=protected)
async def internal_call_event(request: Request) -> dict[str, bool]:
"""Side effects the Reader cannot write: it opens with query_only."""
try:
@@ -1716,7 +1716,7 @@ def create_app(
return {"recorded": False}
return {"recorded": bool(recorded)}
@app.get("/internal/graph-snapshot")
@app.get("/internal/graph-snapshot", dependencies=protected)
async def internal_graph_snapshot(
limit: int = Query(default=160, ge=1, le=300),
) -> dict[str, object]:
@@ -31,7 +31,6 @@ from one_memory.hy_runtime_config import (
diagnostic_log_level,
)
from one_memory.hy_source_context import HySourceContext, HyWriteBindings
from one_memory.hy_store_concurrency import native_store_read_slot
from one_memory.model_profiles import ModelProfileSnapshot, ProviderKind, attach_disable_thinking
from one_memory.model_request_audit import audit_precipitation_request
from one_memory.semantic_deadline import SemanticMemoryHit
@@ -984,27 +983,29 @@ class HyMemoryBridge:
caller could only rank by position — which forced semantic hits to be
placed as a block ahead of everything the keyword lane found. The
score is what lets the two lanes be ranked against each other.
Native search runs without a process-local write gate. MemHub expects
concurrent read during precipitation; store work is a short burst after
network-bound embed/LLM steps, and refusing reads with
``hy_memory_store_busy`` only pushed callers onto weaker fallbacks.
"""
async with native_store_read_slot() as admitted:
if not admitted:
raise HyMemoryBridgeError("hy_memory_store_busy")
client = await self._get_client()
try:
result = await _joined_thread(
client.search,
query,
user_ids=[user_id],
agent_ids=[] if agent_id == "*" else [agent_id],
limit=limit,
min_score=hy_relevance_floor(),
schema_min_score=hy_relevance_floor(),
schema_limit=limit,
intention_limit=limit,
scene="normal",
reader=HY_MEMORY_READER,
)
except Exception as error:
raise HyMemoryBridgeError("hy_memory_search_failed") from error
client = await self._get_client()
try:
result = await _joined_thread(
client.search,
query,
user_ids=[user_id],
agent_ids=[] if agent_id == "*" else [agent_id],
limit=limit,
min_score=hy_relevance_floor(),
schema_min_score=hy_relevance_floor(),
schema_limit=limit,
intention_limit=limit,
scene="normal",
reader=HY_MEMORY_READER,
)
except Exception as error:
raise HyMemoryBridgeError("hy_memory_search_failed") from error
hits = _replied_memories(result, "hy_memory_search_shape_unknown")
found: list[SemanticMemoryHit] = []
seen: set[str] = set()
@@ -1,41 +0,0 @@
"""Process-local coordination for HY's native stores."""
from __future__ import annotations
import asyncio
import weakref
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
_WRITE_LOCKS: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Lock] = (
weakref.WeakKeyDictionary()
)
def native_store_write_lock() -> asyncio.Lock:
"""Return the write lock for the current event loop."""
loop = asyncio.get_running_loop()
lock = _WRITE_LOCKS.get(loop)
if lock is None:
lock = asyncio.Lock()
_WRITE_LOCKS[loop] = lock
return lock
@asynccontextmanager
async def native_store_read_slot() -> AsyncIterator[bool]:
"""Admit a native HY read only while no write is active.
Chroma's native reader may retain the GIL after an asyncio timeout. Reads
therefore share the physical-store lock, but never wait behind a writer:
host recall can immediately fall back to the governance ledger.
"""
lock = native_store_write_lock()
if lock.locked():
yield False
return
await lock.acquire()
try:
yield True
finally:
lock.release()
@@ -140,8 +140,6 @@ async def search_within(
return (), "failed"
error = pending.exception()
if error is not None:
if str(error) == "hy_memory_store_busy":
return (), "timed_out"
return (), "failed"
found = pending.result()
normalized = normalize_semantic_hits(found)
+41
View File
@@ -55,3 +55,44 @@ def test_browser_origin_is_checked_in_addition_to_token() -> None:
)
== 200
)
async def internal_request(
path: str,
*,
token: str | None,
method: str = "GET",
) -> int:
settings = Settings(api_token=SecretStr("test-token"))
app = create_app(settings)
headers = {}
if token:
headers["Authorization"] = f"Bearer {token}"
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as client:
response = await client.request(method, path, headers=headers, json={})
return response.status_code
def test_reader_internal_endpoints_require_local_token() -> None:
for path in (
"/internal/semantic-ids?query=anything",
"/internal/graph-snapshot",
):
assert asyncio.run(internal_request(path, token=None)) == 401
assert asyncio.run(internal_request(path, token="test-token")) == 200
assert (
asyncio.run(
internal_request("/internal/call-event", token=None, method="POST")
)
== 401
)
assert (
asyncio.run(
internal_request("/internal/call-event", token="test-token", method="POST")
)
== 200
)
@@ -436,13 +436,6 @@ def test_unavailable_semantic_result_cannot_discard_lexical_memory(app, monkeypa
assert body["paths"]["memory"]["reason"] == "lexical_only"
def test_busy_hy_store_is_reported_as_a_timed_out_semantic_lane() -> None:
async def busy(_query: str) -> tuple[str, ...]:
raise RuntimeError("hy_memory_store_busy")
assert asyncio.run(search_within(busy, "q", 1.0)) == ((), "timed_out")
def test_context_diagnostics_report_each_lane_without_changing_v1(app, monkeypatch): # noqa: F811
from one_memory import context_routes
@@ -6,7 +6,6 @@ import sqlite3
import threading
import time
from collections.abc import Mapping, Sequence
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
from types import SimpleNamespace
@@ -1199,40 +1198,6 @@ def test_bridge_preserves_sdk_formatted_search_content(tmp_path) -> None:
assert hits[0].formatting_complete is True
def test_semantic_search_skips_native_store_while_a_write_is_active(tmp_path, monkeypatch) -> None:
client = FakeHyMemoryClient(
{"success": True},
search_result={"memories": [{"memory_id": "must-not-be-read"}]},
)
bridge = HyMemoryBridge(
profile=snapshot(
ProviderKind.OLLAMA,
base_url="http://127.0.0.1:11434",
has_api_key=False,
),
api_key=None,
data_dir=tmp_path,
client=client,
)
@asynccontextmanager
async def busy_store():
yield False
monkeypatch.setattr("one_memory.hy_memory_bridge.native_store_read_slot", busy_store)
with pytest.raises(HyMemoryBridgeError, match="hy_memory_store_busy"):
asyncio.run(
bridge.search_ids(
"导入期间查询",
user_id="local",
agent_id="*",
)
)
assert client.search_calls == []
def test_search_ids_does_not_create_nodes_tasks_or_collections(tmp_path) -> None:
"""Search is a read. Creating store objects here would invent a graph.
+1 -1
View File
@@ -37,7 +37,7 @@ Preview 不会弹出给用户;开关点击本身被视为确认。关闭时通
| [Qwen Code](qwen-code.md) | 开放 | MCP + Skill | 无自动 Hook | `$QWEN_HOME/projects` 扫描 |
| [Pi](pi.md) | 开放 | CLI Skill,不使用 MCP | JS Capture Extension | Pi session 扫描 + Extension 实时入口 |
| [OpenClaw](openclaw.md) | 正式产品冻结 | 当前没有;仅有未开放的底层 MCP 写入器 | 没有 Hook、Plugin 或 Prefetch | Adapter 也被冻结,开关不可用 |
| [DeepSeek Harness](deepseek-harness.md) | 详情页排除;Profile UI 有 Bug | Profile 级 MCP Patch | 无自动 Hook | `$DSH_HOME/sessions` 扫描 |
| [DeepSeek Harness](deepseek-harness.md) | 详情页排除,来源管理页按 Profile 接入 | Profile 级 MCP Patch | 无自动 Hook | `$DSH_HOME/sessions` 扫描 |
## 同步开关的统一精确语义
@@ -1,18 +1,18 @@
# DeepSeek Harness 一键插入支持
更新日期:2026-10-14
更新日期:2026-10-18
## 结论
DeepSeek Harness 的 Profile 级 MCP 安装器已经实现,但当前桌面 Profile 安装会因 Host ID 校验 Bug 在 Preview 阶段失败。
DeepSeek Harness 的 Profile 级 MCP 安装器已经实现,Profile Scoped Host ID(`deepseek-harness:<profile>`)在权限层按不透明标签处理,可正常走完 Preview 与安装。
## 接入方式和产品状态
| 项目 | DeepSeek Harness 当前实现 |
|---|---|
| 应用详情页一键插入 | 有意排除,因为无法选择 Profile |
| 来源管理页 Profile 安装 | UI 显示,但当前因 Host ID Bug 失败 |
| Harness 查询 MemHub | 理论上通过 Profile 级 Cordis MCP Client |
| 来源管理页 Profile 安装 | 可用;每个 Profile 独立 Patch、Credential 与 Grant |
| Harness 查询 MemHub | 通过 Profile 级 Cordis MCP Client |
| Skill | 没有 |
| Prompt 前自动 Prefetch | 没有 |
| Capture Hook/Plugin | 没有 |
@@ -22,7 +22,7 @@ DeepSeek Harness 的 Profile 级 MCP 安装器已经实现,但当前桌面 Pro
### “一键插入应用”
应用详情页不可用。来源管理页理论上对每个 Profile 写 `cordis.patch.yml`、创建独立 Credential 并授权;但当前 `deepseek-harness:<profile>` 含冒号,被权限层拒绝,所以实际不写文件。
应用详情页不可用。来源管理页对每个 Profile 写 `cordis.patch.yml`、创建独立 Credential 并授权;Host ID 形如 `deepseek-harness:<profile>`,冒号只作为标签存在,权限层的安装目录按 Host ID 摘要生成,不参与路径拼接。
### 打开“同步开关”
@@ -89,9 +89,9 @@ DeepSeek Harness 不安装 Skill,也不安装 Capture Hook;新会话进入 M
- 首次导入批量连接也排除它。
- 来源管理页会列出每个 Profile,并显示独立安装按钮。
## 当前确定 Bug
## 已修复:Profile Host ID 校验
来源管理页安装实际会失败:
此前来源管理页安装会在写入 `cordis.patch.yml` 和创建 Credential 之前失败,原因是:
```text
系统生成 hostId = deepseek-harness:<profile>
@@ -99,7 +99,7 @@ HostPermissions 只接受 [a-z][a-z0-9-]*
HostGrantStore 也不接受冒号
```
因此安装在写入 `cordis.patch.yml` 和创建 Credential 之前就被拒绝。这不是 DeepSeek Harness 不支持 MCP,而是 Profile Host ID 格式与权限校验不一致。
Host ID 是不可解释的标签:安装目录对它取摘要,不会把它拼进路径。现在权限层与 Grant 存储都接受一个可选的 `:<profile>` 段,因此 Profile 安装可以正常走完。这不是 DeepSeek Harness 不支持 MCP 的问题,而是 Profile Host ID 格式与权限校验不一致,现已对齐。
## 修复后的权限语义风险
@@ -109,7 +109,7 @@ HostGrantStore 也不接受冒号
- `all-apps`;
- 所有已授权 Source 和 Workspace。
这比应用详情页通用授权更宽。修复 Host ID 后应确认是否希望每个 Profile 默认获得如此宽的权限。
这比应用详情页通用授权更宽。Host ID 修复后仍应确认是否希望每个 Profile 默认获得如此宽的权限。
## 重复安装与卸载
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { HOST_ID_MAX_LENGTH, HOST_ID_PATTERN, isHostId } from "./index";
/**
* The grammar is shared by the CLI, the permission layer and the grant store,
* so the boundary cases are pinned once here. The optional profile segment
* exists because a host can be configured per profile
* (`deepseek-harness:desktop`).
*/
const VALID: readonly string[] = [
"codex",
"claude-code",
"pi",
"a1",
"9lives", // the CLI has always accepted a digit-leading host id
"codex-",
"deepseek-harness:desktop",
"deepseek-harness:web",
"a:b",
"2code:9profile",
"a".repeat(64), // host segment at its maximum
`a:${"b".repeat(32)}`, // profile segment at its maximum
`${"a".repeat(64)}:${"b".repeat(32)}`, // the whole id at its maximum
];
const INVALID: readonly string[] = [
"",
" codex",
"codex ",
"DeepSeek-Harness:web", // upper case
"deepseek harness:web", // space
"-codex", // hyphen-first host
"deepseek-harness:", // empty profile
"deepseek-harness:a:b", // more than one profile segment
":web",
"a:",
"a::b",
"a:b:c",
`a${"b".repeat(64)}`, // host segment one over the maximum
`a:${"b".repeat(33)}`, // profile segment one over the maximum
"deepseek_harness:web", // underscore
"deepseek-harness:web\n", // `$` alone would accept this
];
describe("host id grammar", () => {
it.each(VALID)("accepts %j", (value) => {
expect(isHostId(value)).toBe(true);
expect(HOST_ID_PATTERN.test(value)).toBe(true);
expect(value.length).toBeLessThanOrEqual(HOST_ID_MAX_LENGTH);
});
it.each(INVALID)("rejects %j", (value) => {
expect(isHostId(value)).toBe(false);
expect(HOST_ID_PATTERN.test(value)).toBe(false);
});
it("is a type guard for strings only", () => {
for (const value of [undefined, null, 7, {}, []]) {
expect(isHostId(value)).toBe(false);
}
expect(isHostId("codex")).toBe(true);
});
});
+43
View File
@@ -1,4 +1,47 @@
export const CORE_SERVICE = "one-memory-core" as const;
/**
* The one host-id grammar.
*
* A host id is an opaque label for the app a recall grant belongs to. It is
* never a path component: the permission layer keys an installation by a
* digest of the whole id, so a colon is a label separator, not a directory
* separator.
*
* One app can be configured per profile, which is the reason for the single
* optional `:<profile>` segment — DeepSeek Harness reports
* `deepseek-harness:<profile>` and `host list` prints those ids.
*
* The CLI, the permission layer and the grant store all have to read the same
* shape: an id that `host list` prints must be an id `host install` accepts.
* There is one definition here rather than three regexes that drift.
*/
const HOST_SEGMENT_MAX = 64;
const PROFILE_SEGMENT_MAX = 32;
/**
* Lower-case letters, digits and hyphens; first character alphanumeric; at
* most one `:` followed by an alphanumeric-first profile segment.
*
* The trailing `(?![\s\S])` is "end of input". `$` alone would also match
* before a final newline, so `"codex\n"` would pass a pattern a caller reads
* as an exact match.
*/
export const HOST_ID_PATTERN = new RegExp(
`^[a-z0-9][a-z0-9-]{0,${HOST_SEGMENT_MAX - 1}}(?::[a-z0-9][a-z0-9-]{0,${PROFILE_SEGMENT_MAX - 1}})?(?![\\s\\S])`,
);
/** Longest id the grammar can produce: host + ":" + profile. */
export const HOST_ID_MAX_LENGTH = HOST_SEGMENT_MAX + 1 + PROFILE_SEGMENT_MAX;
export function isHostId(value: unknown): value is string {
return (
typeof value === "string" &&
value.length <= HOST_ID_MAX_LENGTH &&
HOST_ID_PATTERN.test(value)
);
}
export type {
ConnectionSetupCapability,
ConnectionSetupExecute,
@@ -44,7 +44,7 @@ await build({
format: ["esm"],
platform: "node",
target: "node24",
noExternal: ["axios"],
noExternal: ["@one-memory/contracts", "axios"],
banner: {
js: 'import { createRequire as __hookCreateRequire } from "node:module"; const require = __hookCreateRequire(import.meta.url);',
},
@@ -913,3 +913,36 @@ describe.skipIf(process.platform === "win32")("HostGrantStore", () => {
});
});
});
it("accepts one profile segment in a host id and still rejects malformed ones", async () => {
// DeepSeek Harness is configured per profile and reports
// `deepseek-harness:<profile>`; the installation directory is a digest of the
// id, so the colon is a label rather than a path component. The boundary
// cases themselves live in `@one-memory/contracts`; this tests that the grant
// store reads the same grammar and revalidates it on load.
const context = await fixture();
const issued = await context.store.createInstallation({
hostId: "deepseek-harness:desktop",
});
expect(issued.receipt.hostId).toBe("deepseek-harness:desktop");
// Every row is validated again on load; the colon has to survive that pass.
const reopened = await HostGrantStore.open(context.directory);
expect(
(await reopened.listInstallations()).map((row) => row.hostId),
).toContain("deepseek-harness:desktop");
for (const hostId of ["9lives", "a:b", `a:${"b".repeat(32)}`]) {
await expect(
context.store.createInstallation({ hostId }),
).resolves.toMatchObject({ receipt: { hostId } });
}
for (const hostId of [
"deepseek-harness:",
"deepseek-harness:a:b",
"DeepSeek-Harness:web",
"deepseek harness:web",
]) {
await expect(
context.store.createInstallation({ hostId }),
).rejects.toMatchObject({ code: "host_grant_invalid_input" });
}
});
@@ -18,6 +18,7 @@ import {
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { inspect } from "node:util";
import { isHostId } from "@one-memory/contracts";
/**
* Trusted local control plane, not an MCP tool or an HTTP authentication route.
@@ -711,12 +712,12 @@ function validMutation(input: HostGrantMutation): boolean {
}
function validHostId(value: unknown): value is string {
return (
typeof value === "string" &&
value.length <= 64 &&
value === value.trim() &&
/^[a-z][a-z0-9-]*$/.test(value)
);
// Same grammar as the permission layer and the CLI: a host id is an opaque
// label, one optional `:<profile>` segment included, and the grant store
// must not reject an id the other two accept. Grants are keyed by
// installation UUID and the installation directory is a digest of the host
// id, so the colon never becomes part of a path.
return isHostId(value);
}
function validDisplayName(value: unknown): value is string {
@@ -169,7 +169,7 @@ it.each(["recall-and-sync", "sync-only"] as const)(
format: ["esm"],
platform: "node",
target: "node20",
noExternal: ["axios"],
noExternal: ["@one-memory/contracts", "axios"],
banner: {
js: 'import { createRequire as __mhCreateRequire } from "node:module"; const require = __mhCreateRequire(import.meta.url);',
},
+5 -1
View File
@@ -7,5 +7,9 @@ export default defineConfig({
outDir: "dist",
// Preserve the separately built sync-hook asset directory.
clean: false,
noExternal: ["axios"],
// `@one-memory/contracts` is a private, source-only workspace package
// (`exports` points at `src/index.ts`). Left external, the emitted CJS asks
// Node for a `.js` file that only exists as `.ts`, and the MCP entry fails at
// startup; inline it the way runtime-control already inlines it.
noExternal: ["@one-memory/contracts", "axios"],
});
@@ -14,6 +14,7 @@ import path from "node:path";
import { RecallBroker, readHostCredentialFile } from "@one-memory/host-bridge";
import {
codexLayout,
deepseekHarnessLayout,
piLayout,
type RecallInstallPreview,
} from "@one-memory/host-bridge/installer";
@@ -258,6 +259,61 @@ it("remote preview is read-only, exact approval installs one real Codex identity
expect(f.dispatched).toContain("host:install");
});
it("accepts a profile-scoped DeepSeek Harness host id instead of calling the transport unsupported", async () => {
// A Harness profile is configured by one `cordis.patch.yml`, so the host ID
// carries the profile (`deepseek-harness:<profile>`). The installation
// directory is a digest of that ID, so the colon is a label, not a path, and
// the permission layer has to read it as one. Before this, `supported()`
// rejected every Harness profile and `host install` told the caller its
// transport was unsupported.
const f = await fixture();
const dshHome = path.join(f.root, ".dsh");
const profileDirectory = path.join(dshHome, "profiles", "desktop");
await mkdir(profileDirectory, { recursive: true, mode: 0o700 });
await chmod(dshHome, 0o700);
await writeFile(path.join(profileDirectory, "cordis.yml"), "[]\n", {
mode: 0o600,
});
await writeFile(path.join(profileDirectory, "cordis.patch.yml"), "[]\n", {
mode: 0o600,
});
const layout = {
...deepseekHarnessLayout({ dshHome, profile: "desktop" }),
hostId: "deepseek-harness:desktop",
hostLabel: "DeepSeek Harness · desktop",
};
expect(await f.owner.getPermission(layout)).toMatchObject({
state: "not-installed",
receipt: null,
});
const preview: RecallInstallPreview = await f.owner.previewInstall(layout);
expect(preview.hostId).toBe("deepseek-harness:desktop");
expect(preview.fingerprint).toMatch(/^[a-f0-9]{64}$/);
const patchFile = preview.files[0];
if (!patchFile?.hunk) throw Error("preview missing patch hunk");
const added = patchFile.hunk.added.join("\n");
expect(added).toContain("one-memory-recall");
expect(added).toContain("'@deepseek-ai/dsh-mcp-client'");
if (layout.configPath === null) throw Error("layout missing config path");
expect(await readFile(layout.configPath, "utf8")).toBe("[]\n");
expect(await f.owner.hostGrantStore.listInstallations()).toEqual([]);
});
it("reads the same host-id grammar the CLI uses, including a digit-leading id", async () => {
// The CLI has always accepted `9codex`; the permission layer used to require
// a leading letter, so `host list` could print an id `host install` refused.
// Both import `isHostId` from `@one-memory/contracts` now.
const f = await fixture();
const layout = { ...f.layout, hostId: "9codex" };
expect(await f.owner.getPermission(layout)).toMatchObject({
state: "not-installed",
receipt: null,
});
const preview = await f.owner.previewInstall(layout);
expect(preview.hostId).toBe("9codex");
});
it("remote explicit revoke changes the original owner receipt and rejects stale or false confirmation", async () => {
const f = await fixture();
const preview = await f.client.previewInstall(f.layout);
@@ -10,6 +10,7 @@ import {
unlink,
} from "node:fs/promises";
import path from "node:path";
import { isHostId } from "@one-memory/contracts";
import {
createClaudeCodeRemovalTarget,
createClaudeCodeTarget,
@@ -1060,10 +1061,19 @@ function status(
};
}
/** Whether a layout can hold a revocable credential.
*
* The id grammar lives in `@one-memory/contracts` so that this layer, the
* grant store and the CLI cannot drift: a host id `host list` prints has to be
* one `host install` accepts. Profile scoping is what DeepSeek Harness needs —
* it configures recall per profile (`deepseek-harness:desktop`) — and the
* installation directory is a digest of the whole id, so the colon never
* becomes a path component.
*/
function supported(layout: RecallHostLayout): boolean {
return (
(layout.channel === "mcp" || layout.channel === "cli") &&
/^[a-z][a-z0-9-]{0,63}$/.test(layout.hostId) &&
isHostId(layout.hostId) &&
layout.hostId !== "openclaw"
);
}