phaedo-mcp 0.2.2 → 0.3.0
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/NOTICE +17 -0
- package/README.md +51 -9
- package/action-binding.js +82 -0
- package/agreement.js +38 -0
- package/ap2.js +214 -0
- package/autonomy-promotion.js +280 -0
- package/build-package.mjs +25 -10
- package/capture-a11y.js +116 -0
- package/capture-local.js +85 -0
- package/capture-sync.js +99 -0
- package/consult-core.js +50 -6
- package/consult-policy.js +427 -33
- package/consult.js +8 -1
- package/delegation-sync.js +89 -0
- package/escalation-policy.sample.json +13 -0
- package/escalation.js +383 -0
- package/fingerprint-source.js +78 -4
- package/health.js +54 -0
- package/hooks/session-end.mjs +240 -0
- package/install.mjs +164 -21
- package/lib/capture-home.js +46 -0
- package/lib/domain.js +101 -0
- package/lib/is-main.js +40 -0
- package/lib/phaedo-crypto.js +17 -1
- package/package.json +25 -5
- package/phone-source.js +79 -11
- package/projection.js +4 -1
- package/pull.js +19 -3
- package/receipts-sync.js +197 -0
- package/receipts.js +33 -2
- package/server.js +305 -15
- package/server.json +3 -3
- package/signing-audit.js +111 -0
- package/signing-request.js +217 -0
- package/signing.js +176 -0
- package/vendor/authored-source.js +250 -0
- package/vendor/capabilities.js +203 -0
- package/vendor/capture/capture-core.js +67 -0
- package/vendor/capture/detectors.js +287 -0
- package/vendor/capture/exchanges.js +77 -0
- package/vendor/capture/model-detect.js +195 -0
- package/vendor/capture/owner.js +35 -0
- package/vendor/capture/package.json +4 -0
- package/vendor/capture/source-id.js +56 -0
- package/vendor/capture/state.js +148 -0
- package/vendor/capture/transcript.js +153 -0
- package/vendor/commitments.js +169 -0
- package/vendor/context-block.js +251 -18
- package/vendor/envelope.js +50 -4
- package/vendor/extraction/capture-combine.js +160 -0
- package/vendor/extraction/catalog.js +232 -0
- package/vendor/extraction/delta-lift.js +109 -0
- package/vendor/extraction/fingerprint-update.js +227 -0
- package/vendor/extraction/package.json +4 -0
- package/vendor/extraction/persist.js +40 -0
- package/vendor/extraction/reconcile.js +269 -0
- package/vendor/extraction/resolve.js +178 -0
- package/vendor/extraction/rule-promotion.js +137 -0
- package/vendor/kdf.js +20 -1
- package/vendor/protocol.js +7 -0
- package/vendored-files.mjs +82 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Delegation-promotion transport (MCP → relay → extension).
|
|
2
|
+
//
|
|
3
|
+
// Deposits act-as-me delegation SUGGESTIONS (built by consult-core.js
|
|
4
|
+
// buildDelegationPromotions from the consult override history) into the relay's
|
|
5
|
+
// `agent_to_ext` mailbox slot, encrypted under fp_sync_key.
|
|
6
|
+
//
|
|
7
|
+
// ⚠️ KEY DEPENDENCY (not merely historical — see
|
|
8
|
+
// docs/roadmap/paired-client-identity-2026-07-22.md). fp_sync_key is derived from
|
|
9
|
+
// pair_key, and the EXTENSION drains this lane with its OWN single fp_sync_key — no
|
|
10
|
+
// multi-client key loop, unlike the phone's drains. So this deposit is readable only
|
|
11
|
+
// when the MCP and the extension share a pair_key (the original "Option B": the
|
|
12
|
+
// extension's pairing exported to mcp/pairing.json). An MCP paired independently via
|
|
13
|
+
// `pair.js` derives a DIFFERENT key, and the extension logs "likely key mismatch" and
|
|
14
|
+
// drops the suggestion silently. Independent pairing is what makes the MCP a distinct
|
|
15
|
+
// principal for §10.5 `agents` scoping, so these two properties are in tension until
|
|
16
|
+
// the extension's drain gains the phone's try-each-verified-client loop.
|
|
17
|
+
//
|
|
18
|
+
// The extension union-merges
|
|
19
|
+
// the suggestions (sync-merge.js mergeAgentSuggestions) into suggested_rules, where the
|
|
20
|
+
// existing popup review card surfaces them for endorsement.
|
|
21
|
+
//
|
|
22
|
+
// Trust posture: the MCP only ever proposes SUGGESTIONS — never an authored rule. The
|
|
23
|
+
// subject still endorses (the authored-only invariant holds). Best-effort and isolated:
|
|
24
|
+
// a deposit failure never affects the consultation that triggered it. The relay only
|
|
25
|
+
// ever holds ciphertext (the agent_to_ext slot is separate from the phone's to_ext, so
|
|
26
|
+
// a deposit can neither clobber nor be clobbered by the phone's fingerprint sync).
|
|
27
|
+
|
|
28
|
+
import { Phaedo, b64uDec } from './lib/phaedo-crypto.js';
|
|
29
|
+
|
|
30
|
+
const { Kdf, Envelope } = Phaedo;
|
|
31
|
+
const DEFAULT_RELAY = 'https://phaedo-relay.fly.dev';
|
|
32
|
+
|
|
33
|
+
export function relayBaseFromEnv(env = process.env) {
|
|
34
|
+
return env.PHAEDO_RELAY || DEFAULT_RELAY;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Wrap delegation suggestions into the same envelope shape the extension's fp-mailbox
|
|
38
|
+
// drain expects: ciphertext over `{ data: { suggested_rules: [...] } }`. Returns the
|
|
39
|
+
// deposit body `{ envelope }`. Pure crypto (no network) so the round-trip is testable.
|
|
40
|
+
export async function buildDelegationDeposit(pairKeyB64u, suggestions, { now = () => new Date().toISOString() } = {}) {
|
|
41
|
+
const pairKey = b64uDec(pairKeyB64u);
|
|
42
|
+
let fpSyncKey;
|
|
43
|
+
try {
|
|
44
|
+
fpSyncKey = await Kdf.deriveFpSyncKey(pairKey);
|
|
45
|
+
} finally {
|
|
46
|
+
pairKey.fill(0);
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const inner = { kind: 'delegation_suggestions', data: { suggested_rules: suggestions } };
|
|
50
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(inner));
|
|
51
|
+
const envelope = await Envelope.wrap(plaintext, fpSyncKey, { artifact_kind: 'delegation_suggestions', created_at: now() });
|
|
52
|
+
plaintext.fill(0);
|
|
53
|
+
return { envelope };
|
|
54
|
+
} finally {
|
|
55
|
+
if (fpSyncKey && fpSyncKey.fill) fpSyncKey.fill(0);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Deposit the suggestions to the relay's agent_to_ext slot. Best-effort: returns
|
|
60
|
+
// { deposited, ... } and never throws (a transport failure must not break the consult).
|
|
61
|
+
// `deps.fetch` is injectable for tests.
|
|
62
|
+
export async function depositDelegationSuggestions(pairing, suggestions, deps = {}) {
|
|
63
|
+
const fetchFn = deps.fetch || globalThis.fetch;
|
|
64
|
+
const list = Array.isArray(suggestions) ? suggestions : [];
|
|
65
|
+
// Precise reasons so the caller can surface WHY a deposit didn't happen (a missing
|
|
66
|
+
// relay_device_id means a local-only pairing — the deposit silently no-ops otherwise).
|
|
67
|
+
if (!list.length) return { deposited: false, reason: 'no-suggestions' };
|
|
68
|
+
if (!pairing || !pairing.pair_key) return { deposited: false, reason: 'unpaired' };
|
|
69
|
+
if (!pairing.relay_device_id) return { deposited: false, reason: 'no-relay-device-id' };
|
|
70
|
+
try {
|
|
71
|
+
const body = await buildDelegationDeposit(pairing.pair_key, list, deps);
|
|
72
|
+
const base = `${deps.relayBase || relayBaseFromEnv()}/fp-mailbox/${encodeURIComponent(pairing.relay_device_id)}/agent_to_ext`;
|
|
73
|
+
// Per-device auth header (relay/auth.js). Without it the relay 401s. A pairing.json
|
|
74
|
+
// from an older mobile build that predates the auth layer won't have relay_device_secret
|
|
75
|
+
// → no header → 401 → the deposit no-ops, which is the correct failure mode (user re-pairs).
|
|
76
|
+
const authHeader = pairing.relay_device_secret
|
|
77
|
+
? { Authorization: `Bearer ${pairing.relay_device_id}:${pairing.relay_device_secret}` }
|
|
78
|
+
: {};
|
|
79
|
+
const res = await fetchFn(base, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: { 'Content-Type': 'application/json', ...authHeader },
|
|
82
|
+
body: JSON.stringify(body),
|
|
83
|
+
signal: AbortSignal.timeout(deps.timeoutMs || 8000),
|
|
84
|
+
});
|
|
85
|
+
return { deposited: !!(res && res.ok), status: res && res.status, count: list.length };
|
|
86
|
+
} catch (e) {
|
|
87
|
+
return { deposited: false, reason: String(e && e.message || e) };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "0.1",
|
|
3
|
+
"_comment": "Subject-configurable PUSH policy for phaedo_escalate (docs/roadmap/escalation-and-push.md). Controls which consult signals WAKE your phone for a live decision. `escalate` ALWAYS pushes (it means a human is needed) and is not configurable here. Only `clarify` is tunable: mode is \"always\" | \"consequential\" (the default: irreversible OR high-magnitude actions only) | \"never\". A per-domain override (by_domain, substring match, case-insensitive) beats the default. Portable: copy into the vault as phaedo_fingerprint.escalation_policy to sync across devices, or point PHAEDO_ESCALATION_POLICY at this file to try it. The file overrides the vault policy.",
|
|
4
|
+
"clarify": {
|
|
5
|
+
"default": "consequential",
|
|
6
|
+
"by_domain": {
|
|
7
|
+
"financial": "always",
|
|
8
|
+
"legal": "always",
|
|
9
|
+
"writing": "never",
|
|
10
|
+
"internal note": "never"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
package/escalation.js
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
// Real-time escalation client (MCP → relay → subject's device → back).
|
|
2
|
+
//
|
|
3
|
+
// The delivery channel the act-as-me endgame needs once agents act AUTONOMOUSLY for
|
|
4
|
+
// the subject (docs/roadmap/escalation-and-push.md). When a consult returns a
|
|
5
|
+
// process-BLOCKING signal — `escalate` (or `clarify`) — and the agent is running
|
|
6
|
+
// unattended, there is no one at the agent's conversation to answer. This routes the
|
|
7
|
+
// decision to the subject's phone in real time, waits within a bounded window, and
|
|
8
|
+
// returns approve / deny / modify so the agent can resume — or applies the SAFE
|
|
9
|
+
// DEFAULT when the window lapses (an `escalate` means "hold for a human", so the
|
|
10
|
+
// no-response default is do-not-proceed, never a guess).
|
|
11
|
+
//
|
|
12
|
+
// §10.4 boundary on the wire: the escalation payload carries the ACTION under
|
|
13
|
+
// question (the agent's own descriptor) + the consult's `signal`/`rationale_hint` —
|
|
14
|
+
// NEVER fingerprint layer content. And even that is sealed under fp_sync_key (the
|
|
15
|
+
// same HKDF(pair_key) key the delegation deposit uses), so the relay stays
|
|
16
|
+
// zero-knowledge: it forwards ciphertext and wakes the device, nothing more.
|
|
17
|
+
//
|
|
18
|
+
// Honesty rail (roadmap Q7): a real-time approve/deny IS an outcome — the strongest
|
|
19
|
+
// possible act-as-me signal. The orchestrator returns a `record_outcome` mapping
|
|
20
|
+
// (approve→approved, deny→rejected, modify→modified) so the SAME §10.6 receipt +
|
|
21
|
+
// override-learning loop picks it up automatically. A TIMEOUT is "no answer", not an
|
|
22
|
+
// override — it maps to `unknown` and teaches nothing.
|
|
23
|
+
|
|
24
|
+
import { Phaedo, b64uDec } from './lib/phaedo-crypto.js';
|
|
25
|
+
import { normalizeActionBinding } from './action-binding.js';
|
|
26
|
+
|
|
27
|
+
const { Kdf, Envelope } = Phaedo;
|
|
28
|
+
const DEFAULT_RELAY = 'https://phaedo-relay.fly.dev';
|
|
29
|
+
|
|
30
|
+
export function relayBaseFromEnv(env = process.env) {
|
|
31
|
+
return env.PHAEDO_RELAY || DEFAULT_RELAY;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The Authorization header for relay HTTP routes (relay/auth.js per-device bearer).
|
|
35
|
+
// Without this, deposit/poll return 401. A pairing without a relay_device_secret (e.g.
|
|
36
|
+
// from an older mobile build that predates the auth layer) gets no header — relay 401s,
|
|
37
|
+
// which surfaces as deposit-failed-401 from the orchestrator.
|
|
38
|
+
function relayAuthHeader(pairing) {
|
|
39
|
+
if (!pairing || !pairing.relay_device_id || !pairing.relay_device_secret) return {};
|
|
40
|
+
return { Authorization: `Bearer ${pairing.relay_device_id}:${pairing.relay_device_secret}` };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Which consult signals are BLOCKING enough to WAKE the subject (roadmap Q1). The two
|
|
44
|
+
// blocking signals are not equal:
|
|
45
|
+
// - `escalate` = "hold for a live human go/no-go on a consequential call." Blocking by
|
|
46
|
+
// definition → ALWAYS push.
|
|
47
|
+
// - `clarify` = "I need more info before acting." This is the agent's default reflex
|
|
48
|
+
// when uncertain, so it fires often, including on low-stakes calls. Waking the phone
|
|
49
|
+
// for every clarify would cry wolf — so push a clarify ONLY when the action is also
|
|
50
|
+
// CONSEQUENTIAL (irreversible OR high magnitude). Below that bar an autonomous agent
|
|
51
|
+
// should resolve the clarify itself (ask in-flow / state a reasonable assumption) and
|
|
52
|
+
// it surfaces in the deferred review queue — no interruption.
|
|
53
|
+
// Unknown stakes (no descriptor) reads as low-stakes for clarify: the agent can always
|
|
54
|
+
// raise an explicit `escalate` when it really needs the subject. Ultimately this whole
|
|
55
|
+
// policy is subject-configurable via a standing rule; this is the sensible default.
|
|
56
|
+
export function isConsequential(actionDescriptor = {}) {
|
|
57
|
+
const ad = actionDescriptor || {};
|
|
58
|
+
return ad.reversible === false || ad.magnitude === 'high';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Subject-configurable push policy (the standing knob) ───────────────────────
|
|
62
|
+
// The stakes-gated default above is just that — a default. A subject can override how
|
|
63
|
+
// `clarify` is pushed, globally and per-domain, via an escalation policy that rides the
|
|
64
|
+
// vault (portable: `phaedo_fingerprint.escalation_policy`, syncs across devices) and/or a
|
|
65
|
+
// local file (`PHAEDO_ESCALATION_POLICY`, for power users / testing) — the same dual home
|
|
66
|
+
// as consult policies (`consult-policy.js`). Shape:
|
|
67
|
+
// { "clarify": { "default": "always" | "consequential" | "never",
|
|
68
|
+
// "by_domain": { "financial": "always", "writing": "never" } } }
|
|
69
|
+
// `escalate` is deliberately NOT configurable: it means "a human is needed", so suppressing
|
|
70
|
+
// the wake would strand a blocking decision — it always pushes. Only `clarify` is tunable.
|
|
71
|
+
const CLARIFY_MODES = new Set(['always', 'consequential', 'never']);
|
|
72
|
+
|
|
73
|
+
// Merge policy sources (later wins; `by_domain` deep-merged, `default` shallow-overridden).
|
|
74
|
+
// Defensive: ignores malformed entries and unknown modes. Never throws.
|
|
75
|
+
export function mergeEscalationPolicy(...sources) {
|
|
76
|
+
const clarify = { default: undefined, by_domain: {} };
|
|
77
|
+
for (const s of sources) {
|
|
78
|
+
const c = s && typeof s === 'object' ? s.clarify : null;
|
|
79
|
+
if (!c || typeof c !== 'object') continue;
|
|
80
|
+
if (typeof c.default === 'string' && CLARIFY_MODES.has(c.default.toLowerCase())) clarify.default = c.default.toLowerCase();
|
|
81
|
+
if (c.by_domain && typeof c.by_domain === 'object') {
|
|
82
|
+
for (const [k, v] of Object.entries(c.by_domain)) {
|
|
83
|
+
if (typeof v === 'string' && CLARIFY_MODES.has(v.toLowerCase())) clarify.by_domain[String(k).toLowerCase()] = v.toLowerCase();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { clarify };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Resolve the effective policy for a consult: the vault-embedded policy (portable) merged
|
|
91
|
+
// under the local-file override (`opts.escalationPolicy`). Pure; defensive.
|
|
92
|
+
export function loadEscalationPolicy(vd, opts = {}) {
|
|
93
|
+
return mergeEscalationPolicy(vd?.phaedo_fingerprint?.escalation_policy, opts.escalationPolicy);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Does a `by_domain` KEY govern this action domain? EXACT match, or a dot-hierarchy
|
|
97
|
+
// PARENT match (`finance` governs `finance.crypto_trading`).
|
|
98
|
+
//
|
|
99
|
+
// This deliberately replaces the old bidirectional substring test
|
|
100
|
+
// (`domain.includes(k) || k.includes(domain)`), which was both wrong and unsafe:
|
|
101
|
+
// - `legal` spuriously governed `paralegal`, and `care` governed `healthcare` —
|
|
102
|
+
// accidental substrings, not subdomains. So a subject who set `legal: never` to
|
|
103
|
+
// silence legal noise ALSO silenced every paralegal action, and a suppressed
|
|
104
|
+
// clarify never reaches the phone: the failure is invisible by construction.
|
|
105
|
+
// - the reverse direction (`k.includes(domain)`) let a narrow key govern a broad
|
|
106
|
+
// action — a `finance.crypto_trading` rule firing on a bare `finance` action.
|
|
107
|
+
// A real subdomain rides a `.` boundary; a coincidental letter overlap does not.
|
|
108
|
+
// Parent→child only (a `finance` rule covers its subdomains; a crypto rule does NOT
|
|
109
|
+
// reach up to govern general finance). Anything unmatched falls to `default`, which
|
|
110
|
+
// the subject controls — never to a wrong, more-specific rule.
|
|
111
|
+
function domainRuleApplies(key, domain) {
|
|
112
|
+
return domain === key || domain.startsWith(key + '.');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The clarify push mode for an action under a policy: the MOST SPECIFIC per-domain
|
|
116
|
+
// override wins over the default, which falls back to the built-in 'consequential'.
|
|
117
|
+
//
|
|
118
|
+
// "Most specific" = the longest applicable key, so `finance.crypto_trading` beats a
|
|
119
|
+
// bare `finance` for a crypto action. This is also what makes the result
|
|
120
|
+
// DETERMINISTIC: the old first-match-wins loop returned different modes for the same
|
|
121
|
+
// action depending on JSON key order, so a subject's specific rule could lose to a
|
|
122
|
+
// general one purely by where it sat in the object.
|
|
123
|
+
export function resolveClarifyMode(actionDescriptor = {}, policy = null) {
|
|
124
|
+
const c = policy && policy.clarify;
|
|
125
|
+
if (!c) return 'consequential';
|
|
126
|
+
const domain = String(actionDescriptor?.domain || '').toLowerCase();
|
|
127
|
+
if (domain && c.by_domain) {
|
|
128
|
+
let best = null;
|
|
129
|
+
for (const [k, mode] of Object.entries(c.by_domain)) {
|
|
130
|
+
if (!CLARIFY_MODES.has(mode)) continue;
|
|
131
|
+
if (domainRuleApplies(k, domain) && (best === null || k.length > best.k.length)) best = { k, mode };
|
|
132
|
+
}
|
|
133
|
+
if (best) return best.mode;
|
|
134
|
+
}
|
|
135
|
+
return CLARIFY_MODES.has(c.default) ? c.default : 'consequential';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Does this signal WAKE the subject? `escalate` always; `clarify` per the policy
|
|
139
|
+
// (`always` | `never` | `consequential` = irreversible/high-magnitude). Backward
|
|
140
|
+
// compatible: with no policy, clarify falls back to the stakes-gated default.
|
|
141
|
+
export function shouldEscalate(signal, actionDescriptor = {}, policy = null) {
|
|
142
|
+
const s = String(signal);
|
|
143
|
+
if (s === 'escalate') return true;
|
|
144
|
+
if (s === 'clarify') {
|
|
145
|
+
const mode = resolveClarifyMode(actionDescriptor, policy);
|
|
146
|
+
return mode === 'always' ? true : mode === 'never' ? false : isConsequential(actionDescriptor);
|
|
147
|
+
}
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The decision vocabulary the phone returns, mapped to the §10.6 receipt user_action.
|
|
152
|
+
const DECISION_OUTCOME = { approve: 'approved', deny: 'rejected', modify: 'modified' };
|
|
153
|
+
|
|
154
|
+
// Build the §10.4-safe escalation payload from the consult request + its response.
|
|
155
|
+
// PURE. Pulls ONLY the agent's own action descriptor + the constrained consult
|
|
156
|
+
// signal/rationale — no vault data is in scope here at all. Truncates free text so a
|
|
157
|
+
// runaway descriptor can't bloat the (encrypted) wire payload.
|
|
158
|
+
export function buildEscalationPayload(consultRequest = {}, consultResponse = {}) {
|
|
159
|
+
const ad = (consultRequest.action_descriptor && typeof consultRequest.action_descriptor === 'object') ? consultRequest.action_descriptor : {};
|
|
160
|
+
const clip = (s, n) => (typeof s === 'string' ? s.replace(/\s+/g, ' ').trim().slice(0, n) : undefined);
|
|
161
|
+
// The §10.2 binding, when the agent sent one. `summary` above is the agent's
|
|
162
|
+
// PROSE — its account of what it is about to do. This is the call itself. The
|
|
163
|
+
// subject approving an escalation should see both, because the whole class of
|
|
164
|
+
// approval-gate bypasses works by making the two differ.
|
|
165
|
+
let bnd = null;
|
|
166
|
+
try { bnd = normalizeActionBinding(consultRequest.action_binding); } catch { bnd = null; }
|
|
167
|
+
return {
|
|
168
|
+
consultation_type: typeof consultRequest.consultation_type === 'string' ? consultRequest.consultation_type : undefined,
|
|
169
|
+
agent_id: clip(consultRequest.agent_id, 120),
|
|
170
|
+
action: {
|
|
171
|
+
domain: clip(ad.domain, 80),
|
|
172
|
+
summary: clip(ad.summary, 280),
|
|
173
|
+
magnitude: ['low', 'medium', 'high'].includes(ad.magnitude) ? ad.magnitude : undefined,
|
|
174
|
+
reversible: typeof ad.reversible === 'boolean' ? ad.reversible : undefined,
|
|
175
|
+
amount: typeof ad.amount === 'number' && isFinite(ad.amount) ? ad.amount : undefined,
|
|
176
|
+
...(bnd ? {
|
|
177
|
+
tool_name: bnd.tool_name,
|
|
178
|
+
arguments_hash: bnd.arguments_hash,
|
|
179
|
+
...(bnd.arguments_preview ? { arguments_preview: bnd.arguments_preview } : {}),
|
|
180
|
+
} : {}),
|
|
181
|
+
},
|
|
182
|
+
signal: clip(consultResponse.signal, 40),
|
|
183
|
+
rationale_hint: clip(consultResponse.rationale_hint, 280),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Seal the payload under fp_sync_key into the deposit body { request_id, envelope,
|
|
188
|
+
// window_ms }. PURE crypto (no network) so the round-trip is testable in isolation,
|
|
189
|
+
// mirroring delegation-sync.js buildDelegationDeposit.
|
|
190
|
+
export async function buildEscalationDeposit(pairKeyB64u, requestId, payload, { windowMs, now = () => new Date().toISOString() } = {}) {
|
|
191
|
+
const pairKey = b64uDec(pairKeyB64u);
|
|
192
|
+
let fpSyncKey;
|
|
193
|
+
try {
|
|
194
|
+
fpSyncKey = await Kdf.deriveFpSyncKey(pairKey);
|
|
195
|
+
} finally {
|
|
196
|
+
pairKey.fill(0);
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
const inner = { kind: 'escalation_request', request_id: requestId, data: payload };
|
|
200
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(inner));
|
|
201
|
+
const envelope = await Envelope.wrap(plaintext, fpSyncKey, { artifact_kind: 'escalation_request', created_at: now() });
|
|
202
|
+
plaintext.fill(0);
|
|
203
|
+
return { request_id: requestId, envelope, ...(windowMs ? { window_ms: windowMs } : {}) };
|
|
204
|
+
} finally {
|
|
205
|
+
if (fpSyncKey && fpSyncKey.fill) fpSyncKey.fill(0);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Decrypt the subject's decision (the phone's response body { envelope }) under
|
|
210
|
+
// fp_sync_key. Returns the inner { decision, note?, modified? } or null on any
|
|
211
|
+
// failure (a malformed/forged response must never crash the agent's resume path).
|
|
212
|
+
export async function unwrapEscalationResponse(pairKeyB64u, body) {
|
|
213
|
+
if (!body || !body.envelope) return null;
|
|
214
|
+
const pairKey = b64uDec(pairKeyB64u);
|
|
215
|
+
let fpSyncKey;
|
|
216
|
+
try {
|
|
217
|
+
fpSyncKey = await Kdf.deriveFpSyncKey(pairKey);
|
|
218
|
+
} catch { pairKey.fill(0); return null; }
|
|
219
|
+
pairKey.fill(0);
|
|
220
|
+
try {
|
|
221
|
+
const plaintext = await Envelope.unwrap(body.envelope, fpSyncKey);
|
|
222
|
+
const inner = JSON.parse(new TextDecoder().decode(plaintext));
|
|
223
|
+
if (inner && inner.kind === 'escalation_response' && inner.data) return inner.data;
|
|
224
|
+
return null;
|
|
225
|
+
} catch {
|
|
226
|
+
return null;
|
|
227
|
+
} finally {
|
|
228
|
+
if (fpSyncKey && fpSyncKey.fill) fpSyncKey.fill(0);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
233
|
+
function genRequestId() {
|
|
234
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID();
|
|
235
|
+
return `esc-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── Non-blocking deposit + single-poll (for the MCP tools) ─────────────────────
|
|
239
|
+
// A multi-minute BLOCKING tool call is hostile to MCP clients (many time out), so the
|
|
240
|
+
// agent-facing tools use a two-call shape: phaedo_escalate deposits + wakes the device
|
|
241
|
+
// and returns immediately; phaedo_escalation_status polls once. The agent decides when
|
|
242
|
+
// to stop waiting; the safe default once it gives up is HOLD (do-not-proceed).
|
|
243
|
+
|
|
244
|
+
// Deposit the (encrypted) decision request and wake the device. Returns
|
|
245
|
+
// { deposited, request_id, window_ms, expires_at } or { deposited:false, reason }.
|
|
246
|
+
// Never throws.
|
|
247
|
+
export async function depositEscalation(pairing, consultRequest, consultResponse, deps = {}) {
|
|
248
|
+
const fetchFn = deps.fetch || globalThis.fetch;
|
|
249
|
+
const signal = consultResponse && consultResponse.signal;
|
|
250
|
+
if (!pairing || !pairing.pair_key) return { deposited: false, reason: 'unpaired', signal };
|
|
251
|
+
if (!pairing.relay_device_id) return { deposited: false, reason: 'no-relay-device-id', signal };
|
|
252
|
+
const windowMs = Math.max(1000, Number(deps.windowMs) || 3 * 60 * 1000);
|
|
253
|
+
const relayBase = deps.relayBase || relayBaseFromEnv();
|
|
254
|
+
const requestId = deps.requestId || genRequestId();
|
|
255
|
+
try {
|
|
256
|
+
const body = await buildEscalationDeposit(pairing.pair_key, requestId, buildEscalationPayload(consultRequest, consultResponse), { windowMs });
|
|
257
|
+
const res = await fetchFn(`${relayBase}/escalation/${encodeURIComponent(pairing.relay_device_id)}`, {
|
|
258
|
+
method: 'POST',
|
|
259
|
+
headers: { 'Content-Type': 'application/json', ...relayAuthHeader(pairing) },
|
|
260
|
+
body: JSON.stringify(body),
|
|
261
|
+
signal: AbortSignal.timeout(deps.depositTimeoutMs || 8000),
|
|
262
|
+
});
|
|
263
|
+
if (!res || !res.ok) return { deposited: false, reason: `deposit-failed-${res && res.status}`, request_id: requestId, signal };
|
|
264
|
+
let info = {};
|
|
265
|
+
try { info = await res.json(); } catch { /* body optional */ }
|
|
266
|
+
return { deposited: true, request_id: requestId, window_ms: windowMs, expires_at: info.expires_at, woke: info.woke, signal };
|
|
267
|
+
} catch (e) {
|
|
268
|
+
return { deposited: false, reason: `deposit-error:${String(e && e.message || e)}`, request_id: requestId, signal };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Poll ONCE for a decision. Returns { resolved:true, decision, proceed, outcome, note?,
|
|
273
|
+
// modified? } when answered, { resolved:false, status:'pending' } while waiting, or
|
|
274
|
+
// { resolved:false, status:'expired' } once the agent's deadline has passed (the caller
|
|
275
|
+
// then applies the safe-default hold). Never throws.
|
|
276
|
+
export async function pollEscalationOnce(pairing, requestId, deps = {}) {
|
|
277
|
+
const fetchFn = deps.fetch || globalThis.fetch;
|
|
278
|
+
if (!pairing || !pairing.pair_key || !requestId) return { resolved: false, status: 'expired', reason: 'bad-args' };
|
|
279
|
+
if (deps.expiresAt && (deps.now ? deps.now() : Date.now()) > deps.expiresAt) return { resolved: false, status: 'expired' };
|
|
280
|
+
const relayBase = deps.relayBase || relayBaseFromEnv();
|
|
281
|
+
let body = null;
|
|
282
|
+
try {
|
|
283
|
+
const res = await fetchFn(`${relayBase}/escalation/response/${encodeURIComponent(requestId)}`, {
|
|
284
|
+
headers: relayAuthHeader(pairing),
|
|
285
|
+
signal: AbortSignal.timeout(deps.pollTimeoutMs || 8000),
|
|
286
|
+
});
|
|
287
|
+
if (res && res.ok) body = await res.json();
|
|
288
|
+
} catch { /* transient */ }
|
|
289
|
+
if (!body) return { resolved: false, status: 'pending' };
|
|
290
|
+
const data = await unwrapEscalationResponse(pairing.pair_key, body);
|
|
291
|
+
if (!data || !DECISION_OUTCOME[data.decision]) return { resolved: false, status: 'pending' };
|
|
292
|
+
return {
|
|
293
|
+
resolved: true,
|
|
294
|
+
decision: data.decision,
|
|
295
|
+
proceed: data.decision !== 'deny',
|
|
296
|
+
note: typeof data.note === 'string' ? data.note : undefined,
|
|
297
|
+
modified: data.decision === 'modify' ? (data.modified ?? null) : undefined,
|
|
298
|
+
outcome: DECISION_OUTCOME[data.decision],
|
|
299
|
+
request_id: requestId,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// The SAFE DEFAULT applied when the window lapses with no answer (roadmap §crux).
|
|
304
|
+
// `escalate` already means "hold for a human", so absent a decision the agent must
|
|
305
|
+
// NOT proceed — it holds. A timeout is not an override, so it teaches nothing
|
|
306
|
+
// (outcome 'unknown'). Returned in the same shape as a real decision so the caller
|
|
307
|
+
// has one code path.
|
|
308
|
+
function safeDefault(signal) {
|
|
309
|
+
return {
|
|
310
|
+
decision: 'hold',
|
|
311
|
+
resolved: false,
|
|
312
|
+
reason: 'timeout',
|
|
313
|
+
proceed: false, // do-not-proceed is the safe default for a blocking escalate/clarify
|
|
314
|
+
outcome: 'unknown', // no answer ⇒ no act-as-me signal
|
|
315
|
+
signal,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Orchestrate one real-time escalation: deposit → poll within the window → decision
|
|
320
|
+
// or safe-default. Best-effort and self-contained — every failure path resolves to a
|
|
321
|
+
// safe-default rather than throwing into the agent. `deps.fetch`, `deps.sleep`,
|
|
322
|
+
// `deps.now`, `deps.pollMs`, `deps.windowMs`, `deps.relayBase` are injectable for tests.
|
|
323
|
+
//
|
|
324
|
+
// pairing : { pair_key, relay_device_id } (loadPairingRecord)
|
|
325
|
+
// consultRequest : the args passed to phaedo_consult
|
|
326
|
+
// consultResponse : the §10.3 response it returned (must be escalate/clarify)
|
|
327
|
+
// Returns:
|
|
328
|
+
// { resolved, decision, proceed, note?, outcome, request_id, signal, reason? }
|
|
329
|
+
export async function requestEscalationDecision(pairing, consultRequest, consultResponse, deps = {}) {
|
|
330
|
+
const fetchFn = deps.fetch || globalThis.fetch;
|
|
331
|
+
const wait = deps.sleep || sleep;
|
|
332
|
+
const now = deps.now || (() => Date.now());
|
|
333
|
+
const signal = consultResponse && consultResponse.signal;
|
|
334
|
+
|
|
335
|
+
if (!pairing || !pairing.pair_key) return { ...safeDefault(signal), reason: 'unpaired' };
|
|
336
|
+
if (!pairing.relay_device_id) return { ...safeDefault(signal), reason: 'no-relay-device-id' };
|
|
337
|
+
|
|
338
|
+
const windowMs = Math.max(1000, Number(deps.windowMs) || 3 * 60 * 1000);
|
|
339
|
+
const pollMs = Math.max(250, Number(deps.pollMs) || 3000);
|
|
340
|
+
const relayBase = deps.relayBase || relayBaseFromEnv();
|
|
341
|
+
const requestId = deps.requestId || genRequestId();
|
|
342
|
+
|
|
343
|
+
// 1. Deposit the (encrypted) decision request; the relay wakes the device.
|
|
344
|
+
try {
|
|
345
|
+
const body = await buildEscalationDeposit(pairing.pair_key, requestId, buildEscalationPayload(consultRequest, consultResponse), { windowMs });
|
|
346
|
+
const res = await fetchFn(`${relayBase}/escalation/${encodeURIComponent(pairing.relay_device_id)}`, {
|
|
347
|
+
method: 'POST',
|
|
348
|
+
headers: { 'Content-Type': 'application/json', ...relayAuthHeader(pairing) },
|
|
349
|
+
body: JSON.stringify(body),
|
|
350
|
+
signal: AbortSignal.timeout(deps.depositTimeoutMs || 8000),
|
|
351
|
+
});
|
|
352
|
+
if (!res || !res.ok) return { ...safeDefault(signal), reason: `deposit-failed-${res && res.status}`, request_id: requestId };
|
|
353
|
+
} catch (e) {
|
|
354
|
+
return { ...safeDefault(signal), reason: `deposit-error:${String(e && e.message || e)}`, request_id: requestId };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// 2. Poll for the decision until the window lapses.
|
|
358
|
+
const deadline = now() + windowMs;
|
|
359
|
+
const pollUrl = `${relayBase}/escalation/response/${encodeURIComponent(requestId)}`;
|
|
360
|
+
while (now() < deadline) {
|
|
361
|
+
await wait(pollMs);
|
|
362
|
+
let body = null;
|
|
363
|
+
try {
|
|
364
|
+
const res = await fetchFn(pollUrl, { headers: relayAuthHeader(pairing), signal: AbortSignal.timeout(deps.pollTimeoutMs || 8000) });
|
|
365
|
+
if (res && res.ok) body = await res.json();
|
|
366
|
+
} catch { /* transient — keep polling */ }
|
|
367
|
+
if (!body) continue;
|
|
368
|
+
const data = await unwrapEscalationResponse(pairing.pair_key, body);
|
|
369
|
+
if (!data || !DECISION_OUTCOME[data.decision]) continue; // ignore unparseable/unknown decisions; keep waiting
|
|
370
|
+
return {
|
|
371
|
+
resolved: true,
|
|
372
|
+
decision: data.decision,
|
|
373
|
+
proceed: data.decision !== 'deny',
|
|
374
|
+
note: typeof data.note === 'string' ? data.note : undefined,
|
|
375
|
+
modified: data.decision === 'modify' ? (data.modified ?? null) : undefined,
|
|
376
|
+
outcome: DECISION_OUTCOME[data.decision],
|
|
377
|
+
request_id: requestId,
|
|
378
|
+
signal,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
// 3. Window lapsed — safe default (hold; no learning).
|
|
382
|
+
return { ...safeDefault(signal), request_id: requestId };
|
|
383
|
+
}
|
package/fingerprint-source.js
CHANGED
|
@@ -12,6 +12,8 @@ import { readFile } from 'fs/promises';
|
|
|
12
12
|
import { resolve } from 'path';
|
|
13
13
|
import { hasPairingRecord, loadPairingRecord, loadFingerprintFromPhone, PhoneError } from './phone-source.js';
|
|
14
14
|
import { defaultStateDir, writeCache, readCache, clearCache } from './cache.js';
|
|
15
|
+
import { foldLocalCapture, isLocalCaptureOwner } from './capture-local.js';
|
|
16
|
+
import { Capabilities, AuthoredSource } from './lib/phaedo-crypto.js';
|
|
15
17
|
|
|
16
18
|
// §4.1 protocol-identity normalization (consumer defense-in-depth, 2026-06-16).
|
|
17
19
|
// A stored fingerprint from a producer that predates identity stamping (or whose
|
|
@@ -38,6 +40,39 @@ function normalizeIdentity(vd) {
|
|
|
38
40
|
return { ...vd, phaedo_fingerprint: { ...fp, ...patch } };
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
|
|
44
|
+
// §5 CANONICAL ON READ — the step-3 read path, applied ONCE at the boundary.
|
|
45
|
+
//
|
|
46
|
+
// The agent's authored-content readers (ap2.js buildIntentMandatePayload, autonomy-promotion.js,
|
|
47
|
+
// consult-core.js delegation authoring) all take a FINGERPRINT, not a vault. Threading the §5
|
|
48
|
+
// configuration through every one of them would mean changing four signatures and every caller,
|
|
49
|
+
// and would leave four places that must each remember to check §5.4.
|
|
50
|
+
//
|
|
51
|
+
// Folding here instead makes every downstream reader correct by construction, and means step 3
|
|
52
|
+
// (deleting fp.standing_rules) changes nothing about what any of them see. One fold, one §5.4
|
|
53
|
+
// check, one place to be wrong.
|
|
54
|
+
//
|
|
55
|
+
// §5.4 matters more on this path than anywhere else: these entries become AUTHORIZATIONS — what
|
|
56
|
+
// the agent may do without asking — so a configuration belonging to another identity, or to a
|
|
57
|
+
// fingerprint since replaced by a restore or a cross-device merge, must not govern. Falls back
|
|
58
|
+
// to the A-home rather than throwing; an agent that cannot resolve policy must still run the
|
|
59
|
+
// safe path.
|
|
60
|
+
export function foldConfigHome(vd) {
|
|
61
|
+
if (!vd || typeof vd !== 'object') return vd;
|
|
62
|
+
const fp = vd.phaedo_fingerprint;
|
|
63
|
+
const cfg = vd.phaedo_user_configuration;
|
|
64
|
+
if (!fp || typeof fp !== 'object' || !cfg || typeof cfg !== 'object') return vd;
|
|
65
|
+
if (!Capabilities || !AuthoredSource) return vd; // vendored module missing → leave as-is
|
|
66
|
+
if (!AuthoredSource.configGoverns(cfg, fp)) return vd; // §5.4 — not this subject's
|
|
67
|
+
if (!Array.isArray(cfg.standing_instructions)) return vd; // not a statement about rules
|
|
68
|
+
const next = { ...fp };
|
|
69
|
+
// An EMPTY standing_instructions is a statement ("no rules"), not an absence — leaving the
|
|
70
|
+
// A-home in place there would resurrect rules the subject deleted (#247's lesson).
|
|
71
|
+
if (cfg.standing_instructions.length) next.standing_rules = cfg.standing_instructions;
|
|
72
|
+
else delete next.standing_rules;
|
|
73
|
+
return { ...vd, phaedo_fingerprint: next };
|
|
74
|
+
}
|
|
75
|
+
|
|
41
76
|
// Resolution order for the local fixture (1a):
|
|
42
77
|
// 1. PHAEDO_FINGERPRINT env var (absolute or cwd-relative path)
|
|
43
78
|
// 2. ./fingerprint.json next to the server
|
|
@@ -52,6 +87,35 @@ function resolveSourcePath(baseDir) {
|
|
|
52
87
|
// phaedo_session_metrics }
|
|
53
88
|
// Throws on unreadable/invalid source — the tool layer maps that to
|
|
54
89
|
// decryption_failed (the §9.5 "couldn't obtain plaintext fingerprint" case).
|
|
90
|
+
// PROVENANCE. A cache fallback used to be indistinguishable from a live phone read — same
|
|
91
|
+
// object, same shape, no marker anywhere — so an agent answered "yes, you're pre-approved"
|
|
92
|
+
// from a 22-hour-old snapshot with total confidence, and `pull.js` printed "Read your
|
|
93
|
+
// profile from the phone" either way. There was no signal to notice. Callers that make
|
|
94
|
+
// AUTHORITY decisions must be able to tell the difference.
|
|
95
|
+
//
|
|
96
|
+
// Carried on a SYMBOL deliberately: it never serializes, never reaches a schema, never rides
|
|
97
|
+
// a sync, and cannot be mistaken for spec'd fingerprint content. It is a fact about THIS
|
|
98
|
+
// read, not about the artifact.
|
|
99
|
+
export const SOURCE = Symbol('phaedo.fingerprint.source');
|
|
100
|
+
|
|
101
|
+
function stamp(vd, cached) {
|
|
102
|
+
if (!vd || typeof vd !== 'object') return vd;
|
|
103
|
+
const cachedAt = Number(cached?.cachedAt) || null; // readCache returns { vault, cachedAt }
|
|
104
|
+
try {
|
|
105
|
+
Object.defineProperty(vd, SOURCE, {
|
|
106
|
+
value: { live: false, cachedAt, ageMs: cachedAt ? Date.now() - cachedAt : null },
|
|
107
|
+
enumerable: false, configurable: true, writable: true,
|
|
108
|
+
});
|
|
109
|
+
} catch { /* frozen object — provenance is best-effort, never fatal */ }
|
|
110
|
+
return vd;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Provenance of a loaded fingerprint. Absent marker → a live read. */
|
|
114
|
+
export function sourceOf(vd) {
|
|
115
|
+
const s = vd && vd[SOURCE];
|
|
116
|
+
return s || { live: true, cachedAt: null, ageMs: 0 };
|
|
117
|
+
}
|
|
118
|
+
|
|
55
119
|
export async function loadFingerprint(baseDir) {
|
|
56
120
|
// Explicit local source wins over phone pairing: if PHAEDO_FINGERPRINT names a
|
|
57
121
|
// file, honor it and skip the phone entirely. Pointing at a concrete fingerprint
|
|
@@ -69,7 +133,7 @@ export async function loadFingerprint(baseDir) {
|
|
|
69
133
|
try {
|
|
70
134
|
const vd = await loadFingerprintFromPhone(rec);
|
|
71
135
|
await writeCache(stateDir, vd);
|
|
72
|
-
return normalizeIdentity(vd);
|
|
136
|
+
return foldConfigHome(normalizeIdentity(vd));
|
|
73
137
|
} catch (err) {
|
|
74
138
|
// A 4xx means the phone REACHED us and rejected this client (revoked /
|
|
75
139
|
// invalid credentials / failed handshake). Revocation must be effective:
|
|
@@ -81,7 +145,7 @@ export async function loadFingerprint(baseDir) {
|
|
|
81
145
|
throw err;
|
|
82
146
|
}
|
|
83
147
|
const cached = await readCache(stateDir);
|
|
84
|
-
if (cached) return normalizeIdentity(cached.vault); // phone unreachable → encrypted cache (may be stale)
|
|
148
|
+
if (cached) return stamp(foldConfigHome(normalizeIdentity(cached.vault)), cached); // phone unreachable → encrypted cache (may be stale)
|
|
85
149
|
throw err; // no phone + no cache → cannot serve
|
|
86
150
|
}
|
|
87
151
|
}
|
|
@@ -98,7 +162,17 @@ export async function loadFingerprint(baseDir) {
|
|
|
98
162
|
if (!vd || typeof vd !== 'object' || !vd.phaedo_fingerprint) {
|
|
99
163
|
throw new Error(`Fingerprint source at ${path} is missing phaedo_fingerprint.`);
|
|
100
164
|
}
|
|
101
|
-
|
|
165
|
+
// mcp-local capture tier (desktop-capture-deposit.md §4 tier 3): unpaired,
|
|
166
|
+
// phone-less serving is the one configuration where NOBODY else drains what the
|
|
167
|
+
// capture hook accumulated on this machine — fold its local state into the
|
|
168
|
+
// served fingerprint at read time (capture-local.js; read-time overlay, no
|
|
169
|
+
// persistence, idempotent). Owner-gated: a paired setup never reaches this
|
|
170
|
+
// branch, and an explicit PHAEDO_FINGERPRINT ("use THIS file") stays verbatim —
|
|
171
|
+
// it is the hermetic-test escape hatch.
|
|
172
|
+
if (!process.env.PHAEDO_FINGERPRINT && await isLocalCaptureOwner(hasPairingRecord(baseDir))) {
|
|
173
|
+
return foldConfigHome(normalizeIdentity(await foldLocalCapture(vd)));
|
|
174
|
+
}
|
|
175
|
+
return foldConfigHome(normalizeIdentity(vd));
|
|
102
176
|
}
|
|
103
177
|
|
|
104
178
|
// Like loadFingerprint, but PREFERS the at-rest encrypted cache over a live phone pull.
|
|
@@ -129,7 +203,7 @@ export async function loadFingerprint(baseDir) {
|
|
|
129
203
|
export async function loadFingerprintPreferCache(baseDir) {
|
|
130
204
|
if (!process.env.PHAEDO_FINGERPRINT && hasPairingRecord(baseDir)) {
|
|
131
205
|
const cached = await readCache(defaultStateDir());
|
|
132
|
-
if (cached) return normalizeIdentity(cached.vault);
|
|
206
|
+
if (cached) return stamp(foldConfigHome(normalizeIdentity(cached.vault)), cached);
|
|
133
207
|
}
|
|
134
208
|
return loadFingerprint(baseDir);
|
|
135
209
|
}
|