@niroai/niro 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +149 -0
- package/README.md +123 -302
- package/bin/niro.js +12 -1
- package/docs/agent-antigravity.md +37 -0
- package/docs/agent-claude-code.md +48 -0
- package/docs/agent-claude-desktop.md +47 -0
- package/docs/agent-cline.md +42 -0
- package/docs/agent-codex.md +45 -0
- package/docs/agent-copilot-cli.md +42 -0
- package/docs/agent-cursor.md +44 -0
- package/docs/agent-factory.md +42 -0
- package/docs/agent-gemini-cli.md +42 -0
- package/docs/agent-goose.md +42 -0
- package/docs/agent-kiro.md +37 -0
- package/docs/agent-opencode.md +37 -0
- package/docs/agent-vscode.md +45 -0
- package/docs/agent-windsurf.md +38 -0
- package/docs/answer-shapes.md +69 -0
- package/docs/builds-and-updates.md +68 -0
- package/docs/cloud-README.md +372 -0
- package/docs/configuring-a-folder.md +51 -0
- package/docs/connect-ci.md +24 -0
- package/docs/connect-overview.md +20 -0
- package/docs/connect-review-bots.md +17 -0
- package/docs/connect-team-server.md +12 -0
- package/docs/connect-terminal.md +49 -0
- package/docs/connect-web-agents.md +9 -0
- package/docs/connect-your-ai-assistant.md +59 -175
- package/docs/coverage-audit-and-consent.md +51 -0
- package/docs/evaluate-what-niro-did.md +271 -0
- package/docs/faq-assistants-and-mcp.md +122 -0
- package/docs/faq-feedback-and-receipts.md +33 -0
- package/docs/faq-indexing.md +127 -0
- package/docs/faq-install-and-setup.md +103 -0
- package/docs/faq-licence-and-plans.md +86 -0
- package/docs/faq-login-and-account.md +69 -0
- package/docs/faq-offline.md +34 -0
- package/docs/faq-performance.md +33 -0
- package/docs/faq-privacy-and-security.md +113 -0
- package/docs/faq-uninstall.md +47 -0
- package/docs/faq-updates.md +59 -0
- package/docs/faq-worktrees-and-branches.md +59 -0
- package/docs/faq-wrong-or-missing-answers.md +275 -0
- package/docs/hosted-only.md +33 -0
- package/docs/how-the-graph-works.md +47 -0
- package/docs/index.md +157 -0
- package/docs/licence-and-plans.md +72 -0
- package/docs/lifecycle-debug.md +48 -0
- package/docs/lifecycle-onboarding.md +32 -0
- package/docs/lifecycle-overview.md +77 -0
- package/docs/lifecycle-plan-and-design.md +44 -0
- package/docs/lifecycle-review.md +30 -0
- package/docs/lifecycle-security-and-privacy.md +35 -0
- package/docs/lifecycle-test.md +28 -0
- package/docs/lifecycle-write-code.md +35 -0
- package/docs/llms-full.txt +8015 -0
- package/docs/llms.txt +126 -0
- package/docs/playbook-add-a-feature.md +108 -0
- package/docs/playbook-change-safely.md +93 -0
- package/docs/playbook-data-and-events.md +107 -0
- package/docs/playbook-debug-after-a-change.md +96 -0
- package/docs/playbook-debug-config.md +95 -0
- package/docs/playbook-debug-exception.md +124 -0
- package/docs/playbook-debug-failing-service-call.md +119 -0
- package/docs/playbook-debug-slow-path.md +80 -0
- package/docs/playbook-debug-wrong-value.md +137 -0
- package/docs/playbook-onboard.md +95 -0
- package/docs/playbook-review-a-change.md +72 -0
- package/docs/playbook-test-a-change.md +73 -0
- package/docs/reference-cli.md +113 -0
- package/docs/reference-config-files.md +42 -0
- package/docs/reference-engine-commands.md +56 -0
- package/docs/reference-environment-variables.md +89 -0
- package/docs/reference-exit-codes.md +67 -0
- package/docs/reference-limits.md +75 -0
- package/docs/reference-mcp-tools.md +326 -0
- package/docs/reference-messages.md +180 -0
- package/docs/secrets-and-masking.md +48 -0
- package/docs/supported-c.md +130 -0
- package/docs/supported-cpp.md +157 -0
- package/docs/supported-csharp.md +211 -0
- package/docs/supported-go.md +258 -0
- package/docs/supported-java.md +226 -0
- package/docs/supported-javascript.md +223 -0
- package/docs/supported-kotlin.md +124 -0
- package/docs/supported-overview.md +89 -0
- package/docs/supported-php.md +157 -0
- package/docs/supported-python.md +224 -0
- package/docs/supported-ruby.md +162 -0
- package/docs/supported-rust.md +150 -0
- package/docs/supported-swift.md +133 -0
- package/docs/supported-typescript.md +220 -0
- package/docs/tokens-speed-and-accuracy.md +99 -0
- package/docs/troubleshooting.md +42 -0
- package/docs/uninstall.md +74 -0
- package/docs/what-leaves-your-machine.md +71 -0
- package/docs/where-things-are-stored.md +59 -0
- package/docs/worktrees-and-branches.md +55 -0
- package/package.json +5 -3
- package/src/commands/add-repo.js +5 -0
- package/src/commands/auditSend.js +67 -0
- package/src/commands/build.js +6 -0
- package/src/commands/docsSend.js +73 -0
- package/src/commands/engine.js +120 -0
- package/src/commands/engineRoutes.js +105 -0
- package/src/commands/guide.js +254 -0
- package/src/commands/init.js +16 -7
- package/src/commands/initLocal.js +96 -0
- package/src/commands/journalSend.js +47 -0
- package/src/commands/login.js +89 -12
- package/src/commands/mcp.js +22 -6
- package/src/commands/plan.js +99 -0
- package/src/commands/repoPush.js +33 -0
- package/src/commands/requestIntegration.js +38 -0
- package/src/commands/share.js +56 -0
- package/src/commands/start.js +451 -371
- package/src/commands/status.js +57 -0
- package/src/commands/takeover.js +6 -0
- package/src/commands/uninstall.js +180 -0
- package/src/commands/upgrade.js +38 -8
- package/src/commands/watch.js +420 -6
- package/src/index.js +174 -3
- package/src/lib/agentApi.js +32 -1
- package/src/lib/api.js +20 -6
- package/src/lib/auditUpload.js +293 -0
- package/src/lib/branchDrift.js +149 -0
- package/src/lib/choose.js +116 -0
- package/src/lib/cloudGuard.js +74 -0
- package/src/lib/credentials.js +2 -2
- package/src/lib/docsMarked.js +55 -0
- package/src/lib/docsState.js +213 -0
- package/src/lib/docsUpload.js +481 -0
- package/src/lib/engine.js +619 -0
- package/src/lib/folderSync.js +2 -1
- package/src/lib/graphCheck.js +40 -0
- package/src/lib/guide.js +293 -0
- package/src/lib/guideSearch.js +252 -0
- package/src/lib/integrationRequest.js +89 -0
- package/src/lib/journalUpload.js +147 -0
- package/src/lib/languages.js +129 -0
- package/src/lib/lease.js +375 -0
- package/src/lib/localBuild.js +285 -0
- package/src/lib/localEngine.js +210 -0
- package/src/lib/localOnboard.js +124 -0
- package/src/lib/localWorktrees.js +243 -0
- package/src/lib/machineCheck.js +58 -0
- package/src/lib/mcpClients.js +10 -1
- package/src/lib/migrationNotice.js +118 -0
- package/src/lib/modelFetch.js +153 -0
- package/src/lib/nestedOutput.js +23 -9
- package/src/lib/onboardingLog.js +34 -0
- package/src/lib/planUsage.js +165 -0
- package/src/lib/progressBar.js +46 -0
- package/src/lib/prompt.js +1 -1
- package/src/lib/redact.js +155 -0
- package/src/lib/release.js +33 -0
- package/src/lib/repoPush.js +495 -0
- package/src/lib/secret.js +1 -1
- package/src/lib/service.js +372 -63
- package/src/lib/shareCode.js +367 -0
- package/src/lib/shareExclusions.js +114 -0
- package/src/lib/shareFiles.js +129 -0
- package/src/lib/spinner.js +9 -5
- package/src/lib/watchDaemon.js +33 -10
- package/src/lib/watchState.js +12 -1
- package/src/lib/windowsPaths.js +67 -0
- package/src/mcp/lineCheck.js +382 -0
- package/src/mcp/lineName.js +92 -0
- package/src/mcp/localBackend.js +751 -0
- package/src/mcp/localMode.js +383 -0
- package/src/mcp/localSkill.js +189 -0
- package/src/mcp/server.js +610 -79
- package/src/mcp/setup.js +519 -131
- package/src/mcp/shellSearchHook.js +509 -0
- package/src/mcp/teardown.js +173 -91
- package/src/mcp/usingNiro.js +39 -0
- package/docs/working-on-branches.md +0 -162
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sends the local engine's record of what it was asked to Niro.
|
|
3
|
+
*
|
|
4
|
+
* The engine answers an assistant's questions on this machine, so none of those calls are
|
|
5
|
+
* visible to the cloud the way a hosted session's are. The engine keeps its own record and
|
|
6
|
+
* this sends it, because the engine holds no account and no credential: anything that leaves
|
|
7
|
+
* the machine leaves through the CLI, which is the part that signed in.
|
|
8
|
+
*
|
|
9
|
+
* The same three steps `audit-send` uses, and the same reason for the shape: the engine builds
|
|
10
|
+
* the payload, so the wire format has one home. It is posted as the engine wrote it, free text and
|
|
11
|
+
* query arguments included (Niraj, 2026-11-06, Q11: the release's journalStrip, which nulled them, is
|
|
12
|
+
* not used; they are what makes the record useful). The engine masks secrets in every value before
|
|
13
|
+
* it records it, with llm-gateway's rules, and keeps each free-text field to about 1,000 characters
|
|
14
|
+
* (niro-local `docs/usecases/on-machine-redaction.md`); each line it emits says so with `redacted`.
|
|
15
|
+
* A line without it comes from an older engine that masked nothing: it stays in the engine's
|
|
16
|
+
* queue, unsent, until the engine is updated.
|
|
17
|
+
*
|
|
18
|
+
* niro-local journal --emit JSON Lines, one session per line
|
|
19
|
+
* POST <assistant>/api/mcp/local-sessions each session's `payload`, verbatim
|
|
20
|
+
* niro-local journal --mark-sent the ids that arrived, one per line
|
|
21
|
+
*
|
|
22
|
+
* **What is in a payload, and what is not.** Which tools were asked, how long each took,
|
|
23
|
+
* whether it failed and the class name of the exception when it did, plus arguments that
|
|
24
|
+
* passed an allowlist. No source, no exception messages, no account and no user: the service
|
|
25
|
+
* stamps the account and the user from this machine's token, and applies the same allowlist
|
|
26
|
+
* again, because its own filtering cannot be trusted to a program on somebody's laptop.
|
|
27
|
+
*
|
|
28
|
+
* Nothing here logs a payload. The arguments are small but they are a customer's names.
|
|
29
|
+
*/
|
|
30
|
+
const localEngine = require("./localEngine");
|
|
31
|
+
const api = require("./api");
|
|
32
|
+
const config = require("./config");
|
|
33
|
+
|
|
34
|
+
/** Where ai-assistant is: the same resolution the MCP bridge uses. */
|
|
35
|
+
const SAAS_URL = "https://aiassistant.niroai.dev";
|
|
36
|
+
|
|
37
|
+
const UPLOAD_PATH = "/api/mcp/local-sessions";
|
|
38
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
39
|
+
const DEFAULT_BATCH = 20;
|
|
40
|
+
|
|
41
|
+
function assistantUrl() {
|
|
42
|
+
return (process.env.NIRO_MCP_SERVER_URL || config.load().mcpServerUrl || SAAS_URL).replace(
|
|
43
|
+
/\/+$/,
|
|
44
|
+
""
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Send everything the engine is holding.
|
|
50
|
+
*
|
|
51
|
+
* Returns counts rather than printing, so a caller can report it its own way: the command
|
|
52
|
+
* prints and the watcher will log one line.
|
|
53
|
+
*/
|
|
54
|
+
async function uploadPending({ limit = DEFAULT_BATCH, freshToken = api.ensureFreshToken } = {}) {
|
|
55
|
+
const result = { sent: 0, calls: 0, failed: 0, failures: [] };
|
|
56
|
+
|
|
57
|
+
// Before the engine is asked for anything, so a machine that cannot authenticate does not
|
|
58
|
+
// pull sessions out of the queue only to fail to send them. One token for the whole run:
|
|
59
|
+
// it outlives a batch, and refreshing per session would be a request each.
|
|
60
|
+
let token;
|
|
61
|
+
try {
|
|
62
|
+
token = await freshToken();
|
|
63
|
+
} catch (err) {
|
|
64
|
+
if (err && err.code === "ENOAUTH") {
|
|
65
|
+
throw new Error(`${err.message} The journal stays on this machine until then.`);
|
|
66
|
+
}
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
const headers = { "Content-Type": "application/json", Authorization: `Bearer ${token}` };
|
|
70
|
+
|
|
71
|
+
const emitted = await localEngine.run(["journal", "--emit", "--limit", String(limit)]);
|
|
72
|
+
const sessions = emitted
|
|
73
|
+
.split("\n")
|
|
74
|
+
.map((line) => line.trim())
|
|
75
|
+
.filter(Boolean)
|
|
76
|
+
.map((line) => JSON.parse(line));
|
|
77
|
+
|
|
78
|
+
const arrived = [];
|
|
79
|
+
for (const session of sessions) {
|
|
80
|
+
if (!session.redacted) {
|
|
81
|
+
// An older engine: never masked. Kept in its queue, not marked, not sent.
|
|
82
|
+
result.unmasked = (result.unmasked || 0) + 1;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
// Posted verbatim: the engine owns the field names, and the free text is kept (Q11).
|
|
87
|
+
const answer = await postJson(UPLOAD_PATH, session.payload, headers);
|
|
88
|
+
if (answer.ok) {
|
|
89
|
+
result.sent += 1;
|
|
90
|
+
result.calls += (session.payload && session.payload.tool_calls
|
|
91
|
+
? session.payload.tool_calls.length
|
|
92
|
+
: 0);
|
|
93
|
+
arrived.push(session.session_id);
|
|
94
|
+
} else if (answer.status === 400) {
|
|
95
|
+
// The service refused the body, which is our own CLI's or the engine's fault. Marked
|
|
96
|
+
// as sent anyway: it will be refused identically for ever, and a queue that can never
|
|
97
|
+
// drain blocks every session behind it. The failure is reported so it is not silent.
|
|
98
|
+
result.failed += 1;
|
|
99
|
+
result.failures.push(`${session.session_id.slice(0, 12)}: refused (${answer.status})`);
|
|
100
|
+
// Kept on disk before it leaves the queue: a 400 would be refused the same way for ever
|
|
101
|
+
// and block every session behind it, but the usage it records must not be lost.
|
|
102
|
+
keepRefused(session);
|
|
103
|
+
arrived.push(session.session_id);
|
|
104
|
+
} else {
|
|
105
|
+
// Anything else may work later: kept in the queue, which is what the engine's own
|
|
106
|
+
// record is for.
|
|
107
|
+
result.failed += 1;
|
|
108
|
+
result.failures.push(`${session.session_id.slice(0, 12)}: HTTP ${answer.status}`);
|
|
109
|
+
}
|
|
110
|
+
} catch (err) {
|
|
111
|
+
result.failed += 1;
|
|
112
|
+
result.failures.push(`${session.session_id.slice(0, 12)}: ${err.message}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (arrived.length > 0) {
|
|
117
|
+
await localEngine.run(["journal", "--mark-sent"], arrived.join("\n") + "\n");
|
|
118
|
+
}
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Where a refused session is kept, one JSON file per session, for a fix and a resend. */
|
|
123
|
+
const REFUSED_DIR = () => require("path").join(require("os").homedir(), ".niro", "journal-refused");
|
|
124
|
+
|
|
125
|
+
function keepRefused(session) {
|
|
126
|
+
const fs = require("fs");
|
|
127
|
+
const dir = REFUSED_DIR();
|
|
128
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
129
|
+
fs.writeFileSync(require("path").join(dir, `${session.session_id}.json`), JSON.stringify(session) + "\n", { mode: 0o600 });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function postJson(path, body, headers) {
|
|
133
|
+
const controller = new AbortController();
|
|
134
|
+
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
|
|
135
|
+
try {
|
|
136
|
+
return await fetch(`${assistantUrl()}${path}`, {
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers,
|
|
139
|
+
body: JSON.stringify(body),
|
|
140
|
+
signal: controller.signal,
|
|
141
|
+
});
|
|
142
|
+
} finally {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = { assistantUrl, uploadPending, UPLOAD_PATH, SAAS_URL, REFUSED_DIR };
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The languages the local engine reads, and which of them a folder holds.
|
|
3
|
+
*
|
|
4
|
+
* The table mirrors niro-local's `src/niro_local/languages.py` (LANGUAGES): same languages, same
|
|
5
|
+
* extensions, grouped as people name them (JSX under JavaScript, TSX under TypeScript, the PHP
|
|
6
|
+
* include extensions under PHP). Change both together. It is shown three ways: on the build line
|
|
7
|
+
* of `niro start`, in the message for a folder with none of them, and under `niro --help`.
|
|
8
|
+
*
|
|
9
|
+
* The engine's build report does not name languages, so the folder is looked at here: the files
|
|
10
|
+
* git tracks when it is a repository (which honours .gitignore), else a bounded walk that skips
|
|
11
|
+
* dependency and build folders. It only says which languages are present, never what was indexed.
|
|
12
|
+
*/
|
|
13
|
+
const fs = require("fs");
|
|
14
|
+
const path = require("path");
|
|
15
|
+
const { spawnSync } = require("child_process");
|
|
16
|
+
const windowsPaths = require("./windowsPaths");
|
|
17
|
+
|
|
18
|
+
const LANGUAGES = [
|
|
19
|
+
["Java", [".java"]],
|
|
20
|
+
["Kotlin", [".kt", ".kts"]],
|
|
21
|
+
["JavaScript", [".js", ".jsx", ".mjs", ".cjs", ".vue", ".svelte"]],
|
|
22
|
+
["TypeScript", [".ts", ".mts", ".cts", ".tsx", ".astro"]],
|
|
23
|
+
["Python", [".py", ".pyw", ".pyi"]],
|
|
24
|
+
["C#", [".cs", ".csx"]],
|
|
25
|
+
["Go", [".go"]],
|
|
26
|
+
["PHP", [".php", ".phtml", ".php3", ".php4", ".php5"]],
|
|
27
|
+
["Ruby", [".rb", ".rake", ".gemspec"]],
|
|
28
|
+
["Swift", [".swift"]],
|
|
29
|
+
["Rust", [".rs"]],
|
|
30
|
+
["C", [".c", ".h"]],
|
|
31
|
+
["C++", [".cpp", ".cc", ".cxx", ".hpp", ".hxx", ".h++", ".c++", ".hh", ".tcc", ".ipp", ".inl", ".tpp"]],
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const BY_EXTENSION = new Map(LANGUAGES.flatMap(([name, exts]) => exts.map((e) => [e, name])));
|
|
35
|
+
|
|
36
|
+
// Languages Niro does not read yet, by extension: named in niro start's summary with the command
|
|
37
|
+
// that asks for them (niro request-integration). Only unambiguous extensions; a few is enough.
|
|
38
|
+
const OTHERS = [
|
|
39
|
+
["Scala", [".scala", ".sc"]],
|
|
40
|
+
["Dart", [".dart"]],
|
|
41
|
+
["Elixir", [".ex", ".exs"]],
|
|
42
|
+
["Erlang", [".erl"]],
|
|
43
|
+
["Haskell", [".hs"]],
|
|
44
|
+
["Clojure", [".clj", ".cljs", ".cljc"]],
|
|
45
|
+
["F#", [".fs", ".fsx"]],
|
|
46
|
+
["Lua", [".lua"]],
|
|
47
|
+
["Perl", [".pl", ".pm"]],
|
|
48
|
+
["R", [".r"]],
|
|
49
|
+
["Julia", [".jl"]],
|
|
50
|
+
["Groovy", [".groovy"]],
|
|
51
|
+
["Objective-C", [".m", ".mm"]],
|
|
52
|
+
["Zig", [".zig"]],
|
|
53
|
+
["OCaml", [".ml", ".mli"]],
|
|
54
|
+
["Visual Basic", [".vb"]],
|
|
55
|
+
["COBOL", [".cbl", ".cob"]],
|
|
56
|
+
["Solidity", [".sol"]],
|
|
57
|
+
];
|
|
58
|
+
const OTHER_BY_EXTENSION = new Map(OTHERS.flatMap(([name, exts]) => exts.map((e) => [e, name])));
|
|
59
|
+
|
|
60
|
+
/** "Java, Kotlin, ... and C++", for help text and messages. */
|
|
61
|
+
function supportedList() {
|
|
62
|
+
const names = LANGUAGES.map(([name]) => name);
|
|
63
|
+
return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Folders whose files are not the developer's own code. Only for the walk; git already leaves
|
|
67
|
+
// out what .gitignore names.
|
|
68
|
+
const SKIP = new Set(["node_modules", "vendor", "dist", "build", "target", "out", "bin", "obj", "venv", ".venv", "__pycache__"]);
|
|
69
|
+
const WALK_LIMIT = 50_000; // ponytail: entries looked at outside git; enough to name what a folder holds
|
|
70
|
+
|
|
71
|
+
function trackedFiles(dir) {
|
|
72
|
+
const git = windowsPaths.gitProgram();
|
|
73
|
+
if (!git) return null;
|
|
74
|
+
// maxBuffer: the default 1 MiB is under a large repository's list (Keycloak 1.1 MB, OpenSearch
|
|
75
|
+
// 1.3 MB), and git then fails with ENOBUFS.
|
|
76
|
+
const r = spawnSync(git, ["ls-files", "-z"], {
|
|
77
|
+
cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000, maxBuffer: 64 * 1024 * 1024,
|
|
78
|
+
});
|
|
79
|
+
return r.status === 0 ? r.stdout.split("\0").filter(Boolean) : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function walkedFiles(dir) {
|
|
83
|
+
const files = [];
|
|
84
|
+
const stack = [dir];
|
|
85
|
+
let seen = 0;
|
|
86
|
+
while (stack.length && seen < WALK_LIMIT) {
|
|
87
|
+
const current = stack.pop();
|
|
88
|
+
let entries;
|
|
89
|
+
try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { continue; }
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
if (++seen > WALK_LIMIT) break;
|
|
92
|
+
if (entry.name.startsWith(".")) continue;
|
|
93
|
+
const full = path.join(current, entry.name);
|
|
94
|
+
if (entry.isDirectory()) { if (!SKIP.has(entry.name)) stack.push(full); } else files.push(full);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return files;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* One listing of `dir`: `found`, the supported languages, and `others`, languages Niro does not read
|
|
102
|
+
* yet, each most files first. Both [] when there are none or the folder cannot be read.
|
|
103
|
+
*/
|
|
104
|
+
function scan(dir) {
|
|
105
|
+
const files = trackedFiles(dir) || walkedFiles(dir);
|
|
106
|
+
const found = new Map();
|
|
107
|
+
const others = new Map();
|
|
108
|
+
for (const file of files) {
|
|
109
|
+
const ext = path.extname(file).toLowerCase();
|
|
110
|
+
const name = BY_EXTENSION.get(ext);
|
|
111
|
+
const other = !name && OTHER_BY_EXTENSION.get(ext);
|
|
112
|
+
if (name) found.set(name, (found.get(name) || 0) + 1);
|
|
113
|
+
if (other) others.set(other, (others.get(other) || 0) + 1);
|
|
114
|
+
}
|
|
115
|
+
const byCount = (counts) => [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([name]) => name);
|
|
116
|
+
return { found: byCount(found), others: byCount(others) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The supported languages in `dir`, most files first. [] when there are none or it cannot be read. */
|
|
120
|
+
function detect(dir) {
|
|
121
|
+
return scan(dir).found;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** At most `max` names, then "+N": "Java, TypeScript, Python +2". */
|
|
125
|
+
function short(names, max = 3) {
|
|
126
|
+
return names.length > max ? `${names.slice(0, max).join(", ")} +${names.length - max}` : names.join(", ");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { LANGUAGES, supportedList, scan, detect, short };
|
package/src/lib/lease.js
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gets a licence for the local engine and hands it over while the engine runs.
|
|
3
|
+
*
|
|
4
|
+
* The engine holds no account and no credential, on purpose. This CLI signed in, so this CLI
|
|
5
|
+
* asks niro-billing for a lease and passes it to the engine. The lease is signed, so the
|
|
6
|
+
* engine can check it came from us and has not been edited, and this file never looks inside
|
|
7
|
+
* it except to work out when to ask for the next one.
|
|
8
|
+
*
|
|
9
|
+
* **The lease is never written to disk** (Niraj, 2026-10-14: it "is never written to the
|
|
10
|
+
* user's disk. It is fetched at intervals and held in memory"). That rules out the obvious
|
|
11
|
+
* reuse: `periodicClaim.js` is the CLI's existing "has this job run recently" helper and it
|
|
12
|
+
* works by writing a timestamp file so the answer outlives the process. Here the opposite is
|
|
13
|
+
* wanted. Nothing about a lease survives the engine it was issued to.
|
|
14
|
+
*
|
|
15
|
+
* **How it travels: two extra pipes, named in the environment.** Standard input and output
|
|
16
|
+
* belong to the engine's own protocol, a command-line argument is visible to every process on
|
|
17
|
+
* the machine through `ps` and cannot be replaced while the process runs, and an environment
|
|
18
|
+
* variable has both problems. So the engine is started with two more pipes and told their
|
|
19
|
+
* numbers. The engine's end is `niro_local/licence/pipe.py`, which is also where the wire
|
|
20
|
+
* format is written down:
|
|
21
|
+
*
|
|
22
|
+
* engine -> CLI NONCE 9f2c1b7e4a3d0c8b
|
|
23
|
+
* CLI -> engine LEASE <payload base64> <signature base64> <key id>
|
|
24
|
+
* CLI -> engine REFUSED This account is suspended.
|
|
25
|
+
*
|
|
26
|
+
* **Every request carries the nonce the engine generated**, including each refresh, because
|
|
27
|
+
* the lease is signed against it: a lease bound to another nonce belongs to another engine
|
|
28
|
+
* process and that engine refuses it. One nonce per engine process, for the life of the
|
|
29
|
+
* process.
|
|
30
|
+
*
|
|
31
|
+
* **Silence and a refusal are different things, and the difference is the whole design.** When
|
|
32
|
+
* niro-billing cannot be reached, or answers 5xx, nothing is written to the pipe: the engine
|
|
33
|
+
* keeps working on the lease it already has until that lease's own offline allowance runs out,
|
|
34
|
+
* which is what lets somebody work on a train. Only a 403, which is us saying this account may
|
|
35
|
+
* not use the engine, is passed on as REFUSED, and that stops the engine at once.
|
|
36
|
+
*/
|
|
37
|
+
const api = require("./api");
|
|
38
|
+
const config = require("./config");
|
|
39
|
+
const color = require("./color");
|
|
40
|
+
|
|
41
|
+
/** Where niro-billing is. The frontend's deployed configuration names the same host. */
|
|
42
|
+
const PROD_BILLING_URL = "https://billing.cloud.niroai.dev";
|
|
43
|
+
|
|
44
|
+
const LEASE_PATH = "/api/local-engine/lease";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* How long to wait for a lease. Shorter than the engine's own wait for a first lease (20s), so
|
|
48
|
+
* that a slow service produces a decision here rather than the engine giving up first.
|
|
49
|
+
*/
|
|
50
|
+
const DEFAULT_FETCH_TIMEOUT_MS = 15_000;
|
|
51
|
+
|
|
52
|
+
/** How long to wait before asking again after we could not reach the service. */
|
|
53
|
+
const DEFAULT_RETRY_MS = 60_000;
|
|
54
|
+
|
|
55
|
+
/** How early to renew: a fifth of the lease's life, kept inside these bounds. */
|
|
56
|
+
const MIN_LEAD_MS = 30_000;
|
|
57
|
+
const MAX_LEAD_MS = 300_000;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The soonest a renewal is ever attempted. It exists because a lease shorter than the lead
|
|
61
|
+
* time above would otherwise be renewed immediately and in a loop.
|
|
62
|
+
*/
|
|
63
|
+
const DEFAULT_MIN_RENEWAL_MS = 5_000;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* All three are read when they are used rather than when this file is loaded.
|
|
67
|
+
*
|
|
68
|
+
* That is not a style choice. Read at load time, an override set by whatever started this
|
|
69
|
+
* process is ignored unless it happened to be exported before the first `require`, and a test
|
|
70
|
+
* that sets one is testing the default while appearing to test the override. One of these
|
|
71
|
+
* tests did exactly that on 2026-10-15: it asked for a 150ms timeout, got the 15s default,
|
|
72
|
+
* and passed because the fixture had already given up waiting.
|
|
73
|
+
*/
|
|
74
|
+
function fetchTimeoutMs() {
|
|
75
|
+
return Number(process.env.NIRO_LEASE_TIMEOUT_MS) || DEFAULT_FETCH_TIMEOUT_MS;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function retryMs() {
|
|
79
|
+
return Number(process.env.NIRO_LEASE_RETRY_MS) || DEFAULT_RETRY_MS;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function minRenewalMs() {
|
|
83
|
+
return Number(process.env.NIRO_LEASE_MIN_RENEW_MS) || DEFAULT_MIN_RENEWAL_MS;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The two extra pipes, as the engine sees them. Matches `pipe.py`. */
|
|
87
|
+
const FD_IN_ENV = "NIRO_LOCAL_LEASE_FD_IN";
|
|
88
|
+
const FD_OUT_ENV = "NIRO_LOCAL_LEASE_FD_OUT";
|
|
89
|
+
const CHILD_FD_IN = 3;
|
|
90
|
+
const CHILD_FD_OUT = 4;
|
|
91
|
+
|
|
92
|
+
/** Which service to ask. Env var, then ~/.niro/config.json, then the deployed service. */
|
|
93
|
+
function billingUrl() {
|
|
94
|
+
return (process.env.NIRO_BILLING_URL || config.load().billingUrl || PROD_BILLING_URL).replace(
|
|
95
|
+
/\/+$/,
|
|
96
|
+
""
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Who is asking, for niro-billing's log only. It never refuses on this. */
|
|
101
|
+
function askingVersion() {
|
|
102
|
+
try {
|
|
103
|
+
return `niro-cli/${require("../../package.json").version}`;
|
|
104
|
+
} catch {
|
|
105
|
+
return "niro-cli";
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** What the engine is told to look at, merged into its environment when it is started. */
|
|
110
|
+
function childEnvironment(env = process.env) {
|
|
111
|
+
return { ...env, [FD_IN_ENV]: String(CHILD_FD_IN), [FD_OUT_ENV]: String(CHILD_FD_OUT) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The claims inside a lease, or null when they cannot be read.
|
|
116
|
+
*
|
|
117
|
+
* Read for one reason only: to know when to ask for the next lease. Nothing here enforces a
|
|
118
|
+
* claim and nothing here would notice an edited one. That is the engine's job, and it does it
|
|
119
|
+
* by checking the signature over these exact bytes before parsing them, which is why this
|
|
120
|
+
* function being fooled costs nothing.
|
|
121
|
+
*/
|
|
122
|
+
function claimsOf(leaseBase64) {
|
|
123
|
+
try {
|
|
124
|
+
const parsed = JSON.parse(Buffer.from(String(leaseBase64), "base64").toString("utf8"));
|
|
125
|
+
// An ARRAY passes `typeof === "object"`, and every claim read off it would be undefined,
|
|
126
|
+
// so the renewal would silently fall back to the retry interval for ever. The same trap
|
|
127
|
+
// cost this CLI a persisted timestamp once before (see periodicClaim.js).
|
|
128
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
129
|
+
return parsed;
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** When to ask for the next lease, in milliseconds from now. */
|
|
136
|
+
function renewalDelay(claims, now = Date.now()) {
|
|
137
|
+
const expiresAt = claims && Number(claims.expires_at);
|
|
138
|
+
const issuedAt = claims && Number(claims.issued_at);
|
|
139
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= 0) {
|
|
140
|
+
// A lease we cannot read the expiry of still has one, and it is at least as long as the
|
|
141
|
+
// shortest we issue. Asking again on the retry interval is wrong in no direction worse
|
|
142
|
+
// than one extra request a minute.
|
|
143
|
+
return retryMs();
|
|
144
|
+
}
|
|
145
|
+
const life = Number.isFinite(issuedAt) && issuedAt > 0 ? (expiresAt - issuedAt) * 1000 : 0;
|
|
146
|
+
const lead = Math.min(MAX_LEAD_MS, Math.max(MIN_LEAD_MS, life * 0.2));
|
|
147
|
+
return Math.max(minRenewalMs(), expiresAt * 1000 - now - lead);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Ask niro-billing for a lease bound to `nonce`.
|
|
152
|
+
*
|
|
153
|
+
* The status code is the contract, not the message (`LocalEngineController`), so each one is
|
|
154
|
+
* turned into one of three answers and nothing branches on prose:
|
|
155
|
+
*
|
|
156
|
+
* - `{ lease, signature, keyId }` — a lease, pass it on.
|
|
157
|
+
* - `{ refused: true, reason }` — 403. The account may not use the engine. Tell the engine.
|
|
158
|
+
* - `{ unreachable: true, detail }` — anything else, including 401 after a refresh, 400,
|
|
159
|
+
* 5xx, a network failure and a timeout. Say nothing to the engine and let its own
|
|
160
|
+
* allowance decide.
|
|
161
|
+
*
|
|
162
|
+
* A 401 is not handled here because the request layer already does it: it exchanges the
|
|
163
|
+
* durable CLI token for a fresh JWT and replays the request once, sharing one refresh between
|
|
164
|
+
* concurrent callers. A 401 that survives that means this machine is not signed in, which is
|
|
165
|
+
* reported as unreachable rather than as a refusal: being signed out is a local problem, and a
|
|
166
|
+
* developer must not lose a running engine to it before the lease in hand has run its course.
|
|
167
|
+
*/
|
|
168
|
+
async function fetchLease(nonce, { engineVersion = askingVersion() } = {}) {
|
|
169
|
+
const abort = new AbortController();
|
|
170
|
+
const timer = setTimeout(() => abort.abort(), fetchTimeoutMs());
|
|
171
|
+
try {
|
|
172
|
+
const data = await api.request("POST", LEASE_PATH, {
|
|
173
|
+
body: { nonce, engine_version: engineVersion },
|
|
174
|
+
apiUrl: billingUrl(),
|
|
175
|
+
signal: abort.signal,
|
|
176
|
+
});
|
|
177
|
+
const lease = data && (data.lease || data.payload);
|
|
178
|
+
const signature = data && data.signature;
|
|
179
|
+
const keyId = data && (data.key_id || data.keyId);
|
|
180
|
+
if (!lease || !signature || !keyId) {
|
|
181
|
+
// A 200 with a field missing is our own fault and not something to retry hard.
|
|
182
|
+
return { unreachable: true, detail: "the lease came back incomplete" };
|
|
183
|
+
}
|
|
184
|
+
return { lease, signature, keyId };
|
|
185
|
+
} catch (err) {
|
|
186
|
+
if (err && err.status === 403) {
|
|
187
|
+
return { refused: true, reason: reasonFrom(err) };
|
|
188
|
+
}
|
|
189
|
+
return { unreachable: true, detail: reasonFrom(err) };
|
|
190
|
+
} finally {
|
|
191
|
+
clearTimeout(timer);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The service's own words for why it said no.
|
|
197
|
+
*
|
|
198
|
+
* The request layer turns a failure into an Error whose message names the call ("POST
|
|
199
|
+
* /api/local-engine/lease failed: ..."), which is right for a log and wrong for a sentence
|
|
200
|
+
* shown to a developer, so the message out of the response body is preferred when there is
|
|
201
|
+
* one. Only niro-billing knows why an account is refused, so nothing here rewrites its
|
|
202
|
+
* sentences; the two account codes (account_blocked, account_waiting) are put into words. A
|
|
203
|
+
* network failure has no body and falls back to the message, which does name the call: that
|
|
204
|
+
* is the right way round, because then the developer is being told we could not be reached.
|
|
205
|
+
*/
|
|
206
|
+
/** The account refusals the services send as codes, said in words a developer reads. */
|
|
207
|
+
const ACCOUNT_REFUSALS = {
|
|
208
|
+
account_blocked: "This Niro account is suspended. Contact ask@niro.ai.",
|
|
209
|
+
account_waiting: "This Niro account is waiting for approval. Contact ask@niro.ai if it takes long.",
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
function reasonFrom(err) {
|
|
213
|
+
const said = serviceWords(err);
|
|
214
|
+
return ACCOUNT_REFUSALS[said] || said;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function serviceWords(err) {
|
|
218
|
+
const body = err && err.body;
|
|
219
|
+
if (body && typeof body === "object") {
|
|
220
|
+
// niro-billing's envelope puts the sentence in `message`; `error` is checked too because
|
|
221
|
+
// the CLI's own request layer writes that spelling and both reach here. Found by the Java
|
|
222
|
+
// compiler on 2026-10-15: this read `error` only, and a fixture written to match it was
|
|
223
|
+
// agreeing with the wrong shape.
|
|
224
|
+
for (const field of ["message", "error"]) {
|
|
225
|
+
if (typeof body[field] === "string" && body[field].trim()) return body[field].trim();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return (err && err.message) || "no reason given";
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** One line on the pipe, with anything that could break the format taken out of it. */
|
|
232
|
+
function refusalLine(reason) {
|
|
233
|
+
const flattened = String(reason || "")
|
|
234
|
+
.replace(/\s+/g, " ")
|
|
235
|
+
.trim()
|
|
236
|
+
.slice(0, 300);
|
|
237
|
+
return `REFUSED ${flattened}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Serve leases to a running engine until it exits.
|
|
242
|
+
*
|
|
243
|
+
* Returns a handle with `stop()`, and stops itself when the child exits, so nothing is left
|
|
244
|
+
* holding the event loop open: the renewal timer is cleared there, and is unreferenced besides
|
|
245
|
+
* so that a pending renewal can never be the reason a command does not finish.
|
|
246
|
+
*/
|
|
247
|
+
function attach(child, { log = console.log, fetch = fetchLease } = {}) {
|
|
248
|
+
const toEngine = child.stdio[CHILD_FD_IN];
|
|
249
|
+
const fromEngine = child.stdio[CHILD_FD_OUT];
|
|
250
|
+
const state = { nonce: null, timer: null, stopped: false, warned: false, leases: 0 };
|
|
251
|
+
|
|
252
|
+
if (!toEngine || !fromEngine) {
|
|
253
|
+
// Nothing to do, and not an error worth stopping a build for: an engine started without
|
|
254
|
+
// the extra pipes is an engine that will decide for itself whether it needs a licence.
|
|
255
|
+
return { stop() {}, state };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// The engine dying mid-write is normal (it finished, or it was interrupted). A write to a
|
|
259
|
+
// closed pipe must not take the CLI down with an unhandled error.
|
|
260
|
+
toEngine.on("error", () => {});
|
|
261
|
+
fromEngine.on("error", () => {});
|
|
262
|
+
|
|
263
|
+
function send(line) {
|
|
264
|
+
if (state.stopped || !toEngine.writable) return;
|
|
265
|
+
try {
|
|
266
|
+
toEngine.write(line + "\n");
|
|
267
|
+
} catch {
|
|
268
|
+
// Same case as above, reached when the pipe closes between the check and the write.
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function later(ms) {
|
|
273
|
+
if (state.stopped) return;
|
|
274
|
+
if (state.timer) clearTimeout(state.timer);
|
|
275
|
+
state.timer = setTimeout(offer, ms);
|
|
276
|
+
if (state.timer.unref) state.timer.unref();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function offer() {
|
|
280
|
+
if (state.stopped || !state.nonce) return;
|
|
281
|
+
const answer = await fetch(state.nonce);
|
|
282
|
+
if (state.stopped) return;
|
|
283
|
+
if (answer.refused) {
|
|
284
|
+
// The one case where the engine is stopped by us rather than by its lease running out.
|
|
285
|
+
send(refusalLine(answer.reason));
|
|
286
|
+
log(color.red(` Niro will not license the local engine: ${answer.reason}`));
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (answer.unreachable) {
|
|
290
|
+
// Deliberately nothing on the pipe. The engine carries on under the lease it has, and
|
|
291
|
+
// its offline allowance is what eventually stops it.
|
|
292
|
+
if (!state.warned) {
|
|
293
|
+
state.warned = true;
|
|
294
|
+
log(
|
|
295
|
+
color.yellow(
|
|
296
|
+
` Could not renew the local engine's licence: ${answer.detail}. ` +
|
|
297
|
+
"It keeps working offline for up to a day."
|
|
298
|
+
)
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
later(retryMs());
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
state.leases += 1;
|
|
305
|
+
state.warned = false;
|
|
306
|
+
send(`LEASE ${answer.lease} ${answer.signature} ${answer.keyId}`);
|
|
307
|
+
later(renewalDelay(claimsOf(answer.lease)));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
let buffered = "";
|
|
311
|
+
fromEngine.on("data", (chunk) => {
|
|
312
|
+
buffered += chunk.toString();
|
|
313
|
+
let newline = buffered.indexOf("\n");
|
|
314
|
+
while (newline !== -1) {
|
|
315
|
+
const line = buffered.slice(0, newline).trim();
|
|
316
|
+
buffered = buffered.slice(newline + 1);
|
|
317
|
+
const parts = line.split(" ");
|
|
318
|
+
if (parts[0] === "NONCE" && parts[1] && !state.nonce) {
|
|
319
|
+
state.nonce = parts[1];
|
|
320
|
+
// Not awaited: this handler must return so the pipe keeps being read while the
|
|
321
|
+
// request is in flight.
|
|
322
|
+
offer();
|
|
323
|
+
}
|
|
324
|
+
newline = buffered.indexOf("\n");
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
function stop() {
|
|
329
|
+
state.stopped = true;
|
|
330
|
+
if (state.timer) clearTimeout(state.timer);
|
|
331
|
+
state.timer = null;
|
|
332
|
+
fromEngine.removeAllListeners("data");
|
|
333
|
+
try {
|
|
334
|
+
toEngine.end();
|
|
335
|
+
} catch {
|
|
336
|
+
// Already gone with the child.
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
child.once("close", stop);
|
|
341
|
+
return { stop, state };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The claims of a fresh lease for this account, or null when none could be had.
|
|
346
|
+
*
|
|
347
|
+
* For reading what the plan says (`plan_name`, `share_code`), not for licensing anything: the
|
|
348
|
+
* nonce is thrown away. Same pattern as repoPush.permitted. Callers treat null as "unknown",
|
|
349
|
+
* and anything that would send code treats unknown as no.
|
|
350
|
+
*/
|
|
351
|
+
async function currentClaims({ fetch = fetchLease } = {}) {
|
|
352
|
+
const answer = await fetch(require("crypto").randomBytes(16).toString("hex"));
|
|
353
|
+
if (!answer || answer.refused || answer.unreachable) return null;
|
|
354
|
+
return claimsOf(answer.lease);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
module.exports = {
|
|
358
|
+
attach,
|
|
359
|
+
currentClaims,
|
|
360
|
+
billingUrl,
|
|
361
|
+
childEnvironment,
|
|
362
|
+
claimsOf,
|
|
363
|
+
fetchLease,
|
|
364
|
+
reasonFrom,
|
|
365
|
+
renewalDelay,
|
|
366
|
+
CHILD_FD_IN,
|
|
367
|
+
CHILD_FD_OUT,
|
|
368
|
+
FD_IN_ENV,
|
|
369
|
+
FD_OUT_ENV,
|
|
370
|
+
LEASE_PATH,
|
|
371
|
+
PROD_BILLING_URL,
|
|
372
|
+
fetchTimeoutMs,
|
|
373
|
+
minRenewalMs,
|
|
374
|
+
retryMs,
|
|
375
|
+
};
|