@remembrance-ai/opencode-plugin 0.1.47 → 0.1.49
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 +9 -3
- package/opencode.json +2 -2
- package/package.json +1 -1
- package/scripts/hook-core.mjs +318 -38
- package/servers/remembrance-mcp.mjs +1200 -170
- package/skills/remembrancer/SKILL.md +34 -19
- package/skills/remembrancer/references/remembrance-payloads.md +8 -7
- package/skills/remembrancer/references/remembrance-setup.md +66 -25
- package/src/index.mjs +2 -2
package/README.md
CHANGED
|
@@ -49,9 +49,10 @@ chmod 600 ~/.config/remembrance/config.json
|
|
|
49
49
|
local MCP server both read `~/.config/remembrance/config.json`, an unset
|
|
50
50
|
`REMEMBRANCE_API_KEY` does **not** prove this install is anonymous.
|
|
51
51
|
|
|
52
|
-
After restarting,
|
|
53
|
-
`
|
|
54
|
-
|
|
52
|
+
After restarting, run MCP `run_connection_doctor` and require
|
|
53
|
+
`safe_to_query: true`. It verifies the active connection and gives one exact
|
|
54
|
+
next step if attention is required. Use `get_connection_status` only for lower-
|
|
55
|
+
level fields. Never infer scope from `REMEMBRANCE_API_KEY` alone, from an environment
|
|
55
56
|
variable, or from an anonymous REST/browser probe — the diagnostic verifies the
|
|
56
57
|
process that will actually serve opencode's tools, without exposing the key.
|
|
57
58
|
|
|
@@ -66,6 +67,11 @@ printf '{"apiKey":"YOUR_ORG_KEY","apiUrl":"https://dev.remembrance.dev"}\n' \
|
|
|
66
67
|
> ~/.config/remembrance/config.json
|
|
67
68
|
```
|
|
68
69
|
|
|
70
|
+
This paired file binds the key to that destination. If environment variables
|
|
71
|
+
are used instead, set `REMEMBRANCE_API_KEY_ORIGIN` equal to the exact custom
|
|
72
|
+
`REMEMBRANCE_API_URL`. Remote registries require HTTPS; a trusted private or
|
|
73
|
+
link-local self-host also requires `REMEMBRANCE_ALLOW_PRIVATE_REGISTRY=true`.
|
|
74
|
+
|
|
69
75
|
## What the plugin does
|
|
70
76
|
|
|
71
77
|
| Event | Behavior |
|
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.49"
|
|
10
10
|
],
|
|
11
11
|
"enabled": true,
|
|
12
12
|
"environment": {
|
|
@@ -15,6 +15,6 @@
|
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
17
|
"plugin": [
|
|
18
|
-
"@remembrance-ai/opencode-plugin@0.1.
|
|
18
|
+
"@remembrance-ai/opencode-plugin@0.1.49"
|
|
19
19
|
]
|
|
20
20
|
}
|
package/package.json
CHANGED
package/scripts/hook-core.mjs
CHANGED
|
@@ -17,15 +17,23 @@
|
|
|
17
17
|
|
|
18
18
|
import { createHash, randomBytes } from "node:crypto";
|
|
19
19
|
import {
|
|
20
|
+
chmodSync,
|
|
21
|
+
closeSync,
|
|
22
|
+
constants,
|
|
20
23
|
existsSync,
|
|
24
|
+
fstatSync,
|
|
25
|
+
lstatSync,
|
|
21
26
|
mkdirSync,
|
|
27
|
+
openSync,
|
|
22
28
|
readFileSync,
|
|
29
|
+
readSync,
|
|
23
30
|
readdirSync,
|
|
24
31
|
renameSync,
|
|
25
32
|
rmSync,
|
|
26
33
|
statSync,
|
|
27
34
|
writeFileSync,
|
|
28
35
|
} from "node:fs";
|
|
36
|
+
import { isIP } from "node:net";
|
|
29
37
|
import { homedir, tmpdir } from "node:os";
|
|
30
38
|
import { dirname, join } from "node:path";
|
|
31
39
|
import process from "node:process";
|
|
@@ -43,7 +51,8 @@ const VALUE_EPISODE_MARKER_LIMIT = 20;
|
|
|
43
51
|
const VALUE_EPISODE_MARKER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
44
52
|
const DIRECT_SELECTION_MARKER_LIMIT = 20;
|
|
45
53
|
const DIRECT_SELECTION_MARKER_TTL_MS = 24 * 60 * 60 * 1000;
|
|
46
|
-
const
|
|
54
|
+
const MAX_LOCAL_CONFIG_BYTES = 64 * 1024;
|
|
55
|
+
const MAX_LOCAL_HEALTH_MARKER_BYTES = 16 * 1024;
|
|
47
56
|
const PLUGIN_HEALTH_COMPONENTS = new Set([
|
|
48
57
|
"session_start",
|
|
49
58
|
"prompt_hook",
|
|
@@ -2279,9 +2288,20 @@ export function contributeDisabled(value) {
|
|
|
2279
2288
|
}
|
|
2280
2289
|
|
|
2281
2290
|
function pluginHealthDir(env = process.env) {
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2291
|
+
if (env?.REMEMBRANCE_PLUGIN_HEALTH_DIR) {
|
|
2292
|
+
return String(env.REMEMBRANCE_PLUGIN_HEALTH_DIR);
|
|
2293
|
+
}
|
|
2294
|
+
// Hook adapters are exercised directly by several Vitest suites. Keep those
|
|
2295
|
+
// observations out of the real user health directory even when a test passes
|
|
2296
|
+
// a deliberately minimal env object that omits Vitest's process variables.
|
|
2297
|
+
if (process.env.VITEST) {
|
|
2298
|
+
return join(
|
|
2299
|
+
tmpdir(),
|
|
2300
|
+
"remembrance-plugin-health-tests",
|
|
2301
|
+
String(process.pid),
|
|
2302
|
+
);
|
|
2303
|
+
}
|
|
2304
|
+
return join(homedir(), ".cache", "remembrance", "plugin-health");
|
|
2285
2305
|
}
|
|
2286
2306
|
|
|
2287
2307
|
function normalizedPluginHealthSurface(value) {
|
|
@@ -2317,7 +2337,9 @@ export function readPluginLifecycleHealth(
|
|
|
2317
2337
|
const path = pluginHealthSessionPath(surface, sessionId, env);
|
|
2318
2338
|
if (!path) return null;
|
|
2319
2339
|
try {
|
|
2320
|
-
const parsed = JSON.parse(
|
|
2340
|
+
const parsed = JSON.parse(
|
|
2341
|
+
readSecureHookFile(path, MAX_LOCAL_HEALTH_MARKER_BYTES),
|
|
2342
|
+
);
|
|
2321
2343
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
2322
2344
|
? parsed
|
|
2323
2345
|
: null;
|
|
@@ -2329,7 +2351,7 @@ export function readPluginLifecycleHealth(
|
|
|
2329
2351
|
// Record that a native lifecycle component actually executed. The marker is
|
|
2330
2352
|
// intentionally local and content-free: it contains only surface/version,
|
|
2331
2353
|
// component timestamps, and the credential source category. Local MCP reads
|
|
2332
|
-
// it through
|
|
2354
|
+
// it through run_connection_doctor so partial activation cannot look healthy.
|
|
2333
2355
|
export function recordPluginLifecycleHealth(
|
|
2334
2356
|
{
|
|
2335
2357
|
surface,
|
|
@@ -2351,6 +2373,28 @@ export function recordPluginLifecycleHealth(
|
|
|
2351
2373
|
) {
|
|
2352
2374
|
return false;
|
|
2353
2375
|
}
|
|
2376
|
+
// Codex currently gives SessionStart a thread identifier while prompt/tool/
|
|
2377
|
+
// completion hooks use turn identifiers. Preserve the specific startup
|
|
2378
|
+
// marker for diagnostics, but also publish a generic startup observation so
|
|
2379
|
+
// the first turn-specific marker can inherit it. Other hosts with stable
|
|
2380
|
+
// session ids remain correct and gain the same conservative fallback.
|
|
2381
|
+
if (
|
|
2382
|
+
normalizedComponent === "session_start" &&
|
|
2383
|
+
sessionId &&
|
|
2384
|
+
sessionId !== "unknown"
|
|
2385
|
+
) {
|
|
2386
|
+
recordPluginLifecycleHealth(
|
|
2387
|
+
{
|
|
2388
|
+
surface: normalizedSurface,
|
|
2389
|
+
component: normalizedComponent,
|
|
2390
|
+
pluginVersion,
|
|
2391
|
+
hostVersion,
|
|
2392
|
+
credentialSource,
|
|
2393
|
+
sessionId: null,
|
|
2394
|
+
},
|
|
2395
|
+
env,
|
|
2396
|
+
);
|
|
2397
|
+
}
|
|
2354
2398
|
const path = pluginHealthSessionPath(normalizedSurface, sessionId, env);
|
|
2355
2399
|
if (!path) return false;
|
|
2356
2400
|
const now = new Date().toISOString();
|
|
@@ -2390,6 +2434,11 @@ export function recordPluginLifecycleHealth(
|
|
|
2390
2434
|
// healthy for as long as the old marker remains fresh.
|
|
2391
2435
|
const currentComponents =
|
|
2392
2436
|
normalizedComponent === "session_start" ? {} : existingComponents;
|
|
2437
|
+
const apiConfiguration = resolveApiConfiguration(env);
|
|
2438
|
+
const apiDestinationFingerprint = createHash("sha256")
|
|
2439
|
+
.update(apiConfiguration.apiUrl.replace(/\/+$/, ""), "utf8")
|
|
2440
|
+
.digest("hex")
|
|
2441
|
+
.slice(0, 16);
|
|
2393
2442
|
const payload = {
|
|
2394
2443
|
schema_version: 1,
|
|
2395
2444
|
surface: normalizedSurface,
|
|
@@ -2414,6 +2463,8 @@ export function recordPluginLifecycleHealth(
|
|
|
2414
2463
|
)
|
|
2415
2464
|
? existing.credential_source
|
|
2416
2465
|
: "none",
|
|
2466
|
+
api_destination_source: apiConfiguration.source,
|
|
2467
|
+
api_destination_fingerprint: apiDestinationFingerprint,
|
|
2417
2468
|
components: {
|
|
2418
2469
|
...currentComponents,
|
|
2419
2470
|
[normalizedComponent]: now,
|
|
@@ -2422,9 +2473,10 @@ export function recordPluginLifecycleHealth(
|
|
|
2422
2473
|
};
|
|
2423
2474
|
const temporaryPath = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
2424
2475
|
try {
|
|
2425
|
-
|
|
2476
|
+
ensureSecureHookDirectory(dirname(path));
|
|
2426
2477
|
writeFileSync(temporaryPath, `${JSON.stringify(payload)}\n`, {
|
|
2427
2478
|
mode: 0o600,
|
|
2479
|
+
flag: "wx",
|
|
2428
2480
|
});
|
|
2429
2481
|
renameSync(temporaryPath, path);
|
|
2430
2482
|
prunePluginHealthSessionMarkers(normalizedSurface, path, env);
|
|
@@ -2481,7 +2533,9 @@ export function remembranceConfigPath(env = process.env) {
|
|
|
2481
2533
|
|
|
2482
2534
|
export function readRemembranceConfig(env = process.env) {
|
|
2483
2535
|
try {
|
|
2484
|
-
const parsed = JSON.parse(
|
|
2536
|
+
const parsed = JSON.parse(
|
|
2537
|
+
readSecureHookFile(remembranceConfigPath(env), MAX_LOCAL_CONFIG_BYTES),
|
|
2538
|
+
);
|
|
2485
2539
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
2486
2540
|
? parsed
|
|
2487
2541
|
: {};
|
|
@@ -2497,32 +2551,59 @@ export function resolveApiCredential(env = process.env) {
|
|
|
2497
2551
|
}
|
|
2498
2552
|
const environmentKey = String(env.REMEMBRANCE_API_KEY ?? "").trim();
|
|
2499
2553
|
if (environmentKey) {
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2554
|
+
const explicitBinding = String(
|
|
2555
|
+
env.REMEMBRANCE_API_KEY_ORIGIN ?? "",
|
|
2556
|
+
).trim();
|
|
2557
|
+
const binding = explicitBinding
|
|
2558
|
+
? normalizeApiUrl(explicitBinding, env)
|
|
2559
|
+
: { apiUrl: DEFAULT_API_URL, issue: null };
|
|
2560
|
+
return credentialForApiDestination(
|
|
2561
|
+
binding.apiUrl && !binding.issue
|
|
2562
|
+
? {
|
|
2563
|
+
apiKey: environmentKey,
|
|
2564
|
+
source: "environment",
|
|
2565
|
+
boundApiUrl: binding.apiUrl,
|
|
2566
|
+
}
|
|
2567
|
+
: unusableDestinationCredential(),
|
|
2568
|
+
apiConfiguration,
|
|
2569
|
+
);
|
|
2504
2570
|
}
|
|
2505
2571
|
const path = remembranceConfigPath(env);
|
|
2506
2572
|
if (!existsSync(path)) {
|
|
2507
2573
|
return { apiKey: "", source: "none" };
|
|
2508
2574
|
}
|
|
2509
2575
|
try {
|
|
2510
|
-
const parsed = JSON.parse(
|
|
2576
|
+
const parsed = JSON.parse(
|
|
2577
|
+
readSecureHookFile(path, MAX_LOCAL_CONFIG_BYTES),
|
|
2578
|
+
);
|
|
2511
2579
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
2512
|
-
return
|
|
2580
|
+
return unusableSharedConfigCredential();
|
|
2513
2581
|
}
|
|
2514
2582
|
if (
|
|
2515
2583
|
Object.prototype.hasOwnProperty.call(parsed, "apiKey") &&
|
|
2516
2584
|
(typeof parsed.apiKey !== "string" || !parsed.apiKey.trim())
|
|
2517
2585
|
) {
|
|
2518
|
-
return
|
|
2586
|
+
return unusableSharedConfigCredential();
|
|
2519
2587
|
}
|
|
2520
2588
|
const fromFile = parsed.apiKey;
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2589
|
+
if (typeof fromFile !== "string" || !fromFile.trim()) {
|
|
2590
|
+
return { apiKey: "", source: "none" };
|
|
2591
|
+
}
|
|
2592
|
+
const binding = Object.prototype.hasOwnProperty.call(parsed, "apiUrl")
|
|
2593
|
+
? normalizeApiUrl(parsed.apiUrl, env)
|
|
2594
|
+
: { apiUrl: DEFAULT_API_URL, issue: null };
|
|
2595
|
+
return credentialForApiDestination(
|
|
2596
|
+
binding.apiUrl && !binding.issue
|
|
2597
|
+
? {
|
|
2598
|
+
apiKey: fromFile.trim(),
|
|
2599
|
+
source: "shared_config",
|
|
2600
|
+
boundApiUrl: binding.apiUrl,
|
|
2601
|
+
}
|
|
2602
|
+
: unusableSharedConfigCredential(),
|
|
2603
|
+
apiConfiguration,
|
|
2604
|
+
);
|
|
2524
2605
|
} catch {
|
|
2525
|
-
return
|
|
2606
|
+
return unusableSharedConfigCredential();
|
|
2526
2607
|
}
|
|
2527
2608
|
}
|
|
2528
2609
|
|
|
@@ -2538,8 +2619,10 @@ export function sharedConfigCredentialNotice(env = process.env) {
|
|
|
2538
2619
|
return (
|
|
2539
2620
|
"Remembrance setup needs attention: REMEMBRANCE_API_URL is invalid. " +
|
|
2540
2621
|
"Remote Remembrance calls are paused locally instead of changing " +
|
|
2541
|
-
"destinations. Set it to an absolute
|
|
2542
|
-
"
|
|
2622
|
+
"destinations. Set it to an absolute HTTPS registry URL (HTTP is allowed " +
|
|
2623
|
+
"only for loopback development) without credentials, query parameters, " +
|
|
2624
|
+
"or fragments, or " +
|
|
2625
|
+
"intentionally remove it, then run run_connection_doctor."
|
|
2543
2626
|
);
|
|
2544
2627
|
}
|
|
2545
2628
|
if (source === "unusable_shared_config") {
|
|
@@ -2547,7 +2630,16 @@ export function sharedConfigCredentialNotice(env = process.env) {
|
|
|
2547
2630
|
"Remembrance setup needs attention: the shared config file exists but " +
|
|
2548
2631
|
"is unreadable or invalid. Remote Remembrance calls are paused locally " +
|
|
2549
2632
|
"instead of falling back to anonymous scope. Fix or intentionally remove " +
|
|
2550
|
-
"~/.config/remembrance/config.json, then
|
|
2633
|
+
"~/.config/remembrance/config.json, then run run_connection_doctor."
|
|
2634
|
+
);
|
|
2635
|
+
}
|
|
2636
|
+
if (source === "unusable_destination_binding") {
|
|
2637
|
+
return (
|
|
2638
|
+
"Remembrance setup needs attention: the API key is not bound to the " +
|
|
2639
|
+
"configured registry destination. For a custom registry, store apiKey " +
|
|
2640
|
+
"and apiUrl together in the shared config, or set " +
|
|
2641
|
+
"REMEMBRANCE_API_KEY_ORIGIN to the exact REMEMBRANCE_API_URL value. " +
|
|
2642
|
+
"Remote calls are paused so the key cannot be forwarded elsewhere."
|
|
2551
2643
|
);
|
|
2552
2644
|
}
|
|
2553
2645
|
if (source !== "shared_config") {
|
|
@@ -2558,24 +2650,30 @@ export function sharedConfigCredentialNotice(env = process.env) {
|
|
|
2558
2650
|
`the shared Remembrance config file (normally ` +
|
|
2559
2651
|
`~/.config/remembrance/config.json). REMEMBRANCE_API_KEY may be unset; do not ` +
|
|
2560
2652
|
`use an anonymous REST/browser probe to infer this hook's scope. Call ` +
|
|
2561
|
-
`
|
|
2653
|
+
`run_connection_doctor for the MCP transport you will use.`
|
|
2562
2654
|
);
|
|
2563
2655
|
}
|
|
2564
2656
|
|
|
2565
2657
|
export function resolveApiConfiguration(env = process.env) {
|
|
2566
2658
|
const environmentUrl = String(env.REMEMBRANCE_API_URL ?? "").trim();
|
|
2567
2659
|
if (environmentUrl) {
|
|
2568
|
-
const normalized = normalizeApiUrl(environmentUrl);
|
|
2569
|
-
return normalized
|
|
2570
|
-
? { apiUrl: normalized, source: "environment" }
|
|
2571
|
-
: {
|
|
2660
|
+
const normalized = normalizeApiUrl(environmentUrl, env);
|
|
2661
|
+
return normalized.apiUrl && !normalized.issue
|
|
2662
|
+
? { apiUrl: normalized.apiUrl, source: "environment" }
|
|
2663
|
+
: {
|
|
2664
|
+
apiUrl: DEFAULT_API_URL,
|
|
2665
|
+
source: "unusable_environment",
|
|
2666
|
+
issue: normalized.issue ?? "invalid_url",
|
|
2667
|
+
};
|
|
2572
2668
|
}
|
|
2573
2669
|
const path = remembranceConfigPath(env);
|
|
2574
2670
|
if (!existsSync(path)) {
|
|
2575
2671
|
return { apiUrl: DEFAULT_API_URL, source: "default" };
|
|
2576
2672
|
}
|
|
2577
2673
|
try {
|
|
2578
|
-
const parsed = JSON.parse(
|
|
2674
|
+
const parsed = JSON.parse(
|
|
2675
|
+
readSecureHookFile(path, MAX_LOCAL_CONFIG_BYTES),
|
|
2676
|
+
);
|
|
2579
2677
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
2580
2678
|
return {
|
|
2581
2679
|
apiUrl: DEFAULT_API_URL,
|
|
@@ -2585,10 +2683,14 @@ export function resolveApiConfiguration(env = process.env) {
|
|
|
2585
2683
|
if (!Object.prototype.hasOwnProperty.call(parsed, "apiUrl")) {
|
|
2586
2684
|
return { apiUrl: DEFAULT_API_URL, source: "default" };
|
|
2587
2685
|
}
|
|
2588
|
-
const normalized = normalizeApiUrl(parsed.apiUrl);
|
|
2589
|
-
return normalized
|
|
2590
|
-
? { apiUrl: normalized, source: "shared_config" }
|
|
2591
|
-
: {
|
|
2686
|
+
const normalized = normalizeApiUrl(parsed.apiUrl, env);
|
|
2687
|
+
return normalized.apiUrl && !normalized.issue
|
|
2688
|
+
? { apiUrl: normalized.apiUrl, source: "shared_config" }
|
|
2689
|
+
: {
|
|
2690
|
+
apiUrl: DEFAULT_API_URL,
|
|
2691
|
+
source: "unusable_shared_config",
|
|
2692
|
+
issue: normalized.issue ?? "invalid_url",
|
|
2693
|
+
};
|
|
2592
2694
|
} catch {
|
|
2593
2695
|
return { apiUrl: DEFAULT_API_URL, source: "unusable_shared_config" };
|
|
2594
2696
|
}
|
|
@@ -2598,8 +2700,10 @@ function apiUrl(env) {
|
|
|
2598
2700
|
return resolveApiConfiguration(env).apiUrl;
|
|
2599
2701
|
}
|
|
2600
2702
|
|
|
2601
|
-
function normalizeApiUrl(value) {
|
|
2602
|
-
if (typeof value !== "string" || !value.trim())
|
|
2703
|
+
function normalizeApiUrl(value, env = process.env) {
|
|
2704
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
2705
|
+
return { apiUrl: null, issue: "invalid_url" };
|
|
2706
|
+
}
|
|
2603
2707
|
const candidate = value.trim();
|
|
2604
2708
|
try {
|
|
2605
2709
|
const parsed = new URL(candidate);
|
|
@@ -2610,17 +2714,193 @@ function normalizeApiUrl(value) {
|
|
|
2610
2714
|
parsed.search ||
|
|
2611
2715
|
parsed.hash
|
|
2612
2716
|
) {
|
|
2613
|
-
return null;
|
|
2717
|
+
return { apiUrl: null, issue: "invalid_url" };
|
|
2718
|
+
}
|
|
2719
|
+
const loopback = isLoopbackHostname(parsed.hostname);
|
|
2720
|
+
const privateDestination = isPrivateDestinationHostname(parsed.hostname);
|
|
2721
|
+
if (parsed.protocol === "http:" && !loopback) {
|
|
2722
|
+
return { apiUrl: null, issue: "insecure_remote_http" };
|
|
2723
|
+
}
|
|
2724
|
+
if (
|
|
2725
|
+
!loopback &&
|
|
2726
|
+
privateDestination &&
|
|
2727
|
+
env.REMEMBRANCE_ALLOW_PRIVATE_REGISTRY !== "true"
|
|
2728
|
+
) {
|
|
2729
|
+
return {
|
|
2730
|
+
apiUrl: null,
|
|
2731
|
+
issue: "private_destination_requires_opt_in",
|
|
2732
|
+
};
|
|
2614
2733
|
}
|
|
2615
|
-
return candidate.replace(/\/+$/, "");
|
|
2734
|
+
return { apiUrl: candidate.replace(/\/+$/, ""), issue: null };
|
|
2616
2735
|
} catch {
|
|
2617
|
-
return null;
|
|
2736
|
+
return { apiUrl: null, issue: "invalid_url" };
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
function credentialForApiDestination(credential, configuration) {
|
|
2741
|
+
if (!credential.apiKey || isUnusableConfigurationSource(configuration.source)) {
|
|
2742
|
+
return credential.apiKey
|
|
2743
|
+
? unusableDestinationCredential()
|
|
2744
|
+
: { apiKey: "", source: credential.source };
|
|
2618
2745
|
}
|
|
2746
|
+
return credential.boundApiUrl === configuration.apiUrl
|
|
2747
|
+
? { apiKey: credential.apiKey, source: credential.source }
|
|
2748
|
+
: unusableDestinationCredential();
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2751
|
+
function unusableSharedConfigCredential() {
|
|
2752
|
+
return {
|
|
2753
|
+
apiKey: "",
|
|
2754
|
+
source: "unusable_shared_config",
|
|
2755
|
+
};
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
function unusableDestinationCredential() {
|
|
2759
|
+
return {
|
|
2760
|
+
apiKey: "",
|
|
2761
|
+
source: "unusable_destination_binding",
|
|
2762
|
+
};
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
function isLoopbackHostname(hostname) {
|
|
2766
|
+
const normalized = normalizeHostname(hostname);
|
|
2767
|
+
if (normalized === "localhost" || normalized.endsWith(".localhost")) {
|
|
2768
|
+
return true;
|
|
2769
|
+
}
|
|
2770
|
+
if (normalized === "::1") return true;
|
|
2771
|
+
if (isIP(normalized) === 4) {
|
|
2772
|
+
return Number(normalized.split(".")[0]) === 127;
|
|
2773
|
+
}
|
|
2774
|
+
return false;
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
function isPrivateDestinationHostname(hostname) {
|
|
2778
|
+
const normalized = normalizeHostname(hostname);
|
|
2779
|
+
if (
|
|
2780
|
+
normalized.endsWith(".local") ||
|
|
2781
|
+
normalized.endsWith(".internal") ||
|
|
2782
|
+
normalized === "localhost"
|
|
2783
|
+
) {
|
|
2784
|
+
return true;
|
|
2785
|
+
}
|
|
2786
|
+
const version = isIP(normalized);
|
|
2787
|
+
if (version === 4) {
|
|
2788
|
+
const [a, b] = normalized.split(".").map(Number);
|
|
2789
|
+
return (
|
|
2790
|
+
a === 0 ||
|
|
2791
|
+
a === 10 ||
|
|
2792
|
+
a === 127 ||
|
|
2793
|
+
(a === 100 && b >= 64 && b <= 127) ||
|
|
2794
|
+
(a === 169 && b === 254) ||
|
|
2795
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
2796
|
+
(a === 192 && b === 168) ||
|
|
2797
|
+
(a === 198 && (b === 18 || b === 19)) ||
|
|
2798
|
+
a >= 224
|
|
2799
|
+
);
|
|
2800
|
+
}
|
|
2801
|
+
if (version === 6) {
|
|
2802
|
+
return (
|
|
2803
|
+
normalized === "::" ||
|
|
2804
|
+
normalized === "::1" ||
|
|
2805
|
+
normalized.startsWith("fc") ||
|
|
2806
|
+
normalized.startsWith("fd") ||
|
|
2807
|
+
/^fe[89ab]/.test(normalized)
|
|
2808
|
+
);
|
|
2809
|
+
}
|
|
2810
|
+
return false;
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
function normalizeHostname(hostname) {
|
|
2814
|
+
return String(hostname).replace(/^\[|\]$/g, "").toLowerCase();
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
function readSecureHookFile(path, maxBytes) {
|
|
2818
|
+
const parent = lstatSync(dirname(path));
|
|
2819
|
+
assertSecureHookDirectory(parent);
|
|
2820
|
+
const before = lstatSync(path);
|
|
2821
|
+
assertSecureHookFile(before, maxBytes);
|
|
2822
|
+
const descriptor = openSync(
|
|
2823
|
+
path,
|
|
2824
|
+
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
|
2825
|
+
);
|
|
2826
|
+
try {
|
|
2827
|
+
const opened = fstatSync(descriptor);
|
|
2828
|
+
assertSecureHookFile(opened, maxBytes);
|
|
2829
|
+
if (before.dev !== opened.dev || before.ino !== opened.ino) {
|
|
2830
|
+
throw new Error("Remembrance local state changed while opening.");
|
|
2831
|
+
}
|
|
2832
|
+
return readBoundedHookDescriptor(descriptor, maxBytes);
|
|
2833
|
+
} finally {
|
|
2834
|
+
closeSync(descriptor);
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
function ensureSecureHookDirectory(path) {
|
|
2839
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
2840
|
+
const metadata = lstatSync(path);
|
|
2841
|
+
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
|
2842
|
+
throw new Error("Remembrance health state is not a regular directory.");
|
|
2843
|
+
}
|
|
2844
|
+
assertCurrentHookUser(metadata.uid);
|
|
2845
|
+
if (isPosixHookRuntime() && (metadata.mode & 0o077) !== 0) {
|
|
2846
|
+
chmodSync(path, 0o700);
|
|
2847
|
+
}
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
function assertSecureHookDirectory(metadata) {
|
|
2851
|
+
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
|
2852
|
+
throw new Error("Remembrance local state parent is unsafe.");
|
|
2853
|
+
}
|
|
2854
|
+
assertCurrentHookUser(metadata.uid);
|
|
2855
|
+
if (isPosixHookRuntime() && (metadata.mode & 0o022) !== 0) {
|
|
2856
|
+
throw new Error("Remembrance local state parent is writable by others.");
|
|
2857
|
+
}
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
function assertSecureHookFile(metadata, maxBytes) {
|
|
2861
|
+
if (metadata.isSymbolicLink() || !metadata.isFile()) {
|
|
2862
|
+
throw new Error("Remembrance local state is not a regular file.");
|
|
2863
|
+
}
|
|
2864
|
+
assertCurrentHookUser(metadata.uid);
|
|
2865
|
+
if (metadata.size > maxBytes) {
|
|
2866
|
+
throw new Error("Remembrance local state exceeds its size limit.");
|
|
2867
|
+
}
|
|
2868
|
+
if (isPosixHookRuntime() && (metadata.mode & 0o077) !== 0) {
|
|
2869
|
+
throw new Error("Remembrance local state permissions are not private.");
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
function assertCurrentHookUser(uid) {
|
|
2874
|
+
if (isPosixHookRuntime() && uid !== process.getuid()) {
|
|
2875
|
+
throw new Error("Remembrance local state is owned by another user.");
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
|
|
2879
|
+
function readBoundedHookDescriptor(descriptor, maxBytes) {
|
|
2880
|
+
const chunks = [];
|
|
2881
|
+
let total = 0;
|
|
2882
|
+
while (total <= maxBytes) {
|
|
2883
|
+
const chunk = Buffer.allocUnsafe(Math.min(8 * 1024, maxBytes + 1 - total));
|
|
2884
|
+
const bytesRead = readSync(descriptor, chunk, 0, chunk.length, null);
|
|
2885
|
+
if (bytesRead === 0) break;
|
|
2886
|
+
chunks.push(chunk.subarray(0, bytesRead));
|
|
2887
|
+
total += bytesRead;
|
|
2888
|
+
}
|
|
2889
|
+
if (total > maxBytes) {
|
|
2890
|
+
throw new Error("Remembrance local state exceeds its size limit.");
|
|
2891
|
+
}
|
|
2892
|
+
return Buffer.concat(chunks, total).toString("utf8");
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
function isPosixHookRuntime() {
|
|
2896
|
+
return typeof process.getuid === "function";
|
|
2619
2897
|
}
|
|
2620
2898
|
|
|
2621
2899
|
function isUnusableConfigurationSource(source) {
|
|
2622
2900
|
return (
|
|
2623
|
-
source === "unusable_environment" ||
|
|
2901
|
+
source === "unusable_environment" ||
|
|
2902
|
+
source === "unusable_shared_config" ||
|
|
2903
|
+
source === "unusable_destination_binding"
|
|
2624
2904
|
);
|
|
2625
2905
|
}
|
|
2626
2906
|
|