@remembrance-ai/opencode-plugin 0.1.72 → 0.1.74
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -102,7 +102,9 @@ local MCP server both read `~/.config/remembrance/config.json`, an unset
|
|
|
102
102
|
After restarting, run MCP `run_connection_doctor` and require
|
|
103
103
|
`safe_to_query: true`. It verifies the active connection and gives one exact
|
|
104
104
|
next step if attention is required. Use `get_connection_status` only for lower-
|
|
105
|
-
level fields.
|
|
105
|
+
level fields. Fresh tool-observer and completion events are informational until
|
|
106
|
+
a later lifecycle opportunity is actually missed. Never infer scope from
|
|
107
|
+
`REMEMBRANCE_API_KEY` alone, from an environment
|
|
106
108
|
variable, or from an anonymous REST/browser probe — the diagnostic verifies the
|
|
107
109
|
process that will actually serve opencode's tools, without exposing the key.
|
|
108
110
|
|
|
@@ -154,7 +156,9 @@ routine organization lesson in memory and stores only an encrypted
|
|
|
154
156
|
post-redaction draft. `submit_private_lesson_candidate` is the separate network
|
|
155
157
|
action; it sends those exact bytes only to the authenticated organization's
|
|
156
158
|
private verifier queue and can never create or automatically propagate public
|
|
157
|
-
content.
|
|
159
|
+
content. Unresolved and terminal drafts never expire or auto-delete. Verified
|
|
160
|
+
submissions discard encrypted lesson content immediately; their content-free
|
|
161
|
+
completion markers are automatically deleted after 14 days.
|
|
158
162
|
|
|
159
163
|
The signed policy pins the corrected `private-lesson-redaction-v2` profile and
|
|
160
164
|
its exact digest. Unsupported-profile drafts become terminal
|
package/opencode.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"command": [
|
|
7
7
|
"npx",
|
|
8
8
|
"-y",
|
|
9
|
-
"@remembrance-ai/mcp-server@0.1.
|
|
9
|
+
"@remembrance-ai/mcp-server@0.1.74"
|
|
10
10
|
],
|
|
11
11
|
"enabled": true,
|
|
12
12
|
"environment": {
|
|
@@ -16,6 +16,6 @@
|
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
18
|
"plugin": [
|
|
19
|
-
"@remembrance-ai/opencode-plugin@0.1.
|
|
19
|
+
"@remembrance-ai/opencode-plugin@0.1.74"
|
|
20
20
|
]
|
|
21
21
|
}
|
package/package.json
CHANGED
package/scripts/hook-core.mjs
CHANGED
|
@@ -3124,7 +3124,7 @@ export function privateLessonLifecycleRecoveryInstruction(env = process.env) {
|
|
|
3124
3124
|
return [
|
|
3125
3125
|
`Private lesson lifecycle recovery: ${pending} retained local draft${pending === 1 ? " needs" : "s need"} attention. Silently call inspect_private_lesson_outbox.`,
|
|
3126
3126
|
"Call submit_private_lesson_candidate for each ready candidate or safety-held draft with pending content-free telemetry. Call retry_private_lesson_candidate only when the candidate or telemetry retry is due.",
|
|
3127
|
-
"Do not retry awaiting_authorization, disabled telemetry, submitted telemetry, or superseded-redactor drafts; do not use another transport, and never delete
|
|
3127
|
+
"Do not retry awaiting_authorization, disabled telemetry, submitted telemetry, or superseded-redactor drafts; do not use another transport, and never delete an unresolved draft automatically. Verified submissions compact automatically and their content-free markers expire after 14 days. Hide routine results.",
|
|
3128
3128
|
].join("\n");
|
|
3129
3129
|
}
|
|
3130
3130
|
|
|
@@ -4986,6 +4986,16 @@ function clientUpdateCheck(update) {
|
|
|
4986
4986
|
update.guidance.command ?? void 0
|
|
4987
4987
|
);
|
|
4988
4988
|
}
|
|
4989
|
+
if (update.status === "current" && compareClientVersions(update.current_version, update.latest_version) > 0) {
|
|
4990
|
+
return warning(
|
|
4991
|
+
"client_update",
|
|
4992
|
+
`This process runs Remembrance ${update.current_version}, ahead of the published ${update.latest_version} manifest for this surface.`,
|
|
4993
|
+
"client_release_manifest_behind",
|
|
4994
|
+
"Do not downgrade this client. Release automation should activate the verified per-surface manifest; querying and contributions may continue.",
|
|
4995
|
+
void 0,
|
|
4996
|
+
true
|
|
4997
|
+
);
|
|
4998
|
+
}
|
|
4989
4999
|
if (update.status === "current") {
|
|
4990
5000
|
return pass(
|
|
4991
5001
|
"client_update",
|
|
@@ -5006,8 +5016,9 @@ function clientUpdateSummary(update) {
|
|
|
5006
5016
|
};
|
|
5007
5017
|
}
|
|
5008
5018
|
if (update.status === "current" || update.status === "update_available") {
|
|
5019
|
+
const aheadOfManifest = update.status === "current" && compareClientVersions(update.current_version, update.latest_version) > 0;
|
|
5009
5020
|
return {
|
|
5010
|
-
status: update.status,
|
|
5021
|
+
status: aheadOfManifest ? "ahead_of_manifest" : update.status,
|
|
5011
5022
|
current_version: update.current_version,
|
|
5012
5023
|
latest_version: update.latest_version
|
|
5013
5024
|
};
|
|
@@ -5062,6 +5073,18 @@ function pluginLifecycleCheck(pluginHealth, issues) {
|
|
|
5062
5073
|
"The native plugin startup and prompt lifecycle is active."
|
|
5063
5074
|
);
|
|
5064
5075
|
}
|
|
5076
|
+
if (pluginHealth.status === "pending") {
|
|
5077
|
+
const pending = Array.isArray(pluginHealth.pending_components) ? pluginHealth.pending_components.filter(
|
|
5078
|
+
(component) => component === "tool_observer" || component === "completion_hook"
|
|
5079
|
+
) : [];
|
|
5080
|
+
const pendingSummary = pending.map(
|
|
5081
|
+
(component) => component === "tool_observer" ? "tool observation" : "completion"
|
|
5082
|
+
).join(" and ");
|
|
5083
|
+
return info(
|
|
5084
|
+
"plugin_lifecycle",
|
|
5085
|
+
`The native plugin startup and prompt lifecycle is active; ${pendingSummary || "remaining lifecycle"} verification is pending until ${pending.length === 1 ? "that event has" : "those events have"} an opportunity to run.`
|
|
5086
|
+
);
|
|
5087
|
+
}
|
|
5065
5088
|
if (stringValue(pluginHealth.surface) === "codex" && issues.includes("hook_trust_required")) {
|
|
5066
5089
|
const hookTrust = record(pluginHealth.hook_trust);
|
|
5067
5090
|
const allowedEvents = /* @__PURE__ */ new Set([
|
|
@@ -5088,7 +5111,7 @@ function pluginLifecycleCheck(pluginHealth, issues) {
|
|
|
5088
5111
|
const codex = stringValue(pluginHealth.surface) === "codex";
|
|
5089
5112
|
return warning(
|
|
5090
5113
|
"plugin_lifecycle",
|
|
5091
|
-
"The core native lifecycle is active
|
|
5114
|
+
"The core native lifecycle is active, but a tool-observer or completion event did not run after an observed opportunity.",
|
|
5092
5115
|
"exercise_plugin_lifecycle",
|
|
5093
5116
|
codex ? `Use one Remembrance tool, complete one turn, and rerun the doctor. If this warning remains, ${codexHookReviewAction("run_connection_doctor")}` : "Use one Remembrance tool, complete one turn, and rerun the doctor.",
|
|
5094
5117
|
issues,
|
|
@@ -5169,6 +5192,9 @@ function issueCodes(value) {
|
|
|
5169
5192
|
function pass(id, summary) {
|
|
5170
5193
|
return { id, status: "pass", summary, remediation: null };
|
|
5171
5194
|
}
|
|
5195
|
+
function info(id, summary) {
|
|
5196
|
+
return { id, status: "info", summary, remediation: null };
|
|
5197
|
+
}
|
|
5172
5198
|
function notApplicable(id, summary) {
|
|
5173
5199
|
return { id, status: "not_applicable", summary, remediation: null };
|
|
5174
5200
|
}
|
|
@@ -7584,7 +7610,7 @@ var seedSkills = [
|
|
|
7584
7610
|
summary: "Operational setup and troubleshooting workflow for Remembrance across Claude Code, Codex, OpenClaw, Cursor, Gemini, MCP, REST, skill-only installs, enterprise keys, and local agent identity.",
|
|
7585
7611
|
status: "active",
|
|
7586
7612
|
visibility: "public",
|
|
7587
|
-
version: "0.1.
|
|
7613
|
+
version: "0.1.24",
|
|
7588
7614
|
domains: ["agent-skills", "mcp", "resource-discovery"],
|
|
7589
7615
|
tags: [
|
|
7590
7616
|
"remembrance",
|
|
@@ -7635,7 +7661,7 @@ var seedSkills = [
|
|
|
7635
7661
|
"gemini",
|
|
7636
7662
|
"rest"
|
|
7637
7663
|
],
|
|
7638
|
-
version: "0.1.
|
|
7664
|
+
version: "0.1.24",
|
|
7639
7665
|
status: "active",
|
|
7640
7666
|
visibility: "public",
|
|
7641
7667
|
providers: ["codex", "claude", "cursor", "openclaw", "generic"],
|
|
@@ -7998,8 +8024,11 @@ draft remains on the device in \`awaiting_authorization\`; do not retry it
|
|
|
7998
8024
|
through REST, hosted MCP, or another transport. Timeouts, 429s, and 5xx
|
|
7999
8025
|
responses retry with bounded backoff during later plugin lifecycles. A 401,
|
|
8000
8026
|
403, policy change, or validation failure remains held for explicit repair.
|
|
8001
|
-
|
|
8002
|
-
with the local private-lesson tools; deletion requires
|
|
8027
|
+
Unresolved and terminal drafts never expire or auto-delete. Inspect, retry, or
|
|
8028
|
+
explicitly delete one with the local private-lesson tools; deletion requires
|
|
8029
|
+
confirmation. After a signed submission receipt is verified, the encrypted
|
|
8030
|
+
lesson content is removed immediately and its content-free completion marker
|
|
8031
|
+
is automatically deleted after 14 days.
|
|
8003
8032
|
|
|
8004
8033
|
The signed organization policy pins \`private-lesson-redaction-v2\` and its exact
|
|
8005
8034
|
supported redactor digest. If either is unsupported, the finalized
|
|
@@ -11042,8 +11071,16 @@ function isAlreadyExistsError(error) {
|
|
|
11042
11071
|
}
|
|
11043
11072
|
|
|
11044
11073
|
// src/connection-status.ts
|
|
11045
|
-
import {
|
|
11046
|
-
|
|
11074
|
+
import {
|
|
11075
|
+
chmodSync,
|
|
11076
|
+
existsSync as existsSync2,
|
|
11077
|
+
lstatSync as lstatSync2,
|
|
11078
|
+
mkdirSync,
|
|
11079
|
+
renameSync,
|
|
11080
|
+
rmSync,
|
|
11081
|
+
writeFileSync
|
|
11082
|
+
} from "node:fs";
|
|
11083
|
+
import { createHash as createHash2, randomBytes } from "node:crypto";
|
|
11047
11084
|
import { isIP } from "node:net";
|
|
11048
11085
|
import { homedir as homedir3 } from "node:os";
|
|
11049
11086
|
import { join as join3 } from "node:path";
|
|
@@ -11338,6 +11375,7 @@ var PLUGIN_HEALTH_COMPONENTS = [
|
|
|
11338
11375
|
"tool_observer",
|
|
11339
11376
|
"completion_hook"
|
|
11340
11377
|
];
|
|
11378
|
+
var CONNECTION_DOCTOR_PROBE_COMPONENT = "connection_doctor_probe";
|
|
11341
11379
|
var PLUGIN_HEALTH_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
11342
11380
|
var PLUGIN_HEALTH_MAX_FUTURE_SKEW_MS = 5 * 60 * 1e3;
|
|
11343
11381
|
var HOST_POLICY_OBSERVATION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
@@ -11360,6 +11398,57 @@ var nodeFileSystem2 = {
|
|
|
11360
11398
|
mode: secureLocalFileMode,
|
|
11361
11399
|
list: listSecureLocalDirectory
|
|
11362
11400
|
};
|
|
11401
|
+
function recordConnectionDoctorLifecycleProbe(options = {}) {
|
|
11402
|
+
const env = options.env ?? process.env;
|
|
11403
|
+
const rawSurface = String(env.REMEMBRANCE_PLUGIN_HOST ?? "").trim().toLowerCase();
|
|
11404
|
+
if (!isPluginHostSurface(rawSurface)) return false;
|
|
11405
|
+
const healthDir = env.REMEMBRANCE_PLUGIN_HEALTH_DIR ? String(env.REMEMBRANCE_PLUGIN_HEALTH_DIR) : join3(
|
|
11406
|
+
options.homeDirectory ?? homedir3(),
|
|
11407
|
+
".cache",
|
|
11408
|
+
"remembrance",
|
|
11409
|
+
"plugin-health"
|
|
11410
|
+
);
|
|
11411
|
+
const path = join3(healthDir, `${rawSurface}.json`);
|
|
11412
|
+
if (!existsSync2(path)) return false;
|
|
11413
|
+
const temporaryPath = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
11414
|
+
try {
|
|
11415
|
+
const parsed = JSON.parse(
|
|
11416
|
+
readSecureLocalText(path, {
|
|
11417
|
+
maxBytes: MAX_LOCAL_LIFECYCLE_MARKER_BYTES
|
|
11418
|
+
})
|
|
11419
|
+
);
|
|
11420
|
+
if (!isRecord3(parsed) || parsed.surface !== rawSurface) return false;
|
|
11421
|
+
const components = isRecord3(parsed.components) ? parsed.components : {};
|
|
11422
|
+
if (typeof components.session_start !== "string") return false;
|
|
11423
|
+
mkdirSync(healthDir, { recursive: true, mode: 448 });
|
|
11424
|
+
const directoryMetadata = lstatSync2(healthDir);
|
|
11425
|
+
if (directoryMetadata.isSymbolicLink() || !directoryMetadata.isDirectory()) {
|
|
11426
|
+
return false;
|
|
11427
|
+
}
|
|
11428
|
+
chmodSync(healthDir, 448);
|
|
11429
|
+
writeFileSync(
|
|
11430
|
+
temporaryPath,
|
|
11431
|
+
`${JSON.stringify({
|
|
11432
|
+
...parsed,
|
|
11433
|
+
components: {
|
|
11434
|
+
...components,
|
|
11435
|
+
[CONNECTION_DOCTOR_PROBE_COMPONENT]: (options.now ?? /* @__PURE__ */ new Date()).toISOString()
|
|
11436
|
+
}
|
|
11437
|
+
})}
|
|
11438
|
+
`,
|
|
11439
|
+
{ mode: 384, flag: "wx" }
|
|
11440
|
+
);
|
|
11441
|
+
renameSync(temporaryPath, path);
|
|
11442
|
+
chmodSync(path, 384);
|
|
11443
|
+
return true;
|
|
11444
|
+
} catch {
|
|
11445
|
+
try {
|
|
11446
|
+
rmSync(temporaryPath, { force: true });
|
|
11447
|
+
} catch {
|
|
11448
|
+
}
|
|
11449
|
+
return false;
|
|
11450
|
+
}
|
|
11451
|
+
}
|
|
11363
11452
|
function remembranceConfigPath(env = process.env, homeDirectory = homedir3()) {
|
|
11364
11453
|
const configRoot = env.REMEMBRANCE_PLUGIN_HOST?.trim().toLowerCase() === "openclaw" ? join3(homeDirectory, ".config") : env.XDG_CONFIG_HOME ?? join3(homeDirectory, ".config");
|
|
11365
11454
|
return join3(configRoot, "remembrance", "config.json");
|
|
@@ -11724,6 +11813,20 @@ function localPluginLifecycleHealth(options) {
|
|
|
11724
11813
|
}
|
|
11725
11814
|
if (Number.isFinite(latestMs)) mergedComponents[component] = latestValue;
|
|
11726
11815
|
}
|
|
11816
|
+
let latestDoctorProbe;
|
|
11817
|
+
let latestDoctorProbeMs = Number.NEGATIVE_INFINITY;
|
|
11818
|
+
for (const entry of cohort) {
|
|
11819
|
+
const components2 = isRecord3(entry.marker.components) ? entry.marker.components : {};
|
|
11820
|
+
const value = components2[CONNECTION_DOCTOR_PROBE_COMPONENT];
|
|
11821
|
+
const valueMs = validLifecycleTimestamp(value, nowMs);
|
|
11822
|
+
if (valueMs > latestDoctorProbeMs) {
|
|
11823
|
+
latestDoctorProbeMs = valueMs;
|
|
11824
|
+
latestDoctorProbe = value;
|
|
11825
|
+
}
|
|
11826
|
+
}
|
|
11827
|
+
if (Number.isFinite(latestDoctorProbeMs)) {
|
|
11828
|
+
mergedComponents[CONNECTION_DOCTOR_PROBE_COMPONENT] = latestDoctorProbe;
|
|
11829
|
+
}
|
|
11727
11830
|
const marker = {
|
|
11728
11831
|
...newestEntry.marker,
|
|
11729
11832
|
components: mergedComponents,
|
|
@@ -11761,6 +11864,7 @@ function localPluginLifecycleHealth(options) {
|
|
|
11761
11864
|
const coreLifecycleObserved = components.session_start.observed && components.prompt_hook.observed;
|
|
11762
11865
|
const hookTrust = normalizedPluginHookTrust(marker.hook_trust);
|
|
11763
11866
|
const hookTrustReviewEvents = hookTrust?.status === "review_required" ? new Set(hookTrust.review_events) : /* @__PURE__ */ new Set();
|
|
11867
|
+
const pendingComponents = [];
|
|
11764
11868
|
if (hookTrustReviewEvents.size > 0) {
|
|
11765
11869
|
issues.push({
|
|
11766
11870
|
code: "hook_trust_required",
|
|
@@ -11769,17 +11873,40 @@ function localPluginLifecycleHealth(options) {
|
|
|
11769
11873
|
])
|
|
11770
11874
|
});
|
|
11771
11875
|
}
|
|
11772
|
-
if (coreLifecycleObserved && !
|
|
11773
|
-
|
|
11774
|
-
|
|
11775
|
-
|
|
11776
|
-
|
|
11876
|
+
if (coreLifecycleObserved && !hookTrustReviewEvents.has("PostToolUse")) {
|
|
11877
|
+
const toolObserverMs = validLifecycleTimestamp(
|
|
11878
|
+
componentRecord.tool_observer,
|
|
11879
|
+
nowMs
|
|
11880
|
+
);
|
|
11881
|
+
const missedProbe = Number.isFinite(latestDoctorProbeMs) && (!Number.isFinite(toolObserverMs) || toolObserverMs < latestDoctorProbeMs);
|
|
11882
|
+
if (missedProbe) {
|
|
11883
|
+
issues.push({
|
|
11884
|
+
code: "tool_observer_not_observed",
|
|
11885
|
+
action: surface === "codex" ? `A prior doctor call completed without a later tool-observer event. ${codexHookReviewAction("get_connection_status")}` : "A prior doctor call completed without a later native tool-observer event. Update or reinstall the plugin, fully restart the host, and retry."
|
|
11886
|
+
});
|
|
11887
|
+
} else if (!components.tool_observer.observed) {
|
|
11888
|
+
pendingComponents.push("tool_observer");
|
|
11889
|
+
}
|
|
11777
11890
|
}
|
|
11778
|
-
if (coreLifecycleObserved && !
|
|
11779
|
-
|
|
11780
|
-
|
|
11781
|
-
|
|
11782
|
-
|
|
11891
|
+
if (coreLifecycleObserved && !hookTrustReviewEvents.has("Stop")) {
|
|
11892
|
+
const promptHookMs = validLifecycleTimestamp(
|
|
11893
|
+
componentRecord.prompt_hook,
|
|
11894
|
+
nowMs
|
|
11895
|
+
);
|
|
11896
|
+
const completionHookMs = validLifecycleTimestamp(
|
|
11897
|
+
componentRecord.completion_hook,
|
|
11898
|
+
nowMs
|
|
11899
|
+
);
|
|
11900
|
+
const completedTurnOpportunity = Number.isFinite(latestDoctorProbeMs) && Number.isFinite(promptHookMs) && promptHookMs > latestDoctorProbeMs;
|
|
11901
|
+
const missedCompletedTurn = completedTurnOpportunity && (!Number.isFinite(completionHookMs) || completionHookMs < latestDoctorProbeMs);
|
|
11902
|
+
if (missedCompletedTurn) {
|
|
11903
|
+
issues.push({
|
|
11904
|
+
code: "completion_hook_not_observed",
|
|
11905
|
+
action: surface === "codex" ? `A new prompt arrived after a prior doctor call without an intervening completion event. ${codexHookReviewAction("get_connection_status")}` : "A new prompt arrived after a prior doctor call without an intervening native completion event. Update or reinstall the plugin, fully restart the host, and retry."
|
|
11906
|
+
});
|
|
11907
|
+
} else if (!components.completion_hook.observed) {
|
|
11908
|
+
pendingComponents.push("completion_hook");
|
|
11909
|
+
}
|
|
11783
11910
|
}
|
|
11784
11911
|
const lastSeenMs = validLifecycleTimestamp(marker.last_seen_at, nowMs);
|
|
11785
11912
|
const lastSeenAt = Number.isFinite(lastSeenMs) ? String(marker.last_seen_at) : null;
|
|
@@ -11823,7 +11950,7 @@ function localPluginLifecycleHealth(options) {
|
|
|
11823
11950
|
);
|
|
11824
11951
|
return {
|
|
11825
11952
|
expected: true,
|
|
11826
|
-
status: issues.length === 0 ? "active" : onlyAwaitingEligibleEvents ? "partial" : "degraded",
|
|
11953
|
+
status: issues.length === 0 ? pendingComponents.length > 0 ? "pending" : "active" : onlyAwaitingEligibleEvents ? "partial" : "degraded",
|
|
11827
11954
|
surface,
|
|
11828
11955
|
plugin_version: markerVersion || null,
|
|
11829
11956
|
host_version: typeof marker.host_version === "string" && marker.host_version ? marker.host_version : null,
|
|
@@ -11836,6 +11963,7 @@ function localPluginLifecycleHealth(options) {
|
|
|
11836
11963
|
},
|
|
11837
11964
|
hook_trust: hookTrust,
|
|
11838
11965
|
components,
|
|
11966
|
+
pending_components: pendingComponents,
|
|
11839
11967
|
issues,
|
|
11840
11968
|
explanation: "Each host process session is tracked independently. Turn-specific prompt, tool, and completion observations are combined only when they share the current SessionStart marker."
|
|
11841
11969
|
};
|
|
@@ -12079,7 +12207,7 @@ async function checkClientUpdate(input) {
|
|
|
12079
12207
|
}
|
|
12080
12208
|
|
|
12081
12209
|
// src/private-lesson-outbox.ts
|
|
12082
|
-
import { createCipheriv, createDecipheriv, randomBytes as
|
|
12210
|
+
import { createCipheriv, createDecipheriv, randomBytes as randomBytes3 } from "node:crypto";
|
|
12083
12211
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
12084
12212
|
import {
|
|
12085
12213
|
chmod as chmod2,
|
|
@@ -12095,7 +12223,7 @@ import { homedir as homedir4 } from "node:os";
|
|
|
12095
12223
|
import { dirname as dirname2, join as join4 } from "node:path";
|
|
12096
12224
|
|
|
12097
12225
|
// src/private-lesson-keychain.ts
|
|
12098
|
-
import { randomBytes } from "node:crypto";
|
|
12226
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
12099
12227
|
import { execFileSync } from "node:child_process";
|
|
12100
12228
|
import { existsSync as existsSync3 } from "node:fs";
|
|
12101
12229
|
var KEYCHAIN_SERVICE = "dev.remembrance.private-lessons";
|
|
@@ -12137,7 +12265,7 @@ function resolvePrivateLessonKeychainKey(env, dependencies = defaultDependencies
|
|
|
12137
12265
|
if (!keychainEnabled(env, dependencies)) return null;
|
|
12138
12266
|
const existing = readPrivateLessonKeychainKey(env, dependencies);
|
|
12139
12267
|
if (existing) return existing;
|
|
12140
|
-
const encoded = dependencies.randomKey ??
|
|
12268
|
+
const encoded = dependencies.randomKey ?? randomBytes2(32).toString("base64url");
|
|
12141
12269
|
const generated = decodeKey(encoded);
|
|
12142
12270
|
if (!generated) return null;
|
|
12143
12271
|
try {
|
|
@@ -12173,9 +12301,11 @@ function decodeKey(encoded) {
|
|
|
12173
12301
|
// src/private-lesson-outbox.ts
|
|
12174
12302
|
var PRIVATE_LESSON_OUTBOX_VERSION = "private-lesson-outbox-v1";
|
|
12175
12303
|
var PRIVATE_LESSON_OUTBOX_MAX_BYTES = 64 * 1024 * 1024;
|
|
12304
|
+
var PRIVATE_LESSON_SUBMITTED_RECORD_RETENTION_MS = 14 * 24 * 60 * 60 * 1e3;
|
|
12176
12305
|
var PRIVATE_LESSON_OUTBOX_MIN_BYTES = 1024 * 1024;
|
|
12177
12306
|
var PRIVATE_LESSON_OUTBOX_MAX_CONFIGURED_BYTES = 1024 * 1024 * 1024;
|
|
12178
12307
|
var PRIVATE_LESSON_DRAFT_MAX_BYTES = 64 * 1024;
|
|
12308
|
+
var PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES = 8 * 1024;
|
|
12179
12309
|
var PRIVATE_LESSON_OUTBOX_LOCK_STALE_MS = 3e4;
|
|
12180
12310
|
var PRIVATE_LESSON_OUTBOX_LOCK_WAIT_MS = 5e3;
|
|
12181
12311
|
var privateLessonOutboxLockContext = new AsyncLocalStorage();
|
|
@@ -12243,6 +12373,9 @@ async function readPrivateLessonDraft(draftId, options = {}) {
|
|
|
12243
12373
|
if (existsSync4(tombstonePath(outbox.directory, draftId))) {
|
|
12244
12374
|
throw new Error("Private lesson draft was explicitly deleted.");
|
|
12245
12375
|
}
|
|
12376
|
+
if (existsSync4(submittedRecordPath(outbox.directory, draftId))) {
|
|
12377
|
+
throw new Error("Private lesson draft was already submitted.");
|
|
12378
|
+
}
|
|
12246
12379
|
const path = draftPath(outbox.directory, draftId);
|
|
12247
12380
|
await assertRegularPrivateFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES);
|
|
12248
12381
|
const serialized = await readBoundedFile(
|
|
@@ -12261,10 +12394,39 @@ async function readPrivateLessonDraft(draftId, options = {}) {
|
|
|
12261
12394
|
return reconciled;
|
|
12262
12395
|
});
|
|
12263
12396
|
}
|
|
12397
|
+
async function readPrivateLessonSubmittedRecord(draftId, options = {}) {
|
|
12398
|
+
assertDraftId(draftId);
|
|
12399
|
+
const directory = options.outboxDirectory ?? privateLessonOutboxDirectory(options.env, options.homeDirectory);
|
|
12400
|
+
if (!existsSync4(directory)) return null;
|
|
12401
|
+
assertPrivateLessonPathMetadata(await lstat(directory), "directory");
|
|
12402
|
+
return withPrivateLessonOutboxLock(directory, async () => {
|
|
12403
|
+
await reconcileSubmittedRecordsUnlocked(
|
|
12404
|
+
directory,
|
|
12405
|
+
options.now ?? /* @__PURE__ */ new Date()
|
|
12406
|
+
);
|
|
12407
|
+
if (existsSync4(tombstonePath(directory, draftId))) {
|
|
12408
|
+
throw new Error("Private lesson draft was explicitly deleted.");
|
|
12409
|
+
}
|
|
12410
|
+
const path = submittedRecordPath(directory, draftId);
|
|
12411
|
+
if (!existsSync4(path)) return null;
|
|
12412
|
+
await assertRegularPrivateFile(
|
|
12413
|
+
path,
|
|
12414
|
+
PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES
|
|
12415
|
+
);
|
|
12416
|
+
return parseSubmittedRecord(
|
|
12417
|
+
await readBoundedFile(path, PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES),
|
|
12418
|
+
draftId
|
|
12419
|
+
);
|
|
12420
|
+
});
|
|
12421
|
+
}
|
|
12264
12422
|
async function reconcilePrivateLessonOutboxProfiles(options = {}) {
|
|
12265
12423
|
const outbox = await readyOutbox(options);
|
|
12266
12424
|
return withPrivateLessonOutboxLock(outbox.directory, async () => {
|
|
12267
12425
|
await reconcileExplicitDeletionTombstones(outbox.directory);
|
|
12426
|
+
await reconcileSubmittedRecordsUnlocked(
|
|
12427
|
+
outbox.directory,
|
|
12428
|
+
options.now ?? /* @__PURE__ */ new Date()
|
|
12429
|
+
);
|
|
12268
12430
|
const entries = await secureOutboxEntries(outbox.directory);
|
|
12269
12431
|
let inspected = 0;
|
|
12270
12432
|
let terminalized = 0;
|
|
@@ -12285,10 +12447,18 @@ async function reconcilePrivateLessonOutboxProfiles(options = {}) {
|
|
|
12285
12447
|
encryption: outbox.encryption
|
|
12286
12448
|
});
|
|
12287
12449
|
inspected += 1;
|
|
12450
|
+
if (draft.state === "submitted") {
|
|
12451
|
+
await compactLegacySubmittedDraftUnlocked(draft, outbox);
|
|
12452
|
+
continue;
|
|
12453
|
+
}
|
|
12288
12454
|
if (before !== "superseded_redactor" && draft.state === "superseded_redactor") {
|
|
12289
12455
|
terminalized += 1;
|
|
12290
12456
|
}
|
|
12291
12457
|
}
|
|
12458
|
+
await reconcileSubmittedRecordsUnlocked(
|
|
12459
|
+
outbox.directory,
|
|
12460
|
+
options.now ?? /* @__PURE__ */ new Date()
|
|
12461
|
+
);
|
|
12292
12462
|
return { inspected, terminalized };
|
|
12293
12463
|
});
|
|
12294
12464
|
}
|
|
@@ -12302,8 +12472,22 @@ async function listPrivateLessonDrafts(options = {}) {
|
|
|
12302
12472
|
const entries = await secureOutboxEntries(outbox.directory);
|
|
12303
12473
|
const limit = Math.max(1, Math.min(options.limit ?? 50, 200));
|
|
12304
12474
|
const drafts = [];
|
|
12305
|
-
for (const name of entries.filter(
|
|
12475
|
+
for (const name of entries.filter(
|
|
12476
|
+
(item) => item.endsWith(".lesson.json") || item.endsWith(".submitted.json")
|
|
12477
|
+
)) {
|
|
12306
12478
|
if (drafts.length >= limit) break;
|
|
12479
|
+
if (name.endsWith(".submitted.json")) {
|
|
12480
|
+
const draftId = name.slice(0, -".submitted.json".length);
|
|
12481
|
+
const completion = parseSubmittedRecord(
|
|
12482
|
+
await readBoundedFile(
|
|
12483
|
+
submittedRecordPath(outbox.directory, draftId),
|
|
12484
|
+
PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES
|
|
12485
|
+
),
|
|
12486
|
+
draftId
|
|
12487
|
+
);
|
|
12488
|
+
drafts.push(submittedRecordListItem(completion));
|
|
12489
|
+
continue;
|
|
12490
|
+
}
|
|
12307
12491
|
const draft = await readPrivateLessonDraft(
|
|
12308
12492
|
name.slice(0, -".lesson.json".length),
|
|
12309
12493
|
{
|
|
@@ -12322,6 +12506,7 @@ async function listPrivateLessonDrafts(options = {}) {
|
|
|
12322
12506
|
created_at: draft.created_at,
|
|
12323
12507
|
updated_at: draft.updated_at,
|
|
12324
12508
|
submitted_at: draft.submitted_at,
|
|
12509
|
+
completion_expires_at: null,
|
|
12325
12510
|
terminal_reason: draft.terminal_reason,
|
|
12326
12511
|
hold_telemetry_status: draft.hold_telemetry_status,
|
|
12327
12512
|
hold_event_digest: draft.hold_event_digest,
|
|
@@ -12349,6 +12534,72 @@ async function updatePrivateLessonDraft(draftId, update, options = {}) {
|
|
|
12349
12534
|
return next;
|
|
12350
12535
|
});
|
|
12351
12536
|
}
|
|
12537
|
+
async function completePrivateLessonSubmission(draftId, input, options = {}) {
|
|
12538
|
+
assertDraftId(draftId);
|
|
12539
|
+
const outbox = await readyOutbox(options);
|
|
12540
|
+
return withPrivateLessonOutboxLock(outbox.directory, async () => {
|
|
12541
|
+
await reconcileSubmittedRecordsUnlocked(
|
|
12542
|
+
outbox.directory,
|
|
12543
|
+
options.now ?? /* @__PURE__ */ new Date()
|
|
12544
|
+
);
|
|
12545
|
+
if (existsSync4(tombstonePath(outbox.directory, draftId))) {
|
|
12546
|
+
throw new Error("Private lesson draft was explicitly deleted.");
|
|
12547
|
+
}
|
|
12548
|
+
const existingPath = submittedRecordPath(outbox.directory, draftId);
|
|
12549
|
+
if (existsSync4(existingPath)) {
|
|
12550
|
+
const completion2 = parseSubmittedRecord(
|
|
12551
|
+
await readBoundedFile(
|
|
12552
|
+
existingPath,
|
|
12553
|
+
PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES
|
|
12554
|
+
),
|
|
12555
|
+
draftId
|
|
12556
|
+
);
|
|
12557
|
+
if (completion2.receipt_digest !== input.receipt_digest) {
|
|
12558
|
+
throw new Error("Private lesson submitted receipt digest conflicts.");
|
|
12559
|
+
}
|
|
12560
|
+
return completion2;
|
|
12561
|
+
}
|
|
12562
|
+
const path = draftPath(outbox.directory, draftId);
|
|
12563
|
+
await assertRegularPrivateFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES);
|
|
12564
|
+
const envelope = parseEnvelope(
|
|
12565
|
+
await readBoundedFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES),
|
|
12566
|
+
draftId
|
|
12567
|
+
);
|
|
12568
|
+
const current = decryptDraft(envelope, outbox.encryption, true);
|
|
12569
|
+
if (current.state === "superseded_redactor") {
|
|
12570
|
+
throw new Error("Terminal private lesson drafts cannot be submitted.");
|
|
12571
|
+
}
|
|
12572
|
+
if (current.state === "submitted" && current.receipt_digest !== input.receipt_digest) {
|
|
12573
|
+
throw new Error("Private lesson submitted receipt digest conflicts.");
|
|
12574
|
+
}
|
|
12575
|
+
const submittedAt = input.submitted_at ?? options.now ?? /* @__PURE__ */ new Date();
|
|
12576
|
+
const submittedIso = submittedAt.toISOString();
|
|
12577
|
+
const draft = current.state === "submitted" ? current : validateDraft({
|
|
12578
|
+
...current,
|
|
12579
|
+
state: "submitted",
|
|
12580
|
+
attempts: Math.min(1e3, current.attempts + 1),
|
|
12581
|
+
next_retry_at: null,
|
|
12582
|
+
last_error_code: null,
|
|
12583
|
+
submitted_at: submittedIso,
|
|
12584
|
+
receipt_digest: input.receipt_digest,
|
|
12585
|
+
updated_at: submittedIso
|
|
12586
|
+
});
|
|
12587
|
+
const completion = submittedRecordFromDraft(draft, {
|
|
12588
|
+
remote_status: input.remote_status,
|
|
12589
|
+
processing_state: input.processing_state
|
|
12590
|
+
});
|
|
12591
|
+
await atomicWritePrivate(
|
|
12592
|
+
submittedRecordPath(outbox.directory, draftId),
|
|
12593
|
+
`${JSON.stringify(completion)}
|
|
12594
|
+
`
|
|
12595
|
+
);
|
|
12596
|
+
await unlink(path).catch(() => {
|
|
12597
|
+
});
|
|
12598
|
+
await syncDirectory(outbox.directory).catch(() => {
|
|
12599
|
+
});
|
|
12600
|
+
return completion;
|
|
12601
|
+
});
|
|
12602
|
+
}
|
|
12352
12603
|
async function deletePrivateLessonDraft(draftId, confirmed, options = {}) {
|
|
12353
12604
|
if (!confirmed) {
|
|
12354
12605
|
throw new Error(
|
|
@@ -12359,7 +12610,19 @@ async function deletePrivateLessonDraft(draftId, confirmed, options = {}) {
|
|
|
12359
12610
|
const outbox = await readyOutbox(options);
|
|
12360
12611
|
return withPrivateLessonOutboxLock(outbox.directory, async () => {
|
|
12361
12612
|
const path = draftPath(outbox.directory, draftId);
|
|
12362
|
-
|
|
12613
|
+
const completionPath = submittedRecordPath(outbox.directory, draftId);
|
|
12614
|
+
if (!existsSync4(path) && !existsSync4(completionPath)) {
|
|
12615
|
+
throw new Error("Private lesson draft does not exist.");
|
|
12616
|
+
}
|
|
12617
|
+
if (existsSync4(path)) {
|
|
12618
|
+
await assertRegularPrivateFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES);
|
|
12619
|
+
}
|
|
12620
|
+
if (existsSync4(completionPath)) {
|
|
12621
|
+
await assertRegularPrivateFile(
|
|
12622
|
+
completionPath,
|
|
12623
|
+
PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES
|
|
12624
|
+
);
|
|
12625
|
+
}
|
|
12363
12626
|
const tombstone = {
|
|
12364
12627
|
version: "private-lesson-outbox-deletion-v1",
|
|
12365
12628
|
draft_id: draftId,
|
|
@@ -12371,7 +12634,12 @@ async function deletePrivateLessonDraft(draftId, confirmed, options = {}) {
|
|
|
12371
12634
|
`${JSON.stringify(tombstone)}
|
|
12372
12635
|
`
|
|
12373
12636
|
);
|
|
12374
|
-
await unlink(path)
|
|
12637
|
+
await unlink(path).catch((error) => {
|
|
12638
|
+
if (!isMissingError(error)) throw error;
|
|
12639
|
+
});
|
|
12640
|
+
await unlink(completionPath).catch((error) => {
|
|
12641
|
+
if (!isMissingError(error)) throw error;
|
|
12642
|
+
});
|
|
12375
12643
|
await syncDirectory(outbox.directory);
|
|
12376
12644
|
return tombstone;
|
|
12377
12645
|
});
|
|
@@ -12404,6 +12672,15 @@ async function privateLessonOutboxSummary(options = {}) {
|
|
|
12404
12672
|
deletionTombstones += 1;
|
|
12405
12673
|
continue;
|
|
12406
12674
|
}
|
|
12675
|
+
if (name.endsWith(".submitted.json")) {
|
|
12676
|
+
const draftId = name.slice(0, -".submitted.json".length);
|
|
12677
|
+
parseSubmittedRecord(
|
|
12678
|
+
await readBoundedFile(path, PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES),
|
|
12679
|
+
draftId
|
|
12680
|
+
);
|
|
12681
|
+
counts.submitted += 1;
|
|
12682
|
+
continue;
|
|
12683
|
+
}
|
|
12407
12684
|
if (!name.endsWith(".lesson.json")) continue;
|
|
12408
12685
|
const envelope = parseEnvelope(
|
|
12409
12686
|
await readBoundedFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES),
|
|
@@ -12466,6 +12743,11 @@ async function inspectPrivateLessonOutboxHealth(options = {}) {
|
|
|
12466
12743
|
outboxDirectory: directory,
|
|
12467
12744
|
encryption
|
|
12468
12745
|
});
|
|
12746
|
+
} else {
|
|
12747
|
+
await withPrivateLessonOutboxLock(
|
|
12748
|
+
directory,
|
|
12749
|
+
() => reconcileSubmittedRecordsUnlocked(directory, options.now ?? /* @__PURE__ */ new Date())
|
|
12750
|
+
);
|
|
12469
12751
|
}
|
|
12470
12752
|
const entries = await secureOutboxEntries(directory);
|
|
12471
12753
|
const counts = emptyOutboxCounts();
|
|
@@ -12481,6 +12763,15 @@ async function inspectPrivateLessonOutboxHealth(options = {}) {
|
|
|
12481
12763
|
deletionTombstones += 1;
|
|
12482
12764
|
continue;
|
|
12483
12765
|
}
|
|
12766
|
+
if (name.endsWith(".submitted.json")) {
|
|
12767
|
+
const draftId = name.slice(0, -".submitted.json".length);
|
|
12768
|
+
parseSubmittedRecord(
|
|
12769
|
+
await readBoundedFile(path, PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES),
|
|
12770
|
+
draftId
|
|
12771
|
+
);
|
|
12772
|
+
counts.submitted += 1;
|
|
12773
|
+
continue;
|
|
12774
|
+
}
|
|
12484
12775
|
if (!name.endsWith(".lesson.json")) continue;
|
|
12485
12776
|
const envelope = parseEnvelope(
|
|
12486
12777
|
await readBoundedFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES),
|
|
@@ -12548,7 +12839,7 @@ async function resolvePrivateLessonOutboxEncryption(options = {}) {
|
|
|
12548
12839
|
encoded = await readConcurrentPrivateLessonFallbackKey(path);
|
|
12549
12840
|
} catch (error) {
|
|
12550
12841
|
if (!isMissingError(error)) throw error;
|
|
12551
|
-
encoded =
|
|
12842
|
+
encoded = randomBytes3(32).toString("base64url");
|
|
12552
12843
|
try {
|
|
12553
12844
|
await atomicWritePrivate(path, `${encoded}
|
|
12554
12845
|
`);
|
|
@@ -12680,7 +12971,7 @@ async function withPrivateLessonOutboxLock(directory, operation) {
|
|
|
12680
12971
|
if (Date.now() - metadata.mtimeMs > PRIVATE_LESSON_OUTBOX_LOCK_STALE_MS) {
|
|
12681
12972
|
const stalePath = join4(
|
|
12682
12973
|
directory,
|
|
12683
|
-
`.outbox.lock.stale-${
|
|
12974
|
+
`.outbox.lock.stale-${randomBytes3(12).toString("hex")}`
|
|
12684
12975
|
);
|
|
12685
12976
|
try {
|
|
12686
12977
|
await rename(lockPath, stalePath);
|
|
@@ -12730,14 +13021,22 @@ async function reconcileExplicitDeletionTombstones(directory) {
|
|
|
12730
13021
|
await readBoundedFile(join4(directory, name), 4 * 1024),
|
|
12731
13022
|
name.slice(0, -".deleted.json".length)
|
|
12732
13023
|
);
|
|
12733
|
-
const
|
|
12734
|
-
|
|
12735
|
-
|
|
12736
|
-
|
|
12737
|
-
|
|
12738
|
-
|
|
12739
|
-
|
|
12740
|
-
|
|
13024
|
+
const retainedPaths = [
|
|
13025
|
+
{
|
|
13026
|
+
path: draftPath(directory, tombstone.draft_id),
|
|
13027
|
+
maxBytes: PRIVATE_LESSON_DRAFT_MAX_BYTES
|
|
13028
|
+
},
|
|
13029
|
+
{
|
|
13030
|
+
path: submittedRecordPath(directory, tombstone.draft_id),
|
|
13031
|
+
maxBytes: PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES
|
|
13032
|
+
}
|
|
13033
|
+
];
|
|
13034
|
+
for (const retained of retainedPaths) {
|
|
13035
|
+
if (!existsSync4(retained.path)) continue;
|
|
13036
|
+
await assertRegularPrivateFile(retained.path, retained.maxBytes);
|
|
13037
|
+
await unlink(retained.path);
|
|
13038
|
+
changed = true;
|
|
13039
|
+
}
|
|
12741
13040
|
}
|
|
12742
13041
|
if (changed) await syncDirectory(directory);
|
|
12743
13042
|
}
|
|
@@ -12768,7 +13067,7 @@ async function writeDraftUnlocked(draft, outbox, replace, _currentOutboxBytes, a
|
|
|
12768
13067
|
if (plaintext.length > PRIVATE_LESSON_DRAFT_MAX_BYTES) {
|
|
12769
13068
|
throw new Error("Private lesson draft exceeds its local size limit.");
|
|
12770
13069
|
}
|
|
12771
|
-
const nonce =
|
|
13070
|
+
const nonce = randomBytes3(12);
|
|
12772
13071
|
const aad = Buffer.from(
|
|
12773
13072
|
canonicalJson({
|
|
12774
13073
|
version: "private-lesson-outbox-envelope-v1",
|
|
@@ -12936,6 +13235,135 @@ function terminalizeUnsupportedProfile(draft, now) {
|
|
|
12936
13235
|
};
|
|
12937
13236
|
}
|
|
12938
13237
|
}
|
|
13238
|
+
function submittedRecordFromDraft(draft, remote) {
|
|
13239
|
+
if (draft.state !== "submitted" || !draft.submitted_at || !draft.receipt_digest) {
|
|
13240
|
+
throw new Error("Private lesson submission completion is incomplete.");
|
|
13241
|
+
}
|
|
13242
|
+
return parseSubmittedRecord(
|
|
13243
|
+
JSON.stringify({
|
|
13244
|
+
version: "private-lesson-outbox-submitted-v1",
|
|
13245
|
+
draft_id: draft.draft_id,
|
|
13246
|
+
state: "submitted",
|
|
13247
|
+
payload_digest: draft.payload_digest,
|
|
13248
|
+
redaction_version: draft.redaction_version,
|
|
13249
|
+
redactor_digest: draft.redactor_digest,
|
|
13250
|
+
attempts: draft.attempts,
|
|
13251
|
+
created_at: draft.created_at,
|
|
13252
|
+
updated_at: draft.updated_at,
|
|
13253
|
+
submitted_at: draft.submitted_at,
|
|
13254
|
+
expires_at: new Date(
|
|
13255
|
+
Date.parse(draft.submitted_at) + PRIVATE_LESSON_SUBMITTED_RECORD_RETENTION_MS
|
|
13256
|
+
).toISOString(),
|
|
13257
|
+
receipt_digest: draft.receipt_digest,
|
|
13258
|
+
remote_status: remote.remote_status,
|
|
13259
|
+
processing_state: remote.processing_state
|
|
13260
|
+
}),
|
|
13261
|
+
draft.draft_id
|
|
13262
|
+
);
|
|
13263
|
+
}
|
|
13264
|
+
async function compactLegacySubmittedDraftUnlocked(draft, outbox) {
|
|
13265
|
+
const completion = submittedRecordFromDraft(draft, {
|
|
13266
|
+
remote_status: null,
|
|
13267
|
+
processing_state: null
|
|
13268
|
+
});
|
|
13269
|
+
const path = submittedRecordPath(outbox.directory, draft.draft_id);
|
|
13270
|
+
try {
|
|
13271
|
+
await atomicWritePrivate(path, `${JSON.stringify(completion)}
|
|
13272
|
+
`);
|
|
13273
|
+
} catch (error) {
|
|
13274
|
+
if (!isAlreadyExistsError2(error)) throw error;
|
|
13275
|
+
const existing = parseSubmittedRecord(
|
|
13276
|
+
await readBoundedFile(path, PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES),
|
|
13277
|
+
draft.draft_id
|
|
13278
|
+
);
|
|
13279
|
+
if (existing.payload_digest !== completion.payload_digest || existing.receipt_digest !== completion.receipt_digest) {
|
|
13280
|
+
throw new Error("Private lesson submitted record conflicts.");
|
|
13281
|
+
}
|
|
13282
|
+
}
|
|
13283
|
+
await unlink(draftPath(outbox.directory, draft.draft_id));
|
|
13284
|
+
await syncDirectory(outbox.directory);
|
|
13285
|
+
}
|
|
13286
|
+
async function reconcileSubmittedRecordsUnlocked(directory, now) {
|
|
13287
|
+
const entries = await secureOutboxEntries(directory);
|
|
13288
|
+
let changed = false;
|
|
13289
|
+
for (const name of entries.filter(
|
|
13290
|
+
(entry) => entry.endsWith(".submitted.json")
|
|
13291
|
+
)) {
|
|
13292
|
+
const draftId = name.slice(0, -".submitted.json".length);
|
|
13293
|
+
const path = submittedRecordPath(directory, draftId);
|
|
13294
|
+
const completion = parseSubmittedRecord(
|
|
13295
|
+
await readBoundedFile(path, PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES),
|
|
13296
|
+
draftId
|
|
13297
|
+
);
|
|
13298
|
+
const retainedDraft = draftPath(directory, draftId);
|
|
13299
|
+
if (existsSync4(retainedDraft)) {
|
|
13300
|
+
await assertRegularPrivateFile(
|
|
13301
|
+
retainedDraft,
|
|
13302
|
+
PRIVATE_LESSON_DRAFT_MAX_BYTES
|
|
13303
|
+
);
|
|
13304
|
+
await unlink(retainedDraft);
|
|
13305
|
+
changed = true;
|
|
13306
|
+
}
|
|
13307
|
+
if (Date.parse(completion.expires_at) <= now.getTime()) {
|
|
13308
|
+
await unlink(path);
|
|
13309
|
+
changed = true;
|
|
13310
|
+
}
|
|
13311
|
+
}
|
|
13312
|
+
if (changed) await syncDirectory(directory);
|
|
13313
|
+
}
|
|
13314
|
+
function submittedRecordListItem(completion) {
|
|
13315
|
+
return {
|
|
13316
|
+
draft_id: completion.draft_id,
|
|
13317
|
+
state: "submitted",
|
|
13318
|
+
payload_digest: completion.payload_digest,
|
|
13319
|
+
attempts: completion.attempts,
|
|
13320
|
+
next_retry_at: null,
|
|
13321
|
+
last_error_code: null,
|
|
13322
|
+
created_at: completion.created_at,
|
|
13323
|
+
updated_at: completion.updated_at,
|
|
13324
|
+
submitted_at: completion.submitted_at,
|
|
13325
|
+
completion_expires_at: completion.expires_at,
|
|
13326
|
+
terminal_reason: null,
|
|
13327
|
+
hold_telemetry_status: "not_required",
|
|
13328
|
+
hold_event_digest: null,
|
|
13329
|
+
hold_reported_at: null
|
|
13330
|
+
};
|
|
13331
|
+
}
|
|
13332
|
+
function parseSubmittedRecord(serialized, expectedDraftId) {
|
|
13333
|
+
const value = JSON.parse(serialized);
|
|
13334
|
+
const allowedKeys = /* @__PURE__ */ new Set([
|
|
13335
|
+
"version",
|
|
13336
|
+
"draft_id",
|
|
13337
|
+
"state",
|
|
13338
|
+
"payload_digest",
|
|
13339
|
+
"redaction_version",
|
|
13340
|
+
"redactor_digest",
|
|
13341
|
+
"attempts",
|
|
13342
|
+
"created_at",
|
|
13343
|
+
"updated_at",
|
|
13344
|
+
"submitted_at",
|
|
13345
|
+
"expires_at",
|
|
13346
|
+
"receipt_digest",
|
|
13347
|
+
"remote_status",
|
|
13348
|
+
"processing_state"
|
|
13349
|
+
]);
|
|
13350
|
+
if (!isRecord4(value) || Object.keys(value).some((key) => !allowedKeys.has(key)) || value.version !== "private-lesson-outbox-submitted-v1" || value.draft_id !== expectedDraftId || value.state !== "submitted" || !isSha256Digest(value.payload_digest) || typeof value.redaction_version !== "string" || !isSha256Digest(value.redactor_digest) || !Number.isInteger(value.attempts) || Number(value.attempts) < 1 || Number(value.attempts) > 1e3 || !isIsoTimestamp(value.created_at) || !isIsoTimestamp(value.updated_at) || !isIsoTimestamp(value.submitted_at) || !isIsoTimestamp(value.expires_at) || !isSha256Digest(value.receipt_digest) || value.remote_status !== null && value.remote_status !== "accepted_private_candidate" && value.remote_status !== "duplicate" || value.processing_state !== null && value.processing_state !== "queued_for_verification" && value.processing_state !== "already_queued") {
|
|
13351
|
+
throw new Error("Private lesson submitted record is invalid.");
|
|
13352
|
+
}
|
|
13353
|
+
const submittedAt = Date.parse(value.submitted_at);
|
|
13354
|
+
if (Date.parse(value.created_at) > submittedAt || Date.parse(value.updated_at) < submittedAt || Date.parse(value.expires_at) !== submittedAt + PRIVATE_LESSON_SUBMITTED_RECORD_RETENTION_MS || value.remote_status === null !== (value.processing_state === null) || value.remote_status === "accepted_private_candidate" && value.processing_state !== "queued_for_verification" || value.remote_status === "duplicate" && value.processing_state !== "already_queued") {
|
|
13355
|
+
throw new Error("Private lesson submitted record is inconsistent.");
|
|
13356
|
+
}
|
|
13357
|
+
return value;
|
|
13358
|
+
}
|
|
13359
|
+
function isSha256Digest(value) {
|
|
13360
|
+
return typeof value === "string" && /^sha256:[a-f0-9]{64}$/.test(value);
|
|
13361
|
+
}
|
|
13362
|
+
function isIsoTimestamp(value) {
|
|
13363
|
+
if (typeof value !== "string") return false;
|
|
13364
|
+
const parsed = Date.parse(value);
|
|
13365
|
+
return Number.isFinite(parsed) && new Date(parsed).toISOString() === value;
|
|
13366
|
+
}
|
|
12939
13367
|
function isHoldTelemetryStatus(value) {
|
|
12940
13368
|
return value === "not_required" || value === "pending" || value === "awaiting_authorization" || value === "retry_scheduled" || value === "submitted" || value === "disabled";
|
|
12941
13369
|
}
|
|
@@ -12983,7 +13411,7 @@ async function atomicReplacePrivate(path, data) {
|
|
|
12983
13411
|
await assertRegularPrivateFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES);
|
|
12984
13412
|
const temporary = join4(
|
|
12985
13413
|
dirname2(path),
|
|
12986
|
-
`.${
|
|
13414
|
+
`.${randomBytes3(12).toString("hex")}.tmp`
|
|
12987
13415
|
);
|
|
12988
13416
|
try {
|
|
12989
13417
|
await atomicWritePrivate(temporary, data);
|
|
@@ -13045,6 +13473,10 @@ function draftPath(directory, draftId) {
|
|
|
13045
13473
|
assertDraftId(draftId);
|
|
13046
13474
|
return join4(directory, `${draftId}.lesson.json`);
|
|
13047
13475
|
}
|
|
13476
|
+
function submittedRecordPath(directory, draftId) {
|
|
13477
|
+
assertDraftId(draftId);
|
|
13478
|
+
return join4(directory, `${draftId}.submitted.json`);
|
|
13479
|
+
}
|
|
13048
13480
|
function tombstonePath(directory, draftId) {
|
|
13049
13481
|
assertDraftId(draftId);
|
|
13050
13482
|
return join4(directory, `${draftId}.deleted.json`);
|
|
@@ -13097,7 +13529,7 @@ async function readConcurrentPrivateLessonFallbackKey(path) {
|
|
|
13097
13529
|
// src/server.ts
|
|
13098
13530
|
var MAX_REMOTE_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
13099
13531
|
var DOCTOR_PROBE_TIMEOUT_MS = 7500;
|
|
13100
|
-
var SERVER_VERSION = true ? "0.1.
|
|
13532
|
+
var SERVER_VERSION = true ? "0.1.74" : "0.0.0-dev";
|
|
13101
13533
|
var tools = toolDefinitions;
|
|
13102
13534
|
var doctorCliRequested = process.argv[2] === "doctor";
|
|
13103
13535
|
var inputBuffer = Buffer.alloc(0);
|
|
@@ -13565,6 +13997,13 @@ async function preparePrivateLessonDraft(input) {
|
|
|
13565
13997
|
async function inspectPrivateLessonOutbox(input) {
|
|
13566
13998
|
const summary = await privateLessonOutboxSummary();
|
|
13567
13999
|
if (input.draft_id) {
|
|
14000
|
+
const completion = await readPrivateLessonSubmittedRecord(input.draft_id);
|
|
14001
|
+
if (completion) {
|
|
14002
|
+
return {
|
|
14003
|
+
draft: privateLessonSubmittedActionResult(completion),
|
|
14004
|
+
outbox: publicOutboxSummary(summary)
|
|
14005
|
+
};
|
|
14006
|
+
}
|
|
13568
14007
|
const draft = await readPrivateLessonDraft(input.draft_id);
|
|
13569
14008
|
return {
|
|
13570
14009
|
draft: privateLessonDraftActionResult(draft),
|
|
@@ -13577,6 +14016,12 @@ async function inspectPrivateLessonOutbox(input) {
|
|
|
13577
14016
|
};
|
|
13578
14017
|
}
|
|
13579
14018
|
async function submitPrivateLessonDraft(draftId, access, allowPolicyRefresh) {
|
|
14019
|
+
const completion = await readPrivateLessonSubmittedRecord(draftId);
|
|
14020
|
+
if (completion) {
|
|
14021
|
+
return privateLessonSubmittedActionResult(completion, {
|
|
14022
|
+
status: "already_submitted"
|
|
14023
|
+
});
|
|
14024
|
+
}
|
|
13580
14025
|
let draft = await readPrivateLessonDraft(draftId);
|
|
13581
14026
|
if (draft.state === "superseded_redactor") {
|
|
13582
14027
|
return privateLessonDraftActionResult(draft, {
|
|
@@ -13585,7 +14030,18 @@ async function submitPrivateLessonDraft(draftId, access, allowPolicyRefresh) {
|
|
|
13585
14030
|
});
|
|
13586
14031
|
}
|
|
13587
14032
|
if (draft.state === "submitted") {
|
|
13588
|
-
|
|
14033
|
+
if (!draft.receipt_digest) {
|
|
14034
|
+
throw new PrivateLessonPolicySafetyError(
|
|
14035
|
+
"Submitted private lesson draft is missing its receipt digest."
|
|
14036
|
+
);
|
|
14037
|
+
}
|
|
14038
|
+
const migrated = await completePrivateLessonSubmission(draft.draft_id, {
|
|
14039
|
+
receipt_digest: draft.receipt_digest,
|
|
14040
|
+
remote_status: null,
|
|
14041
|
+
processing_state: null,
|
|
14042
|
+
submitted_at: draft.submitted_at ? new Date(draft.submitted_at) : void 0
|
|
14043
|
+
});
|
|
14044
|
+
return privateLessonSubmittedActionResult(migrated, {
|
|
13589
14045
|
status: "already_submitted"
|
|
13590
14046
|
});
|
|
13591
14047
|
}
|
|
@@ -13747,23 +14203,43 @@ async function submitPrivateLessonDraft(draftId, access, allowPolicyRefresh) {
|
|
|
13747
14203
|
);
|
|
13748
14204
|
}
|
|
13749
14205
|
const receiptDigest = hashValue(canonicalJson(receipt));
|
|
13750
|
-
|
|
13751
|
-
|
|
14206
|
+
const completionProcessingState = processingState === "queued_for_verification" || processingState === "already_queued" ? processingState : null;
|
|
14207
|
+
if (!completionProcessingState) {
|
|
14208
|
+
throw new PrivateLessonPolicySafetyError(
|
|
14209
|
+
"Private lesson submission omitted its processing state."
|
|
14210
|
+
);
|
|
14211
|
+
}
|
|
14212
|
+
const completion2 = await completePrivateLessonSubmission(draft.draft_id, {
|
|
14213
|
+
receipt_digest: receiptDigest,
|
|
14214
|
+
remote_status: status,
|
|
14215
|
+
processing_state: completionProcessingState
|
|
14216
|
+
});
|
|
14217
|
+
draft = {
|
|
14218
|
+
...draft,
|
|
13752
14219
|
state: "submitted",
|
|
13753
|
-
attempts:
|
|
14220
|
+
attempts: completion2.attempts,
|
|
13754
14221
|
next_retry_at: null,
|
|
13755
14222
|
last_error_code: null,
|
|
13756
|
-
submitted_at:
|
|
13757
|
-
receipt_digest:
|
|
13758
|
-
policy: receiptPolicy
|
|
13759
|
-
|
|
14223
|
+
submitted_at: completion2.submitted_at,
|
|
14224
|
+
receipt_digest: completion2.receipt_digest,
|
|
14225
|
+
policy: receiptPolicy,
|
|
14226
|
+
updated_at: completion2.updated_at
|
|
14227
|
+
};
|
|
13760
14228
|
return {
|
|
13761
|
-
...privateLessonDraftActionResult(draft, {
|
|
14229
|
+
...privateLessonDraftActionResult(draft, {
|
|
14230
|
+
status,
|
|
14231
|
+
completion_expires_at: completion2.expires_at
|
|
14232
|
+
}),
|
|
13762
14233
|
receipt,
|
|
13763
14234
|
processing_state: processingState
|
|
13764
14235
|
};
|
|
13765
14236
|
} catch (error) {
|
|
13766
14237
|
draft = await persistPrivateLessonFailure(draft, error);
|
|
14238
|
+
if (draft.state === "submitted") {
|
|
14239
|
+
return privateLessonDraftActionResult(draft, {
|
|
14240
|
+
status: "already_submitted"
|
|
14241
|
+
});
|
|
14242
|
+
}
|
|
13767
14243
|
return privateLessonDraftActionResult(draft, {
|
|
13768
14244
|
status: "not_submitted",
|
|
13769
14245
|
action_required: draft.state === "awaiting_authorization" ? "Authorize the exact submit_private_lesson_candidate action, then retry." : null
|
|
@@ -14141,13 +14617,37 @@ function privateLessonPolicyBindingConflict(stored, current, allowPolicyRefresh)
|
|
|
14141
14617
|
return null;
|
|
14142
14618
|
}
|
|
14143
14619
|
async function persistPrivateLessonFailure(draft, error, explicitCode) {
|
|
14144
|
-
|
|
14145
|
-
|
|
14146
|
-
|
|
14147
|
-
|
|
14148
|
-
|
|
14620
|
+
const completed = await readPrivateLessonSubmittedRecord(draft.draft_id);
|
|
14621
|
+
if (completed) return submittedDraftFromCompletion(draft, completed);
|
|
14622
|
+
try {
|
|
14623
|
+
return await updatePrivateLessonDraft(
|
|
14624
|
+
draft.draft_id,
|
|
14625
|
+
(current) => current.state === "submitted" || current.state === "superseded_redactor" ? current : {
|
|
14626
|
+
...current,
|
|
14627
|
+
...privateLessonFailureTransition(current, error, explicitCode)
|
|
14628
|
+
}
|
|
14629
|
+
);
|
|
14630
|
+
} catch (updateError) {
|
|
14631
|
+
const racedCompletion = await readPrivateLessonSubmittedRecord(
|
|
14632
|
+
draft.draft_id
|
|
14633
|
+
);
|
|
14634
|
+
if (racedCompletion) {
|
|
14635
|
+
return submittedDraftFromCompletion(draft, racedCompletion);
|
|
14149
14636
|
}
|
|
14150
|
-
|
|
14637
|
+
throw updateError;
|
|
14638
|
+
}
|
|
14639
|
+
}
|
|
14640
|
+
function submittedDraftFromCompletion(draft, completion) {
|
|
14641
|
+
return {
|
|
14642
|
+
...draft,
|
|
14643
|
+
state: "submitted",
|
|
14644
|
+
attempts: completion.attempts,
|
|
14645
|
+
next_retry_at: null,
|
|
14646
|
+
last_error_code: null,
|
|
14647
|
+
submitted_at: completion.submitted_at,
|
|
14648
|
+
receipt_digest: completion.receipt_digest,
|
|
14649
|
+
updated_at: completion.updated_at
|
|
14650
|
+
};
|
|
14151
14651
|
}
|
|
14152
14652
|
async function persistPrivateLessonHoldFailure(draft, error, explicitCode) {
|
|
14153
14653
|
return updatePrivateLessonDraft(draft.draft_id, (current) => {
|
|
@@ -14239,6 +14739,29 @@ function privateLessonDraftActionResult(draft, extra = {}) {
|
|
|
14239
14739
|
...extra
|
|
14240
14740
|
};
|
|
14241
14741
|
}
|
|
14742
|
+
function privateLessonSubmittedActionResult(completion, extra = {}) {
|
|
14743
|
+
return {
|
|
14744
|
+
draft_id: completion.draft_id,
|
|
14745
|
+
state: "submitted",
|
|
14746
|
+
payload_digest: completion.payload_digest,
|
|
14747
|
+
redaction_version: completion.redaction_version,
|
|
14748
|
+
redactor_digest: completion.redactor_digest,
|
|
14749
|
+
redaction_counts: {},
|
|
14750
|
+
held_reasons: [],
|
|
14751
|
+
attempts: completion.attempts,
|
|
14752
|
+
next_retry_at: null,
|
|
14753
|
+
last_error_code: null,
|
|
14754
|
+
terminal_reason: null,
|
|
14755
|
+
hold_telemetry_status: "not_required",
|
|
14756
|
+
hold_event_digest: null,
|
|
14757
|
+
hold_receipt_digest: null,
|
|
14758
|
+
hold_reported_at: null,
|
|
14759
|
+
submitted_at: completion.submitted_at,
|
|
14760
|
+
completion_expires_at: completion.expires_at,
|
|
14761
|
+
retained_locally: false,
|
|
14762
|
+
...extra
|
|
14763
|
+
};
|
|
14764
|
+
}
|
|
14242
14765
|
function publicOutboxSummary(summary) {
|
|
14243
14766
|
const { directory: _directory, ...safe } = summary;
|
|
14244
14767
|
return safe;
|
|
@@ -14781,6 +15304,9 @@ async function runLocalConnectionDoctor(options, execution = "mcp_tool", access
|
|
|
14781
15304
|
policyResult,
|
|
14782
15305
|
outboxResult
|
|
14783
15306
|
});
|
|
15307
|
+
if (execution === "mcp_tool") {
|
|
15308
|
+
recordConnectionDoctorLifecycleProbe();
|
|
15309
|
+
}
|
|
14784
15310
|
return {
|
|
14785
15311
|
...report,
|
|
14786
15312
|
status: report.status === "healthy" && privateLessonAutomation.status !== "ready" && privateLessonAutomation.status !== "not_applicable" ? "attention" : report.status,
|
|
@@ -22,8 +22,8 @@ background and register a privacy-bounded runtime profile. They never transmit
|
|
|
22
22
|
the hostname, username, config path, or repository path. Follow the returned
|
|
23
23
|
machine-readable `next_action` rather than asking the user to invent an
|
|
24
24
|
identity. Tool-observer and completion timestamps appear after those events
|
|
25
|
-
become eligible
|
|
26
|
-
|
|
25
|
+
become eligible. A fresh doctor reports them as informational; only a later
|
|
26
|
+
missed lifecycle opportunity raises an attention warning.
|
|
27
27
|
|
|
28
28
|
If this skill is visible but `run_connection_doctor` is absent, explicitly tell
|
|
29
29
|
the user that Remembrance is only partially active. Do not silently fall back
|
|
@@ -176,8 +176,10 @@ nothing was sent and the encrypted draft remains local in
|
|
|
176
176
|
`awaiting_authorization`; do not retry it through REST or another transport.
|
|
177
177
|
Transient timeouts, 429s, and 5xx responses remain queued for bounded retry on a
|
|
178
178
|
later lifecycle. Authentication, policy, or validation failures remain held.
|
|
179
|
-
|
|
180
|
-
confirmed local action.
|
|
179
|
+
Unresolved and terminal lessons never expire or auto-delete; deleting one
|
|
180
|
+
requires an explicit confirmed local action. After a signed submission receipt
|
|
181
|
+
is verified, the encrypted lesson content is removed immediately and its
|
|
182
|
+
content-free completion marker is automatically deleted after 14 days.
|
|
181
183
|
|
|
182
184
|
The signed policy pins `private-lesson-redaction-v2` and the exact digest of its
|
|
183
185
|
current rules. An unsupported version or digest moves the encrypted draft to
|
|
@@ -292,8 +292,11 @@ draft remains on the device in `awaiting_authorization`; do not retry it
|
|
|
292
292
|
through REST, hosted MCP, or another transport. Timeouts, 429s, and 5xx
|
|
293
293
|
responses retry with bounded backoff during later plugin lifecycles. A 401,
|
|
294
294
|
403, policy change, or validation failure remains held for explicit repair.
|
|
295
|
-
|
|
296
|
-
with the local private-lesson tools; deletion requires
|
|
295
|
+
Unresolved and terminal drafts never expire or auto-delete. Inspect, retry, or
|
|
296
|
+
explicitly delete one with the local private-lesson tools; deletion requires
|
|
297
|
+
confirmation. After a signed submission receipt is verified, the encrypted
|
|
298
|
+
lesson content is removed immediately and its content-free completion marker
|
|
299
|
+
is automatically deleted after 14 days.
|
|
297
300
|
|
|
298
301
|
The signed organization policy pins `private-lesson-redaction-v2` and its exact
|
|
299
302
|
supported redactor digest. If either is unsupported, the finalized
|