Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1ff4571bd | ||
|
|
6b0bd86679 | ||
|
|
91250d7c87 | ||
|
|
3c89a535fb | ||
|
|
1151878265 | ||
|
|
e82c4c939e | ||
|
|
ca4a66349b | ||
|
|
82044d6172 | ||
|
|
7f23b74c36 | ||
|
|
617e2d0ba6 | ||
|
|
083b837f32 | ||
|
|
3b44d8381c |
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")}
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user