Compare commits

...
Author SHA1 Message Date
kristinhe d1ff4571bd fix(memhub): stop reading released provenance as a revoked source
A citation whose `evidence` row is gone made the whole memory unreadable:
`_MEMORY_READABLE` treated absence as "cannot prove the source is not
revoked", so `GET /api/v1/memories/{id}` answered 403 `memory_source_revoked`
(issue #157: 85 of 2,382 memories, on two sources nobody revoked). Evidence
rows are released by design once the SDK owns the content; the memory's text
lives in `governed_memories` and was never damaged. `get_memory` already
degraded an unresolvable citation to `EvidenceExcerpt(available=False)`, and
the 403 made that degradation unreachable.

The policy was also written four times and had drifted: `context_memory`
carried a comment asserting the opposite rule. It is now one function,
`memory_source_readable`, and the three other call sites use it.

Revocation is unchanged: it is recorded on the grant and never deletes
evidence, so a revoked source still matches.
2026-10-24 08:01:18 +08:00
kristinhe1111 6b0bd86679 Merge pull request #156 from Tencent-Hunyuan/fix/build-17-test-index
docs(memhub): regenerate the core test index for build 17
2026-10-23 20:22:05 +08:00
kristinhe1111 91250d7c87 fix(desktop): keep an unknown startup failure code in the dialog
Build 17 routed every startup error through the friendly-text map, so three
desktop contracts that assert the dialog body (page_load_failed,
runtime_broker_unavailable, host_permission_store_unavailable) started failing.

Only codes we wrote copy for become sentences now; anything else keeps the code
it was thrown with, which is what support reads.
2026-10-23 20:02:05 +08:00
kristinhe1111 3c89a535fb docs(memhub): regenerate the core test index for build 17
The build-17 test file was added without regenerating TEST_INDEX.md, so the
public structure gate failed on main. Generated artifact only.
2026-10-23 19:49:44 +08:00
kristinhe1111 1151878265 Merge pull request #155 from Tencent-Hunyuan/release/memhub-build-17
fix(memhub): build 17 — migration fixes carry through to first launch
2026-10-23 19:34:32 +08:00
kristinhe e82c4c939e fix(memhub): name a blocked cutover without its counts
The cutover gate reports sdk_data_plane_cutover_work_pending plus a JSON body of
table counts. That string went straight into the document the startup dialog
reads, so the person saw braces and table names.

Collapse it to sdk_migration_work_pending and let the dialog say the data is
already upgraded but earlier memory work is still finishing. Any other code that
is not a plain identifier becomes sdk_migration_cutover_failed, so no free text
reaches the dialog.
2026-10-23 19:33:31 +08:00
kristinhe ca4a66349b fix(memhub): explain the wait and the failure on first launch
Three things a person meets before the app can open:

- The waiting window had no drag region element, so a frameless window could not
  be moved at all; the CSS alone never applied to anything.
- The page said only that the service was starting. It now says a first launch
  or a memory upgrade can take a few minutes and the app opens by itself, in all
  three interface languages.
- A migration failure surfaced as a raw code in one path and a generic sentence
  in the other. Every migration code now maps to a sentence that says what
  happened, what happens to their memories, and what to do next; the dispatch
  path reuses the same mapping instead of printing error.message.

Build number 17 carries the diagnostics fix and the merged SDK migration fix.
2026-10-23 19:19:06 +08:00
kristinhe 82044d6172 fix(memhub): name the migration failure instead of one opaque code
A full disk, an unconfigured model profile and a Coding classification gap all
reached the person as sdk_migration_backfill_failed, so the startup dialog could
only ever say the upgrade failed.

Classify the exception chain before the code is written: ENOSPC/EDQUOT becomes
sdk_migration_disk_space_insufficient, SDK productivity errors become
sdk_migration_coding_inventory_failed, the legacy working_fact import failure
keeps its own name, and existing sdk_data_plane_/sdk_migration_ codes pass
through. Anything unrecognized keeps its message out of the document.
2026-10-23 19:18:56 +08:00
kristinhe 7f23b74c36 fix(memhub): separate local diagnostics from safe exports 2026-10-23 19:03:50 +08:00
kristinhe 617e2d0ba6 fix(memhub): keep diagnostics from blocking startup 2026-10-23 19:03:50 +08:00
feitianxiang 083b837f32 Merge pull request #154 from Tencent-Hunyuan/fix/sdk-migration-turns-and-coding
fix(memhub): harden SDK migration inventory and fencing
2026-10-23 17:37:15 +08:00
feitianxiang 3b44d8381c Merge pull request #153 from Tencent-Hunyuan/fix/sdk-migration-turns-and-coding
fix(memhub): repair legacy SDK authority migration
2026-10-23 17:27:39 +08:00
43 changed files with 2377 additions and 785 deletions

No files matched your search

+6
View File
@@ -38,6 +38,12 @@ Hy Memory and MemHub are versioned and released independently.
parked pre-invocation work, and drop only the obsolete
`hy_node_permissions` proof table. Evidence, governed memories, model
profiles, SDK stores, and source/host grants are preserved.
- A memory whose cited evidence row has been released no longer reads as a
revoked source. The read policy is now spelled once instead of in four
copies, so the memory library, the detail endpoint, call-event references
and context assembly agree that missing provenance is a fact about the
citation, not about the grant. Real revocations still hide memories; they
are recorded on the grant and never on the evidence row.
## Hy Memory 2.0.5 — 2026-10-22
+42 -1
View File
@@ -1,6 +1,7 @@
import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { inflateRawSync } from "node:zlib";
import { DiagnosticLogger } from "@one-memory/runtime-control";
import { afterEach, expect, it, vi } from "vitest";
@@ -29,6 +30,27 @@ async function fixture() {
return dataDir;
}
function readZipEntry(archive: Buffer, expected: string): string {
let offset = 0;
while (archive.readUInt32LE(offset) === 0x04034b50) {
const method = archive.readUInt16LE(offset + 8);
const compressedSize = archive.readUInt32LE(offset + 18);
const nameLength = archive.readUInt16LE(offset + 26);
const extraLength = archive.readUInt16LE(offset + 28);
const nameStart = offset + 30;
const dataStart = nameStart + nameLength + extraLength;
const name = archive.subarray(nameStart, nameStart + nameLength).toString();
const compressed = archive.subarray(dataStart, dataStart + compressedSize);
if (name === expected) {
if (method === 0) return compressed.toString();
if (method === 8) return inflateRawSync(compressed).toString();
throw new Error("unsupported zip compression");
}
offset = dataStart + compressedSize;
}
throw new Error(`missing ${expected}`);
}
it("prints matching diagnostic events as NDJSON without starting Runtime", async () => {
const dataDir = await fixture();
let stdout = "";
@@ -74,6 +96,25 @@ it("exports one support zip to the requested downloads directory", async () => {
});
expect(receipt.path).toMatch(/MemHub-日志-3800-\d{8}-\d{4}\.zip$/);
expect(await readdir(downloads)).toEqual([path.basename(receipt.path)]);
const archive = await readFile(receipt.path);
expect(JSON.parse(readZipEntry(archive, "summary.json")).commit).toBeNull();
});
it("fails closed for an explicitly supplied invalid commit", async () => {
const dataDir = await fixture();
const downloads = await mkdtemp(path.join(os.tmpdir(), "om-cli-downloads-"));
roots.push(downloads);
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
await expect(
run(["logs", "export", "--json"], {
ONE_MEMORY_DATA_DIR: dataDir,
ONE_MEMORY_EXPORT_DIR: downloads,
ONE_MEMORY_BUILD_NUMBER: "3800",
ONE_MEMORY_COMMIT: "unknown",
}),
).rejects.toThrow("diagnostic_commit_invalid");
expect(await readdir(downloads)).toEqual([]);
});
it("never emits a secret from a malformed manually written event", async () => {
+11 -5
View File
@@ -1048,15 +1048,21 @@ async function runCommand(
entry: "cli",
}));
const target = path.join(downloads, diagnosticZipFilename(build));
const environmentCommit = environment.ONE_MEMORY_COMMIT?.trim();
const embeddedCommit =
typeof __ONE_MEMORY_COMMIT__ === "string"
? __ONE_MEMORY_COMMIT__.trim()
: "";
const commit =
environmentCommit ||
(embeddedCommit && embeddedCommit !== "unknown"
? embeddedCommit
: undefined);
const receipt = await exportDiagnosticZip({
dataDir: config.dataDir,
target,
productVersion: build,
commit:
environment.ONE_MEMORY_COMMIT?.trim() ||
(typeof __ONE_MEMORY_COMMIT__ === "string"
? __ONE_MEMORY_COMMIT__
: "unknown"),
...(commit === undefined ? {} : { commit }),
coreSha256: await sha256File(config.coreCommand).catch(() => undefined),
home: os.homedir(),
crashReportsDir: path.join(
+31 -1
View File
@@ -1,4 +1,4 @@
import { mkdtemp, rm } from "node:fs/promises";
import { mkdtemp, rm, symlink } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { DiagnosticLogger } from "@one-memory/runtime-control";
@@ -41,11 +41,15 @@ it("redacts unsafe details and absolute stack frames from the read surface", ()
session_id: "11111111-1111-4111-8111-111111111111",
details: {
count: 1,
reason: "/Users/me/private-query.txt",
status: "sk-live-secret-token",
source_id: "credential.private",
prompt: "secret-token-123",
authorization: "Bearer secret-token-123",
} as unknown as Record<string, string | number | boolean | null>,
safe_stack: [
"packages/runtime-control/src/example.ts:42:7",
"packages/runtime/sk-live-secret-token.ts:1:1",
"/Users/me/private.py:1",
"C:\\Users\\me\\private.py:1",
],
@@ -56,6 +60,8 @@ it("redacts unsafe details and absolute stack frames from the read surface", ()
"packages/runtime-control/src/example.ts:42:7",
]);
expect(JSON.stringify(redacted)).not.toContain("secret-token-123");
expect(JSON.stringify(redacted)).not.toContain("sk-live-secret-token");
expect(JSON.stringify(redacted)).not.toContain("credential.private");
expect(JSON.stringify(redacted)).not.toContain("/Users/me");
expect(JSON.stringify(redacted)).not.toContain("C:\\\\Users");
});
@@ -132,3 +138,27 @@ it("follows a newly appended event without stopping Runtime", async () => {
});
expect(seen).toEqual(["service.ready", "core.failed"]);
});
it("fails follow mode when the diagnostic directory is unsafe", async () => {
const dataDir = await mkdtemp(
path.join(os.tmpdir(), "om-cli-follow-unsafe-"),
);
const external = await mkdtemp(
path.join(os.tmpdir(), "om-cli-follow-external-"),
);
roots.push(dataDir, external);
await symlink(external, path.join(dataDir, "logs"));
const query = parseLogQuery(["--follow"]);
expect(query).not.toBeNull();
if (!query) throw new Error("query_missing");
await expect(
followLogs({
dataDir,
query,
signal: new AbortController().signal,
maxEvents: 1,
emit: () => undefined,
wait: async () => undefined,
}),
).rejects.toThrow("log_directory_unreadable");
});
+6 -102
View File
@@ -3,6 +3,7 @@ import path from "node:path";
import type { DiagnosticEventV1 } from "@one-memory/runtime-control";
import {
diagnosticLogFiles,
projectDiagnosticEventForSafeOutput,
readDiagnosticEvents,
} from "@one-memory/runtime-control";
@@ -24,54 +25,6 @@ export interface LogQuery {
const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
const NAME = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+){0,15}$/;
const SAFE_DETAIL_KEYS = new Set([
"action",
"attempt",
"count",
"method",
"mode",
"phase",
"platform",
"reason",
"result_count",
"source_id",
"spawned",
"status",
"version",
"parent_session_id",
"invalid_lines",
"truncated",
"runtime_state",
"core_state",
"broker_state",
"instance_id",
"open_file_count",
"memory_bytes",
"thread_count",
"database_error_count",
"file_count",
"sqlite_file_count",
"byte_file_count",
"total_bytes",
"copied_bytes",
"bytes_per_second",
"stored_count",
"revised_count",
"skipped_count",
"error_count",
"pending_count",
"prepared_count",
"queue_depth",
"in_flight_count",
"call_count",
"created_count",
"updated_count",
"superseded_count",
"deleted_count",
"operation_kind",
"file_kind",
]);
export function parseLogQuery(arguments_: readonly string[]): LogQuery | null {
if (arguments_.filter((item) => item === "--json").length > 1) return null;
const values: Partial<LogQuery> = { errorsOnly: false, follow: false };
@@ -237,63 +190,13 @@ export function renderLogText(
return `${lines.join("\n")}\n`;
}
/** Defence in depth for the CLI read surface. `DiagnosticLogger` already
* allow-lists keys, but a future writer, a manually edited log, or a malformed
* line must not turn `memhub logs` into an exfiltration path. */
/** Defence in depth for the CLI read surface. Local writers accept arbitrary
* bounded scalar keys, so every outward read surface must reuse the same
* field-and-value projection as the support-bundle exporter. */
export function redactDiagnosticEvent(
event: DiagnosticEventV1,
): DiagnosticEventV1 {
const redacted: DiagnosticEventV1 = {
schema_version: 1,
timestamp: event.timestamp,
level: event.level,
service: event.service,
component: event.component,
event: event.event,
outcome: event.outcome,
session_id: event.session_id,
...(event.trace_id === undefined ? {} : { trace_id: event.trace_id }),
...(event.incident_id === undefined
? {}
: { incident_id: event.incident_id }),
...(event.instance_id === undefined
? {}
: { instance_id: event.instance_id }),
...(event.operation_id === undefined
? {}
: { operation_id: event.operation_id }),
...(event.request_id === undefined ? {} : { request_id: event.request_id }),
...(event.job_id === undefined ? {} : { job_id: event.job_id }),
...(event.task_id === undefined ? {} : { task_id: event.task_id }),
...(event.duration_ms === undefined
? {}
: { duration_ms: event.duration_ms }),
...(event.state_before === undefined
? {}
: { state_before: event.state_before }),
...(event.state_after === undefined
? {}
: { state_after: event.state_after }),
...(event.error_code === undefined ? {} : { error_code: event.error_code }),
...(event.error_type === undefined ? {} : { error_type: event.error_type }),
...(event.safe_stack === undefined
? {}
: {
safe_stack: event.safe_stack.filter(
(frame) =>
!frame.startsWith("/") &&
!/^[A-Za-z]:\\/.test(frame) &&
!frame.includes(".."),
),
}),
};
const details = Object.fromEntries(
Object.entries(event.details ?? {}).filter(([key]) =>
SAFE_DETAIL_KEYS.has(key),
),
);
if (Object.keys(details).length > 0) redacted.details = details;
return redacted;
return projectDiagnosticEventForSafeOutput(event);
}
export async function followLogs(options: {
@@ -315,6 +218,7 @@ export async function followLogs(options: {
...options.query,
tail: 5_000,
});
if (report.failure) throw new Error(report.failure);
for (const event of report.events) {
const identity = JSON.stringify(event);
if (seen.has(identity)) continue;
+24 -38
View File
@@ -998,9 +998,7 @@ def create_app(
app.state.recall_target = GovernedRecallTarget(
app.state.memory_governance_store,
filter_memories=app.state.source_read_access.memories,
semantic_search=lambda query: _sdk_memory_runtime(
app
).semantic_recall(query),
semantic_search=lambda query: _sdk_memory_runtime(app).semantic_recall(query),
# Read per request rather than cached: an import that lands
# while the app is open should make its workspace reachable
# without a restart. Affordable only because the read is served
@@ -1102,9 +1100,7 @@ def create_app(
authorize_generation=lambda profile, question, grounding: (
_authorize_answer_generation(app, profile, question, grounding)
),
semantic_search=lambda query: _sdk_memory_runtime(app).semantic_recall(
query
),
semantic_search=lambda query: _sdk_memory_runtime(app).semantic_recall(query),
resolve_workspace=lambda workspace_path: resolve_workspace_scopes(
workspace_path,
evidence_store.known_workspaces(user_id="local"),
@@ -1128,21 +1124,21 @@ def create_app(
profiles=app.state.model_profile_store,
secrets=app.state.secret_store,
data_dir=runtime_settings.data_dir,
transport_factory=lambda profile: lambda: AuditedModelTransport(
app.state.model_request_audit_store,
profile_id=profile.profile_id,
profile_revision=profile.revision,
provider_kind=str(profile.provider_kind),
model=profile.model,
inner=(
BudgetedModelTransport(model_request_budget)
if model_request_budget is not None
else None
),
transport_factory=lambda profile: (
lambda: AuditedModelTransport(
app.state.model_request_audit_store,
profile_id=profile.profile_id,
profile_revision=profile.revision,
provider_kind=str(profile.provider_kind),
model=profile.model,
inner=(
BudgetedModelTransport(model_request_budget)
if model_request_budget is not None
else None
),
)
),
sedimentation_enabled=lambda: _config_store(
app
).sedimentation_enabled(),
sedimentation_enabled=lambda: _config_store(app).sedimentation_enabled(),
)
app.state.sdk_memory_runtime = SdkMemoryRuntime(
coordinator=app.state.sdk_write_coordinator,
@@ -1167,10 +1163,8 @@ def create_app(
exclude_error_codes=NON_RETRYABLE_POST_WRITE_CODES,
)
for job in unknown:
receipt = (
await app.state.sdk_write_coordinator.inquire_write_outcome(
job.request_id
)
receipt = await app.state.sdk_write_coordinator.inquire_write_outcome(
job.request_id
)
if receipt is None:
continue
@@ -1610,9 +1604,7 @@ def create_app(
)
if getattr(
app.state, "memory_authority", MemoryAuthority.LEGACY
) is MemoryAuthority.SDK and request.url.path.startswith(
legacy_paths
):
) is MemoryAuthority.SDK and request.url.path.startswith(legacy_paths):
return JSONResponse(
status_code=409,
content={"detail": "sdk_memory_authority_required"},
@@ -1734,7 +1726,7 @@ def create_app(
trace_id=trace_id,
instance_id=runtime_settings.instance_id,
duration_ms=elapsed_ms(started),
details={**diagnostic_details, "status": str(response.status_code)},
details={**diagnostic_details, "status_code": response.status_code},
)
return response
except Exception as error:
@@ -3783,9 +3775,7 @@ def create_app(
all_memories, projected, nodes, edges = await asyncio.to_thread(read_governed_part)
try:
hy_graph = (
None
if revoked
else await _sdk_memory_runtime(app).graph_snapshot(limit=limit)
None if revoked else await _sdk_memory_runtime(app).graph_snapshot(limit=limit)
)
except HySdkError:
hy_graph = None
@@ -3894,9 +3884,7 @@ def create_app(
active_queue, in_flight_count = supervisor.active_depths()
planned, current = supervisor.manual_batch_progress()
sdk_counts = (
supervisor.status_counts()
if app.state.memory_authority is MemoryAuthority.SDK
else {}
supervisor.status_counts() if app.state.memory_authority is MemoryAuthority.SDK else {}
)
# `running` counts rows the store still marks running, which includes
# work a worker has already given up on. Reporting that as "what is
@@ -3917,9 +3905,7 @@ def create_app(
),
recent_completions=depths.recent_completions,
running=sdk_counts.get("running", running_rows),
stranded_running=(
0 if sdk_counts else max(0, running_rows - in_flight_count)
),
stranded_running=(0 if sdk_counts else max(0, running_rows - in_flight_count)),
unexecuted_policy_failures=store.unexecuted_policy_failures(),
waiting_for_model=sdk_counts.get(
"waiting_for_model",
@@ -5286,7 +5272,7 @@ def _report_visibility_failure(phase: str, error: BaseException) -> None:
event="memory_visibility.background_failed",
outcome="failed",
error_type=type(error).__name__,
details={"phase": phase, "reason": str(error)[:200]},
details={"phase": phase},
)
@@ -10,6 +10,7 @@ from pydantic import Field, model_validator
from one_memory.call_event_store import CallEvent, CallReference
from one_memory.database_access import main_database
from one_memory.memory_evidence_freshness import register_memory_freshness
from one_memory.source_read_access import memory_source_readable
class SourceRecordRef(CallReference):
@@ -76,17 +77,13 @@ def _memory(db: sqlite3.Connection, reference: CallReference) -> SourceRecordRef
if row["status"] == "deleted" or row["deleted_at"] is not None:
return _hidden(reference)
# Same local revocation/ownership predicate as SourceReadAccess.memories.
# Its public methods open their own connections, so cannot be used here.
# Same local revocation/ownership predicate as SourceReadAccess.memories,
# taken from there rather than copied: its public methods open their own
# connections, so the predicate is the part that can be shared.
access = db.execute(
"SELECT EXISTS (SELECT 1 FROM governed_memory_evidence "
"WHERE memory_id=? AND active=1), NOT EXISTS ("
"SELECT 1 FROM governed_memory_evidence link "
"LEFT JOIN evidence e ON e.evidence_id=link.evidence_id "
"LEFT JOIN source_grants g ON g.user_id=e.user_id AND g.source_id=e.source_id "
"WHERE link.memory_id=? AND +link.active=1 "
"AND (e.evidence_id IS NULL OR e.user_id!='local' OR g.revoked_at IS NOT NULL))",
(reference.id, reference.id),
"WHERE memory_id=? AND active=1), " + memory_source_readable("?", "?"),
(reference.id, reference.id, "local"),
).fetchone()
if not all(access):
return _hidden(reference)
+85 -28
View File
@@ -31,13 +31,60 @@ def _end_by(seconds: str | None) -> None:
if __name__ == "__main__" and sys.argv[1:2] == ["capture"]:
_end_by(os.environ.get("ONE_MEMORY_CAPTURE_DEADLINE_S"))
import errno # noqa: E402
import json # noqa: E402
from collections.abc import Sequence # noqa: E402
import re # noqa: E402
from collections.abc import Iterator, Sequence # noqa: E402
from typing import TextIO # noqa: E402
from one_memory.config import load_settings # noqa: E402
from one_memory.runtime_client import RuntimeStatus, inspect_runtime # noqa: E402
# A single `sdk_migration_backfill_failed` told the person nothing: running out
# of disk, a model that was never configured, and a Coding/Working-Fact gap are
# three different next steps, and the startup dialog can only act on the name it
# is given. These are the names that reach it.
_SAFE_MIGRATION_CODE = re.compile(r"^[a-z][a-z0-9_]{0,95}$")
_OUT_OF_SPACE = frozenset({errno.ENOSPC, getattr(errno, "EDQUOT", errno.ENOSPC)})
def _exception_chain(error: BaseException) -> Iterator[BaseException]:
seen: set[int] = set()
current: BaseException | None = error
while current is not None and id(current) not in seen:
seen.add(id(current))
yield current
current = current.__cause__ or current.__context__
def migration_failure_code(
error: BaseException, *, fallback: str = "sdk_migration_backfill_failed"
) -> str:
"""Name a migration failure; never guess a code from free text.
Errs toward the caller's generic code: an exception this does not recognize
keeps its message out of the document Desktop reads.
"""
chain = list(_exception_chain(error))
for item in chain:
if isinstance(item, OSError) and item.errno in _OUT_OF_SPACE:
return "sdk_migration_disk_space_insufficient"
for item in chain:
code = getattr(item, "code", "")
if not isinstance(code, str) or not _SAFE_MIGRATION_CODE.match(code):
continue
if code.startswith(("sdk_data_plane_", "sdk_migration_")):
return code
if code.startswith("hy_sdk_productivity_"):
# The Coding inventory could not be read or classified; that is a
# data-shape problem the person cannot fix by retrying.
return "sdk_migration_coding_inventory_failed"
if code == "hy_sdk_memory_import_failed" and any(
"working_fact" in str(part) for part in chain
):
return "sdk_migration_working_fact_conflict"
return fallback
def main() -> None:
if sys.argv[1:] == ["--keychain-read"]:
@@ -345,9 +392,16 @@ def migration_bundle(options: Sequence[str], stream: TextIO) -> int:
document = {
"schema_version": 1,
"state": "failed",
"code": error.code,
"code": migration_failure_code(error, fallback=error.code),
}
_write_restore(stream, as_json, document, f"迁移备份失败:{error.code}")
_write_restore(stream, as_json, document, f"迁移备份失败:{document['code']}")
return 1
except OSError as error:
# A full disk while copying is the most common first-launch failure and
# the only one the person fixes by deleting something.
code = migration_failure_code(error, fallback="sdk_migration_bundle_copy_failed")
document = {"schema_version": 1, "state": "failed", "code": code}
_write_restore(stream, as_json, document, f"迁移备份失败:{code}")
return 1
document = {
"schema_version": 1,
@@ -361,6 +415,21 @@ def migration_bundle(options: Sequence[str], stream: TextIO) -> int:
return 0
def cutover_failure_code(error: BaseException) -> str:
"""The cutover's own codes, minus the counts it appends to one of them.
`sdk_data_plane_cutover_work_pending` carries a JSON body of table names and
counts. That belongs in the diagnostic event, not in the single string the
startup dialog reads, so it collapses to one name here.
"""
code = getattr(error, "code", "")
if isinstance(code, str) and code.startswith("sdk_data_plane_cutover_work_pending"):
return "sdk_migration_work_pending"
if isinstance(code, str) and _SAFE_MIGRATION_CODE.match(code):
return code
return "sdk_migration_cutover_failed"
def sdk_migrate(options: Sequence[str], stream: TextIO) -> int:
"""Backfill legacy memory/raw into SDK while keeping legacy authority."""
migration_id: str | None = None
@@ -461,14 +530,15 @@ def sdk_migrate(options: Sequence[str], stream: TextIO) -> int:
except SecretStoreUnavailableError:
code = "secure_storage_unavailable"
except Exception as error:
message = str(error)
code = (
str(error)
if str(error)
message
if message
in {
"model_profile_not_active",
"model_secret_unavailable",
}
else "sdk_migration_preflight_failed"
else migration_failure_code(error, fallback="sdk_migration_preflight_failed")
)
else:
namespace_fingerprint = hashlib.sha256(HY_MEMORY_NAMESPACE.encode()).hexdigest()
@@ -497,8 +567,8 @@ def sdk_migrate(options: Sequence[str], stream: TextIO) -> int:
paths=staging,
database_path=database,
)
except Exception:
code = "sdk_migration_staging_failed"
except Exception as error:
code = migration_failure_code(error, fallback="sdk_migration_staging_failed")
staging_data_dir = None
async def run_backfill() -> BackfillResult:
@@ -525,13 +595,9 @@ def sdk_migrate(options: Sequence[str], stream: TextIO) -> int:
await asyncio.to_thread(lease.close)
try:
result = (
asyncio.run(run_backfill())
if staging_data_dir is not None
else None
)
except Exception:
code = "sdk_migration_backfill_failed"
result = asyncio.run(run_backfill()) if staging_data_dir is not None else None
except Exception as error:
code = migration_failure_code(error)
else:
if result is None:
document = {"schema_version": 1, "state": "failed", "code": code}
@@ -645,7 +711,7 @@ def sdk_cutover(options: Sequence[str], stream: TextIO) -> int:
database_path=database_path,
)
except (DataPlaneMigrationError, MigrationBundleRejected, OSError) as error:
code = getattr(error, "code", str(error))
code = cutover_failure_code(error)
document = {
"schema_version": 1,
"state": "blocked",
@@ -708,18 +774,11 @@ def sdk_migration_status(options: Sequence[str], stream: TextIO) -> int:
migration_id=marker.migration_id,
namespace=HY_MEMORY_NAMESPACE,
)
staging_cleanup = (
staging.protective_root.exists()
or staging.staging_home.exists()
)
staging_cleanup = staging.protective_root.exists() or staging.staging_home.exists()
document = {
"schema_version": 1,
"authority": "sdk",
"state": (
"cleanup_required"
if legacy_tables or staging_cleanup
else "complete"
),
"state": ("cleanup_required" if legacy_tables or staging_cleanup else "complete"),
"migration_id": marker.migration_id if marker else None,
"generation": generation,
"legacy_tables": list(legacy_tables),
@@ -759,9 +818,7 @@ def sdk_migration_status(options: Sequence[str], stream: TextIO) -> int:
"authority": "legacy",
"state": "required" if any(counts.values()) else "empty",
"migration_id": (
str(active_migration["migration_id"])
if active_migration is not None
else None
str(active_migration["migration_id"]) if active_migration is not None else None
),
"bundle_id": (
str(active_migration["backup_bundle_id"])
@@ -25,6 +25,7 @@ from one_memory.memory_evidence_freshness import register_memory_freshness
from one_memory.recall import _terms
from one_memory.retrieval.models import Channel, RetrievalPath, TimeRange
from one_memory.semantic_deadline import SemanticMemoryHit, SemanticOutcome
from one_memory.source_read_access import memory_source_readable
_BODY_LIMIT = 20000
_REFERENCE_LIMIT = 128
@@ -283,28 +284,18 @@ def _memory_readable(freshness: str) -> str:
return _READABLE + f" AND {freshness}"
# Match the local governed read policy before scoring or candidate LIMIT. A
# missing grant is not a revocation; a missing/cross-user active citation is.
_READABLE = """
# Match the local governed read policy before scoring or candidate LIMIT. That
# policy is `source_read_access`'s and is spelled once there so it cannot drift
# from the endpoint that answers 403; this module only adds its own status
# filter on top.
_READABLE = f"""
m.user_id = 'local' AND m.status = 'confirmed' AND m.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM governed_memory_evidence link
LEFT JOIN evidence e ON e.evidence_id = link.evidence_id
LEFT JOIN source_grants g ON g.user_id = e.user_id AND g.source_id = e.source_id
WHERE link.memory_id = m.memory_id AND +link.active = 1
AND (e.evidence_id IS NULL OR e.user_id != m.user_id OR g.revoked_at IS NOT NULL)
)
AND {memory_source_readable("m.memory_id", "m.user_id")}
"""
_SDK_MEMBER_READABLE = """
_SDK_MEMBER_READABLE = f"""
m.user_id = 'local' AND m.status != 'deleted' AND m.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM governed_memory_evidence link
LEFT JOIN evidence e ON e.evidence_id = link.evidence_id
LEFT JOIN source_grants g ON g.user_id = e.user_id AND g.source_id = e.source_id
WHERE link.memory_id = m.memory_id AND +link.active = 1
AND (e.evidence_id IS NULL OR e.user_id != m.user_id OR g.revoked_at IS NOT NULL)
)
AND {memory_source_readable("m.memory_id", "m.user_id")}
"""
+217 -122
View File
@@ -20,8 +20,10 @@ from __future__ import annotations
import atexit
import json
import logging
import math
import os
import re
import stat
import sys
import threading
import time
@@ -72,80 +74,30 @@ class _Refused(OSError):
"""The path is not one we are willing to write to, and never will be."""
DiagnosticLevel = Literal["info", "warn", "error"]
DiagnosticLevel = Literal["debug", "info", "warn", "error"]
_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$")
_EVENT = re.compile(r"^[a-z][a-z0-9]*(?:[._-][a-z0-9]+){0,15}$")
_DETAIL_KEYS = frozenset(
{
"action",
"attempt",
"count",
"method",
"mode",
"phase",
"platform",
"reason",
"result_count",
"source_id",
"spawned",
"status",
"version",
"parent_session_id",
"invalid_lines",
"truncated",
"open_file_count",
"memory_bytes",
"thread_count",
"database_error_count",
"route_path",
"sqlite_errorcode",
"sqlite_errorname",
"file_count",
"sqlite_file_count",
"byte_file_count",
"total_bytes",
"copied_bytes",
"bytes_per_second",
"stored_count",
"revised_count",
"skipped_count",
"error_count",
"pending_count",
"prepared_count",
"queue_depth",
"in_flight_count",
"call_count",
"created_count",
"updated_count",
"superseded_count",
"deleted_count",
"operation_kind",
"index_complete",
"lexical_hits",
"semantic_hits",
"generation",
"timed_out",
"failure_site",
"error_class",
"file_kind",
"file_size",
"file_mtime_ns",
"file_inode",
"cursor_new",
"cursor_old",
"cursor_read_to_end",
"cursor_rewind_branch",
"summed_ms",
"model_ms",
"accounted_ms",
"unaccounted_ms",
"conversation_hash",
# The sedimentation switch: a bounded boolean, the same shape as
# `spawned` and `index_complete`. Left out of this list it did not
# drop a log line, it turned the switch's own request into a 500.
"enabled",
}
_DIAGNOSTIC_LOG_NAME = re.compile(
r"^diagnostic-(runtime|core|host-bridge|desktop|cli)\.jsonl(?:\.\d+)?$"
)
_DETAIL_KEY = re.compile(r"^[A-Za-z][A-Za-z0-9_.-]{0,63}$")
_CODE_FRAME = re.compile(
r"^(?:(?:apps|packages|scripts)/(?:[A-Za-z0-9_-]+/)*[A-Za-z0-9_.-]+:\d+:\d+"
r"|one_memory/(?:[A-Za-z0-9_-]+/)*[A-Za-z0-9_.-]+:\d+:[A-Za-z_][A-Za-z0-9_]*"
r"|[A-Za-z0-9_][A-Za-z0-9_.-]*\.py:\d+:[A-Za-z_][A-Za-z0-9_]*)$"
)
_SHORT_CREDENTIAL_TOKEN = re.compile(
r"(?<![A-Za-z0-9])([A-Za-z0-9]{24,47})(?:[_-])?(?![A-Za-z0-9])"
)
_REFUSED_DETAIL_KEYS = frozenset({"__proto__", "prototype", "constructor"})
_MAX_DETAIL_KEYS = 32
_MAX_SAFE_INTEGER = (1 << 53) - 1
_EXPORT_SECRET_MARKER = re.compile(
r"(?:^|[._:-])(?:authorization|credential|password|prompt|query|secret|token)(?:$|[._:-])",
re.IGNORECASE,
)
_TRACE_STRING_DETAIL_KEYS = frozenset({"phase", "mode", "status", "reason", "failure_site"})
_TRACE_NUMBER_DETAIL_KEYS = frozenset({"count"})
_trace_id: ContextVar[str | None] = ContextVar("one_memory_trace_id", default=None)
_active: DiagnosticLog | None = None
_database_errors: Counter[tuple[str, str]] = Counter()
@@ -264,7 +216,12 @@ class DiagnosticLog:
}
value.update({key: _identifier(item) for key, item in identifiers.items() if item})
if duration_ms is not None:
if not isinstance(duration_ms, int) or duration_ms < 0:
if (
not isinstance(duration_ms, int)
or isinstance(duration_ms, bool)
or duration_ms < 0
or duration_ms > _MAX_SAFE_INTEGER
):
raise ValueError("diagnostic_duration_invalid")
value["duration_ms"] = duration_ms
for field_name, item in {
@@ -276,7 +233,9 @@ class DiagnosticLog:
if item is not None:
value[field_name] = _identifier(item)
if safe_stack:
value["safe_stack"] = [_safe_stack_line(line) for line in safe_stack[:256]]
if len(safe_stack) > 16:
raise ValueError("diagnostic_stack_invalid")
value["safe_stack"] = [_safe_stack_line(line) for line in safe_stack]
if details:
value["details"] = _details(details)
self._submit(_serialize(value))
@@ -289,6 +248,8 @@ class DiagnosticLog:
event: str,
outcome: str,
) -> dict[str, object]:
if level not in ("debug", "info", "warn", "error"):
raise ValueError("diagnostic_level_invalid")
return {
"schema_version": 1,
"timestamp": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
@@ -651,24 +612,17 @@ def _drain_diagnostics_at_exit() -> None:
def diagnostic_event(**values: object) -> None:
active = _active
if active is None:
return
if values.get("level") == "debug" and not logging.getLogger().isEnabledFor(logging.DEBUG):
return
try:
active = _active
if active is None:
return
if values.get("level") == "debug" and not logging.getLogger().isEnabledFor(logging.DEBUG):
return
active.write(**values) # type: ignore[arg-type]
except OSError:
# Diagnostics must not become a new product outage when a disk is full
# or the log directory is temporarily unavailable.
return
except ValueError:
# Same promise, same reason, for a rejected payload instead of a failed
# write. An unlisted `details` key once turned every external recall
# into a 500 and then killed the process, and the sedimentation switch
# into a permanent failure. The payload itself is still wrong and is
# still caught in CI by tests/test_diagnostic_payload_keys.py; what
# must not happen is the caller paying for it.
except Exception:
# Diagnostics are best-effort: validation, serialization, logging, and
# filesystem failures must never become a product outage. BaseException
# deliberately remains visible so process-control signals still work.
return
@@ -691,46 +645,95 @@ def read_trace_events(
if (request_id is None) == (trace_id is None):
raise ValueError("exactly_one_trace_identity_required")
key, expected = ("request_id", request_id) if request_id is not None else ("trace_id", trace_id)
if expected is None or not _IDENTIFIER.fullmatch(expected):
if expected is None or not _export_identifier(expected):
raise ValueError("invalid_trace_identity")
selected: list[dict[str, object]] = []
allowed_details = {"phase", "mode", "status", "reason", "failure_site", "count"}
files = sorted(
(data_dir / "logs").glob("diagnostic-*.jsonl*"),
key=lambda path: path.stat().st_mtime,
)
for path in files:
if path.is_symlink() or not path.is_file():
directory = data_dir / "logs"
try:
directory_info = directory.lstat()
except FileNotFoundError:
return ()
if stat.S_ISLNK(directory_info.st_mode) or not stat.S_ISDIR(directory_info.st_mode):
raise ValueError("diagnostic_directory_unsafe")
files: list[tuple[Path, float]] = []
for path in directory.iterdir():
if not _DIAGNOSTIC_LOG_NAME.fullmatch(path.name):
continue
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
try:
info = path.lstat()
except OSError:
continue
if stat.S_ISREG(info.st_mode) and not stat.S_ISLNK(info.st_mode):
files.append((path, info.st_mtime))
for path, _mtime in sorted(files, key=lambda item: item[1]):
for line in _read_regular_file_no_follow(path).splitlines():
try:
event = json.loads(line)
except (TypeError, json.JSONDecodeError):
continue
if not isinstance(event, dict) or event.get(key) != expected:
continue
details = event.get("details")
projected = {
name: event[name]
for name in (
"timestamp",
"component",
"event",
"outcome",
"duration_ms",
"error_code",
"error_type",
)
if name in event
timestamp = event.get("timestamp")
if not isinstance(timestamp, str):
continue
try:
datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
except ValueError:
continue
component = event.get("component")
event_name = event.get("event")
outcome = event.get("outcome")
projected: dict[str, object] = {
"timestamp": timestamp,
"component": (
component
if isinstance(component, str) and _export_identifier(component)
else "diagnostic.redacted"
),
"event": (
event_name
if isinstance(event_name, str) and _export_identifier(event_name)
else "diagnostic.redacted"
),
"outcome": (
outcome
if isinstance(outcome, str) and _export_identifier(outcome)
else "redacted"
),
}
duration_ms = event.get("duration_ms")
if (
isinstance(duration_ms, int)
and not isinstance(duration_ms, bool)
and 0 <= duration_ms <= _MAX_SAFE_INTEGER
):
projected["duration_ms"] = duration_ms
for name in ("error_code", "error_type"):
value = event.get(name)
if isinstance(value, str) and _export_identifier(value):
projected[name] = value
details = event.get("details")
if isinstance(details, dict):
projected["details"] = {
name: value for name, value in details.items() if name in allowed_details
}
safe_details = _trace_details(details)
if safe_details:
projected["details"] = safe_details
selected.append(projected)
return tuple(selected[-max(1, min(limit, 500)) :])
def _read_regular_file_no_follow(path: Path) -> str:
descriptor = os.open(path, os.O_RDONLY | _O_NOFOLLOW)
try:
if not stat.S_ISREG(os.fstat(descriptor).st_mode):
raise ValueError("diagnostic_file_unsafe")
with os.fdopen(descriptor, encoding="utf-8", errors="replace") as handle:
descriptor = -1
return handle.read()
finally:
if descriptor >= 0:
os.close(descriptor)
def current_trace_id() -> str | None:
return _trace_id.get()
@@ -837,8 +840,77 @@ def diagnostic_exception(error: BaseException) -> dict[str, object]:
}
def _contains_credential_material(value: str) -> bool:
return bool(
re.search(
r"(?:^|[^A-Za-z0-9])(?:sk-|sk_|omhg_|ghp_|github_pat_)[A-Za-z0-9._-]+",
value,
re.IGNORECASE,
)
or re.search(r"(?:^|[^A-Za-z0-9])xox[baprs]-[A-Za-z0-9-]+", value, re.IGNORECASE)
or re.search(r"(?:^|[^A-Za-z0-9])(?:AKIA|ASIA)[A-Z0-9]{16}(?![A-Za-z0-9])", value)
or re.search(r"(?:^|[^A-Za-z0-9])AKID[A-Za-z0-9]{12,}", value, re.IGNORECASE)
or re.search(r"(?:^|[^A-Za-z0-9])AIza[A-Za-z0-9_-]{20,}", value)
or re.search(r"(?:^|[^A-Za-z0-9])GOCSPX-[A-Za-z0-9_-]+", value)
or any(
_is_conservative_short_credential(match.group(1))
for match in _SHORT_CREDENTIAL_TOKEN.finditer(value)
)
or re.search(
r"\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b",
value,
)
or any(
len(candidate) >= 48
and any(character.isalpha() for character in candidate)
and any(character.isdigit() for character in candidate)
for candidate in re.findall(r"\b[A-Za-z0-9._-]{48,}\b", value)
)
)
def _is_conservative_short_credential(value: str) -> bool:
return bool(
re.search(r"[A-Z]", value)
and re.search(r"[a-z]", value)
and re.search(r"\d", value)
and not re.fullmatch(r"[A-Fa-f0-9]+", value)
)
def _export_identifier(value: str) -> bool:
return bool(
_IDENTIFIER.fullmatch(value)
and not _EXPORT_SECRET_MARKER.search(value)
and not _contains_credential_material(value)
)
def _trace_details(values: Mapping[object, object]) -> dict[str, str | int | float]:
result: dict[str, str | int | float] = {}
for key, value in values.items():
if (
key in _TRACE_STRING_DETAIL_KEYS
and isinstance(value, str)
and _export_identifier(value)
):
result[str(key)] = value
elif key in _TRACE_NUMBER_DETAIL_KEYS and isinstance(value, (int, float)):
if isinstance(value, bool):
continue
safe_integer = isinstance(value, int) and abs(value) <= _MAX_SAFE_INTEGER
safe_float = (
isinstance(value, float)
and math.isfinite(value)
and (not value.is_integer() or abs(value) <= _MAX_SAFE_INTEGER)
)
if safe_integer or safe_float:
result[str(key)] = value
return result
def _serialize(value: Mapping[str, object]) -> str:
return json.dumps(value, ensure_ascii=True, separators=(",", ":")) + "\n"
return json.dumps(value, ensure_ascii=True, allow_nan=False, separators=(",", ":")) + "\n"
def _uuid(value: str | None) -> str | None:
@@ -864,24 +936,47 @@ def _event_name(value: str) -> str:
def _safe_stack_line(value: str) -> str:
if not value or len(value) > 256 or "\n" in value or "\r" in value:
if (
not value
or len(value) > 256
or any(character in value for character in "\0\r\n")
or _contains_credential_material(value)
):
raise ValueError("diagnostic_stack_invalid")
if Path(value.split(":", 1)[0]).is_absolute() or "file://" in value:
raise ValueError("diagnostic_stack_invalid")
return value
if _CODE_FRAME.fullmatch(value):
return value
if value.startswith("thread|"):
frames = value.removeprefix("thread|").split(" > ")
if frames and all(_CODE_FRAME.fullmatch(frame) for frame in frames):
return value
raise ValueError("diagnostic_stack_invalid")
def _details(
values: Mapping[str, str | int | float | bool | None],
) -> dict[str, str | int | float | bool | None]:
if len(values) > _MAX_DETAIL_KEYS:
raise ValueError("diagnostic_detail_count_invalid")
result: dict[str, str | int | float | bool | None] = {}
for key, value in values.items():
if key not in _DETAIL_KEYS:
if (
not isinstance(key, str)
or not _DETAIL_KEY.fullmatch(key)
or key in _REFUSED_DETAIL_KEYS
):
raise ValueError("diagnostic_detail_key_invalid")
if isinstance(value, str):
if not value or len(value) > 256 or "\n" in value or "\r" in value:
if not value or len(value) > 256 or any(character in value for character in "\0\r\n"):
raise ValueError("diagnostic_detail_value_invalid")
elif value is not None and not isinstance(value, (int, float, bool)):
elif isinstance(value, float):
if not math.isfinite(value) or (value.is_integer() and abs(value) > _MAX_SAFE_INTEGER):
raise ValueError("diagnostic_detail_value_invalid")
elif isinstance(value, bool) or value is None:
pass
elif isinstance(value, int):
if abs(value) > _MAX_SAFE_INTEGER:
raise ValueError("diagnostic_detail_value_invalid")
else:
raise ValueError("diagnostic_detail_value_invalid")
result[key] = value
return result
@@ -3,8 +3,8 @@
Every site that catches `Exception` broadly must call `report_exception`
before (or instead of) continuing, so a failure never looks like success
and a stack is always on disk. Content (conversation text, keys, SQL
parameters) never reaches the log: only exception type, a truncated
message, and file:line:function frames.
parameters, and exception messages) never reaches the diagnostic log: only
exception type, a repeat count, and file:line:function frames.
"""
from __future__ import annotations
@@ -67,7 +67,7 @@ def report_exception(
event=event,
outcome="failed",
**payload,
details={"reason": _safe_message(error), "count": _RECENT[key]},
details={"count": _RECENT[key]},
)
if full:
_logger.error(
@@ -557,12 +557,10 @@ class PollingLiveSyncSupervisor:
)
def _note_internal_error(self, source_id: str, error: Exception) -> None:
# Type alone used to be all this said. It is not enough to act on:
# `OperationalError` covers a locked database, a missing table and a
# corrupt page alike, and without the message and the frames two dozen
# of these in a log could only be guessed at. The stack is the
# redacted, `one_memory`-relative one, so it names the call site
# without printing anyone's paths or query text.
# The ordinary local log keeps the exception message for debugging.
# The structured diagnostic event deliberately keeps only the stable
# error code, source id, type, and relative stack: exception messages
# can contain paths, query text, or credentials and are exportable data.
logging.getLogger(__name__).warning(
"live_sync_internal_error %s: %s", type(error).__name__, error
)
@@ -572,10 +570,7 @@ class PollingLiveSyncSupervisor:
event="live_sync.internal_error",
outcome="failed",
error_code="live_sync_internal_error",
# `reason` is the listed key that can carry the message; see
# `diagnostic_log._DETAIL_KEYS`. Truncated because a detail value
# longer than 256 characters is rejected.
details={"source_id": source_id, "reason": str(error)[:200]},
details={"source_id": source_id},
**diagnostic_exception(error),
)
if isinstance(error, sqlite3.Error):
@@ -867,10 +867,7 @@ class ImportRunner:
outcome="failed",
job_id=job_id,
error_code="import_heartbeat_failed",
# `reason` is the listed key for this; see
# `diagnostic_log._DETAIL_KEYS`. Truncated: a detail value
# over 256 characters is rejected.
details={"reason": str(error)[:200]},
details={"phase": "heartbeat"},
**diagnostic_exception(error),
)
@@ -14,6 +14,7 @@ stuck in it.
"""
import logging
import re
import sys
import threading
import time
@@ -23,6 +24,7 @@ from types import FrameType
logger = logging.getLogger("one_memory.loop_watchdog")
DEFAULT_STALL_SECONDS = 2.0
_SAFE_FALLBACK_FILE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.-]*\.py$")
class LoopWatchdog:
@@ -120,22 +122,28 @@ def _safe_stack_for_main_thread() -> tuple[str, ...]:
def _safe_stacks_for_all_threads() -> tuple[str, ...]:
"""Return one content-free, bounded stack summary for every live thread."""
names = {thread.ident: thread.name for thread in threading.enumerate()}
"""Return bounded stack summaries without recording thread names."""
lines: list[str] = []
for ident, frame in sorted(sys._current_frames().items()):
for _ident, frame in sorted(sys._current_frames().items()):
stack: list[str] = []
# Most specific frame first so the 256-character privacy bound keeps
# the likely culprit rather than only the thread bootstrap frames.
# Most specific frame first; append only complete frames so truncation
# can never turn a valid location into free text.
for entry in reversed(traceback.extract_stack(frame)[-8:]):
parts = entry.filename.replace("\\", "/").split("/")
try:
root = parts.index("one_memory")
relative = "/".join(parts[root:])
except ValueError:
relative = parts[-1]
fallback = parts[-1]
relative = fallback if _SAFE_FALLBACK_FILE.fullmatch(fallback) else "unknown.py"
name = entry.name if entry.name.isidentifier() else "call"
stack.append(f"{relative}:{entry.lineno}:{name}")
thread_name = names.get(ident, "unknown").replace("\n", " ").replace("\r", " ")
lines.append(f"thread={thread_name[:48]}|{' > '.join(stack)}"[:256])
candidate = f"{relative}:{entry.lineno}:{name}"
separator = "" if not stack else " > "
if len("thread|") + len(" > ".join(stack)) + len(separator) + len(candidate) > 256:
break
stack.append(candidate)
if stack:
lines.append(f"thread|{' > '.join(stack)}")
if len(lines) == 16:
break
return tuple(lines)
@@ -14,24 +14,44 @@ from one_memory.memory_governance import GovernedMemory
from one_memory.memory_visibility import mode as visibility_mode
from one_memory.memory_visibility import readable_ids
# A memory is readable while every active citation is: still current, owned by
# the reader, and not on a revoked source. `+link.active` keeps SQLite on the
# (memory_id, evidence_id) primary key. Left on its own, SQLite picks the
# (active, evidence_id, memory_id) index and walks every active link in the
# library once per memory, which on a 1,226-memory library took 5.4 s for 500
# memories and hit the 5 s interrupt, so the memory library and the memory map
# both answered 503 (D78 missed this path).
_MEMORY_READABLE = """
is_memory_evidence_current(?,?,?) AND NOT EXISTS (
def memory_source_readable(memory_id: str, user_id: str) -> str:
"""SQL: no active citation of this memory sits behind a revoked source.
Takes expressions rather than values because the callers differ: the
checks here bind a parameter, `context_memory` reads an already-open row.
Absence is not revocation, and used to be read as one. A citation whose
`evidence` row is gone proves nothing about the grant — the row was
released as import staging, not by the user — so it neither hides the
memory nor excuses it. `_revoked_evidence` below has always answered it
that way; only this predicate disagreed, and it answered 403
`memory_source_revoked` for 85 of 2,382 memories on two sources nobody
revoked (issue #157). Revocation itself is unaffected: it marks the grant
and never deletes evidence, so a revoked source still has a row here to
match against.
`+link.active` keeps SQLite on the (memory_id, evidence_id) primary key.
Left on its own, SQLite picks the (active, evidence_id, memory_id) index
and walks every active link in the library once per memory, which on a
1,226-memory library took 5.4 s for 500 memories and hit the 5 s
interrupt, so the memory library and the memory map both answered 503
(D78 missed this path).
"""
return f"""
NOT EXISTS (
SELECT 1 FROM governed_memory_evidence link
LEFT JOIN evidence e ON e.evidence_id=link.evidence_id
LEFT JOIN source_grants g ON g.user_id=e.user_id AND g.source_id=e.source_id
WHERE link.memory_id=? AND +link.active=1
AND (e.evidence_id IS NULL OR e.user_id!=? OR g.revoked_at IS NOT NULL)
WHERE link.memory_id={memory_id} AND +link.active=1
AND (e.user_id!={user_id} OR g.revoked_at IS NOT NULL)
)
"""
_MEMORY_READABLE = "is_memory_evidence_current(?,?,?) AND " + memory_source_readable("?", "?")
class SourceReadAccess:
def __init__(self, database_path: Path) -> None:
self.database_path = database_path
+2 -1
View File
@@ -54,6 +54,7 @@
| `test_claude_adoption_probe.py` | `contract` | The external-use verifier must distinguish a read from merely mentioning it. |
| `test_cli_commands.py` | `contract` | 覆盖 status reports the verified runtime as json、doctor fails when runtime identity cannot be verified、serve and legacy empty command share the same entry 等 4 个场景。 |
| `test_cli_lifecycle.py` | `integration` | 覆盖 cli starts and stops without residual runtime 场景。 |
| `test_cli_migration_error_codes.py` | `unit` | The startup dialog can only act on the code it is handed. |
| `test_cli_restore.py` | `contract` | 覆盖 restore requires a source、restore refuses while the runtime is running、restore previews without confirmation 等 4 个场景。 |
| `test_codex_header_provenance.py` | `contract` | 覆盖 header workspace versions and native ids reach file origin、new adapter rereads header after a real checkpoint、machine and unknown session sources do not establish human or agent sender 等 14 个场景。 |
| `test_connection_lifetime.py` | `integration` | Every SQLite connection a request opens must be closed when the request ends. |
@@ -327,4 +328,4 @@
| `test_workspace_recall.py` | `contract` | Workspace memories were unreachable from every host; these pin the way in. |
| `test_write_queue_lease_renewal.py` | `contract` | Rule 8: a lease must outlive the work it covers, and be renewed while it runs. |
共 322 个测试文件。
共 323 个测试文件。
@@ -14,13 +14,14 @@ from pathlib import Path
import pytest
PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live")
EXPECTED_TEST_FILE_COUNT = 322
EXPECTED_TEST_FILES_SHA256 = "d242783adb4015ebdbb5c099f3b5aa88a99c828ca6fddc8ca8d6c74c56e48945"
EXPECTED_TEST_FILE_COUNT = 323
EXPECTED_TEST_FILES_SHA256 = "10a2327bd047a281511980f5b558c1ea64d857f0fff550e4bd96cb53800205ba"
# Pure transformations, validation models, parity checks and state machines.
# This list is intentionally small: uncertain files default to contract.
UNIT_FILES = frozenset(
{
"test_cli_migration_error_codes.py",
"test_context_v2_contract_parity.py",
"test_daemon_contract.py",
"test_diagnostic_payload_keys.py",
@@ -0,0 +1,131 @@
"""The startup dialog can only act on the code it is handed.
`sdk_migration_backfill_failed` covered a full disk, a missing model profile and
a Coding classification gap alike, so every one of them reached the person as
the same unactionable sentence. These tests pin the names that replace it.
"""
from __future__ import annotations
import errno
from pathlib import Path
import pytest
from one_memory.cli import cutover_failure_code, migration_failure_code
class _Coded(RuntimeError):
def __init__(self, code: str) -> None:
super().__init__(code)
self.code = code
def test_running_out_of_disk_is_named_however_deep_it_is_wrapped() -> None:
error = OSError(errno.ENOSPC, "No space left on device")
wrapped: BaseException = error
for _ in range(3):
try:
raise RuntimeError("wrapper") from wrapped
except RuntimeError as caught:
wrapped = caught
assert migration_failure_code(wrapped) == "sdk_migration_disk_space_insufficient"
# The explicit context chain counts too: `raise ... from None` discards the
# cause but Python still keeps the original as __context__.
try:
try:
raise OSError(errno.ENOSPC, "No space left on device")
except OSError:
raise RuntimeError("sdk_snapshot_failed") from None
except RuntimeError as caught:
assert migration_failure_code(caught) == "sdk_migration_disk_space_insufficient"
def test_quota_exhaustion_counts_as_no_space(tmp_path: Path) -> None:
quota = getattr(errno, "EDQUOT", errno.ENOSPC)
assert (
migration_failure_code(OSError(quota, "Disk quota exceeded"))
== "sdk_migration_disk_space_insufficient"
)
def test_a_coding_inventory_gap_is_not_reported_as_a_generic_failure() -> None:
assert (
migration_failure_code(_Coded("hy_sdk_productivity_variant_invalid"))
== "sdk_migration_coding_inventory_failed"
)
def test_the_legacy_working_fact_failure_keeps_its_own_name() -> None:
error = _Coded("hy_sdk_memory_import_failed")
error.__cause__ = ValueError("Invalid memory layer: working_fact")
assert migration_failure_code(error) == "sdk_migration_working_fact_conflict"
def test_migration_state_codes_pass_through_because_they_are_already_actionable() -> None:
assert (
migration_failure_code(_Coded("sdk_data_plane_migration_item_conflict"))
== "sdk_data_plane_migration_item_conflict"
)
assert (
migration_failure_code(_Coded("sdk_migration_staging_invalid"))
== "sdk_migration_staging_invalid"
)
def test_unknown_failures_keep_their_message_out_of_the_code() -> None:
error = RuntimeError("/Users/someone/private/path blew up")
assert migration_failure_code(error) == "sdk_migration_backfill_failed"
assert (
migration_failure_code(error, fallback="sdk_migration_preflight_failed")
== "sdk_migration_preflight_failed"
)
def test_a_non_code_attribute_never_becomes_a_code() -> None:
class Odd(RuntimeError):
code = 17
assert migration_failure_code(Odd("x")) == "sdk_migration_backfill_failed"
def test_the_chain_survives_a_cycle() -> None:
first = RuntimeError("first")
second = RuntimeError("second")
first.__cause__ = second
second.__cause__ = first
assert migration_failure_code(first) == "sdk_migration_backfill_failed"
@pytest.mark.parametrize(
"code",
[
"sdk_migration_disk_space_insufficient",
"sdk_migration_working_fact_conflict",
"sdk_migration_coding_inventory_failed",
"sdk_migration_work_pending",
],
)
def test_the_codes_the_desktop_translates_stay_in_sync(code: str) -> None:
"""Desktop maps these exact names to sentences; renaming one blinds it."""
desktop = Path(__file__).resolve().parents[2] / "desktop" / "src" / "stable-runtime.ts"
assert desktop.is_file(), desktop
assert f'"{code}"' in desktop.read_text(encoding="utf-8")
def test_cutover_work_counts_do_not_become_the_dialog_text() -> None:
"""The blocker code carries a JSON body; the person gets one name."""
blocked = _Coded('sdk_data_plane_cutover_work_pending:{"memory_tasks":591}')
assert cutover_failure_code(blocked) == "sdk_migration_work_pending"
assert (
cutover_failure_code(_Coded("sdk_data_plane_cutover_missing"))
== "sdk_data_plane_cutover_missing"
)
assert (
cutover_failure_code(RuntimeError("/Users/someone/private"))
== "sdk_migration_cutover_failed"
)
+24 -4
View File
@@ -131,7 +131,10 @@ def test_scope_user_status_and_active_source_isolation(database):
revoke(db, "workbuddy")
revoke(db, "cursor", user="other")
assert ids(read(database, scopes=frozenset({"workspace:one"}))) == ["workspace"]
assert ids(read(database, scopes=frozenset({"all-apps"}))) == [
# `dangling` cites an evidence row that is gone. That is released
# provenance, not a revocation, so the memory reads like any other.
assert sorted(ids(read(database, scopes=frozenset({"all-apps"})))) == [
"dangling",
"global",
"inactive",
"no-evidence",
@@ -375,7 +378,7 @@ def test_lock_wait_is_bounded_by_shared_deadline(database):
writer.rollback()
@pytest.mark.parametrize("change", ["revoke", "revision", "scope", "delete", "bad-link", "user"])
@pytest.mark.parametrize("change", ["revoke", "revision", "scope", "delete", "user"])
def test_final_revalidation_drops_changed_items_without_new_text(database, change):
with closing(sqlite3.connect(database)) as db, db:
put(db, "change", links=("e2",))
@@ -385,8 +388,6 @@ def test_final_revalidation_drops_changed_items_without_new_text(database, chang
with closing(sqlite3.connect(database)) as db, db:
if change == "revoke":
revoke(db, "workbuddy")
elif change == "bad-link":
db.execute("DELETE FROM evidence WHERE evidence_id='e2'")
else:
assignment = {
"revision": "revision=8,content='new text never return'",
@@ -402,6 +403,25 @@ def test_final_revalidation_drops_changed_items_without_new_text(database, chang
assert validated[0] is original[1]
def test_released_provenance_is_not_a_change_to_the_memory(database):
"""Revalidation is about the memory, and its evidence row is not it.
A citation can be released as import staging; that says nothing about the
text, the scopes or the grant, so the item keeps its place in the batch.
"""
with closing(sqlite3.connect(database)) as db, db:
put(db, "released", links=("e2",))
original = read(database, scopes=frozenset({"all-apps"})).items
assert [item.memory_id for item in original] == ["released"]
with closing(sqlite3.connect(database)) as db, db:
db.execute("DELETE FROM evidence WHERE evidence_id='e2'")
validated = revalidate_memory_context(
database, original, scopes=frozenset({"all-apps"}), deadline=time.monotonic() + 2
)
assert [item.memory_id for item in validated] == ["released"]
assert validated[0] is original[0]
def test_final_revalidation_failure_propagates_for_aggregator_not_false_empty(database):
with closing(sqlite3.connect(database)) as db, db:
put(db, "memory")
+310 -18
View File
@@ -18,11 +18,13 @@ from one_memory.diagnostic_log import (
diagnostic_exception,
diagnostics_enabled,
diagnostics_requested,
read_trace_events,
record_database_error,
reset_diagnostics,
reset_trace_id,
resource_snapshot,
)
from one_memory.exception_reporting import report_exception
def test_debug_events_are_collected_only_at_debug_level(tmp_path, monkeypatch) -> None:
@@ -105,7 +107,31 @@ def test_structured_log_is_private_correlated_and_owner_only(tmp_path):
assert log.path.stat().st_mode & 0o777 == 0o600
def test_arbitrary_detail_and_absolute_stack_are_rejected(tmp_path):
def test_arbitrary_valid_detail_keys_and_scalars_are_recorded(tmp_path):
log = DiagnosticLog(tmp_path)
details = {
"Custom.metric-v1": "ready",
"noneValue": None,
"boolValue": False,
"intValue": 7,
"floatValue": 1.5,
}
log.write(
level="info",
component="core.lifecycle",
event="core.started",
outcome="succeeded",
details=details,
)
assert log.flush()
assert json.loads(log.path.read_text())["details"] == details
@pytest.mark.parametrize(
"key",
["__proto__", "prototype", "constructor", "_private", "9startsWithDigit", "a" * 65],
)
def test_dangerous_or_malformed_detail_keys_are_rejected(tmp_path, key):
log = DiagnosticLog(tmp_path)
with pytest.raises(ValueError, match="diagnostic_detail_key_invalid"):
log.write(
@@ -113,8 +139,76 @@ def test_arbitrary_detail_and_absolute_stack_are_rejected(tmp_path):
component="hy.bridge",
event="hy.failed",
outcome="failed",
details={"prompt": "private"},
details={key: "value"},
)
@pytest.mark.parametrize("value", [{}, [], object()])
def test_complex_detail_values_are_rejected(tmp_path, value):
log = DiagnosticLog(tmp_path)
with pytest.raises(ValueError, match="diagnostic_detail_value_invalid"):
log.write(
level="error",
component="hy.bridge",
event="hy.failed",
outcome="failed",
details={"payload": value},
)
@pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf")])
def test_non_finite_detail_values_are_rejected(tmp_path, value):
log = DiagnosticLog(tmp_path)
with pytest.raises(ValueError, match="diagnostic_detail_value_invalid"):
log.write(
level="error",
component="hy.bridge",
event="hy.failed",
outcome="failed",
details={"metric": value},
)
@pytest.mark.parametrize("value", [2**53, -(2**53), float(2**53), -float(2**53)])
def test_numeric_detail_values_outside_json_safe_range_are_rejected(tmp_path, value):
log = DiagnosticLog(tmp_path)
with pytest.raises(ValueError, match="diagnostic_detail_value_invalid"):
log.write(
level="error",
component="hy.bridge",
event="hy.failed",
outcome="failed",
details={"count": value},
)
@pytest.mark.parametrize("value", ["", "nul\0byte", "carriage\rreturn", "line\nfeed", "x" * 257])
def test_invalid_detail_strings_are_rejected(tmp_path, value):
log = DiagnosticLog(tmp_path)
with pytest.raises(ValueError, match="diagnostic_detail_value_invalid"):
log.write(
level="error",
component="hy.bridge",
event="hy.failed",
outcome="failed",
details={"message": value},
)
def test_more_than_32_detail_keys_are_rejected(tmp_path):
log = DiagnosticLog(tmp_path)
with pytest.raises(ValueError, match="diagnostic_detail_count_invalid"):
log.write(
level="error",
component="hy.bridge",
event="hy.failed",
outcome="failed",
details={f"key{index}": index for index in range(33)},
)
def test_absolute_or_credential_bearing_stack_is_rejected(tmp_path):
log = DiagnosticLog(tmp_path)
with pytest.raises(ValueError, match="diagnostic_stack_invalid"):
log.write(
level="error",
@@ -123,16 +217,90 @@ def test_arbitrary_detail_and_absolute_stack_are_rejected(tmp_path):
outcome="failed",
safe_stack=("/Users/me/private.py:1",),
)
with pytest.raises(ValueError, match="diagnostic_stack_invalid"):
log.write(
level="error",
component="hy.bridge",
event="hy.failed",
outcome="failed",
safe_stack=("one_memory/sk-live-secret-token.py:1:call",),
)
def test_a_rejected_payload_does_not_break_the_caller(tmp_path, monkeypatch) -> None:
"""Rejection protects the log; it must not become the caller's outage.
def test_safe_stack_uses_strict_shared_grammar_and_sixteen_line_limit(tmp_path):
log = DiagnosticLog(tmp_path)
for line in (
"arbitrary stack text",
"thread=worker|threading.py:12:wait",
"FILE:///Users/me/private.py:1:call",
"one_memory/worker.py:9:call /Users/private",
"one_memory/GOCSPX-privateCredential.py:9:call",
"packages/ASIA1234567890ABCDEF/file.ts:1:1",
"packages/Aa1Bb2Cc3Dd4Ee5Ff6Gg7Hh8/file.ts:1:1",
):
with pytest.raises(ValueError, match="diagnostic_stack_invalid"):
log.write(
level="error",
component="hy.bridge",
event="hy.failed",
outcome="failed",
safe_stack=(line,),
)
Two of our own nodes shipped with unlisted `details` keys. Both sat on a
request path, so the rejection surfaced as a 500 to the person using the
app — including the sedimentation switch, whose every request failed
because logging the toggle raised before the endpoint could answer.
"""
accepted = (
"apps/desktop/src/main.ts:80:1",
"one_memory/diagnostic_log.py:895:_safe_stack_line",
"threading.py:1045:_bootstrap_inner",
"thread|threading.py:1045:_bootstrap_inner > one_memory/worker.py:9:call",
)
log.write(
level="error",
component="hy.bridge",
event="hy.failed",
outcome="failed",
safe_stack=accepted,
)
with pytest.raises(ValueError, match="diagnostic_stack_invalid"):
log.write(
level="error",
component="hy.bridge",
event="hy.failed",
outcome="failed",
safe_stack=tuple(f"one_memory/worker.py:{index + 1}:call" for index in range(17)),
)
assert log.flush()
assert json.loads(log.path.read_text())["safe_stack"] == list(accepted)
def test_invalid_level_is_strict_but_diagnostic_event_remains_fail_open(
tmp_path, monkeypatch
) -> None:
log = DiagnosticLog(tmp_path)
with pytest.raises(ValueError, match="diagnostic_level_invalid"):
log.write(
level="fatal", # type: ignore[arg-type]
component="core.lifecycle",
event="core.failed",
outcome="failed",
)
monkeypatch.setenv("ONE_MEMORY_DIAGNOSTICS", "1")
receipt = configure_diagnostics(tmp_path / "fail-open")
try:
diagnostic_event(
level="fatal",
component="core.lifecycle",
event="core.failed",
outcome="failed",
)
finally:
reset_diagnostics()
assert not receipt.path.exists()
def test_a_rejected_payload_does_not_break_the_caller_or_later_events(
tmp_path, monkeypatch
) -> None:
monkeypatch.setenv("ONE_MEMORY_DIAGNOSTICS", "1")
receipt = configure_diagnostics(tmp_path)
try:
@@ -141,20 +309,122 @@ def test_a_rejected_payload_does_not_break_the_caller(tmp_path, monkeypatch) ->
component="memory.sedimentation",
event="memory_sedimentation.toggled",
outcome="succeeded",
details={"prompt": "private"},
details={"payload": ["not", "scalar"]},
)
diagnostic_event(
level="info",
component="memory.sedimentation",
event="memory_sedimentation.toggled",
outcome="succeeded",
details={"custom.metric": 1},
)
finally:
reset_diagnostics()
# Rejected, so nothing is written — but the call returned.
assert not receipt.path.exists() or receipt.path.read_text() == ""
events = [json.loads(line) for line in receipt.path.read_text().splitlines()]
assert [event["details"] for event in events] == [{"custom.metric": 1}]
def test_trace_projection_does_not_expose_arbitrary_local_detail_keys(tmp_path):
log = DiagnosticLog(tmp_path)
log.write(
level="info",
component="core.api",
event="memory.request.completed",
outcome="succeeded",
request_id="request-1",
details={
"phase": "recall",
"reason": "/Users/private/query.txt",
"status": "sk-live-secret-token",
"custom.private": "local-only",
},
)
assert log.flush()
events = read_trace_events(tmp_path, request_id="request-1")
assert events[0]["details"] == {"phase": "recall"}
def test_trace_projection_rejects_symlinked_directory_and_redacts_top_level_secrets(
tmp_path,
) -> None:
request_id = "request-1"
logs = tmp_path / "logs"
logs.mkdir()
event = {
"timestamp": "2026-10-23T01:00:00Z",
"request_id": request_id,
"component": "secret.token",
"event": "credential.failed",
"outcome": "Aa1Bb2Cc3Dd4Ee5Ff6Gg7Hh8",
"error_code": "GOCSPX-clientSecret_A1",
"error_type": "ASIA1234567890ABCDEF",
"details": {
"phase": "recall",
"reason": "x_ASIA1234567890ABCDEF",
"mode": "x_GOCSPX-clientSecret_A1",
"status": "Aa1Bb2Cc3Dd4Ee5Ff6Gg7Hh8",
"failure_site": "AIzaSyExampleCredential1234567890",
"count": 1,
},
}
(logs / "diagnostic-core.jsonl").write_text(json.dumps(event) + "\n")
projected = read_trace_events(tmp_path, request_id=request_id)
assert projected == (
{
"timestamp": "2026-10-23T01:00:00Z",
"component": "diagnostic.redacted",
"event": "diagnostic.redacted",
"outcome": "redacted",
"details": {"phase": "recall", "count": 1},
},
)
external = tmp_path / "external"
external.mkdir()
(logs / "diagnostic-core.jsonl").unlink()
logs.rmdir()
logs.symlink_to(external, target_is_directory=True)
with pytest.raises(ValueError, match="diagnostic_directory_unsafe"):
read_trace_events(tmp_path, request_id=request_id)
def test_credential_detection_is_conservative_and_matches_export_rules() -> None:
for value in (
"ASIA1234567890ABCDEF",
"GOCSPX-clientSecret_A1",
"Aa1Bb2Cc3Dd4Ee5Ff6Gg7Hh8",
"Aa1Bb2Cc3Dd4Ee5Ff6Gg7Hh8_",
):
assert diagnostic_log._contains_credential_material(value)
for value in (
"11111111-1111-4111-8111-111111111111",
"abcdef1234567890abcdef1234567890abcdef12",
"cursor",
):
assert not diagnostic_log._contains_credential_material(value)
def test_trace_reader_never_follows_a_diagnostic_file_symlink(tmp_path) -> None:
logs = tmp_path / "logs"
logs.mkdir()
outside = tmp_path / "outside.jsonl"
outside.write_text(
json.dumps(
{
"timestamp": "2026-10-23T01:00:00Z",
"request_id": "request-1",
"component": "core.api",
"event": "memory.request.completed",
"outcome": "succeeded",
}
)
+ "\n"
)
(logs / "diagnostic-core.jsonl").symlink_to(outside)
assert read_trace_events(tmp_path, request_id="request-1") == ()
def test_the_sedimentation_toggle_payload_is_recorded(tmp_path, monkeypatch) -> None:
"""`enabled` is a bounded boolean and has to be a legal detail key.
Omitting it from the allow-list is what made the switch's own endpoint
answer 500 while still saving the new value.
"""
monkeypatch.setenv("ONE_MEMORY_DIAGNOSTICS", "1")
receipt = configure_diagnostics(tmp_path)
try:
@@ -181,7 +451,7 @@ def test_exception_diagnostic_keeps_code_location_not_message():
component="hy.bridge",
event="hy.failed",
outcome="failed",
details={"prompt": secret},
details={"prompt": f"{secret}\n"},
)
except ValueError as error:
safe = diagnostic_exception(error)
@@ -191,6 +461,28 @@ def test_exception_diagnostic_keeps_code_location_not_message():
assert str(__file__) not in json.dumps(safe)
def test_report_exception_never_writes_the_exception_message_to_diagnostics(
tmp_path, monkeypatch
) -> None:
monkeypatch.setenv("ONE_MEMORY_DIAGNOSTICS", "1")
receipt = configure_diagnostics(tmp_path)
secret = "sk-live-private-provider-response"
try:
report_exception(
"core.test",
"core.test.failed",
RuntimeError(f"provider failed with {secret} at /Users/private/query.txt"),
)
finally:
reset_diagnostics()
payload = receipt.path.read_text()
assert secret not in payload
assert "/Users/private" not in payload
event = json.loads(payload)
assert event["details"] == {"count": 1}
assert event["error_type"] == "RuntimeError"
def test_old_logs_are_removed_but_symlinks_are_never_followed(tmp_path):
log = DiagnosticLog(tmp_path)
log.path.parent.mkdir(parents=True)
@@ -449,7 +449,7 @@ def test_invalid_input_is_still_rejected_on_the_caller_side(tmp_path):
component="hy.bridge",
event="hy.failed",
outcome="failed",
details={"prompt": "private"},
details={"invalid key": "private"},
)
log.close()
@@ -1,22 +1,57 @@
"""An instrument must never be able to break the thing it measures.
`diagnostic_event` validates its payload: an unlisted `details` key, a missing
`level`, or a detail value over 256 characters all raise. Two of our own nodes
shipped with unlisted keys, and because they sat on a request path and on the
loop heartbeat, they turned every external recall into a 500 and then killed
the process. Both were caught only by running the built runtime, not by tests.
`diagnostic_event` validates its payload: a malformed `details` key, a missing
`level`, or an unsafe detail value is rejected. A diagnostic payload must never
be able to break the request path or loop heartbeat it is intended to measure.
So this checks the payloads at their source, by walking every literal call.
"""
import ast
import re
from pathlib import Path
import pytest
from one_memory.diagnostic_log import _DETAIL_KEYS
ROOT = Path(__file__).resolve().parent.parent / "src" / "one_memory"
DETAIL_KEY = re.compile(r"^[A-Za-z][A-Za-z0-9_.-]{0,63}$")
REFUSED_DETAIL_KEYS = {"__proto__", "prototype", "constructor"}
EXPORTED_STRING_DETAIL_KEYS = {
"action",
"method",
"mode",
"phase",
"platform",
"reason",
"source_id",
"status",
"version",
"parent_session_id",
"runtime_state",
"core_state",
"broker_state",
"instance_id",
"operation_kind",
"file_kind",
"last_probe",
"signal",
}
PRIVATE_DETAIL_KEYS = {
"api_key",
"authorization",
"content",
"credential",
"memory",
"memory_text",
"message",
"messages",
"password",
"prompt",
"query",
"raw_text",
"secret",
"token",
}
#: Every module, not a hand-picked list. The two payloads that caused real
#: outages were in files nobody thought to add here, so the only shape of this
@@ -33,6 +68,19 @@ def _literal_calls(path: Path):
yield node
def _contains_free_text_conversion(node: ast.AST) -> bool:
for item in ast.walk(node):
if (
isinstance(item, ast.Call)
and isinstance(item.func, ast.Name)
and item.func.id in {"str", "repr", "_safe_message"}
):
return True
if isinstance(item, ast.Attribute) and item.attr in {"args", "message"}:
return True
return False
@pytest.mark.parametrize("relative", SOURCES)
def test_every_diagnostic_payload_is_valid(relative: str) -> None:
path = ROOT / relative
@@ -45,7 +93,22 @@ def test_every_diagnostic_payload_is_valid(relative: str) -> None:
for keyword in node.keywords:
if keyword.arg != "details" or not isinstance(keyword.value, ast.Dict):
continue
for key in keyword.value.keys:
if isinstance(key, ast.Constant) and key.value not in _DETAIL_KEYS:
problems.append(f"{relative}:{node.lineno}: unlisted details key {key.value!r}")
for key, expression in zip(keyword.value.keys, keyword.value.values, strict=True):
if not isinstance(key, ast.Constant):
continue
value = key.value
if (
not isinstance(value, str)
or not DETAIL_KEY.fullmatch(value)
or value in REFUSED_DETAIL_KEYS
):
problems.append(f"{relative}:{node.lineno}: invalid details key {value!r}")
elif value.lower() in PRIVATE_DETAIL_KEYS:
problems.append(f"{relative}:{node.lineno}: private details key {value!r}")
elif value in EXPORTED_STRING_DETAIL_KEYS and _contains_free_text_conversion(
expression
):
problems.append(
f"{relative}:{node.lineno}: exported detail {value!r} uses free text"
)
assert not problems, "\n".join(problems)
+4 -2
View File
@@ -102,6 +102,8 @@ def test_stall_captures_a_safe_stack_for_every_thread() -> None:
assert watchdog.stall_stacks
captured = watchdog.stall_stacks[0]
assert any(line.startswith("thread=MainThread|") for line in captured)
assert any(line.startswith("thread=source-reader-test|") for line in captured)
assert captured
assert len(captured) <= 16
assert all(line.startswith("thread|") for line in captured)
assert all("source-reader-test" not in line and "MainThread" not in line for line in captured)
assert all("/Users/" not in line and "\n" not in line for line in captured)
@@ -21,14 +21,13 @@ def _predicates():
home = re.search(r'readable = """(.*?)"""', _source(home_summary), re.S).group(1)
library = _source(memory_library)
library_clauses = re.findall(r'clauses\.append\("""(NOT EXISTS.*?)"""\)', library, re.S)
access = re.search(
r"_MEMORY_READABLE = \"\"\"(.*?)\"\"\"", _source(source_read_access), re.S
).group(1)
return {
"home": (home, ("local",)),
"library": ("m.user_id = ? AND " + " AND ".join(library_clauses), ("local",)),
"context": (context_memory._READABLE, ()),
"source_read_access": (access, ()),
# Built rather than lifted out of the module's source: the policy is a
# function now, so what has to be planned is what it returns.
"source_read_access": (source_read_access._MEMORY_READABLE, ()),
}
@@ -134,3 +134,76 @@ def test_the_read_does_not_scale_its_query_count_with_the_page(library):
# One batched read per call, carrying the whole page — not one per memory.
assert small == [3]
assert large == [12]
def test_a_citation_without_its_evidence_row_stays_readable(tmp_path) -> None:
"""Missing provenance is not a revoked source.
Regression: `_MEMORY_READABLE` treated an active citation whose `evidence`
row was gone as unreadable and answered 403 `memory_source_revoked`. The
memory's own content is in `governed_memories`, and the detail response
already degrades a missing citation to `available=False`, so the upstream
403 made that degradation unreachable and blamed a source nobody revoked.
"""
from contextlib import closing
from one_memory.memory_governance import HyCreate, MemoryOrigin
database = tmp_path / "one-memory.sqlite3"
with closing(sqlite3.connect(database)) as setup:
setup.executescript(GRANT_SCHEMA)
evidence = EvidenceStore(database)
governance = MemoryGovernanceStore(database)
record = evidence.put(
EvidenceInput(
source_id="workbuddy",
conversation_id="fixture",
message_id="one",
role="user",
written_by=WrittenBy.PERSON,
verbatim="真实对话内容。",
origin_meta={EvidenceOrigin.FILE: OriginMetadata()},
)
).record
memory = governance.write_from_hy(
HyCreate(
draft=MemoryDraft(
user_id="local",
kind=MemoryKind.CHAT,
content="真实对话内容。",
scopes=("all-apps",),
evidence_ids=(record.evidence_id,),
origin=MemoryOrigin.EXTRACTED,
),
request_id="hy-0",
memory_id="hy-memory-0",
),
user_id="local",
derivation=governance.hy_derivation((record.evidence_id,)),
)
with closing(sqlite3.connect(database)) as db, db:
db.execute(
"INSERT INTO source_grants(user_id,source_id,root_path,requested_path,"
"root_identity,candidate_kind,granted_at,revoked_at,revision) "
"VALUES('local','workbuddy','/synthetic','/synthetic','fixture',"
"'default','2026-10-01T00:00:00+00:00',NULL,1)"
)
access = SourceReadAccess(database)
assert access.memories([memory]) == [memory], "granted and cited: readable"
# A real revocation still hides it. Checked while the evidence row is
# still here, because attribution to a source lives only on that row.
with closing(sqlite3.connect(database)) as db, db:
db.execute(
"UPDATE source_grants SET revoked_at='2026-10-02T00:00:00+00:00' "
"WHERE user_id='local' AND source_id='workbuddy'"
)
assert access.memories([memory]) == [], "revoked source hides the memory"
with closing(sqlite3.connect(database)) as db, db:
db.execute("UPDATE source_grants SET revoked_at=NULL WHERE user_id='local'")
# The evidence row disappears — released as import staging, not revoked.
with closing(sqlite3.connect(database)) as db, db:
db.execute("DELETE FROM evidence WHERE evidence_id=?", (record.evidence_id,))
assert access.memories([memory]) == [memory], "missing provenance still reads"
@@ -94,12 +94,14 @@ it("returns only diagnostic events written during this desktop launch", async ()
await mkdir(logs, { recursive: true });
const target = path.join(logs, "diagnostic-core.jsonl");
const old = {
schema_version: 1,
timestamp: "2026-10-14T09:00:00.000Z",
level: "info",
service: "core",
component: "core.startup",
event: "core.old",
outcome: "succeeded",
session_id: "11111111-1111-4111-8111-111111111111",
};
await writeFile(target, `${JSON.stringify(old)}\n`);
const cursor = await captureDebugLogCursor(dataDir);
@@ -109,6 +111,11 @@ it("returns only diagnostic events written during this desktop launch", async ()
timestamp: "2026-10-14T10:01:00.000Z",
event: "core.current",
level: "warn",
details: {
count: 1,
"custom.private": "/Users/private/query.txt",
credential_hint: "ghp_private_token",
},
};
await appendFile(target, `${JSON.stringify(current)}\nnot-json\n`);
@@ -118,8 +125,11 @@ it("returns only diagnostic events written during this desktop launch", async ()
expect(snapshot.entries[0]).toMatchObject({
event: "core.current",
level: "warn",
details: { count: 1 },
source: "diagnostic",
});
expect(JSON.stringify(snapshot)).not.toContain("/Users/private");
expect(JSON.stringify(snapshot)).not.toContain("ghp_private_token");
});
it("tracks diagnostic JSONL across rotation without rereading the old prefix", async () => {
@@ -130,12 +140,14 @@ it("tracks diagnostic JSONL across rotation without rereading the old prefix", a
const target = path.join(logs, "diagnostic-core.jsonl");
const event = (name: string, timestamp: string) =>
JSON.stringify({
schema_version: 1,
timestamp,
level: "info",
service: "core",
component: "core.test",
event: name,
outcome: "succeeded",
session_id: "22222222-2222-4222-8222-222222222222",
});
await writeFile(target, `${event("before", "2026-10-14T09:00:00.000Z")}\n`);
const cursor = await captureDebugLogCursor(dataDir);
+51 -51
View File
@@ -1,10 +1,13 @@
import { lstat, open, readdir } from "node:fs/promises";
import path from "node:path";
import {
type DiagnosticEventV1,
type DiagnosticLogger,
diagnosticEvent,
diagnosticZipFilename,
exportDiagnosticZip,
inspectBackgroundHealth,
projectDiagnosticEventForSafeOutput,
recordDiagnosticMarker,
} from "@one-memory/runtime-control";
import type { BrowserWindow, IpcMainInvokeEvent } from "electron";
@@ -370,42 +373,58 @@ function debugLogEntry(
const timestamp = typeof value.timestamp === "string" ? value.timestamp : "";
const timestampMs = Date.parse(timestamp);
if (!Number.isFinite(timestampMs) || timestampMs < startedAt) return null;
const level = value.level;
const service = value.service;
const component = value.component;
const event = value.event;
const outcome = value.outcome;
if (
!["info", "warn", "error"].includes(String(level)) ||
!["runtime", "core", "host-bridge", "desktop", "cli"].includes(
String(service),
) ||
typeof component !== "string" ||
typeof event !== "string" ||
typeof outcome !== "string"
) {
let projected: DiagnosticEventV1;
try {
projected = projectDiagnosticEventForSafeOutput(
diagnosticEvent({
timestamp,
level: value.level as never,
service: value.service as never,
component: value.component as never,
event: value.event as never,
outcome: value.outcome as never,
session_id: value.session_id as never,
...(typeof value.trace_id === "string"
? { trace_id: value.trace_id }
: {}),
...(typeof value.duration_ms === "number"
? { duration_ms: value.duration_ms }
: {}),
...(typeof value.error_code === "string"
? { error_code: value.error_code }
: {}),
...(typeof value.error_type === "string"
? { error_type: value.error_type }
: {}),
...(value.details && typeof value.details === "object"
? { details: value.details as never }
: {}),
}),
);
} catch {
return null;
}
const details = safeDebugDetails(value.details);
return {
id: `${filename}:${lineNumber}:${timestamp}`,
timestamp,
level: level as DebugLogEntry["level"],
service: service as DebugLogEntry["service"],
component,
event,
outcome,
...(typeof value.duration_ms === "number"
? { duration_ms: value.duration_ms }
: {}),
...(typeof value.error_code === "string"
? { error_code: value.error_code }
: {}),
...(typeof value.error_type === "string"
? { error_type: value.error_type }
: {}),
...(typeof value.trace_id === "string" ? { trace_id: value.trace_id } : {}),
...(details === undefined ? {} : { details }),
timestamp: projected.timestamp,
level: projected.level,
service: projected.service,
component: projected.component,
event: projected.event,
outcome: projected.outcome,
...(projected.duration_ms === undefined
? {}
: { duration_ms: projected.duration_ms }),
...(projected.error_code === undefined
? {}
: { error_code: projected.error_code }),
...(projected.error_type === undefined
? {}
: { error_type: projected.error_type }),
...(projected.trace_id === undefined
? {}
: { trace_id: projected.trace_id }),
...(projected.details === undefined ? {} : { details: projected.details }),
source: "diagnostic",
};
}
@@ -523,25 +542,6 @@ function logLineTimestamp(
return new Date(Math.max(startedAt, fallback)).toISOString();
}
function safeDebugDetails(
value: unknown,
): Record<string, string | number | boolean | null> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value))
return undefined;
const details: Record<string, string | number | boolean | null> = {};
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
if (
item === null ||
typeof item === "string" ||
typeof item === "number" ||
typeof item === "boolean"
) {
details[key] = item;
}
}
return Object.keys(details).length ? details : undefined;
}
async function pipelineLogFiles(root: string): Promise<PipelineLogCursor[]> {
const rootInfo = await lstat(root).catch(() => null);
if (!rootInfo?.isDirectory() || rootInfo.isSymbolicLink()) return [];
+3 -2
View File
@@ -113,6 +113,7 @@ import {
installServiceWithDisabledFallback,
isInstalledApplicationsApp,
stableRuntimeErrorCode,
stableRuntimeFailureDetail,
startStableRuntimeRepair,
} from "./stable-runtime";
import { STARTUP_SCREEN_URL, startupScreenHtml } from "./startup-screen";
@@ -1098,7 +1099,7 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
? "blocked"
: "skipped",
...(migration.code === undefined ? {} : { error_code: migration.code }),
details: { state: migration.state },
details: { runtime_state: migration.state },
});
requireCompletedStartupSdkMigration(migration);
};
@@ -1671,7 +1672,7 @@ if (desktopVersion !== null) {
languagePreferences?.read().locale ?? "zh-CN",
"nativeErrorTitle",
),
error instanceof Error ? error.message : "desktop_unavailable",
stableRuntimeFailureDetail(error),
);
},
});
@@ -16,6 +16,7 @@ import {
isDisabledServiceInstallError,
isInstalledApplicationsApp,
stableRuntimeErrorCode,
stableRuntimeFailureDetail,
stableRuntimeUserMessage,
startStableRuntimeRepair,
} from "./stable-runtime";
@@ -274,4 +275,46 @@ describe("startup install errors", () => {
"MemHub 启动失败",
);
});
it("says why a data upgrade failed, and what the person can do", () => {
const noSpace = stableRuntimeUserMessage(
new Error("sdk_migration_disk_space_insufficient"),
);
// The only failure here the person fixes themselves: name the action.
expect(noSpace).toContain("磁盘空间不足");
expect(noSpace).toContain("重新启动");
expect(noSpace).toContain("不会丢失");
expect(noSpace).not.toContain("sdk_migration_disk_space_insufficient");
const noModel = stableRuntimeUserMessage(
new Error("model_profile_not_active"),
);
expect(noModel).toContain("模型");
expect(noModel).not.toContain("model_profile_not_active");
const coding = stableRuntimeUserMessage(
new Error("sdk_migration_working_fact_conflict"),
);
expect(coding).toContain("Coding");
expect(coding).toContain("不会丢失");
});
});
describe("startup dialog body", () => {
it("keeps a code we have no copy for, so support can see which failure fired", () => {
expect(stableRuntimeFailureDetail(new Error("page_load_failed"))).toBe(
"page_load_failed",
);
expect(
stableRuntimeFailureDetail(new Error("runtime_broker_unavailable")),
).toBe("runtime_broker_unavailable");
});
it("shows the sentence we wrote for a migration failure", () => {
const detail = stableRuntimeFailureDetail(
new Error("sdk_migration_disk_space_insufficient"),
);
expect(detail).toContain("磁盘空间不足");
expect(detail).not.toContain("sdk_migration_disk_space_insufficient");
});
});
+58
View File
@@ -126,6 +126,23 @@ const SAFE_STABLE_RUNTIME_ERROR_CODES = new Set([
"installed_version_conflict",
"distribution_downgrade_refused",
"install_current_unmanaged",
// Migration runs before the app window exists, so these are the only names
// a first launch can be given. They stay codes here and become sentences in
// STABLE_RUNTIME_USER_MESSAGES: a person reading the dialog never sees them.
"model_profile_not_active",
"model_secret_unavailable",
"sdk_migration_preflight_failed",
"sdk_migration_disk_space_insufficient",
"sdk_migration_staging_failed",
"sdk_migration_backfill_failed",
"sdk_migration_working_fact_conflict",
"sdk_migration_coding_inventory_failed",
"sdk_migration_work_pending",
"sdk_migration_cutover_failed",
"sdk_migration_bundle_copy_failed",
"sdk_migration_response_invalid",
"sdk_migration_status_unavailable",
"sdk_migration_incomplete",
]);
export function stableRuntimeErrorCode(error: unknown): string {
@@ -191,6 +208,34 @@ export const STABLE_RUNTIME_USER_MESSAGES: Record<string, string> = {
"已安装的运行环境比这个安装包更新,无法用旧安装包启动。请安装最新版本的 MemHub;你的记忆数据不会受到影响。",
install_current_unmanaged:
"运行环境的安装目录被改动过,无法安全接管。请重新安装 MemHub,你的记忆数据不会受到影响。",
model_profile_not_active:
"还没有配置可用的模型,无法升级记忆数据格式。请在旧版本中完成模型配置后重启;你的记忆数据不会受到影响。",
model_secret_unavailable:
"模型密钥读不到(可能被系统钥匙串拒绝)。请允许 MemHub 访问钥匙串后重新启动;你的记忆数据不会受到影响。",
sdk_migration_disk_space_insufficient:
"磁盘空间不足,升级记忆数据格式时中断了。请至少腾出与记忆数据同等大小的可用空间(可在「关于本机 → 储存空间」查看),然后重新启动 MemHub;升级会自动继续,原有记忆数据不会丢失。",
sdk_migration_staging_failed:
"升级准备工作没有完成,通常是磁盘空间不足或文件被占用。请清理磁盘空间后重新启动;原有记忆数据不会丢失。",
sdk_migration_bundle_copy_failed:
"升级前备份没有写完,通常是磁盘空间不足。请清理磁盘空间后重新启动;原有记忆数据不会丢失。",
sdk_migration_working_fact_conflict:
"旧版数据里有一条 Coding 记忆的记录类型在升级中无法安全对应。为避免写错你的记忆,升级已停止。请把这个提示反馈给我们;原有记忆数据不会丢失。",
sdk_migration_coding_inventory_failed:
"读取旧版 Coding 记忆时发现无法识别的结构,升级已停止。请把这个提示反馈给我们;原有记忆数据不会丢失。",
sdk_migration_backfill_failed:
"升级记忆数据格式时失败,已停止且没有切换数据源。请重新启动重试;如果仍然失败,请把这个提示反馈给我们,原有记忆数据不会丢失。",
sdk_migration_work_pending:
"记忆数据已经升级完成,但还有之前没处理完的记忆任务,所以暂时不能切换数据源。请保持 MemHub 打开几分钟,等这些任务处理完再重新启动;原有记忆数据不会丢失。",
sdk_migration_cutover_failed:
"切换数据源时失败,原有记忆数据仍然完整可用。请重新启动重试;如果仍然失败,请把这个提示反馈给我们。",
sdk_migration_preflight_failed:
"升级前的检查没有通过,升级已停止。请重新启动重试;如果仍然失败,请把这个提示反馈给我们,原有记忆数据不会丢失。",
sdk_migration_status_unavailable:
"读不到升级状态,无法判断是否需要升级。请重新启动 MemHub;如果仍然失败,请重新安装,你的记忆数据不会受到影响。",
sdk_migration_response_invalid:
"升级过程返回了无法识别的内容,已停止。原有记忆数据不会丢失;请重新启动重试,如果仍然失败,请把这个提示反馈给我们。",
sdk_migration_incomplete:
"记忆数据格式升级没有完成,MemHub 暂时无法打开。请重新启动重试;如果仍然失败,请把这个提示反馈给我们,原有记忆数据不会丢失。",
desktop_start_failed:
"MemHub 启动失败。请重新启动;如果仍然打不开,请重新安装,你的记忆数据不会受到影响。",
};
@@ -203,3 +248,16 @@ export function stableRuntimeUserMessage(error: unknown): string {
""
);
}
/** What the startup dialog body says.
*
* A code we wrote copy for becomes a sentence. Every other code is shown as it
* is: this dialog is the last thing a person sees before the app closes, and an
* unexpected failure is more useful as the code support can look up than as a
* reassuring sentence that erases which failure actually fired. */
export function stableRuntimeFailureDetail(error: unknown): string {
if (!(error instanceof Error)) {
return STABLE_RUNTIME_USER_MESSAGES.desktop_start_failed ?? "";
}
return STABLE_RUNTIME_USER_MESSAGES[error.message] ?? error.message;
}
@@ -21,6 +21,21 @@ describe("startup screen", () => {
);
expect(spanish).not.toMatch(/[一-鿿]/);
});
it("says an upgrade may take a while instead of leaving a silent wait", () => {
expect(startupScreenHtml()).toContain(
"首次启动或升级记忆数据可能需要几分钟,完成后会自动进入应用。",
);
expect(startupScreenHtml("en")).toContain(
"The first launch or a memory-data upgrade can take a few minutes.",
);
});
it("keeps a draggable strip so the waiting window can be moved", () => {
const html = startupScreenHtml();
// CSS alone does not move a frameless window: the region has to be a real
// element in the document, and it has to be the top strip.
expect(html).toContain('<div class="window-drag-region"');
expect(html).toContain("-webkit-app-region: drag");
});
it("is served from the app origin, not from the web bundle", () => {
expect(STARTUP_SCREEN_URL).toBe("one-memory://app/__startup");
});
+22
View File
@@ -37,6 +37,19 @@ export function startupScreenHtml(locale: GuiLocale = "zh-CN"): string {
"Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
/* A frameless window still needs somewhere to grab; without this the
whole wait is undraggable. */
.window-drag-region {
position: absolute;
z-index: 1;
top: 0;
right: 0;
left: 0;
height: 58px;
-webkit-app-region: drag;
-webkit-user-select: none;
user-select: none;
}
@media (prefers-color-scheme: dark) {
body {
background: #15181d;
@@ -61,6 +74,13 @@ export function startupScreenHtml(locale: GuiLocale = "zh-CN"): string {
font-size: 13px;
opacity: 0.62;
}
p.hint {
max-width: 320px;
font-size: 12px;
line-height: 1.55;
text-align: center;
opacity: 0.45;
}
.bar {
position: relative;
width: 168px;
@@ -101,9 +121,11 @@ export function startupScreenHtml(locale: GuiLocale = "zh-CN"): string {
</style>
</head>
<body>
<div class="window-drag-region" aria-hidden="true"></div>
<main>
<h1>MemHub</h1>
<p>${translate(locale, "startupPreparing")}</p>
<p class="hint">${translate(locale, "startupUpgradeHint")}</p>
<div class="bar"><span></span></div>
</main>
</body>
+9 -5
View File
@@ -6,10 +6,13 @@
2. 记忆沉淀有没有持续运行、产出了多少条;
3. 如果停住,停在导入、模型调用还是最终写入。
默认 `info` 下的结构化诊断日志不包含会话正文、模型输入输出或 API Key。`debug`
会额外启用 HY pipeline trace,其中可能包含完整 prompt/response 和获授权的对话正文。
排查时优先通过 App 的“导出日志”提供文件,不要直接发送整个 `~/.one-memory` 数据目录;
debug 期间产生的 pipeline 日志在分享前必须由开发人员确认并脱敏。
默认 `info` 下的官方结构化事件不应写入会话正文、模型输入输出或 API Key;但本地原始日志允许
新增受限标量标签,仍应按私人数据处理,不能直接对外发送。`debug` 会额外启用 HY pipeline
trace,其中可能包含完整 prompt/response 和获授权的对话正文。
排查时只通过 App 的“导出日志”提供文件:导出过程会按安全白名单重新生成诊断事件,不会打包
原始日志。不要直接发送 `diagnostic-*.jsonl`、整个 `~/.one-memory` 数据目录或 debug pipeline
日志;后者如确需分享,必须由开发人员另行确认并脱敏。
## 1. Log level 设什么
@@ -39,7 +42,8 @@ debug 可能把完整模型 prompt/response 写入本机调试日志,不得长
## 2. 日志在哪里
App 内推荐直接使用“运行诊断 → 导出日志”。开发人员远程指导时,优先使用
`memhub logs`,它会再次过滤不安全字段。需要现场查看原始结构化日志时,Core 日志默认位于:
`memhub logs`,它会再次过滤不安全字段。原始结构化日志只用于本机现场查看,不得作为附件发送。
Core 日志默认位于:
```text
~/.one-memory/logs/diagnostic-core.jsonl
+24 -8
View File
@@ -15,9 +15,17 @@ Desktop -> Runtime -> Core -> HY -> Host 的实际执行链。日志不是用户
可选关联字段为 `trace_id`、`incident_id`、`instance_id`、`operation_id`、`request_id`、
`job_id`、`task_id`、`duration_ms`、状态、稳定错误码、错误类型和清理后的仓库相对栈帧。
`details` 只接受代码声明的标量白名单。禁止记录对话原文、查询、回答、模型输入输出、
请求体、响应体、token、API Key、secret ref、完整用户路径或异常 message/cause。未知字段和
不合规标识拒绝写入。日志写入失败不能阻断产品主链。
`details` 的本地写入合同只约束结构,不维护业务字段白名单:最多 32 个 key;key 必须是
1–64 字符的字母、数字、点、下划线或连字符并以字母开头;value 只接受 `null`、布尔值、
跨 Python/JavaScript 可无损表示的有限数字(整数限于 ±(2^53-1))或不超过 256 字符且不含
控制换行的短字符串,不接受对象和数组。这样新增诊断标签
不需要先修改中央白名单。诊断日志的校验、序列化、排队或落盘发生任何异常时都只能丢弃该条
日志,不能阻断产品主链。
本地写入放宽不代表允许官方代码记录用户内容。对话原文、查询、回答、模型输入输出、请求体、
响应体、token、API Key、secret ref、完整用户路径和异常 message/cause 仍禁止进入诊断事件,
由类型、静态检查和隐私测试共同防止误用;确需保存完整用户输入的调试能力必须使用独立的本地
trace/session 文件,不得混入可导出的诊断日志。
## 关联规则
@@ -34,9 +42,16 @@ Desktop -> Runtime -> Core -> HY -> Host 的实际执行链。日志不是用户
host-bridge、desktop 和 cli。单文件 4 MiB,保留 3 份轮转;额外清理 14 天以前的文件并将
总量限制在 128 MiB。POSIX 目录为 0700、文件为 0600。
读取器只枚举上述固定文件名的 regular file,不跟随符号链接,不接受任意路径。损坏行跳过
并计数,不让一行损坏遮蔽其余证据。诊断导出是单个脱敏 JSON,包含版本、平台、架构、
非秘密状态和最多 5000 条已验证事件,不包含数据库或配置文件。
读取器只枚举上述固定文件名的 regular file,不跟随符号链接,不接受任意路径。日常本地查询
遇到损坏行时跳过并计数,不让一行损坏遮蔽其余证据。
导出是独立的安全边界:它重新读取并校验全部候选事件,只把明确列入导出白名单的字段投影为
新的 `diagnostics/events.jsonl`;details 不只检查 key,还按字段约束 value 类型,并拒绝自由文本、
路径和凭证形态的字符串。绝不复制原始 `diagnostic-*.jsonl`、普通运行日志或崩溃报告。
导出包还可以包含固定 schema 的系统摘要和问题时间点,但不接受调用方传入的任意对象展开。
任何事件、marker、安全投影或日志目录读取失败时,导出必须 fail-closed:删除临时文件、不发布
诊断包,更不能回退为打包原始日志。发布使用已设好权限的临时文件原子创建最终目标,已存在的
目标不得覆盖,发布后也不存在再 chmod 失败留下伪成功文件的窗口。
## 覆盖与节流
@@ -56,7 +71,8 @@ host-bridge、desktop 和 cli。单文件 4 MiB,保留 3 份轮转;额外清
## 验收
隐私测试必须先植入一条可检测的秘密作为正向对照,再证明真实链路日志和导出均不含该秘密。
至少用隔离数据制造 Core 启动失败、HY 失败和宿主连接失败,并仅凭日志还原失败组件、时间、
隐私测试必须先向本地日志植入带未知 key 的可检测秘密作为正向对照,再证明本地读取仍保留该
证据、导出安全投影必定剔除它;另需证明损坏行会阻止导出且不会留下伪成功文件。至少用隔离
数据制造 Core 启动失败、HY 失败和宿主连接失败,并仅凭允许导出的字段还原失败组件、时间、
关联 ID、稳定错误码和代码位置。自动测试通过只能标记 verified,正式安装 App 的真实体验仍需
单独验收。
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "one-memory",
"version": "1.0.5",
"release": {
"build_number": 14,
"build_number": 17,
"macos_min_version": "13.0",
"app_update": "manual",
"runtime_manifest_schema": 1,
@@ -192,16 +192,12 @@ describe("RecallBroker", () => {
level: "error",
component: "host.broker",
outcome: "failed",
details: expect.objectContaining({
action: "host",
code: "connection_control_unavailable",
error_type: "Error",
}),
error_code: "connection_control_unavailable",
error_type: "Error",
details: { action: "host" },
});
expect(typeof exception?.details).toBe("object");
const details = exception?.details as Record<string, unknown>;
expect(typeof details.stack_origin).toBe("string");
expect(details.stack_origin).toContain("recall-broker");
expect(exception?.details).not.toHaveProperty("message");
expect(exception?.details).not.toHaveProperty("stack_origin");
});
it.each(["recall", "ask"])(
"answers %s without waiting for a slow diagnostic write",
@@ -702,20 +702,10 @@ export class RecallBroker {
event: "connection_control.exception",
outcome: "failed",
instance_id: this.options.instanceId,
details: {
action: action ?? "invalid",
code,
error_type:
error instanceof Error ? error.constructor.name : typeof error,
message:
error instanceof Error
? error.message.slice(0, 256)
: String(error).slice(0, 256),
stack_origin:
error instanceof Error && error.stack
? (error.stack.split("\n").at(1)?.trim() ?? "").slice(0, 256)
: null,
},
error_code: code,
error_type:
error instanceof Error ? error.constructor.name : typeof error,
details: { action: action ?? "invalid" },
});
}
const fingerprint = `error:${code}`;
@@ -1933,6 +1933,8 @@ const baseEnglishMessages = {
languageLoadError: "Could not load the language setting. Please retry.",
nativeErrorTitle: "MemHub could not start",
startupPreparing: "Starting and preparing the memory service…",
startupUpgradeHint:
"The first launch or a memory-data upgrade can take a few minutes. MemHub opens automatically when it is done.",
} as const;
export const englishMessages = {
@@ -52,6 +52,8 @@ const languageChineseMessages = {
languageLoadError: "无法加载语言设置,请重试。",
nativeErrorTitle: "MemHub 无法启动",
startupPreparing: "正在启动,正在准备记忆服务…",
startupUpgradeHint:
"首次启动或升级记忆数据可能需要几分钟,完成后会自动进入应用。",
} as const;
const languageSpanishMessages: Record<
@@ -71,6 +73,8 @@ const languageSpanishMessages: Record<
languageLoadError: "No se pudo cargar el idioma. Inténtalo de nuevo.",
nativeErrorTitle: "MemHub no pudo iniciarse",
startupPreparing: "Iniciando y preparando el servicio de memorias…",
startupUpgradeHint:
"El primer inicio o la actualización de tus memorias puede tardar unos minutos. La aplicación se abrirá automáticamente al terminar.",
};
type Catalog = {
@@ -680,7 +680,7 @@ export class CoreSupervisor {
outcome: "failed",
instance_id: this.activeInstanceId,
error_code: "core_restart_circuit_open",
details: { streak: this.restartStreak },
details: { restart_streak: this.restartStreak },
});
return;
}
@@ -765,7 +765,7 @@ export class CoreSupervisor {
outcome: "failed",
instance_id: this.activeInstanceId,
error_code: "core_restart_exhausted",
details: { attempts: budget, streak: this.restartStreak },
details: { attempts: budget, restart_streak: this.restartStreak },
});
await this.options.diagnostics?.log({
level: "error",
@@ -774,7 +774,7 @@ export class CoreSupervisor {
outcome: "failed",
instance_id: this.activeInstanceId,
error_code: "core_restart_circuit_open",
details: { streak: this.restartStreak },
details: { restart_streak: this.restartStreak },
});
}
} finally {
@@ -1,4 +1,5 @@
import {
chmod,
lstat,
mkdir,
mkdtemp,
@@ -21,8 +22,10 @@ import {
diagnosticLogFiles,
exportDiagnosticBundle,
exportDiagnosticZip,
projectDiagnosticEventForSafeOutput,
pruneDiagnosticLogs,
readDiagnosticEvents,
recordDiagnosticMarker,
writeDiagnosticEventSync,
} from "./diagnostic-log.js";
@@ -66,6 +69,30 @@ describe("diagnostic log", () => {
});
});
it("keeps the synchronous writer fail-open for invalid input", async () => {
const dataDir = await root();
expect(() =>
writeDiagnosticEventSync(
{
dataDir,
service: "desktop",
sessionId: "55555555-5555-4555-8555-555555555555",
},
{
level: "error",
component: "desktop.lifecycle",
event: "desktop.early_start_failed",
outcome: "failed",
details: { nested: ["private"] } as never,
},
),
).not.toThrow();
expect(await readDiagnosticEvents(dataDir)).toEqual({
events: [],
invalid_lines: 0,
});
});
it("writes structured, private events and reads them by correlation identity", async () => {
const dataDir = await root();
const session = "11111111-1111-4111-8111-111111111111";
@@ -89,6 +116,8 @@ describe("diagnostic log", () => {
open_file_count: 7,
memory_bytes: 4096,
thread_count: 4,
"cache.hit-rate": 0.75,
custom_label: "warm",
},
});
const result = await readDiagnosticEvents(dataDir, { traceId: trace });
@@ -101,6 +130,8 @@ describe("diagnostic log", () => {
event: "runtime.ready",
details: expect.objectContaining({
open_file_count: 7,
"cache.hit-rate": 0.75,
custom_label: "warm",
}),
});
const file = (await diagnosticLogFiles(dataDir))[0];
@@ -184,7 +215,7 @@ describe("diagnostic log", () => {
}
});
it("rejects arbitrary detail fields and absolute stack paths", () => {
it("strictly rejects complex detail values, dangerous keys and absolute stacks", () => {
const base = {
timestamp: new Date().toISOString(),
level: "error" as const,
@@ -195,11 +226,142 @@ describe("diagnostic log", () => {
session_id: "11111111-1111-4111-8111-111111111111",
};
expect(() =>
diagnosticEvent({ ...base, details: { prompt: "private" } }),
diagnosticEvent({
...base,
details: { nested: { secret: "private" } } as never,
}),
).toThrow("diagnostic_detail_value_invalid");
expect(() =>
diagnosticEvent({ ...base, details: { constructor: "private" } }),
).toThrow("diagnostic_detail_key_invalid");
expect(() =>
diagnosticEvent({ ...base, details: { "9invalid": "private" } }),
).toThrow("diagnostic_detail_key_invalid");
expect(() =>
diagnosticEvent({
...base,
details: Object.fromEntries(
Array.from({ length: 33 }, (_, index) => [`key_${index}`, index]),
),
}),
).toThrow("diagnostic_detail_count_invalid");
expect(() =>
diagnosticEvent({ ...base, details: { message: "line1\nline2" } }),
).toThrow("diagnostic_text_invalid");
expect(() =>
diagnosticEvent({ ...base, details: { metric: Number.NaN } }),
).toThrow("diagnostic_detail_value_invalid");
expect(() =>
diagnosticEvent({
...base,
details: { count: Number.MAX_SAFE_INTEGER + 1 },
}),
).toThrow("diagnostic_detail_value_invalid");
expect(() =>
diagnosticEvent({ ...base, details: { list: ["private"] } as never }),
).toThrow("diagnostic_detail_value_invalid");
expect(() =>
diagnosticEvent({ ...base, safe_stack: ["/Users/me/private.py:1"] }),
).toThrow("diagnostic_stack_invalid");
expect(() =>
diagnosticEvent({
...base,
safe_stack: ["packages/runtime/sk-live-secret-token.ts:1:1"],
}),
).toThrow("diagnostic_stack_invalid");
for (const line of [
"arbitrary stack text",
"thread=worker|threading.py:12:wait",
"FILE:///Users/me/private.py:1:call",
"one_memory/worker.py:9:call /Users/private",
"one_memory/GOCSPX-privateCredential.py:9:call",
"packages/ASIA1234567890ABCDEF/file.ts:1:1",
"packages/Aa1Bb2Cc3Dd4Ee5Ff6Gg7Hh8/file.ts:1:1",
]) {
expect(() => diagnosticEvent({ ...base, safe_stack: [line] })).toThrow(
"diagnostic_stack_invalid",
);
}
expect(() =>
diagnosticEvent({
...base,
safe_stack: Array.from(
{ length: 17 },
(_, index) => `one_memory/worker.py:${index + 1}:call`,
),
}),
).toThrow("diagnostic_stack_invalid");
const accepted = diagnosticEvent({
...base,
safe_stack: [
"apps/desktop/src/main.ts:80:1",
"one_memory/diagnostic_log.py:895:_safe_stack_line",
"threading.py:1045:_bootstrap_inner",
"thread|threading.py:1045:_bootstrap_inner > one_memory/worker.py:9:call",
],
});
expect(accepted.safe_stack).toHaveLength(4);
const projected = projectDiagnosticEventForSafeOutput({
...accepted,
safe_stack: [
"free text",
"thread=worker|one_memory/worker.py:9:call",
"packages/runtime-control/src/diagnostic-log.ts:42:7",
"thread|threading.py:1045:_bootstrap_inner > one_memory/worker.py:9:call",
],
});
expect(projected.safe_stack).toEqual([
"packages/runtime-control/src/diagnostic-log.ts:42:7",
"thread|threading.py:1045:_bootstrap_inner > one_memory/worker.py:9:call",
]);
const conservative = projectDiagnosticEventForSafeOutput(
diagnosticEvent({
...base,
details: {
source_id: "cursor",
reason: "abcdef1234567890abcdef1234567890abcdef12",
status: "11111111-1111-4111-8111-111111111111",
},
}),
);
expect(conservative.details).toEqual({
source_id: "cursor",
reason: "abcdef1234567890abcdef1234567890abcdef12",
status: "11111111-1111-4111-8111-111111111111",
});
});
it("drops malformed logger events without interrupting later writes", async () => {
const dataDir = await root();
const logger = new DiagnosticLogger({
dataDir,
service: "desktop",
sessionId: "11111111-1111-4111-8111-111111111111",
});
await expect(
logger.log({
level: "info",
component: "desktop.sdk_migration",
event: "sdk_migration.startup",
outcome: "succeeded",
details: { nested: { secret: "private" } } as never,
}),
).resolves.toBeUndefined();
await logger.log({
level: "info",
component: "desktop.sdk_migration",
event: "sdk_migration.startup",
outcome: "succeeded",
details: { arbitrary_state: "cutover" },
});
const result = await readDiagnosticEvents(dataDir);
expect(result.invalid_lines).toBe(0);
expect(result.events).toHaveLength(1);
expect(result.events[0].details).toEqual({ arbitrary_state: "cutover" });
});
it("keeps code locations but strips exception messages and absolute prefixes", () => {
@@ -215,29 +377,162 @@ describe("diagnostic log", () => {
expect(JSON.stringify(safe)).not.toContain("/Users/me");
});
it("marks incidents and exports only validated events", async () => {
it("exports events through an independent detail whitelist", async () => {
const dataDir = await root();
const logger = new DiagnosticLogger({ dataDir, service: "desktop" });
const incident = await logger.markIncident({
component: "desktop.diagnostics",
status: "degraded",
});
await writeFile(
path.join(dataDir, "logs", "diagnostic-core.jsonl"),
"not-json\n",
{ mode: 0o600 },
);
await logger.log({
level: "info",
component: "desktop.diagnostics",
event: "diagnostics.checked",
outcome: "succeeded",
details: {
count: 1,
reason: "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.signaturevalue",
status: "ASIA1234567890ABCDEF",
source_id: "GOCSPX-clientSecret_A1",
mode: "Aa1Bb2Cc3Dd4Ee5Ff6Gg7Hh8",
action: "AIzaSyExampleCredential1234567890",
platform: "x_ASIA1234567890ABCDEF",
phase: "x_GOCSPX-clientSecret_A1",
local_only_secret: "must-not-export",
},
});
await logger.log({
level: "error",
component: "credential.private",
event: "token.failed",
outcome: "ghp_private_token",
trace_id: "ghp_private_token",
instance_id: "ASIA1234567890ABCDEF",
request_id: "GOCSPX-clientSecret_A1",
task_id: "Aa1Bb2Cc3Dd4Ee5Ff6Gg7Hh8",
error_code: "secret.token",
});
const target = path.join(await root(), "bundle.json");
const result = await exportDiagnosticBundle({
dataDir,
target,
productVersion: "1.2.3",
status: { status: "degraded" },
status: { status: "degraded", local_only_secret: "must-not-export" },
});
expect(result).toEqual({ event_count: 1, invalid_lines: 1 });
expect(result).toEqual({ event_count: 3, invalid_lines: 0 });
const payload = JSON.parse(await readFile(target, "utf8"));
expect(payload.events[0].incident_id).toBe(incident.incident_id);
expect(JSON.stringify(payload)).not.toContain(dataDir);
expect(payload.events[1].details).toEqual({ count: 1 });
expect(payload.events[2]).toMatchObject({
component: "diagnostic.redacted",
event: "diagnostic.redacted",
outcome: "redacted",
});
expect(payload.events[2].trace_id).toBeUndefined();
expect(payload.events[2].instance_id).toBeUndefined();
expect(payload.events[2].request_id).toBeUndefined();
expect(payload.events[2].task_id).toBeUndefined();
expect(payload.events[2].error_code).toBeUndefined();
expect(JSON.stringify(payload)).not.toContain("local_only_secret");
expect(JSON.stringify(payload)).not.toContain("must-not-export");
expect(JSON.stringify(payload)).not.toContain("eyJhbGciOiJIUzI1NiJ9");
expect(JSON.stringify(payload)).not.toContain("ASIA1234567890ABCDEF");
expect(JSON.stringify(payload)).not.toContain("GOCSPX-clientSecret_A1");
expect(JSON.stringify(payload)).not.toContain("x_ASIA1234567890ABCDEF");
expect(JSON.stringify(payload)).not.toContain("x_GOCSPX-clientSecret_A1");
expect(JSON.stringify(payload)).not.toContain("Aa1Bb2Cc3Dd4Ee5Ff6Gg7Hh8");
expect(JSON.stringify(payload)).not.toContain(
"AIzaSyExampleCredential1234567890",
);
expect(JSON.stringify(payload)).not.toContain("ghp_private_token");
});
it("fails closed when exported build identity has a credential shape", async () => {
const dataDir = await root();
const outputDir = await root();
await expect(
exportDiagnosticBundle({
dataDir,
target: path.join(outputDir, "bundle.json"),
productVersion: "xoxb-123456-abcdef",
}),
).rejects.toThrow("diagnostic_product_version_invalid");
await expect(
exportDiagnosticZip({
dataDir,
target: path.join(outputDir, "commit.zip"),
productVersion: "1.2.3",
commit: "ghp_private_token",
}),
).rejects.toThrow("diagnostic_commit_invalid");
await expect(
exportDiagnosticZip({
dataDir,
target: path.join(outputDir, "sha.zip"),
productVersion: "1.2.3",
coreSha256: "AKIDExampleSecretId1234567890",
}),
).rejects.toThrow("diagnostic_sha256_invalid");
expect(await readdir(outputDir)).toEqual([]);
});
it("fails closed without publishing a JSON bundle when any line is invalid", async () => {
const dataDir = await root();
await mkdir(path.join(dataDir, "logs"), { recursive: true });
await writeFile(
path.join(dataDir, "logs", "diagnostic-core.jsonl"),
"not-json\n",
{ mode: 0o600 },
);
const outputDir = await root();
const target = path.join(outputDir, "bundle.json");
await expect(
exportDiagnosticBundle({
dataDir,
target,
productVersion: "1.2.3",
}),
).rejects.toThrow("diagnostic_export_invalid_lines");
expect(await readdir(outputDir)).toEqual([]);
});
it("fails closed when the diagnostic directory cannot be read", async () => {
if (process.platform === "win32") return;
const dataDir = await root();
const logs = path.join(dataDir, "logs");
await mkdir(logs, { recursive: true, mode: 0o700 });
await writeFile(path.join(logs, "diagnostic-core.jsonl"), "{}\n");
const outputDir = await root();
const target = path.join(outputDir, "bundle.json");
await chmod(logs, 0o000);
try {
await expect(
exportDiagnosticBundle({
dataDir,
target,
productVersion: "1.2.3",
}),
).rejects.toMatchObject({ code: "EACCES" });
} finally {
await chmod(logs, 0o700);
}
expect(await readdir(outputDir)).toEqual([]);
});
it("never overwrites an existing export target or leaves temporary files", async () => {
const dataDir = await root();
const outputDir = await root();
const target = path.join(outputDir, "bundle.json");
await writeFile(target, "existing-artifact", { mode: 0o600 });
await expect(
exportDiagnosticBundle({
dataDir,
target,
productVersion: "1.2.3",
}),
).rejects.toMatchObject({ code: "EEXIST" });
expect(await readFile(target, "utf8")).toBe("existing-artifact");
expect(await readdir(outputDir)).toEqual(["bundle.json"]);
});
it("preserves microsecond ordering across service log files", async () => {
@@ -280,7 +575,7 @@ describe("diagnostic log", () => {
expect(result.events[1].timestamp).toBe("2026-10-14T00:00:00.123999Z");
});
it("exports a private seven-day zip with summary, logs, crashes and markers", async () => {
it("exports only reprojected events, a strict summary and README", async () => {
const dataDir = await root();
const outputDir = await root();
const crashDir = await root();
@@ -326,39 +621,178 @@ describe("diagnostic log", () => {
markers: [
{
timestamp: "2026-10-05T01:30:00.000Z",
description: "蓝莓没出来",
description:
"蓝莓没出来 /Users/private/query.txt ASIA1234567890ABCDEF GOCSPX-clientSecret_A1 Aa1Bb2Cc3Dd4Ee5Ff6Gg7Hh8 token=ghp_private_token xoxb-123456-abcdef AKIDExampleSecretId1234567890 AIzaSyExampleCredential1234567890 eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.signaturevalue",
},
],
summary: {
sources: [{ source_id: "workbuddy", imported_messages: 12 }],
memory_tasks: { unknown: 1, last_error_codes: { model_failed: 1 } },
summary_secret: conversation,
background: {
status: "exported",
runtime_state: "ready",
core_state: "healthy",
broker_state: "ready",
instance_id: "instance-1",
secret: conversation,
},
},
});
expect(result.filename).toBe("MemHub.zip");
expect(result).toEqual({ filename: "MemHub.zip", file_count: 3 });
const files = unzipSync(await readFile(target));
expect(Object.keys(files)).toEqual(
expect.arrayContaining([
"README.txt",
"summary.json",
"logs/diagnostic-core.jsonl",
"logs/core.log",
"crashes/MemHub-2026-10-05.ips",
]),
);
const summary = files["summary.json"];
expect(Object.keys(files).sort()).toEqual([
"README.txt",
"diagnostics/events.jsonl",
"summary.json",
]);
const eventFile = files["diagnostics/events.jsonl"];
const summaryFile = files["summary.json"];
const readme = files["README.txt"];
if (!summary || !readme) throw new Error("missing support bundle metadata");
expect(strFromU8(summary)).toContain("model_failed");
expect(strFromU8(readme)).toContain("蓝莓没出来");
if (!eventFile || !summaryFile || !readme)
throw new Error("missing support bundle metadata");
const exportedEvent = JSON.parse(strFromU8(eventFile).trim());
expect(exportedEvent.details).toEqual({ count: 1 });
const summary = JSON.parse(strFromU8(summaryFile));
expect(summary.background).toEqual({
status: "exported",
runtime_state: "ready",
core_state: "healthy",
broker_state: "ready",
instance_id: "instance-1",
});
expect(summary.sources).toBeUndefined();
expect(summary.summary_secret).toBeUndefined();
expect(summary.problem_markers).toEqual([
{
timestamp: "2026-10-05T01:30:00.000Z",
description: "user_marked_incident",
},
]);
expect(strFromU8(readme)).toContain("user_marked_incident");
expect(strFromU8(readme)).not.toContain("蓝莓没出来");
const all = Object.values(files)
.map((value) => strFromU8(value))
.join("\n");
expect(all).not.toContain(secret);
expect(all).not.toContain("ghp_private_token");
expect(all).not.toContain("xoxb-123456-abcdef");
expect(all).not.toContain("AKIDExampleSecretId1234567890");
expect(all).not.toContain("ASIA1234567890ABCDEF");
expect(all).not.toContain("GOCSPX-clientSecret_A1");
expect(all).not.toContain("Aa1Bb2Cc3Dd4Ee5Ff6Gg7Hh8");
expect(all).not.toContain("AIzaSyExampleCredential1234567890");
expect(all).not.toContain("eyJhbGciOiJIUzI1NiJ9");
expect(all).not.toContain(conversation);
expect(all).not.toContain(home);
expect(all).not.toContain("/Users/");
expect(all).toContain("~/MemHub");
});
it("fails closed without publishing a ZIP when a diagnostic line is invalid", async () => {
const dataDir = await root();
await mkdir(path.join(dataDir, "logs"), { recursive: true });
await writeFile(
path.join(dataDir, "logs", "diagnostic-core.jsonl"),
"not-json\n",
);
const outputDir = await root();
const target = path.join(outputDir, "MemHub.zip");
await expect(
exportDiagnosticZip({
dataDir,
target,
productVersion: "3800",
}),
).rejects.toThrow("diagnostic_export_invalid_lines");
expect(await readdir(outputDir)).toEqual([]);
});
it("stores and exports only the fixed marker description", async () => {
const dataDir = await root();
const markerFile = path.join(dataDir, "logs", "problem-markers.jsonl");
await recordDiagnosticMarker(
dataDir,
{
timestamp: "2026-10-05T01:00:00.000Z",
description:
"蓝莓 /Users/private/query.txt ASIA1234567890ABCDEF GOCSPX-clientSecret_A1 Aa1Bb2Cc3Dd4Ee5Ff6Gg7Hh8",
},
"/Users/private",
);
expect(await readFile(markerFile, "utf8")).toBe(
`${JSON.stringify({ timestamp: "2026-10-05T01:00:00.000Z", description: "user_marked_incident" })}\n`,
);
await writeFile(
markerFile,
`${await readFile(markerFile, "utf8")}${JSON.stringify({ timestamp: "2026-10-05T01:30:00.000Z", description: "legacy arbitrary text" })}\n`,
{ mode: 0o600 },
);
const target = path.join(await root(), "markers.zip");
await exportDiagnosticZip({
dataDir,
target,
productVersion: "3800",
now: new Date("2026-10-05T02:00:00.000Z"),
});
const files = unzipSync(await readFile(target));
const summaryFile = files["summary.json"];
if (!summaryFile) throw new Error("missing summary.json");
const payload = JSON.stringify(JSON.parse(strFromU8(summaryFile)));
expect(payload.match(/user_marked_incident/g)).toHaveLength(2);
expect(payload).not.toContain("legacy arbitrary text");
expect(payload).not.toContain("蓝莓");
expect(payload).not.toContain("/Users/");
});
it("never follows a problem marker symlink for reads or writes", async () => {
const dataDir = await root();
const logs = path.join(dataDir, "logs");
await mkdir(logs, { recursive: true });
const outside = path.join(await root(), "outside-marker");
await writeFile(outside, "private");
await symlink(outside, path.join(logs, "problem-markers.jsonl"));
await expect(
recordDiagnosticMarker(dataDir, {
timestamp: "2026-10-05T01:00:00.000Z",
description: "anything",
}),
).rejects.toThrow("diagnostic_marker_file_unsafe");
await expect(
exportDiagnosticZip({
dataDir,
target: path.join(await root(), "markers.zip"),
productVersion: "3800",
}),
).rejects.toThrow("diagnostic_marker_file_unsafe");
expect(await readFile(outside, "utf8")).toBe("private");
});
it("rejects invalid explicit and persisted markers without publishing a ZIP", async () => {
const dataDir = await root();
const logs = path.join(dataDir, "logs");
await mkdir(logs, { recursive: true });
const outputDir = await root();
const explicitTarget = path.join(outputDir, "explicit.zip");
await expect(
exportDiagnosticZip({
dataDir,
target: explicitTarget,
productVersion: "3800",
markers: [{ timestamp: "invalid", description: "marker" }],
}),
).rejects.toThrow("diagnostic_marker_invalid");
await writeFile(path.join(logs, "problem-markers.jsonl"), "not-json\n");
const persistedTarget = path.join(outputDir, "persisted.zip");
await expect(
exportDiagnosticZip({
dataDir,
target: persistedTarget,
productVersion: "3800",
}),
).rejects.toThrow("diagnostic_marker_invalid");
expect(await readdir(outputDir)).toEqual([]);
});
it("an incident retrieves its surrounding session rather than only its marker", async () => {
File diff suppressed because it is too large. Load diff
@@ -59,6 +59,7 @@ export {
diagnosticZipFilename,
exportDiagnosticBundle,
exportDiagnosticZip,
projectDiagnosticEventForSafeOutput,
pruneDiagnosticLogs,
readDiagnosticEvents,
recordDiagnosticMarker,