@agentpulselabs/cli-plugin-pulse 0.2.0 → 1.1.2
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 +115 -32
- package/package.json +14 -19
- package/src/clients/hub-bridge.mjs +99 -0
- package/src/clients/hub.mjs +324 -0
- package/src/commands/pulse/cutover.js +39 -0
- package/src/commands/pulse/doctor.js +15 -0
- package/src/commands/pulse/hub-write.js +26 -0
- package/src/commands/pulse/import.js +62 -0
- package/src/commands/pulse/install.js +14 -0
- package/src/commands/pulse/log-write.js +26 -0
- package/src/commands/pulse/login.js +20 -0
- package/src/commands/pulse/mirror.js +27 -0
- package/src/commands/pulse/pair.js +38 -0
- package/src/commands/pulse/parity.js +20 -0
- package/src/commands/pulse/restart.js +14 -0
- package/src/commands/pulse/shadow.js +19 -0
- package/src/commands/pulse/start.js +6 -18
- package/src/commands/pulse/status.js +6 -6
- package/src/commands/pulse/stop.js +24 -0
- package/src/daemon/main.js +43 -0
- package/src/hub/core/auth.mjs +80 -0
- package/src/hub/core/changes.mjs +38 -0
- package/src/hub/core/conversations.mjs +121 -0
- package/src/hub/core/db.mjs +263 -0
- package/src/hub/core/edgeIds.mjs +9 -0
- package/src/hub/core/hub.mjs +20 -0
- package/src/hub/core/ids.mjs +39 -0
- package/src/hub/core/leases.mjs +55 -0
- package/src/hub/core/messaging.mjs +322 -0
- package/src/hub/core/registry.mjs +281 -0
- package/src/hub/core/ticks.mjs +58 -0
- package/src/hub/core/wake.mjs +125 -0
- package/src/hub/home.mjs +16 -0
- package/src/hub/hooks/hub-bridge.mjs +167 -0
- package/src/hub/keepawake/agent.mjs +96 -0
- package/src/hub/lib/brief.mjs +133 -0
- package/src/hub/lib/channels.mjs +268 -0
- package/src/hub/lib/chat.mjs +163 -0
- package/src/hub/lib/closure.mjs +92 -0
- package/src/hub/lib/collect-worker.mjs +20 -0
- package/src/hub/lib/collect.mjs +342 -0
- package/src/hub/lib/deliveries.mjs +222 -0
- package/src/hub/lib/git.mjs +93 -0
- package/src/hub/lib/me.mjs +123 -0
- package/src/hub/lib/personas.mjs +143 -0
- package/src/hub/lib/status.mjs +147 -0
- package/src/hub/lib/store.mjs +106 -0
- package/src/hub/lib/titles.mjs +29 -0
- package/src/hub/lib/transcript-feed.mjs +72 -0
- package/src/hub/lib/transcripts.mjs +364 -0
- package/src/hub/lib/writing.mjs +18 -0
- package/src/hub/projections/deliveries.mjs +31 -0
- package/src/hub/projections/graph.mjs +149 -0
- package/src/hub/runners/drivers.mjs +162 -0
- package/src/hub/workers/collector.mjs +128 -0
- package/src/hub/workers/manager.mjs +366 -0
- package/src/hub/workers/runner.mjs +145 -0
- package/src/lib/BaseCommand.js +23 -23
- package/src/lib/agentSessions.js +279 -0
- package/src/lib/application.js +704 -0
- package/src/lib/backoff.js +8 -0
- package/src/lib/budgets.js +85 -0
- package/src/lib/channelScanner.js +99 -0
- package/src/lib/cliDefaults.js +20 -0
- package/src/lib/clientInstaller.js +111 -0
- package/src/lib/cloudAuth.js +189 -0
- package/src/lib/cloudFirstCommands.js +293 -0
- package/src/lib/contract.sha256 +2 -0
- package/src/lib/cutover.js +376 -0
- package/src/lib/cutoverWiring.js +137 -0
- package/src/lib/daemonComposition.js +338 -0
- package/src/lib/daemonLock.js +57 -0
- package/src/lib/daemonLoop.js +72 -0
- package/src/lib/daemonProcess.js +380 -0
- package/src/lib/daemonRuntime.js +828 -0
- package/src/lib/doctorWarnings.js +53 -0
- package/src/lib/drivers.js +280 -0
- package/src/lib/errors.js +33 -0
- package/src/lib/graphWindows.js +149 -0
- package/src/lib/http.js +84 -0
- package/src/lib/hubActions.js +74 -0
- package/src/lib/hubDbReader.js +132 -0
- package/src/lib/hubMapping.js +108 -0
- package/src/lib/hubScan.js +611 -0
- package/src/lib/hubSnapshot.js +64 -0
- package/src/lib/identity.js +102 -0
- package/src/lib/intents.js +590 -0
- package/src/lib/ledger.js +535 -0
- package/src/lib/lifecycle.js +123 -0
- package/src/lib/localHub.js +217 -0
- package/src/lib/localHubWriter.js +69 -0
- package/src/lib/mirror-contract.json +4299 -0
- package/src/lib/mirrorContract.js +513 -0
- package/src/lib/mirrorDaemon.js +127 -0
- package/src/lib/mirrorService.js +365 -0
- package/src/lib/mirrorSource.js +430 -0
- package/src/lib/mirrorSync.js +353 -0
- package/src/lib/nodeFloor.js +19 -0
- package/src/lib/oauthLogin.js +132 -0
- package/src/lib/outboxFlusher.js +75 -0
- package/src/lib/ownerLock.js +99 -0
- package/src/lib/pairedConfig.js +40 -0
- package/src/lib/pairingLifecycle.js +179 -0
- package/src/lib/pathRedaction.js +89 -0
- package/src/lib/planContract.js +141 -0
- package/src/lib/priorityLane.js +80 -0
- package/src/lib/processOwnership.js +252 -0
- package/src/lib/processSupervisor.js +376 -0
- package/src/lib/reconcile.js +316 -0
- package/src/lib/reconcileView.js +129 -0
- package/src/lib/redaction.js +128 -0
- package/src/lib/rootPolicy.js +372 -0
- package/src/lib/rulePacks.js +162 -0
- package/src/lib/runnerStore.js +418 -0
- package/src/lib/secretStore.js +169 -0
- package/src/lib/selfRestart.js +261 -0
- package/src/lib/serviceManager.js +419 -0
- package/src/lib/shadowService.js +435 -0
- package/src/lib/sqlite.js +28 -0
- package/src/lib/storage.js +131 -0
- package/src/lib/subprocess.js +45 -0
- package/src/lib/tokenStore.js +136 -0
- package/src/lib/transport.js +334 -0
- package/src/lib/transportContract.js +40 -0
- package/src/lib/uploadRedactor.js +102 -0
- package/src/runner/applier.js +297 -0
- package/src/runner/bridge.js +289 -0
- package/src/runner/busIdentity.js +499 -0
- package/src/runner/capture.js +103 -0
- package/src/runner/captureMapping.js +21 -0
- package/src/runner/channelDiscipline.js +425 -0
- package/src/runner/deliveryStates.js +220 -0
- package/src/runner/facade.js +876 -0
- package/src/runner/hubCapture.js +160 -0
- package/src/runner/lease.js +46 -0
- package/src/runner/main.js +35 -0
- package/src/runner/render.js +91 -0
- package/src/runner/runnerChild.js +57 -0
- package/src/runner/runnerHub.js +104 -0
- package/src/runner/runnerProcess.js +124 -0
- package/src/runner/runnerSchema.js +51 -0
- package/src/runner/runnerService.js +140 -0
- package/src/runner/spool.js +86 -0
- package/src/runner/spoolInto.js +22 -0
- package/src/runner/statusPage.js +30 -0
- package/LICENSE +0 -21
- package/src/commands/pulse/gate.js +0 -25
- package/src/commands/pulse/next.js +0 -14
- package/src/commands/pulse/submit.js +0 -21
- package/src/commands/pulse/verify.js +0 -21
- package/src/runPulse.js +0 -58
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { rm } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { HubError } from './errors.js';
|
|
5
|
+
import { acquireOwnerLock } from './ownerLock.js';
|
|
6
|
+
import { ProcessIdentityProvider } from './processOwnership.js';
|
|
7
|
+
import { readOwnerOnlyJson, writeOwnerOnlyJson } from './storage.js';
|
|
8
|
+
|
|
9
|
+
/** The one name every config used before sign-ins were named per config dir; a config without `secretRef` still uses it. */
|
|
10
|
+
export const LEGACY_SECRET_REFERENCE = 'oauth-session';
|
|
11
|
+
/** This config dir's own sign-in item, so a second config (another XDG_CONFIG_HOME or profile) never overwrites it. */
|
|
12
|
+
export const sessionSecretReference = (configDir) => `oauth-session-${createHash('sha256').update(path.resolve(configDir)).digest('hex').slice(0, 16)}`;
|
|
13
|
+
const METADATA_FILE = 'oauth-session.json';
|
|
14
|
+
const LOCK_FILE = 'oauth-refresh.lock';
|
|
15
|
+
const inProcessLocks = new Map();
|
|
16
|
+
|
|
17
|
+
export class TokenStore {
|
|
18
|
+
constructor({ secretStore, configDir, lockTimeoutMs = 10_000, identityProvider = new ProcessIdentityProvider() }) {
|
|
19
|
+
this.secretStore = secretStore;
|
|
20
|
+
this.ownReference = sessionSecretReference(configDir);
|
|
21
|
+
this.metadataFile = path.join(configDir, METADATA_FILE);
|
|
22
|
+
this.lockFile = path.join(configDir, LOCK_FILE);
|
|
23
|
+
this.lockTimeoutMs = lockTimeoutMs;
|
|
24
|
+
this.identityProvider = identityProvider;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The item a config's sign-in lives in: its recorded one, the legacy one for a config signed in before, else its own. */
|
|
28
|
+
#reference(metadata) {
|
|
29
|
+
if (!metadata) return this.ownReference;
|
|
30
|
+
return metadata.secretRef || LEGACY_SECRET_REFERENCE;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async load() {
|
|
34
|
+
const metadata = await readOwnerOnlyJson(this.metadataFile);
|
|
35
|
+
if (!metadata) return null;
|
|
36
|
+
const secret = await this.secretStore.get(this.#reference(metadata));
|
|
37
|
+
if (!secret) return null;
|
|
38
|
+
let tokens;
|
|
39
|
+
try {
|
|
40
|
+
tokens = JSON.parse(secret);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
throw new HubError('HUB_TOKEN_STORE_CORRUPT', 'Stored Pulse sign-in is unreadable; run `agentpulse pulse login`', {
|
|
43
|
+
cause: error,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
platformUrl: metadata.platformUrl,
|
|
48
|
+
userId: metadata.userId,
|
|
49
|
+
teamId: metadata.teamId,
|
|
50
|
+
accessToken: tokens.accessToken,
|
|
51
|
+
refreshToken: tokens.refreshToken ?? null,
|
|
52
|
+
expiresAt: metadata.expiresAt,
|
|
53
|
+
scopes: metadata.scopes || [],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async scopes() {
|
|
58
|
+
const metadata = await readOwnerOnlyJson(this.metadataFile);
|
|
59
|
+
return Array.isArray(metadata?.scopes) ? metadata.scopes : [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async save(session) {
|
|
63
|
+
if (typeof session?.accessToken !== 'string' || !session.userId || !session.teamId || !session.platformUrl) {
|
|
64
|
+
throw new HubError('HUB_TOKEN_STORE_INVALID', 'Refusing to store an incomplete Pulse sign-in');
|
|
65
|
+
}
|
|
66
|
+
const reference = this.#reference(await readOwnerOnlyJson(this.metadataFile));
|
|
67
|
+
await this.secretStore.set(reference, JSON.stringify({
|
|
68
|
+
accessToken: session.accessToken,
|
|
69
|
+
refreshToken: session.refreshToken ?? null,
|
|
70
|
+
}));
|
|
71
|
+
await writeOwnerOnlyJson(this.metadataFile, {
|
|
72
|
+
...(reference === LEGACY_SECRET_REFERENCE ? {} : { secretRef: reference }),
|
|
73
|
+
platformUrl: session.platformUrl,
|
|
74
|
+
userId: session.userId,
|
|
75
|
+
teamId: session.teamId,
|
|
76
|
+
expiresAt: session.expiresAt,
|
|
77
|
+
scopes: session.scopes || [],
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async clear() {
|
|
82
|
+
await this.secretStore.delete(this.#reference(await readOwnerOnlyJson(this.metadataFile)));
|
|
83
|
+
await rm(this.metadataFile, { force: true });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** What `migrateSecret` would do, read-only: `{ from, to }` for a legacy sign-in, else null. */
|
|
87
|
+
async secretPlan() {
|
|
88
|
+
const metadata = await readOwnerOnlyJson(this.metadataFile);
|
|
89
|
+
return metadata && !metadata.secretRef ? { from: LEGACY_SECRET_REFERENCE, to: this.ownReference } : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Move a legacy sign-in to this config's own item (at cutover): copy, read it back, record it, and only then remove
|
|
94
|
+
* the legacy item. Runs under the refresh lock, so a refresh can't write the legacy item in between.
|
|
95
|
+
*/
|
|
96
|
+
async migrateSecret() {
|
|
97
|
+
return this.withRefreshLock(async () => {
|
|
98
|
+
const metadata = await readOwnerOnlyJson(this.metadataFile);
|
|
99
|
+
if (!metadata || metadata.secretRef) return { moved: false, reference: metadata?.secretRef ?? null };
|
|
100
|
+
const secret = await this.secretStore.get(LEGACY_SECRET_REFERENCE);
|
|
101
|
+
if (!secret) return { moved: false, reference: null };
|
|
102
|
+
await this.secretStore.set(this.ownReference, secret);
|
|
103
|
+
if ((await this.secretStore.get(this.ownReference)) !== secret) {
|
|
104
|
+
throw new HubError('HUB_TOKEN_MIGRATION_FAILED', 'The sign-in did not read back from its new Keychain item; the old item is kept');
|
|
105
|
+
}
|
|
106
|
+
await writeOwnerOnlyJson(this.metadataFile, { secretRef: this.ownReference, ...metadata });
|
|
107
|
+
await this.secretStore.delete(LEGACY_SECRET_REFERENCE);
|
|
108
|
+
return { moved: true, from: LEGACY_SECRET_REFERENCE, to: this.ownReference };
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async withRefreshLock(action) {
|
|
113
|
+
const previous = inProcessLocks.get(this.lockFile) || Promise.resolve();
|
|
114
|
+
let release;
|
|
115
|
+
const current = previous.then(() => new Promise((resolve) => { release = resolve; }));
|
|
116
|
+
inProcessLocks.set(this.lockFile, current);
|
|
117
|
+
await previous;
|
|
118
|
+
try {
|
|
119
|
+
const releaseFile = await acquireOwnerLock({
|
|
120
|
+
file: this.lockFile,
|
|
121
|
+
identity: await this.identityProvider.capture(process.pid),
|
|
122
|
+
identityProvider: this.identityProvider,
|
|
123
|
+
timeoutMs: this.lockTimeoutMs,
|
|
124
|
+
heldError: () => new HubError('HUB_REFRESH_LOCKED', 'Another Pulse process is refreshing the sign-in; retry shortly'),
|
|
125
|
+
});
|
|
126
|
+
try {
|
|
127
|
+
return await action();
|
|
128
|
+
} finally {
|
|
129
|
+
await releaseFile();
|
|
130
|
+
}
|
|
131
|
+
} finally {
|
|
132
|
+
release();
|
|
133
|
+
if (inProcessLocks.get(this.lockFile) === current) inProcessLocks.delete(this.lockFile);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
2
|
+
import { fullJitterDelay } from './backoff.js';
|
|
3
|
+
import { loginRequired } from './cloudAuth.js';
|
|
4
|
+
import { HubError, SCOPE_FIX } from './errors.js';
|
|
5
|
+
import { cleanOrigin, fetchJson } from './http.js';
|
|
6
|
+
import { CONTRACT_HEADER, CONTRACT_VERSION, MIRROR_LIMITS, MIRROR_PATH, parseAccepts } from './mirrorContract.js';
|
|
7
|
+
import { PriorityLane } from './priorityLane.js';
|
|
8
|
+
import {
|
|
9
|
+
CONNECTOR_API_BASE,
|
|
10
|
+
CONNECTOR_RESPONSE_MAX_BYTES,
|
|
11
|
+
MACHINE_ID_HEADER,
|
|
12
|
+
POLICY_VERSION_HEADER,
|
|
13
|
+
SESSION_ID_HEADER,
|
|
14
|
+
} from './transportContract.js';
|
|
15
|
+
|
|
16
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
17
|
+
|
|
18
|
+
const CLAIMED_INTEGERS = ['attempt_no', 'fence_token', 'policy_version', 'attemptNo', 'fenceToken', 'policyVersion'];
|
|
19
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
20
|
+
const MIRROR_TIMEOUT_MS = 30_000;
|
|
21
|
+
// The platform's JSON parser inflates gzip bodies; the contract's bodyBytes limit still applies to the JSON.
|
|
22
|
+
const MIRROR_GZIP_ABOVE_BYTES = 8 * 1024;
|
|
23
|
+
// The cloud holds a claim up to waitSeconds before answering; the request waits that long plus this much.
|
|
24
|
+
const CLAIM_TIMEOUT_MARGIN_MS = 10_000;
|
|
25
|
+
// The cloud's PULSE_LIMITS: a command batch holds at most 100 commands in 512 KB.
|
|
26
|
+
const PULSE_MAX_BATCH = 100;
|
|
27
|
+
const PULSE_BODY_BYTES = 512 * 1024;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A claimed job with its integers as numbers. The cloud returns agent_hub_jobs rows as node-pg serialises them, so the
|
|
31
|
+
* bigint fence_token arrives as a decimal string; anything that is not a plain integer stays as sent for the ledger to
|
|
32
|
+
* refuse.
|
|
33
|
+
*/
|
|
34
|
+
function claimedJob(job) {
|
|
35
|
+
if (!job || typeof job !== 'object') return job;
|
|
36
|
+
const out = { ...job };
|
|
37
|
+
for (const key of CLAIMED_INTEGERS) {
|
|
38
|
+
if (typeof out[key] === 'string' && /^[0-9]{1,15}$/.test(out[key])) out[key] = Number(out[key]);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const SERVER_TIMING_NAMES = { lock: 'lockMs', db: 'dbMs', total: 'totalMs' };
|
|
44
|
+
|
|
45
|
+
/** `lock;dur=2.5, db;dur=31, total;dur=48` as { lockMs, dbMs, totalMs }; metrics without a duration are left out. */
|
|
46
|
+
export function parseServerTiming(header) {
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const metric of String(header || '').split(',')) {
|
|
49
|
+
const [name, ...params] = metric.trim().split(';');
|
|
50
|
+
const dur = params.map((param) => param.trim().match(/^dur=([0-9.]+)$/)).find(Boolean);
|
|
51
|
+
if (SERVER_TIMING_NAMES[name] && dur) out[SERVER_TIMING_NAMES[name]] = Number(dur[1]);
|
|
52
|
+
}
|
|
53
|
+
return Object.keys(out).length ? out : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The range this connector's contract version is outside of, as a contract_unsupported error. */
|
|
57
|
+
export function contractUnsupported(accepts, message) {
|
|
58
|
+
return new HubError('contract_unsupported', message || `Pulse accepts ${accepts}; this connector speaks mirror/${CONTRACT_VERSION}`, {
|
|
59
|
+
details: { status: 409, accepts },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function requestBytes(payload, maxBodyBytes) {
|
|
64
|
+
if (payload !== undefined && Buffer.byteLength(JSON.stringify(payload), 'utf8') > maxBodyBytes) {
|
|
65
|
+
throw new HubError('HUB_TRANSPORT_PAYLOAD_TOO_LARGE', `Request exceeds ${maxBodyBytes} bytes`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class ConnectorTransport {
|
|
70
|
+
constructor({
|
|
71
|
+
baseUrl,
|
|
72
|
+
machine,
|
|
73
|
+
auth,
|
|
74
|
+
fetch: fetchImplementation = globalThis.fetch,
|
|
75
|
+
random = Math.random,
|
|
76
|
+
delay = sleep,
|
|
77
|
+
maxAttempts = 8,
|
|
78
|
+
onAttempt = () => {},
|
|
79
|
+
now = Date.now,
|
|
80
|
+
laneMaxWaitMs,
|
|
81
|
+
}) {
|
|
82
|
+
this.onAttempt = onAttempt;
|
|
83
|
+
this.now = now;
|
|
84
|
+
this.baseUrl = cleanOrigin(baseUrl, 'Connector base URL');
|
|
85
|
+
this.machine = machine;
|
|
86
|
+
this.auth = auth;
|
|
87
|
+
this.fetch = fetchImplementation;
|
|
88
|
+
this.random = random;
|
|
89
|
+
this.delay = delay;
|
|
90
|
+
this.maxAttempts = maxAttempts;
|
|
91
|
+
this.pollOutstanding = false;
|
|
92
|
+
// The cloud's handshake range from the last answer that named one (null until then, and from a v1 cloud).
|
|
93
|
+
this.contractAccepts = null;
|
|
94
|
+
this.lane = new PriorityLane({ now, ...(laneMaxWaitMs !== undefined ? { maxWaitMs: laneMaxWaitMs } : {}) });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#identityHeaders() {
|
|
98
|
+
return {
|
|
99
|
+
[MACHINE_ID_HEADER]: this.machine.machineId,
|
|
100
|
+
[SESSION_ID_HEADER]: this.machine.sessionId,
|
|
101
|
+
[POLICY_VERSION_HEADER]: String(this.machine.policyVersion),
|
|
102
|
+
[CONTRACT_HEADER]: `mirror/${CONTRACT_VERSION}`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* onAttempt({ path, phase }) marks each credentials read, request and backoff, so a watchdog can see retries progress.
|
|
108
|
+
* trace, when given, gathers where the time went: attempts and their statuses ('error' for a failed fetch), backoff,
|
|
109
|
+
* reading credentials (a token refresh or dev-org mint), the JSON and wire bytes, and the cloud's Server-Timing of
|
|
110
|
+
* the attempt that answered.
|
|
111
|
+
*/
|
|
112
|
+
async #request(path, payload, { retry = false, maxBodyBytes, timeoutMs = REQUEST_TIMEOUT_MS, gzipAboveBytes, trace = {}, method = 'POST' }) {
|
|
113
|
+
requestBytes(payload, maxBodyBytes);
|
|
114
|
+
trace.attempts = 0;
|
|
115
|
+
trace.statuses = [];
|
|
116
|
+
trace.backoffMs = 0;
|
|
117
|
+
trace.credentialsMs = 0;
|
|
118
|
+
const backoff = async (ms) => {
|
|
119
|
+
this.onAttempt({ path, phase: 'backoff' });
|
|
120
|
+
const started = this.now();
|
|
121
|
+
await this.delay(ms);
|
|
122
|
+
trace.backoffMs += this.now() - started;
|
|
123
|
+
};
|
|
124
|
+
let lastError;
|
|
125
|
+
let reauthenticated = false;
|
|
126
|
+
for (let attempt = 0; attempt < this.maxAttempts; attempt += 1) {
|
|
127
|
+
this.onAttempt({ path, phase: 'credentials' });
|
|
128
|
+
const credentialsFrom = this.now();
|
|
129
|
+
const credentials = await this.auth.credentials();
|
|
130
|
+
trace.credentialsMs += this.now() - credentialsFrom;
|
|
131
|
+
this.onAttempt({ path, phase: 'request' });
|
|
132
|
+
trace.attempts += 1;
|
|
133
|
+
let response;
|
|
134
|
+
try {
|
|
135
|
+
response = await fetchJson({
|
|
136
|
+
fetch: this.fetch,
|
|
137
|
+
url: `${this.baseUrl}${path}`,
|
|
138
|
+
method,
|
|
139
|
+
headers: { ...credentials, ...this.#identityHeaders() },
|
|
140
|
+
body: payload,
|
|
141
|
+
maxBytes: CONNECTOR_RESPONSE_MAX_BYTES,
|
|
142
|
+
timeoutMs,
|
|
143
|
+
gzipAboveBytes,
|
|
144
|
+
});
|
|
145
|
+
} catch (error) {
|
|
146
|
+
trace.statuses.push('error');
|
|
147
|
+
if (error instanceof HubError || !retry) throw error;
|
|
148
|
+
lastError = error;
|
|
149
|
+
if (attempt + 1 >= this.maxAttempts) break;
|
|
150
|
+
await backoff(fullJitterDelay(attempt, { random: this.random }));
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
trace.statuses.push(response.status);
|
|
154
|
+
trace.bytes = response.bytes;
|
|
155
|
+
trace.sentBytes = response.sentBytes;
|
|
156
|
+
trace.server = parseServerTiming(response.serverTiming);
|
|
157
|
+
const accepts = response.contractAccepts || response.body.contractAccepts || response.body.accepts;
|
|
158
|
+
if (parseAccepts(accepts)) this.contractAccepts = accepts;
|
|
159
|
+
if (response.ok) return response.body;
|
|
160
|
+
if (response.status === 409 && response.body.error === 'contract_unsupported') {
|
|
161
|
+
throw contractUnsupported(parseAccepts(accepts) ? accepts : null, response.body.message);
|
|
162
|
+
}
|
|
163
|
+
if (response.status === 401) {
|
|
164
|
+
if (reauthenticated) throw loginRequired('the cloud refused the refreshed credentials');
|
|
165
|
+
reauthenticated = true;
|
|
166
|
+
this.auth.invalidate();
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (response.status === 403 && response.body.error === 'FORBIDDEN') {
|
|
170
|
+
const missingScopes = (Array.isArray(response.body.required) ? response.body.required : []).filter((s) => typeof s === 'string').slice(0, 10).map((s) => s.slice(0, 80));
|
|
171
|
+
throw new HubError('HUB_SCOPE_DENIED', missingScopes.length
|
|
172
|
+
? `The Pulse sign-in lacks ${missingScopes.join(', ')}; run \`${SCOPE_FIX}\``
|
|
173
|
+
: 'Your Pulse tier does not grant the connector scopes for this operation', {
|
|
174
|
+
details: { status: 403, missingScopes, fix: SCOPE_FIX },
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
if (retry && (response.status === 429 || response.status >= 500)) {
|
|
178
|
+
const wait = Number.isFinite(response.retryAfter)
|
|
179
|
+
? Math.max(0, Math.min(60_000, response.retryAfter * 1_000))
|
|
180
|
+
: fullJitterDelay(attempt, { random: this.random });
|
|
181
|
+
await backoff(wait);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
throw new HubError(
|
|
185
|
+
response.body.error || `HTTP_${response.status}`,
|
|
186
|
+
response.body.message || `Connector request failed with ${response.status}`,
|
|
187
|
+
{ details: { status: response.status } },
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
throw new HubError('HUB_TRANSPORT_UNAVAILABLE', 'Connector transport unavailable', { cause: lastError });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
reconnect(payload) {
|
|
194
|
+
if (Array.isArray(payload.activeAttempts) && payload.activeAttempts.length > 100) {
|
|
195
|
+
throw new HubError('HUB_TRANSPORT_PAYLOAD_TOO_LARGE', 'Reconnect exceeds 100 active attempts');
|
|
196
|
+
}
|
|
197
|
+
return this.lane.priority(async (trace) => {
|
|
198
|
+
const opened = await this.#request(`${CONNECTOR_API_BASE}/session`, payload, { retry: true, maxBodyBytes: 32 * 1024, trace });
|
|
199
|
+
const range = parseAccepts(this.contractAccepts);
|
|
200
|
+
if (range && (CONTRACT_VERSION < range.min || CONTRACT_VERSION > range.max)) throw contractUnsupported(this.contractAccepts);
|
|
201
|
+
return opened;
|
|
202
|
+
}, { path: '/session' });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
heartbeat(payload) {
|
|
206
|
+
if (Array.isArray(payload.activeAttempts) && payload.activeAttempts.length > 100) {
|
|
207
|
+
throw new HubError('HUB_TRANSPORT_PAYLOAD_TOO_LARGE', 'Heartbeat exceeds 100 active attempts');
|
|
208
|
+
}
|
|
209
|
+
return this.lane.priority((trace) => this.#request(`${CONNECTOR_API_BASE}/heartbeat`, payload, { retry: true, maxBodyBytes: 64 * 1024, trace }), { path: '/heartbeat' });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async poll(payload) {
|
|
213
|
+
if (this.pollOutstanding) {
|
|
214
|
+
throw new HubError('HUB_POLL_ALREADY_OUTSTANDING', 'Only one claim poll may be outstanding');
|
|
215
|
+
}
|
|
216
|
+
this.pollOutstanding = true;
|
|
217
|
+
try {
|
|
218
|
+
const claimed = await this.#request(`${CONNECTOR_API_BASE}/jobs/claim`, payload, {
|
|
219
|
+
retry: true,
|
|
220
|
+
maxBodyBytes: 16 * 1024,
|
|
221
|
+
timeoutMs: (Number(payload?.waitSeconds) || 0) * 1_000 + CLAIM_TIMEOUT_MARGIN_MS,
|
|
222
|
+
});
|
|
223
|
+
return Array.isArray(claimed?.jobs) ? { ...claimed, jobs: claimed.jobs.map(claimedJob) } : claimed;
|
|
224
|
+
} finally {
|
|
225
|
+
this.pollOutstanding = false;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
acknowledge(jobId, payload) {
|
|
230
|
+
if (!UUID.test(jobId)) throw new HubError('HUB_JOB_ID_INVALID', 'jobId must be a UUID');
|
|
231
|
+
return this.lane.priority((trace) => this.#request(`${CONNECTOR_API_BASE}/jobs/${encodeURIComponent(jobId)}/ack`, payload, {
|
|
232
|
+
maxBodyBytes: 64 * 1024,
|
|
233
|
+
trace,
|
|
234
|
+
}), { path: '/jobs/:id/ack' });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
renew(jobId, payload) {
|
|
238
|
+
if (!UUID.test(jobId)) throw new HubError('HUB_JOB_ID_INVALID', 'jobId must be a UUID');
|
|
239
|
+
return this.lane.priority((trace) => this.#request(`${CONNECTOR_API_BASE}/jobs/${encodeURIComponent(jobId)}/renew`, payload, {
|
|
240
|
+
retry: true,
|
|
241
|
+
maxBodyBytes: 8 * 1024,
|
|
242
|
+
trace,
|
|
243
|
+
}), { path: '/jobs/:id/renew' });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
uploadEvents(payload) {
|
|
247
|
+
if (!Array.isArray(payload.events) || payload.events.length < 1 || payload.events.length > 100) {
|
|
248
|
+
throw new HubError('HUB_EVENT_BATCH_INVALID', 'Event batch must contain 1 to 100 events');
|
|
249
|
+
}
|
|
250
|
+
for (const event of payload.events) {
|
|
251
|
+
if (Buffer.byteLength(JSON.stringify(event), 'utf8') > 32 * 1024) {
|
|
252
|
+
throw new HubError('HUB_EVENT_TOO_LARGE', 'Event exceeds 32 KiB');
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return this.lane.priority((trace) => this.#request(`${CONNECTOR_API_BASE}/events`, payload, {
|
|
256
|
+
retry: true,
|
|
257
|
+
maxBodyBytes: CONNECTOR_RESPONSE_MAX_BYTES,
|
|
258
|
+
trace,
|
|
259
|
+
}), { path: '/events' });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* One batch of Pulse log commands (`[{ type, idempotencyKey, payload, group? }]`, at most 100), answered per item.
|
|
264
|
+
* It waits behind heartbeats like a mirror batch. 429 and 5xx are retried with Retry-After; a refusal of the whole
|
|
265
|
+
* batch (the team's cloud-first flag is off, a missing capability) is thrown with the cloud's code.
|
|
266
|
+
*/
|
|
267
|
+
commands(payload) {
|
|
268
|
+
if (!Array.isArray(payload?.commands) || payload.commands.length < 1 || payload.commands.length > PULSE_MAX_BATCH) {
|
|
269
|
+
throw new HubError('HUB_COMMAND_BATCH_INVALID', `A command batch holds 1 to ${PULSE_MAX_BATCH} commands`);
|
|
270
|
+
}
|
|
271
|
+
return this.lane.background(() => this.#request(`${CONNECTOR_API_BASE}/commands`, payload, {
|
|
272
|
+
retry: true,
|
|
273
|
+
maxBodyBytes: PULSE_BODY_BYTES,
|
|
274
|
+
timeoutMs: MIRROR_TIMEOUT_MS,
|
|
275
|
+
gzipAboveBytes: MIRROR_GZIP_ABOVE_BYTES,
|
|
276
|
+
}));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* One page of this machine's Pulse log after `since` (design §5.4), held up to `wait` seconds when nothing is new:
|
|
281
|
+
* `{ events, next, head, floor, presence, presenceNext }`. `presence` is the last answer's `presenceNext`, an opaque
|
|
282
|
+
* keyset, so beats already sent never come back and a held wait holds. Behind the retention floor the cloud answers 410.
|
|
283
|
+
*/
|
|
284
|
+
sync({ since, wait = 20, presence = null } = {}) {
|
|
285
|
+
const query = new URLSearchParams({ since: String(since), wait: String(wait), ...(presence ? { presence } : {}) });
|
|
286
|
+
return this.#request(`${CONNECTOR_API_BASE}/sync?${query}`, undefined, {
|
|
287
|
+
retry: true, maxBodyBytes: 0, method: 'GET', timeoutMs: (Number(wait) + 10) * 1000,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Whether this team has Pulse cloud-first turned on: `{ enabled, since }`. */
|
|
292
|
+
cloudFirst() {
|
|
293
|
+
return this.#request(`${CONNECTOR_API_BASE}/cloud-first`, undefined, { retry: true, maxBodyBytes: 0, method: 'GET' });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** The parity report of this machine's mirror against the Pulse log, for the last `hours`; `view: 'reconcile'` asks for
|
|
297
|
+
* the import reconciliation view of this machine's log instead (ADR-4). */
|
|
298
|
+
parity({ hours = 6, view = null } = {}) {
|
|
299
|
+
const query = new URLSearchParams(view ? { view } : { hours: String(hours) });
|
|
300
|
+
return this.#request(`${CONNECTOR_API_BASE}/parity?${query}`, undefined, { retry: true, maxBodyBytes: 0, method: 'GET', timeoutMs: MIRROR_TIMEOUT_MS });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** The heartbeat, session, renew, ack and events calls in flight at any time since `since`, for a pass record. */
|
|
304
|
+
priorityCalls({ since } = {}) {
|
|
305
|
+
return this.lane.calls({ since });
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* trace, when given, also gets laneWaitMs (behind priority calls, 1 s at most), laneCapped, heldBy (those calls) and
|
|
310
|
+
* ms, the whole call.
|
|
311
|
+
*/
|
|
312
|
+
mirror(payload, { trace = {} } = {}) {
|
|
313
|
+
const items = payload.items;
|
|
314
|
+
if (!Array.isArray(items) || items.length > MIRROR_LIMITS.maxItems || (items.length === 0 && payload.reset !== true)) {
|
|
315
|
+
throw new HubError('HUB_MIRROR_BATCH_INVALID', `Mirror batch must contain 1 to ${MIRROR_LIMITS.maxItems} items, or reset`);
|
|
316
|
+
}
|
|
317
|
+
const queuedAt = this.now();
|
|
318
|
+
return this.lane.background(() => this.#request(MIRROR_PATH, payload, {
|
|
319
|
+
retry: true,
|
|
320
|
+
maxBodyBytes: MIRROR_LIMITS.bodyBytes,
|
|
321
|
+
timeoutMs: MIRROR_TIMEOUT_MS,
|
|
322
|
+
gzipAboveBytes: MIRROR_GZIP_ABOVE_BYTES,
|
|
323
|
+
trace,
|
|
324
|
+
}), { trace }).finally(() => {
|
|
325
|
+
trace.ms = this.now() - queuedAt;
|
|
326
|
+
}).catch((error) => {
|
|
327
|
+
if (error.details?.status !== 403 || String(error.code).startsWith('machine_')) throw error;
|
|
328
|
+
throw new HubError('HUB_MIRROR_SCOPE_MISSING', 'Pulse refused mirroring for this machine: the seat or plan lacks the mirror scope', {
|
|
329
|
+
cause: error,
|
|
330
|
+
details: { status: 403, code: error.code },
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export const CONNECTOR_CONTRACT_VERSION = 2;
|
|
2
|
+
export const OAUTH_CLIENT_ID = 'agentpulse-cli';
|
|
3
|
+
export const USER_API_BASE = '/api/agent-hub/user';
|
|
4
|
+
export const CONNECTOR_API_BASE = '/api/agent-hub/connector';
|
|
5
|
+
export const MACHINE_REGISTRATION_PATH = `${USER_API_BASE}/machines`;
|
|
6
|
+
export const MACHINE_ID_HEADER = 'x-agent-hub-machine-id';
|
|
7
|
+
export const SESSION_ID_HEADER = 'x-agent-hub-session-id';
|
|
8
|
+
export const POLICY_VERSION_HEADER = 'x-agent-hub-policy-version';
|
|
9
|
+
export const DEV_ORG_INGRESS_HEADER = 'x-serverless-authorization';
|
|
10
|
+
export const CONNECTOR_RESPONSE_MAX_BYTES = 512 * 1024;
|
|
11
|
+
|
|
12
|
+
export const CONNECTOR_SCOPES = Object.freeze([
|
|
13
|
+
'agent-hub:connector:session',
|
|
14
|
+
'agent-hub:connector:heartbeat',
|
|
15
|
+
'agent-hub:connector:claim',
|
|
16
|
+
'agent-hub:connector:ack',
|
|
17
|
+
'agent-hub:connector:renew',
|
|
18
|
+
'agent-hub:connector:event',
|
|
19
|
+
'agent-hub:connector:mirror',
|
|
20
|
+
// The Pulse log (cloud-first Phase 2): the shadow feed writes commands and reads the cloud-first flag and parity.
|
|
21
|
+
'agent-hub:connector:sync',
|
|
22
|
+
'agent-hub:connector:commands',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
export const CONNECTOR_AUTH_CONTRACT = Object.freeze({
|
|
26
|
+
scheme: 'Bearer',
|
|
27
|
+
machineIdHeader: MACHINE_ID_HEADER,
|
|
28
|
+
sessionIdHeader: SESSION_ID_HEADER,
|
|
29
|
+
policyVersionHeader: POLICY_VERSION_HEADER,
|
|
30
|
+
devOrgIngressHeader: DEV_ORG_INGRESS_HEADER,
|
|
31
|
+
connectorScopes: CONNECTOR_SCOPES,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const LOGIN_SCOPE = [
|
|
35
|
+
'team-setup:dev-orgs:read',
|
|
36
|
+
'agent-hub:machines:read',
|
|
37
|
+
'agent-hub:machines:write',
|
|
38
|
+
...CONNECTOR_SCOPES,
|
|
39
|
+
'offline_access',
|
|
40
|
+
].join(' ');
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { redactSecretSubtree, secretKeyReplacement } from './mirrorContract.js';
|
|
3
|
+
import { createPathRedactor } from './pathRedaction.js';
|
|
4
|
+
import { redactSecrets } from './redaction.js';
|
|
5
|
+
|
|
6
|
+
const SEEN_MAX = 100_000;
|
|
7
|
+
// The line net can fire again on text a first pass redacted (a marker earlier on the line); a stable redaction repeats
|
|
8
|
+
// the passes until nothing changes, so the cloud's own pass over it changes nothing either.
|
|
9
|
+
const STABLE_PASSES = 4;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What leaves this machine for Pulse passes through here, for the mirror and the Pulse log alike: the contract's
|
|
13
|
+
* secret passes first, then path redaction, on every string and key, so a secret inside a path is never cut down to
|
|
14
|
+
* a last segment that still holds it, and nothing is projected, truncated or hashed before both have run. Repository
|
|
15
|
+
* roots are learned from the deliveries the hub reports; the redacted form of a value depends on those roots, the
|
|
16
|
+
* workspace, the home folder and the held secrets, and on nothing else.
|
|
17
|
+
*/
|
|
18
|
+
export class UploadRedactor {
|
|
19
|
+
constructor({ workspace, home, approvedRoots = [] }) {
|
|
20
|
+
this.workspace = workspace;
|
|
21
|
+
this.home = home;
|
|
22
|
+
this.repoRoots = new Map(approvedRoots.map((root) => [root, path.basename(root)]));
|
|
23
|
+
this.worktreeRoots = new Map();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
learnRepos(deliveries) {
|
|
27
|
+
for (const stream of deliveries.flatMap((delivery) => delivery.streams || [])) {
|
|
28
|
+
const git = stream.git || {};
|
|
29
|
+
if (typeof git.mainRepo === 'string' && path.isAbsolute(git.mainRepo)) this.repoRoots.set(git.mainRepo, path.basename(git.mainRepo));
|
|
30
|
+
for (const extra of [git.worktreePath, git.channelCheckout]) {
|
|
31
|
+
if (typeof extra === 'string' && path.isAbsolute(extra) && !this.repoRoots.has(extra)) this.worktreeRoots.set(extra, path.basename(extra));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The learned roots, to persist: a worktree whose delivery is gone must keep redacting as it did, or a rescan of the
|
|
38
|
+
* same text gives another body than the one already sent (CUT-02).
|
|
39
|
+
*/
|
|
40
|
+
roots() {
|
|
41
|
+
return { repo: [...this.repoRoots], worktree: [...this.worktreeRoots] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Roots persisted by an earlier scan, added to what this one learns. */
|
|
45
|
+
seed(roots) {
|
|
46
|
+
for (const [root, name] of Array.isArray(roots?.repo) ? roots.repo : []) {
|
|
47
|
+
if (typeof root === 'string' && path.isAbsolute(root) && typeof name === 'string') this.repoRoots.set(root, name);
|
|
48
|
+
}
|
|
49
|
+
for (const [root, name] of Array.isArray(roots?.worktree) ? roots.worktree : []) {
|
|
50
|
+
if (typeof root === 'string' && path.isAbsolute(root) && typeof name === 'string' && !this.repoRoots.has(root)) this.worktreeRoots.set(root, name);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Everything the redacted form depends on besides the input, as one string (for memo keys and digests). */
|
|
55
|
+
basis(secrets) {
|
|
56
|
+
return JSON.stringify([[...this.repoRoots], [...this.worktreeRoots], this.workspace, this.home, secrets]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `stable` (the Pulse log) repeats the secret passes until a pass changes nothing; the mirror keeps one pass. */
|
|
60
|
+
redactor(secrets, { stable = false } = {}) {
|
|
61
|
+
const repos = [...this.repoRoots, ...this.worktreeRoots].map(([root, name]) => ({ root, name }));
|
|
62
|
+
const paths = createPathRedactor({ repos, workspace: this.workspace, home: this.home });
|
|
63
|
+
const seen = new Map();
|
|
64
|
+
let redactions = 0;
|
|
65
|
+
const redactString = (input) => {
|
|
66
|
+
const original = String(input ?? '');
|
|
67
|
+
let done = seen.get(original);
|
|
68
|
+
if (done === undefined) {
|
|
69
|
+
let secret = redactSecrets(original, { secrets });
|
|
70
|
+
let count = secret.redactions;
|
|
71
|
+
for (let pass = 1; stable && pass < STABLE_PASSES; pass += 1) {
|
|
72
|
+
const again = redactSecrets(secret.text, { secrets });
|
|
73
|
+
if (again.text === secret.text) break;
|
|
74
|
+
count += again.redactions;
|
|
75
|
+
secret = again;
|
|
76
|
+
}
|
|
77
|
+
done = { text: paths.redactString(secret.text), redactions: count };
|
|
78
|
+
if (seen.size >= SEEN_MAX) seen.clear();
|
|
79
|
+
seen.set(original, done);
|
|
80
|
+
}
|
|
81
|
+
redactions += done.redactions;
|
|
82
|
+
return done.text;
|
|
83
|
+
};
|
|
84
|
+
const entry = (key, item) => {
|
|
85
|
+
const replaced = secretKeyReplacement(key, item);
|
|
86
|
+
if (replaced === undefined || replaced === null) return redactSecretSubtree(key, item, redactString);
|
|
87
|
+
redactions += 1;
|
|
88
|
+
return replaced;
|
|
89
|
+
};
|
|
90
|
+
const redactValue = (value, { keep = [] } = {}) => paths.redactValue(value, { keep, redactString, entry });
|
|
91
|
+
return {
|
|
92
|
+
redactString,
|
|
93
|
+
redactValue,
|
|
94
|
+
/** Secret replacements made since the last call (memoised strings count each time they are used). */
|
|
95
|
+
takeRedactions: () => {
|
|
96
|
+
const count = redactions;
|
|
97
|
+
redactions = 0;
|
|
98
|
+
return count;
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|