@nforma.ai/nforma 0.2.1
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/LICENSE +22 -0
- package/README.md +1024 -0
- package/agents/qgsd-codebase-mapper.md +764 -0
- package/agents/qgsd-debugger.md +1201 -0
- package/agents/qgsd-executor.md +472 -0
- package/agents/qgsd-integration-checker.md +443 -0
- package/agents/qgsd-phase-researcher.md +502 -0
- package/agents/qgsd-plan-checker.md +643 -0
- package/agents/qgsd-planner.md +1182 -0
- package/agents/qgsd-project-researcher.md +621 -0
- package/agents/qgsd-quorum-orchestrator.md +628 -0
- package/agents/qgsd-quorum-slot-worker.md +41 -0
- package/agents/qgsd-quorum-synthesizer.md +133 -0
- package/agents/qgsd-quorum-test-worker.md +37 -0
- package/agents/qgsd-quorum-worker.md +161 -0
- package/agents/qgsd-research-synthesizer.md +239 -0
- package/agents/qgsd-roadmapper.md +660 -0
- package/agents/qgsd-verifier.md +628 -0
- package/bin/accept-debug-invariant.cjs +165 -0
- package/bin/account-manager.cjs +719 -0
- package/bin/aggregate-requirements.cjs +466 -0
- package/bin/analyze-assumptions.cjs +757 -0
- package/bin/analyze-state-space.cjs +921 -0
- package/bin/attribute-trace-divergence.cjs +150 -0
- package/bin/auth-drivers/gh-cli.cjs +93 -0
- package/bin/auth-drivers/index.cjs +46 -0
- package/bin/auth-drivers/pool.cjs +67 -0
- package/bin/auth-drivers/simple.cjs +95 -0
- package/bin/autoClosePtoF.cjs +110 -0
- package/bin/blessed-terminal.cjs +350 -0
- package/bin/build-phase-index.cjs +472 -0
- package/bin/call-quorum-slot.cjs +541 -0
- package/bin/ccr-secure-config.cjs +99 -0
- package/bin/ccr-secure-start.cjs +83 -0
- package/bin/check-bundled-sdks.cjs +177 -0
- package/bin/check-coverage-guard.cjs +112 -0
- package/bin/check-liveness-fairness.cjs +95 -0
- package/bin/check-mcp-health.cjs +123 -0
- package/bin/check-provider-health.cjs +395 -0
- package/bin/check-results-exit.cjs +24 -0
- package/bin/check-spec-sync.cjs +360 -0
- package/bin/check-trace-redaction.cjs +271 -0
- package/bin/check-trace-schema-drift.cjs +99 -0
- package/bin/compareDrift.cjs +21 -0
- package/bin/conformance-schema.cjs +12 -0
- package/bin/count-scenarios.cjs +420 -0
- package/bin/debt-dedup.cjs +144 -0
- package/bin/debt-ledger.cjs +61 -0
- package/bin/debt-retention.cjs +76 -0
- package/bin/debt-state-machine.cjs +80 -0
- package/bin/detect-coverage-gaps.cjs +204 -0
- package/bin/detect-project-intent.cjs +362 -0
- package/bin/export-prism-constants.cjs +164 -0
- package/bin/extract-annotations.cjs +633 -0
- package/bin/extractFormalExpected.cjs +104 -0
- package/bin/fingerprint-drift.cjs +24 -0
- package/bin/fingerprint-issue.cjs +46 -0
- package/bin/formal-core.cjs +519 -0
- package/bin/formal-ref-linker.cjs +141 -0
- package/bin/formal-test-sync.cjs +788 -0
- package/bin/generate-formal-specs.cjs +588 -0
- package/bin/generate-petri-net.cjs +397 -0
- package/bin/generate-phase-spec.cjs +249 -0
- package/bin/generate-proposed-changes.cjs +194 -0
- package/bin/generate-tla-cfg.cjs +122 -0
- package/bin/generate-traceability-matrix.cjs +701 -0
- package/bin/generate-triage-bundle.cjs +300 -0
- package/bin/gh-account-rotate.cjs +34 -0
- package/bin/initialize-model-registry.cjs +105 -0
- package/bin/install-formal-tools.cjs +382 -0
- package/bin/install.js +2424 -0
- package/bin/isNumericThreshold.cjs +34 -0
- package/bin/issue-classifier.cjs +151 -0
- package/bin/levenshtein.cjs +74 -0
- package/bin/lint-formal-models.cjs +580 -0
- package/bin/load-baseline-requirements.cjs +275 -0
- package/bin/manage-agents-core.cjs +815 -0
- package/bin/migrate-formal-dir.cjs +172 -0
- package/bin/migrate-planning.cjs +206 -0
- package/bin/migrate-to-slots.cjs +255 -0
- package/bin/nForma.cjs +2726 -0
- package/bin/observe-config.cjs +353 -0
- package/bin/observe-debt-writer.cjs +140 -0
- package/bin/observe-handler-grafana.cjs +128 -0
- package/bin/observe-handler-internal.cjs +301 -0
- package/bin/observe-handler-logstash.cjs +153 -0
- package/bin/observe-handler-prometheus.cjs +185 -0
- package/bin/observe-handlers.cjs +436 -0
- package/bin/observe-registry.cjs +131 -0
- package/bin/observe-render.cjs +168 -0
- package/bin/planning-paths.cjs +167 -0
- package/bin/polyrepo.cjs +560 -0
- package/bin/prism-priority.cjs +153 -0
- package/bin/probe-quorum-slots.cjs +167 -0
- package/bin/promote-model.cjs +225 -0
- package/bin/propose-debug-invariants.cjs +165 -0
- package/bin/providers.json +392 -0
- package/bin/pty-proxy.py +129 -0
- package/bin/qgsd-solve.cjs +2477 -0
- package/bin/quorum-consensus-gate.cjs +238 -0
- package/bin/quorum-formal-context.cjs +183 -0
- package/bin/quorum-slot-dispatch.cjs +934 -0
- package/bin/read-policy.cjs +60 -0
- package/bin/requirement-map.cjs +63 -0
- package/bin/requirements-core.cjs +247 -0
- package/bin/resolve-cli.cjs +101 -0
- package/bin/review-mcp-logs.cjs +294 -0
- package/bin/run-account-manager-tlc.cjs +188 -0
- package/bin/run-account-pool-alloy.cjs +158 -0
- package/bin/run-alloy.cjs +153 -0
- package/bin/run-audit-alloy.cjs +187 -0
- package/bin/run-breaker-tlc.cjs +181 -0
- package/bin/run-formal-check.cjs +395 -0
- package/bin/run-formal-verify.cjs +701 -0
- package/bin/run-installer-alloy.cjs +188 -0
- package/bin/run-oauth-rotation-prism.cjs +132 -0
- package/bin/run-oscillation-tlc.cjs +202 -0
- package/bin/run-phase-tlc.cjs +228 -0
- package/bin/run-prism.cjs +446 -0
- package/bin/run-protocol-tlc.cjs +201 -0
- package/bin/run-quorum-composition-alloy.cjs +155 -0
- package/bin/run-sensitivity-sweep.cjs +231 -0
- package/bin/run-stop-hook-tlc.cjs +188 -0
- package/bin/run-tlc.cjs +467 -0
- package/bin/run-transcript-alloy.cjs +173 -0
- package/bin/run-uppaal.cjs +264 -0
- package/bin/secrets.cjs +134 -0
- package/bin/sensitivity-report.cjs +219 -0
- package/bin/sensitivity-sweep-feedback.cjs +194 -0
- package/bin/set-secret.cjs +29 -0
- package/bin/setup-telemetry-cron.sh +36 -0
- package/bin/sweepPtoF.cjs +63 -0
- package/bin/sync-baseline-requirements.cjs +290 -0
- package/bin/task-envelope.cjs +360 -0
- package/bin/telemetry-collector.cjs +229 -0
- package/bin/unified-mcp-server.mjs +735 -0
- package/bin/update-agents.cjs +369 -0
- package/bin/update-scoreboard.cjs +1134 -0
- package/bin/validate-debt-entry.cjs +207 -0
- package/bin/validate-invariant.cjs +419 -0
- package/bin/validate-memory.cjs +389 -0
- package/bin/validate-requirements-haiku.cjs +435 -0
- package/bin/validate-traces.cjs +438 -0
- package/bin/verify-formal-results.cjs +124 -0
- package/bin/verify-quorum-health.cjs +273 -0
- package/bin/write-check-result.cjs +106 -0
- package/bin/xstate-to-tla.cjs +483 -0
- package/bin/xstate-trace-walker.cjs +205 -0
- package/commands/qgsd/add-phase.md +43 -0
- package/commands/qgsd/add-requirement.md +24 -0
- package/commands/qgsd/add-todo.md +47 -0
- package/commands/qgsd/audit-milestone.md +37 -0
- package/commands/qgsd/check-todos.md +45 -0
- package/commands/qgsd/cleanup.md +18 -0
- package/commands/qgsd/close-formal-gaps.md +33 -0
- package/commands/qgsd/complete-milestone.md +136 -0
- package/commands/qgsd/debug.md +166 -0
- package/commands/qgsd/discuss-phase.md +83 -0
- package/commands/qgsd/execute-phase.md +117 -0
- package/commands/qgsd/fix-tests.md +27 -0
- package/commands/qgsd/formal-test-sync.md +32 -0
- package/commands/qgsd/health.md +22 -0
- package/commands/qgsd/help.md +22 -0
- package/commands/qgsd/insert-phase.md +32 -0
- package/commands/qgsd/join-discord.md +18 -0
- package/commands/qgsd/list-phase-assumptions.md +46 -0
- package/commands/qgsd/map-codebase.md +71 -0
- package/commands/qgsd/map-requirements.md +20 -0
- package/commands/qgsd/mcp-restart.md +176 -0
- package/commands/qgsd/mcp-set-model.md +134 -0
- package/commands/qgsd/mcp-setup.md +1371 -0
- package/commands/qgsd/mcp-status.md +274 -0
- package/commands/qgsd/mcp-update.md +238 -0
- package/commands/qgsd/new-milestone.md +44 -0
- package/commands/qgsd/new-project.md +42 -0
- package/commands/qgsd/observe.md +260 -0
- package/commands/qgsd/pause-work.md +38 -0
- package/commands/qgsd/plan-milestone-gaps.md +34 -0
- package/commands/qgsd/plan-phase.md +44 -0
- package/commands/qgsd/polyrepo.md +50 -0
- package/commands/qgsd/progress.md +24 -0
- package/commands/qgsd/queue.md +54 -0
- package/commands/qgsd/quick.md +133 -0
- package/commands/qgsd/quorum-test.md +275 -0
- package/commands/qgsd/quorum.md +707 -0
- package/commands/qgsd/reapply-patches.md +110 -0
- package/commands/qgsd/remove-phase.md +31 -0
- package/commands/qgsd/research-phase.md +189 -0
- package/commands/qgsd/resume-work.md +40 -0
- package/commands/qgsd/set-profile.md +34 -0
- package/commands/qgsd/settings.md +39 -0
- package/commands/qgsd/solve.md +565 -0
- package/commands/qgsd/sync-baselines.md +119 -0
- package/commands/qgsd/triage.md +233 -0
- package/commands/qgsd/update.md +37 -0
- package/commands/qgsd/verify-work.md +38 -0
- package/hooks/dist/config-loader.js +297 -0
- package/hooks/dist/conformance-schema.cjs +12 -0
- package/hooks/dist/gsd-context-monitor.js +64 -0
- package/hooks/dist/qgsd-check-update.js +62 -0
- package/hooks/dist/qgsd-circuit-breaker.js +682 -0
- package/hooks/dist/qgsd-precompact.js +156 -0
- package/hooks/dist/qgsd-prompt.js +653 -0
- package/hooks/dist/qgsd-session-start.js +122 -0
- package/hooks/dist/qgsd-slot-correlator.js +58 -0
- package/hooks/dist/qgsd-spec-regen.js +86 -0
- package/hooks/dist/qgsd-statusline.js +91 -0
- package/hooks/dist/qgsd-stop.js +553 -0
- package/hooks/dist/qgsd-token-collector.js +133 -0
- package/hooks/dist/unified-mcp-server.mjs +669 -0
- package/package.json +95 -0
- package/scripts/build-hooks.js +46 -0
- package/scripts/postinstall.js +48 -0
- package/scripts/secret-audit.sh +45 -0
- package/templates/qgsd.json +49 -0
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* call-quorum-slot.cjs — bash-callable quorum slot dispatcher
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* echo "<prompt>" | node call-quorum-slot.cjs --slot <name> [--timeout <ms>] [--cwd <dir>]
|
|
9
|
+
* node call-quorum-slot.cjs --slot <name> [--timeout <ms>] [--cwd <dir>] <<'EOF'
|
|
10
|
+
* <multi-line prompt>
|
|
11
|
+
* EOF
|
|
12
|
+
*
|
|
13
|
+
* --cwd <dir> Set the working directory for spawned CLI processes (defaults to process.cwd()).
|
|
14
|
+
* Pass the project repo path so CLIs auto-detect the correct git context.
|
|
15
|
+
*
|
|
16
|
+
* Reads providers.json, dispatches to the slot's CLI (subprocess) or HTTP provider,
|
|
17
|
+
* prints the response text to stdout.
|
|
18
|
+
*
|
|
19
|
+
* Used by qgsd-quorum-orchestrator (sub-agent) which cannot access MCP tools.
|
|
20
|
+
*
|
|
21
|
+
* Exit codes: 0 = success, 1 = error (message on stderr)
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const { spawn } = require('child_process');
|
|
25
|
+
const https = require('https');
|
|
26
|
+
const http = require('http');
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const path = require('path');
|
|
29
|
+
const os = require('os');
|
|
30
|
+
|
|
31
|
+
// ─── Utilities ──────────────────────────────────────────────────────────────
|
|
32
|
+
function sleep(ms) {
|
|
33
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ─── Token sentinel for CLI slots (OBSV-04) ───────────────────────────────────
|
|
37
|
+
function appendTokenSentinel(slotName) {
|
|
38
|
+
try {
|
|
39
|
+
const record = JSON.stringify({
|
|
40
|
+
ts: new Date().toISOString(),
|
|
41
|
+
session_id: null,
|
|
42
|
+
agent_id: null,
|
|
43
|
+
slot: slotName,
|
|
44
|
+
input_tokens: null,
|
|
45
|
+
output_tokens: null,
|
|
46
|
+
cache_creation_input_tokens: null,
|
|
47
|
+
cache_read_input_tokens: null,
|
|
48
|
+
});
|
|
49
|
+
const pp = require('./planning-paths.cjs');
|
|
50
|
+
const logPath = pp.resolve(findProjectRoot(), 'token-usage');
|
|
51
|
+
fs.appendFileSync(logPath, record + '\n', 'utf8');
|
|
52
|
+
} catch (_) {} // observational — never fails
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─── Telemetry logging for quorum slot dispatch (OBS-01) ─────────────────────
|
|
56
|
+
function recordTelemetry(slotName, round, verdict, latencyMs, provider, providerStatus, retryCount, errorType) {
|
|
57
|
+
try {
|
|
58
|
+
const sessionId = process.env.CLAUDE_SESSION_ID || 'session-' + Date.now();
|
|
59
|
+
const record = JSON.stringify({
|
|
60
|
+
ts: new Date().toISOString(),
|
|
61
|
+
session_id: sessionId,
|
|
62
|
+
round: parseInt(round, 10) || 0,
|
|
63
|
+
slot: slotName,
|
|
64
|
+
verdict: verdict,
|
|
65
|
+
latency_ms: latencyMs,
|
|
66
|
+
provider: provider,
|
|
67
|
+
provider_status: providerStatus,
|
|
68
|
+
retry_count: retryCount,
|
|
69
|
+
error_type: errorType,
|
|
70
|
+
});
|
|
71
|
+
const pp = require('./planning-paths.cjs');
|
|
72
|
+
const logPath = pp.resolve(findProjectRoot(), 'quorum-rounds', { sessionId });
|
|
73
|
+
fs.appendFileSync(logPath, record + '\n', 'utf8');
|
|
74
|
+
} catch (_) {
|
|
75
|
+
// Fail-open: telemetry errors never block or crash the dispatch
|
|
76
|
+
// Log to stderr for observability, but do not rethrow
|
|
77
|
+
process.stderr.write('[call-quorum-slot] telemetry error (non-fatal): recordTelemetry failed\n');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── Failure log ───────────────────────────────────────────────────────────────
|
|
82
|
+
function findProjectRoot() {
|
|
83
|
+
let dir = __dirname;
|
|
84
|
+
for (let i = 0; i < 8; i++) {
|
|
85
|
+
if (fs.existsSync(path.join(dir, '.planning'))) return dir;
|
|
86
|
+
const parent = path.dirname(dir);
|
|
87
|
+
if (parent === dir) break;
|
|
88
|
+
dir = parent;
|
|
89
|
+
}
|
|
90
|
+
return process.cwd();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function writeFailureLog(slotName, errorMsg, stderrText) {
|
|
94
|
+
try {
|
|
95
|
+
const pp = require('./planning-paths.cjs');
|
|
96
|
+
const logPath = pp.resolve(findProjectRoot(), 'quorum-failures');
|
|
97
|
+
|
|
98
|
+
// Classify error type
|
|
99
|
+
let error_type;
|
|
100
|
+
if (/usage:|unknown flag|unknown option|invalid flag|unrecognized/i.test(errorMsg)) {
|
|
101
|
+
error_type = 'CLI_SYNTAX';
|
|
102
|
+
} else if (/TIMEOUT/i.test(errorMsg)) {
|
|
103
|
+
error_type = 'TIMEOUT';
|
|
104
|
+
} else if (/401|403|unauthorized|forbidden/i.test(errorMsg)) {
|
|
105
|
+
error_type = 'AUTH';
|
|
106
|
+
} else {
|
|
107
|
+
error_type = 'UNKNOWN';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Extract pattern: first 200 chars of stderrText or errorMsg, strip ANSI codes
|
|
111
|
+
const rawPattern = (stderrText && stderrText.length > 0) ? stderrText : errorMsg;
|
|
112
|
+
const pattern = rawPattern.replace(/\x1b\[[0-9;]*m/g, '').slice(0, 200);
|
|
113
|
+
|
|
114
|
+
// Read existing log
|
|
115
|
+
let records = [];
|
|
116
|
+
if (fs.existsSync(logPath)) {
|
|
117
|
+
try {
|
|
118
|
+
records = JSON.parse(fs.readFileSync(logPath, 'utf8'));
|
|
119
|
+
if (!Array.isArray(records)) records = [];
|
|
120
|
+
} catch (_) { records = []; }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Update or insert record
|
|
124
|
+
const existing = records.find(r => r.slot === slotName && r.error_type === error_type);
|
|
125
|
+
if (existing) {
|
|
126
|
+
existing.count++;
|
|
127
|
+
existing.last_seen = new Date().toISOString();
|
|
128
|
+
} else {
|
|
129
|
+
records.push({ slot: slotName, error_type, pattern, count: 1, last_seen: new Date().toISOString() });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
fs.writeFileSync(logPath, JSON.stringify(records, null, 2), 'utf8');
|
|
133
|
+
} catch (_) { /* failure logging must never interrupt the primary flow */ }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── Retry with exponential backoff (FAIL-01) ───────────────────────────────
|
|
137
|
+
function isRetryable(error) {
|
|
138
|
+
const msg = (error && error.message) ? error.message : String(error);
|
|
139
|
+
|
|
140
|
+
// Non-retryable errors: fail immediately
|
|
141
|
+
if (/spawn error/i.test(msg)) {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
if (/usage:|unknown flag|unknown option|invalid flag|unrecognized/i.test(msg)) {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Retryable errors: TIMEOUT and network errors
|
|
149
|
+
if (/TIMEOUT/i.test(msg)) {
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
if (/ECONNREFUSED|ENOTFOUND|ECONNRESET|ETIMEDOUT/i.test(msg)) {
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Fail-open: unknown errors are retryable
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function retryWithBackoff(fn, slotName, maxRetries = 2, delays = [1000, 3000]) {
|
|
161
|
+
const MAX_RETRIES = maxRetries;
|
|
162
|
+
let retryAttempts = 0;
|
|
163
|
+
|
|
164
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
165
|
+
try {
|
|
166
|
+
const result = await fn();
|
|
167
|
+
return { result, retryCount: retryAttempts };
|
|
168
|
+
} catch (err) {
|
|
169
|
+
const isLastAttempt = attempt >= MAX_RETRIES;
|
|
170
|
+
const isNonRetryable = !isRetryable(err);
|
|
171
|
+
|
|
172
|
+
// Fail immediately if non-retryable or no more retries
|
|
173
|
+
if (isNonRetryable || isLastAttempt) {
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Log retry attempt and sleep before next attempt
|
|
178
|
+
const delayMs = delays[attempt] ?? 3000; // default to 3s if delay not specified
|
|
179
|
+
retryAttempts++;
|
|
180
|
+
process.stderr.write(`[call-quorum-slot] retry ${attempt + 1}/${MAX_RETRIES} for slot ${slotName} after ${delayMs}ms\n`);
|
|
181
|
+
await sleep(delayMs);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ─── Args ──────────────────────────────────────────────────────────────────────
|
|
187
|
+
const argv = process.argv.slice(2);
|
|
188
|
+
const getArg = (f) => { const i = argv.indexOf(f); return i !== -1 && argv[i + 1] ? argv[i + 1] : null; };
|
|
189
|
+
|
|
190
|
+
const slot = getArg('--slot');
|
|
191
|
+
const _timeoutArg = getArg('--timeout');
|
|
192
|
+
// Treat 0 / negative / NaN as "not set" — fall through to provider.quorum_timeout_ms.
|
|
193
|
+
// Zero can be passed when the quorum orchestrator LLM fails to compute SLOT_TIMEOUTS,
|
|
194
|
+
// causing the process to be killed in ~1 ms and logged as "TIMEOUT after 0ms".
|
|
195
|
+
let timeoutMs = _timeoutArg !== null ? parseInt(_timeoutArg, 10) : null;
|
|
196
|
+
if (timeoutMs !== null && (isNaN(timeoutMs) || timeoutMs <= 0)) timeoutMs = null;
|
|
197
|
+
const roundNum = getArg('--round');
|
|
198
|
+
const spawnCwd = getArg('--cwd') ?? process.cwd();
|
|
199
|
+
|
|
200
|
+
if (!slot) {
|
|
201
|
+
process.stderr.write('Usage: echo "<prompt>" | node call-quorum-slot.cjs --slot <name> [--timeout <ms>] [--cwd <dir>]\n');
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ─── Find providers.json ───────────────────────────────────────────────────────
|
|
206
|
+
function findProviders() {
|
|
207
|
+
const searchPaths = [
|
|
208
|
+
path.join(__dirname, 'providers.json'), // same dir (qgsd-bin)
|
|
209
|
+
path.join(os.homedir(), '.claude', 'qgsd-bin', 'providers.json'), // installed fallback
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
// Also derive path from unified-1 MCP server config in ~/.claude.json
|
|
213
|
+
try {
|
|
214
|
+
const claudeJson = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude.json'), 'utf8'));
|
|
215
|
+
const u1args = claudeJson?.mcpServers?.['unified-1']?.args ?? [];
|
|
216
|
+
const serverScript = u1args.find(a => typeof a === 'string' && a.endsWith('unified-mcp-server.mjs'));
|
|
217
|
+
if (serverScript) {
|
|
218
|
+
searchPaths.unshift(path.join(path.dirname(serverScript), 'providers.json'));
|
|
219
|
+
}
|
|
220
|
+
} catch (_) { /* no claude.json — fine */ }
|
|
221
|
+
|
|
222
|
+
for (const p of searchPaths) {
|
|
223
|
+
try {
|
|
224
|
+
if (fs.existsSync(p)) {
|
|
225
|
+
return JSON.parse(fs.readFileSync(p, 'utf8')).providers;
|
|
226
|
+
}
|
|
227
|
+
} catch (_) { /* try next */ }
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ─── Read stdin ────────────────────────────────────────────────────────────────
|
|
233
|
+
function readStdin() {
|
|
234
|
+
return new Promise((resolve) => {
|
|
235
|
+
let data = '';
|
|
236
|
+
process.stdin.setEncoding('utf8');
|
|
237
|
+
process.stdin.on('data', chunk => { data += chunk; });
|
|
238
|
+
process.stdin.on('end', () => resolve(data.trim()));
|
|
239
|
+
// If stdin is a TTY (no pipe), resolve immediately with empty string
|
|
240
|
+
if (process.stdin.isTTY) resolve('');
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ─── Subprocess dispatch ───────────────────────────────────────────────────────
|
|
245
|
+
function runSubprocess(provider, prompt, timeoutMs) {
|
|
246
|
+
const args = provider.args_template.map(a => (a === '{prompt}' ? prompt : a));
|
|
247
|
+
const env = { ...process.env, ...(provider.env ?? {}) };
|
|
248
|
+
|
|
249
|
+
return new Promise((resolve, reject) => {
|
|
250
|
+
let child;
|
|
251
|
+
try {
|
|
252
|
+
// detached: true creates a new process group — required to kill all descendants
|
|
253
|
+
// (ccr → Claude Code → node, opencode → LLM subprocess, etc.)
|
|
254
|
+
child = spawn(provider.cli, args, { env, cwd: spawnCwd, stdio: ['pipe', 'pipe', 'pipe'], detached: true });
|
|
255
|
+
} catch (err) {
|
|
256
|
+
reject(new Error(`[spawn error: ${err.message}]`));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
child.stdin.end(); // non-interactive
|
|
261
|
+
|
|
262
|
+
let stdout = '';
|
|
263
|
+
let stderr = '';
|
|
264
|
+
let timedOut = false;
|
|
265
|
+
const MAX_BUF = 10 * 1024 * 1024;
|
|
266
|
+
|
|
267
|
+
// Kill entire process group, then destroy streams to force 'close' even if
|
|
268
|
+
// grandchildren keep the pipes open (the common case with ccr/opencode).
|
|
269
|
+
const killGroup = () => {
|
|
270
|
+
try { process.kill(-child.pid, 'SIGTERM'); } catch (_) { try { child.kill('SIGTERM'); } catch (_) {} }
|
|
271
|
+
setTimeout(() => {
|
|
272
|
+
try { process.kill(-child.pid, 'SIGKILL'); } catch (_) { try { child.kill('SIGKILL'); } catch (_) {} }
|
|
273
|
+
try { child.stdout.destroy(); } catch (_) {}
|
|
274
|
+
try { child.stderr.destroy(); } catch (_) {}
|
|
275
|
+
}, 2000);
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const timer = setTimeout(() => {
|
|
279
|
+
timedOut = true;
|
|
280
|
+
killGroup();
|
|
281
|
+
}, timeoutMs);
|
|
282
|
+
|
|
283
|
+
child.stdout.on('data', d => {
|
|
284
|
+
if (stdout.length < MAX_BUF) stdout += d.toString().slice(0, MAX_BUF - stdout.length);
|
|
285
|
+
});
|
|
286
|
+
child.stderr.on('data', d => { stderr += d.toString().slice(0, 4096); });
|
|
287
|
+
|
|
288
|
+
child.on('close', (code) => {
|
|
289
|
+
clearTimeout(timer);
|
|
290
|
+
if (timedOut) {
|
|
291
|
+
reject(new Error(`TIMEOUT after ${timeoutMs}ms`));
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const output = stdout || stderr || '(no output)';
|
|
295
|
+
resolve(code !== 0 ? `${output}\n[exit code ${code}]` : output);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
child.on('error', (err) => {
|
|
299
|
+
clearTimeout(timer);
|
|
300
|
+
reject(new Error(`[spawn error: ${err.message}]`));
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ─── OAuth account rotation ────────────────────────────────────────────────────
|
|
306
|
+
function matchesRotationPattern(text, patterns) {
|
|
307
|
+
const lower = (text ?? '').toLowerCase();
|
|
308
|
+
return patterns.some(p => lower.includes(p.toLowerCase()));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function spawnRotateCmd(cmdArray) {
|
|
312
|
+
return new Promise((resolve) => {
|
|
313
|
+
const [bin, ...args] = cmdArray;
|
|
314
|
+
const child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
315
|
+
child.on('close', (code) => {
|
|
316
|
+
if (code !== 0) process.stderr.write(`[oauth-rotation] ${bin} exited ${code}\n`);
|
|
317
|
+
resolve(); // non-fatal — always attempt retry
|
|
318
|
+
});
|
|
319
|
+
child.on('error', (err) => {
|
|
320
|
+
process.stderr.write(`[oauth-rotation] ${bin} error: ${err.message}\n`);
|
|
321
|
+
resolve(); // non-fatal
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function runSubprocessWithRotation(provider, prompt, timeoutMs) {
|
|
327
|
+
const rot = provider.oauth_rotation;
|
|
328
|
+
const max = rot.max_retries ?? 3;
|
|
329
|
+
const patterns = rot.retry_on_patterns ?? ['quota', 'resource_exhausted', 'unauthorized', '401', '403'];
|
|
330
|
+
let lastErr = null;
|
|
331
|
+
let totalRetryCount = 0;
|
|
332
|
+
|
|
333
|
+
for (let attempt = 0; attempt <= max; attempt++) {
|
|
334
|
+
if (attempt > 0) {
|
|
335
|
+
process.stderr.write(`[oauth-rotation] attempt ${attempt}/${max} — rotating OAuth account\n`);
|
|
336
|
+
await spawnRotateCmd(rot.rotate_cmd);
|
|
337
|
+
}
|
|
338
|
+
try {
|
|
339
|
+
// Wrap inner call with retry-with-backoff (each oauth attempt gets retry protection)
|
|
340
|
+
const retryResult = await retryWithBackoff(() => runSubprocess(provider, prompt, timeoutMs), provider.name);
|
|
341
|
+
const out = retryResult.result;
|
|
342
|
+
totalRetryCount = attempt + retryResult.retryCount;
|
|
343
|
+
if (matchesRotationPattern(out, patterns) && attempt < max) {
|
|
344
|
+
lastErr = new Error('quota/auth pattern in output');
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
return { result: out, retryCount: totalRetryCount };
|
|
348
|
+
} catch (err) {
|
|
349
|
+
if (matchesRotationPattern(err.message, patterns) && attempt < max) {
|
|
350
|
+
lastErr = err;
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
throw err;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
throw lastErr ?? new Error('[oauth-rotation] all attempts exhausted');
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ─── Read per-slot env from ~/.claude.json (for HTTP PROVIDER_SLOT pattern) ───
|
|
360
|
+
function loadSlotEnv(slotName) {
|
|
361
|
+
try {
|
|
362
|
+
const claudeJson = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude.json'), 'utf8'));
|
|
363
|
+
return claudeJson?.mcpServers?.[slotName]?.env ?? {};
|
|
364
|
+
} catch (_) { return {}; }
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ─── HTTP dispatch ─────────────────────────────────────────────────────────────
|
|
368
|
+
function runHttp(provider, prompt, timeoutMs) {
|
|
369
|
+
// HTTP slots use PROVIDER_SLOT mode: API keys live in ~/.claude.json server env,
|
|
370
|
+
// not in process.env. Load them from there, falling back to process.env.
|
|
371
|
+
const slotEnv = loadSlotEnv(provider.name);
|
|
372
|
+
const apiKey = slotEnv['ANTHROPIC_API_KEY']
|
|
373
|
+
?? process.env[provider.apiKeyEnv]
|
|
374
|
+
?? process.env['ANTHROPIC_API_KEY']
|
|
375
|
+
?? '';
|
|
376
|
+
const baseUrl = slotEnv['ANTHROPIC_BASE_URL'] ?? provider.baseUrl;
|
|
377
|
+
const model = slotEnv['CLAUDE_DEFAULT_MODEL'] ?? provider.model;
|
|
378
|
+
|
|
379
|
+
const body = JSON.stringify({
|
|
380
|
+
model: model,
|
|
381
|
+
messages: [{ role: 'user', content: prompt }],
|
|
382
|
+
stream: false,
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
const url = new URL(`${baseUrl}/chat/completions`);
|
|
386
|
+
const isHttps = url.protocol === 'https:';
|
|
387
|
+
const transport = isHttps ? https : http;
|
|
388
|
+
const options = {
|
|
389
|
+
hostname: url.hostname,
|
|
390
|
+
port: url.port || (isHttps ? 443 : 80),
|
|
391
|
+
path: url.pathname + url.search,
|
|
392
|
+
method: 'POST',
|
|
393
|
+
headers: {
|
|
394
|
+
'Content-Type': 'application/json',
|
|
395
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
396
|
+
'Content-Length': Buffer.byteLength(body),
|
|
397
|
+
},
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
return new Promise((resolve, reject) => {
|
|
401
|
+
let timedOut = false;
|
|
402
|
+
|
|
403
|
+
const req = transport.request(options, (res) => {
|
|
404
|
+
let data = '';
|
|
405
|
+
res.on('data', chunk => { data += chunk; });
|
|
406
|
+
res.on('end', () => {
|
|
407
|
+
if (timedOut) return;
|
|
408
|
+
clearTimeout(timer);
|
|
409
|
+
try {
|
|
410
|
+
const parsed = JSON.parse(data);
|
|
411
|
+
const content = parsed?.choices?.[0]?.message?.content;
|
|
412
|
+
if (content) {
|
|
413
|
+
resolve(content);
|
|
414
|
+
} else {
|
|
415
|
+
reject(new Error(`[HTTP error: unexpected response] ${data.slice(0, 500)}`));
|
|
416
|
+
}
|
|
417
|
+
} catch (e) {
|
|
418
|
+
reject(new Error(`[HTTP error: JSON parse failed] ${data.slice(0, 500)}`));
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
const timer = setTimeout(() => {
|
|
424
|
+
timedOut = true;
|
|
425
|
+
req.destroy();
|
|
426
|
+
reject(new Error(`TIMEOUT after ${timeoutMs}ms`));
|
|
427
|
+
}, timeoutMs);
|
|
428
|
+
|
|
429
|
+
req.on('error', (err) => {
|
|
430
|
+
clearTimeout(timer);
|
|
431
|
+
if (!timedOut) reject(new Error(`[HTTP request error: ${err.message}]`));
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
req.write(body);
|
|
435
|
+
req.end();
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ─── Main ──────────────────────────────────────────────────────────────────────
|
|
440
|
+
async function main() {
|
|
441
|
+
const providers = findProviders();
|
|
442
|
+
if (!providers) {
|
|
443
|
+
process.stderr.write('[call-quorum-slot] Could not find providers.json\n');
|
|
444
|
+
process.exit(1);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (providers.length === 0) {
|
|
448
|
+
process.stderr.write('[call-quorum-slot] No providers configured in providers.json — cannot dispatch slot\n');
|
|
449
|
+
process.exit(1);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const provider = providers.find(p => p.name === slot);
|
|
453
|
+
if (!provider) {
|
|
454
|
+
const names = providers.map(p => p.name).join(', ');
|
|
455
|
+
process.stderr.write(`[call-quorum-slot] Unknown slot: "${slot}". Available: ${names}\n`);
|
|
456
|
+
process.exit(1);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const prompt = await readStdin();
|
|
460
|
+
if (!prompt) {
|
|
461
|
+
process.stderr.write('[call-quorum-slot] No prompt received on stdin\n');
|
|
462
|
+
process.exit(1);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// timeoutMs is null when --timeout not passed → fall through to provider.quorum_timeout_ms.
|
|
466
|
+
// provider.timeout_ms (300s) is intentionally last — it's the full session timeout, not quorum.
|
|
467
|
+
// When both are set, take the minimum so provider.quorum_timeout_ms always acts as a hard cap.
|
|
468
|
+
const providerCap = provider.quorum_timeout_ms ?? null;
|
|
469
|
+
const effectiveTimeout = (timeoutMs !== null && providerCap !== null)
|
|
470
|
+
? Math.min(timeoutMs, providerCap)
|
|
471
|
+
: (timeoutMs ?? providerCap ?? provider.timeout_ms ?? 30000);
|
|
472
|
+
|
|
473
|
+
const startMs = Date.now();
|
|
474
|
+
|
|
475
|
+
try {
|
|
476
|
+
let result;
|
|
477
|
+
let retryCount = 0;
|
|
478
|
+
|
|
479
|
+
if (provider.type === 'subprocess') {
|
|
480
|
+
if (provider.oauth_rotation?.enabled) {
|
|
481
|
+
const retryResult = await runSubprocessWithRotation(provider, prompt, effectiveTimeout);
|
|
482
|
+
result = retryResult.result;
|
|
483
|
+
retryCount = retryResult.retryCount;
|
|
484
|
+
} else {
|
|
485
|
+
const retryResult = await retryWithBackoff(() => runSubprocess(provider, prompt, effectiveTimeout), slot);
|
|
486
|
+
result = retryResult.result;
|
|
487
|
+
retryCount = retryResult.retryCount;
|
|
488
|
+
}
|
|
489
|
+
} else if (provider.type === 'http') {
|
|
490
|
+
const retryResult = await retryWithBackoff(() => runHttp(provider, prompt, effectiveTimeout), slot);
|
|
491
|
+
result = retryResult.result;
|
|
492
|
+
retryCount = retryResult.retryCount;
|
|
493
|
+
} else {
|
|
494
|
+
process.stderr.write(`[call-quorum-slot] Unknown provider type: ${provider.type}\n`);
|
|
495
|
+
writeFailureLog(slot, `Unknown provider type: ${provider.type}`, '');
|
|
496
|
+
appendTokenSentinel(slot);
|
|
497
|
+
const latencyMs = Date.now() - startMs;
|
|
498
|
+
recordTelemetry(slot, roundNum, 'FLAG', latencyMs, provider.provider || provider.name, 'unavailable', 0, 'UNKNOWN_TYPE');
|
|
499
|
+
process.exit(1);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Extract verdict from result using regex
|
|
503
|
+
const verdict = (/APPROVE|BLOCK|FLAG/.exec(result) || [])[0] || 'UNKNOWN';
|
|
504
|
+
const latencyMs = Date.now() - startMs;
|
|
505
|
+
const providerName = provider.provider || provider.name;
|
|
506
|
+
|
|
507
|
+
recordTelemetry(slot, roundNum, verdict, latencyMs, providerName, 'available', retryCount, null);
|
|
508
|
+
|
|
509
|
+
process.stdout.write(result);
|
|
510
|
+
if (!result.endsWith('\n')) process.stdout.write('\n');
|
|
511
|
+
appendTokenSentinel(slot);
|
|
512
|
+
process.exit(0);
|
|
513
|
+
} catch (err) {
|
|
514
|
+
const latencyMs = Date.now() - startMs;
|
|
515
|
+
const providerName = provider.provider || provider.name;
|
|
516
|
+
|
|
517
|
+
// Classify error type
|
|
518
|
+
let errorType = 'UNKNOWN';
|
|
519
|
+
if (/TIMEOUT/i.test(err.message)) {
|
|
520
|
+
errorType = 'TIMEOUT';
|
|
521
|
+
} else if (/401|403|unauthorized|forbidden/i.test(err.message)) {
|
|
522
|
+
errorType = 'AUTH';
|
|
523
|
+
} else if (/spawn error/i.test(err.message)) {
|
|
524
|
+
errorType = 'SPAWN_ERROR';
|
|
525
|
+
} else if (/usage:|unknown flag/i.test(err.message)) {
|
|
526
|
+
errorType = 'CLI_SYNTAX';
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
recordTelemetry(slot, roundNum, 'FLAG', latencyMs, providerName, 'unavailable', 0, errorType);
|
|
530
|
+
|
|
531
|
+
process.stderr.write(`[call-quorum-slot] ${err.message}\n`);
|
|
532
|
+
writeFailureLog(slot, err.message, '');
|
|
533
|
+
appendTokenSentinel(slot);
|
|
534
|
+
process.exit(1);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
main().catch(err => {
|
|
539
|
+
process.stderr.write(`[call-quorum-slot] Fatal: ${err.message}\n`);
|
|
540
|
+
process.exit(1);
|
|
541
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// bin/ccr-secure-config.cjs
|
|
3
|
+
// Reads the 3 CCR provider API keys from keytar (qgsd service) and writes them
|
|
4
|
+
// into ~/.claude-code-router/config.json with chmod 600.
|
|
5
|
+
// Designed to be called at session start and on-demand. Fail-silent when keytar
|
|
6
|
+
// is unavailable or keys are not yet stored.
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
const { execFileSync } = require('child_process');
|
|
14
|
+
|
|
15
|
+
const CONFIG_PATH = path.join(os.homedir(), '.claude-code-router', 'config.json');
|
|
16
|
+
|
|
17
|
+
// Locate secrets.cjs — try installed global path first, then local dev path.
|
|
18
|
+
function findSecrets() {
|
|
19
|
+
const candidates = [
|
|
20
|
+
path.join(os.homedir(), '.claude', 'qgsd-bin', 'secrets.cjs'), // installed path
|
|
21
|
+
path.join(__dirname, 'secrets.cjs'), // local dev path
|
|
22
|
+
];
|
|
23
|
+
for (const p of candidates) {
|
|
24
|
+
try {
|
|
25
|
+
if (fs.existsSync(p)) {
|
|
26
|
+
return require(p);
|
|
27
|
+
}
|
|
28
|
+
} catch (_) {}
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function main() {
|
|
34
|
+
const secrets = findSecrets();
|
|
35
|
+
if (!secrets) {
|
|
36
|
+
process.stderr.write('[ccr-secure-config] secrets.cjs not found — skipping CCR config population\n');
|
|
37
|
+
process.exit(0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let akashKey, togetherKey, fireworksKey;
|
|
41
|
+
try {
|
|
42
|
+
akashKey = await secrets.get('qgsd', 'AKASHML_API_KEY');
|
|
43
|
+
togetherKey = await secrets.get('qgsd', 'TOGETHER_API_KEY');
|
|
44
|
+
fireworksKey = await secrets.get('qgsd', 'FIREWORKS_API_KEY');
|
|
45
|
+
} catch (e) {
|
|
46
|
+
process.stderr.write('[ccr-secure-config] keytar unavailable: ' + e.message + '\n');
|
|
47
|
+
process.exit(0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!akashKey && !togetherKey && !fireworksKey) {
|
|
51
|
+
process.stderr.write('[ccr-secure-config] No CCR provider keys found in keytar — run manage-agents (option 9) to set them\n');
|
|
52
|
+
process.exit(0);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let config;
|
|
56
|
+
try {
|
|
57
|
+
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
|
58
|
+
config = JSON.parse(raw);
|
|
59
|
+
} catch (e) {
|
|
60
|
+
process.stderr.write('[ccr-secure-config] Could not read ' + CONFIG_PATH + ': ' + e.message + '\n');
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!Array.isArray(config.providers)) {
|
|
65
|
+
process.stderr.write('[ccr-secure-config] config.json has no providers array\n');
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const providerKeyMap = {
|
|
70
|
+
akashml: akashKey,
|
|
71
|
+
together: togetherKey,
|
|
72
|
+
fireworks: fireworksKey,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
let patched = 0;
|
|
76
|
+
for (const provider of config.providers) {
|
|
77
|
+
if (!provider.name) continue;
|
|
78
|
+
const keyName = provider.name.toLowerCase();
|
|
79
|
+
if (keyName in providerKeyMap && providerKeyMap[keyName]) {
|
|
80
|
+
provider.api_key = providerKeyMap[keyName];
|
|
81
|
+
patched++;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Write config back with restrictive permissions
|
|
86
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
87
|
+
|
|
88
|
+
// Enforce permissions on existing file (writeFileSync mode only applies to new files on some systems)
|
|
89
|
+
try {
|
|
90
|
+
execFileSync('chmod', ['600', CONFIG_PATH]);
|
|
91
|
+
} catch (_) {}
|
|
92
|
+
|
|
93
|
+
console.log('[ccr-secure-config] Populated ' + patched + ' provider key(s)');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
main().catch((e) => {
|
|
97
|
+
process.stderr.write('[ccr-secure-config] Unexpected error: ' + e.message + '\n');
|
|
98
|
+
process.exit(1);
|
|
99
|
+
});
|