@wirecat/cli-core 0.441.0 → 0.443.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 +43 -0
- package/dist/cli/command-runs.d.ts +4 -1
- package/dist/cli/command-runs.js +7 -5
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +1 -0
- package/dist/runs/error-detail.d.ts +3 -2
- package/dist/runs/error-detail.js +5 -4
- package/dist/runs/run-context.js +4 -23
- package/dist/runs/secrets.d.ts +12 -0
- package/dist/runs/secrets.js +88 -0
- package/dist/terminal/sign-in-steps.d.ts +38 -0
- package/dist/terminal/sign-in-steps.js +87 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,49 @@ shape of this file.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 0.443.0 — 26.10.2026
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`signInSteps` and `answerStdinOption` (`@wirecat/cli-core/cli`): a sign-in a window can drive.** With `--json`, a
|
|
15
|
+
tool prints each step of its sign-in as one JSON line on stderr: `open_url` (a page to open), `pairing_code` (a code
|
|
16
|
+
to type into the phone), `progress`, and `needs` (`field`, `secret`, `prompt`, `hint`), whose answer is one line on
|
|
17
|
+
stdin. A QR code stays `loginQr`'s `qr` line. Reading answers is opt-in, `--answer-stdin`, and needs `--json`:
|
|
18
|
+
without it a run with no terminal still refuses, so an agent's shell never waits on a question. One reader serves
|
|
19
|
+
the whole run, a question can be withdrawn when the answer came another way (a browser callback), stdin is let go
|
|
20
|
+
between questions so the tool ends with its work, and an answer is never printed or kept in a run record.
|
|
21
|
+
|
|
22
|
+
## 0.442.0 — 26.10.2026
|
|
23
|
+
|
|
24
|
+
### Security
|
|
25
|
+
|
|
26
|
+
- **A run record and a kept error message now find a secret by what it looks like, not only by the flag's name.**
|
|
27
|
+
The secret is replaced by `[redacted]` inside the word and the words around it stay:
|
|
28
|
+
- a token in a known format: GitHub (`ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_`, `github_pat_`), OpenAI and
|
|
29
|
+
Anthropic (`sk-`), Slack (`xoxb-` and the like), an AWS access key id (`AKIA`, `ASIA`), a Google API key
|
|
30
|
+
(`AIza`), a Stripe secret or restricted key (`sk_live_`, `rk_live_` and the `_test_` ones; a `pk_` key is public
|
|
31
|
+
and stays), an npm token (`npm_`), a Telegram bot token, a JWT, and a PEM private key block;
|
|
32
|
+
- a `Bearer` or `Basic` credential;
|
|
33
|
+
- the value of a pair whose name says it is a secret, `password=…`, `API_TOKEN=…`, `client_secret:…`,
|
|
34
|
+
`"accessToken":"…"`, the name kept;
|
|
35
|
+
- a random-looking string of 20 or more characters: mixed upper and lower case with a digit or symbol, entropy of
|
|
36
|
+
at least 3.5 bits a character, case changing often and cut into short pieces. Words, camelCase names, UUIDs,
|
|
37
|
+
hex hashes, run ids, dates, paths and URLs without a password in them stay.
|
|
38
|
+
|
|
39
|
+
A profile name or a mistyped command word that looks like a secret is now `[redacted]` even with `record_input`
|
|
40
|
+
off.
|
|
41
|
+
|
|
42
|
+
- **A config that will not load no longer lets a run record keep the typed words.** When `recordRuns` or
|
|
43
|
+
`commandRuns` could not read the CLI's settings, it fell back to the defaults, so `record_input` counted as on and
|
|
44
|
+
the command line was kept as typed, even from a config that says `record_input: false`. A failed settings read now
|
|
45
|
+
masks every value that may be content, as cli-core's own commands already did.
|
|
46
|
+
|
|
47
|
+
### Changed — may break callers
|
|
48
|
+
|
|
49
|
+
- **`recordRuns` keeps the command line in the run record by itself.** A CLI with its own program (cli-memo,
|
|
50
|
+
drive-cli) kept no `argv` unless it passed the typed words through `run()`; the words its `parseAsync` is given are
|
|
51
|
+
now kept, secrets hidden, and masked when `settings()` returns `recordInput: false`.
|
|
52
|
+
|
|
10
53
|
## 0.441.0 — 26.10.2026
|
|
11
54
|
|
|
12
55
|
### Added
|
|
@@ -18,7 +18,10 @@ export interface RunSettings {
|
|
|
18
18
|
export declare const runSettings: (flag: boolean | undefined, saved?: SavedRunSettings) => RunSettings;
|
|
19
19
|
export interface CommandRunOptions {
|
|
20
20
|
app: AppIdentity;
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* The owner's saved choice. One that throws — a config that will not load — leaves the defaults, except that the
|
|
23
|
+
* typed words are masked: the broken config may be the one that says `record_input: false`.
|
|
24
|
+
*/
|
|
22
25
|
settings: () => SavedRunSettings;
|
|
23
26
|
/**
|
|
24
27
|
* The profile the command runs for; one that throws or is empty leaves the run without one. Given only by a CLI
|
package/dist/cli/command-runs.js
CHANGED
|
@@ -21,7 +21,7 @@ const pathOf = (command) => {
|
|
|
21
21
|
words.unshift(at.name());
|
|
22
22
|
return words;
|
|
23
23
|
};
|
|
24
|
-
const tracker = (options) => {
|
|
24
|
+
const tracker = (options, typed) => {
|
|
25
25
|
let recording;
|
|
26
26
|
const open = (program, words, flag, format) => {
|
|
27
27
|
recording = undefined;
|
|
@@ -33,7 +33,9 @@ const tracker = (options) => {
|
|
|
33
33
|
try {
|
|
34
34
|
saved = options.settings();
|
|
35
35
|
}
|
|
36
|
-
catch {
|
|
36
|
+
catch {
|
|
37
|
+
saved = { recordInput: false };
|
|
38
|
+
}
|
|
37
39
|
let profile;
|
|
38
40
|
try {
|
|
39
41
|
profile = options.profile?.() || undefined;
|
|
@@ -59,7 +61,7 @@ const tracker = (options) => {
|
|
|
59
61
|
bin: options.app.command,
|
|
60
62
|
command: words.length > 0 ? command : "",
|
|
61
63
|
program: rootOf(program),
|
|
62
|
-
...(environment.argv ? { argv: environment.argv } : {}),
|
|
64
|
+
...((environment.argv ?? typed) ? { argv: environment.argv ?? typed } : {}),
|
|
63
65
|
env: env ?? process.env,
|
|
64
66
|
recordInput: saved.recordInput !== false,
|
|
65
67
|
}),
|
|
@@ -153,7 +155,8 @@ export const recordRuns = (program, options) => {
|
|
|
153
155
|
});
|
|
154
156
|
const parseAsync = program.parseAsync.bind(program);
|
|
155
157
|
program.parseAsync = (argv, settings) => {
|
|
156
|
-
const
|
|
158
|
+
const args = settings?.from === "user" ? (argv ?? []) : (argv ?? process.argv).slice(2);
|
|
159
|
+
const parse = { runs: tracker(options, args), started: false };
|
|
157
160
|
return parses.run(parse, async () => {
|
|
158
161
|
try {
|
|
159
162
|
const parsed = await parseAsync(argv, settings);
|
|
@@ -164,7 +167,6 @@ export const recordRuns = (program, options) => {
|
|
|
164
167
|
if (!isHelpExit(error) && !wasSettled(error)) {
|
|
165
168
|
try {
|
|
166
169
|
if (!parse.started) {
|
|
167
|
-
const args = settings?.from === "user" ? (argv ?? []) : (argv ?? process.argv).slice(2);
|
|
168
170
|
const flag = args.includes("--no-record") ? false : args.includes("--record") ? true : undefined;
|
|
169
171
|
const json = args.some((arg) => arg === "--json" || arg.startsWith("--json=") || arg === "--jsonl");
|
|
170
172
|
const words = pathFromArgs(program, args, options.profile !== undefined);
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -37,6 +37,7 @@ export { maskedAccount } from "../services/people.js";
|
|
|
37
37
|
export { unanswered } from "../services/questions.js";
|
|
38
38
|
export { openRequestTask } from "../services/task-rules.js";
|
|
39
39
|
export { TASK_PAIR_QUERY, TASK_QUERY } from "../services/tasks.js";
|
|
40
|
+
export { answerStdinOption, type Need, type SignInSteps, type SignInStepsOptions, signInSteps, } from "../terminal/sign-in-steps.js";
|
|
40
41
|
export { type AgentPolicy, type AgentTask, type EvaluationResult, evaluateAgent, type Observation, } from "./agent-evaluation.js";
|
|
41
42
|
export { answerEntity } from "./answer-entity.js";
|
|
42
43
|
export type { ApiCommandInput, ApiCommands } from "./bot/api.js";
|
package/dist/cli/index.js
CHANGED
|
@@ -32,6 +32,7 @@ export { maskedAccount } from "../services/people.js";
|
|
|
32
32
|
export { unanswered } from "../services/questions.js";
|
|
33
33
|
export { openRequestTask } from "../services/task-rules.js";
|
|
34
34
|
export { TASK_PAIR_QUERY, TASK_QUERY } from "../services/tasks.js";
|
|
35
|
+
export { answerStdinOption, signInSteps, } from "../terminal/sign-in-steps.js";
|
|
35
36
|
export { evaluateAgent, } from "./agent-evaluation.js";
|
|
36
37
|
export { answerEntity } from "./answer-entity.js";
|
|
37
38
|
export { apiFlagOf, apiJson, apiOptionKey, apiPlainJson, checkApiBody, checkApiParameter, parseApiJson, readApiBody, } from "./bot/api-input.js";
|
|
@@ -39,7 +39,8 @@ export declare const INTERNATIONAL_PHONE: string;
|
|
|
39
39
|
export declare const BARE_PHONE: string;
|
|
40
40
|
/**
|
|
41
41
|
* An error's sentence with what could be somebody's words or a secret taken out: anything in quotes, an email, a
|
|
42
|
-
* phone number, a bot token, a bearer or a long opaque string, a URL's query, the
|
|
43
|
-
* defence — errors here are written to name things, not quote them — so it errs
|
|
42
|
+
* phone number, a secret `withoutSecrets` finds, a bot token, a bearer or a long opaque string, a URL's query, the
|
|
43
|
+
* home directory. A second line of defence — errors here are written to name things, not quote them — so it errs
|
|
44
|
+
* toward masking.
|
|
44
45
|
*/
|
|
45
46
|
export declare const redactText: (text: string) => string;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { isCliFailure } from "../errors/failures.js";
|
|
3
3
|
import { providerErrorKey } from "./events.js";
|
|
4
|
+
import { withoutSecrets } from "./secrets.js";
|
|
4
5
|
const MESSAGE_AT_MOST = 500;
|
|
5
6
|
const CAUSES_AT_MOST = 5;
|
|
6
7
|
const FRAMES_AT_MOST = 15;
|
|
@@ -114,14 +115,14 @@ export const INTERNATIONAL_PHONE = /\+\d[\d\s()-]{6,}\d/.source;
|
|
|
114
115
|
export const BARE_PHONE = /(^|[^\w-])\d{11,15}\b/.source;
|
|
115
116
|
/**
|
|
116
117
|
* An error's sentence with what could be somebody's words or a secret taken out: anything in quotes, an email, a
|
|
117
|
-
* phone number, a bot token, a bearer or a long opaque string, a URL's query, the
|
|
118
|
-
* defence — errors here are written to name things, not quote them — so it errs
|
|
118
|
+
* phone number, a secret `withoutSecrets` finds, a bot token, a bearer or a long opaque string, a URL's query, the
|
|
119
|
+
* home directory. A second line of defence — errors here are written to name things, not quote them — so it errs
|
|
120
|
+
* toward masking.
|
|
119
121
|
*/
|
|
120
122
|
export const redactText = (text) => {
|
|
121
123
|
const home = homedir();
|
|
122
124
|
let out = home && home !== "/" ? text.split(home).join("~") : text;
|
|
123
|
-
out = out
|
|
124
|
-
.replace(/"[^"\n]*"|“[^”\n]*”|«[^»\n]*»/g, '"[redacted]"')
|
|
125
|
+
out = withoutSecrets(out.replace(/"[^"\n]*"|“[^”\n]*”|«[^»\n]*»/g, '"[redacted]"'))
|
|
125
126
|
.replace(/\b(bearer|basic|token|key|password|secret)(\s*[:=]\s*|\s+)[^\s'"]+/gi, "$1$2[redacted]")
|
|
126
127
|
.replace(/[\w.+-]+@[\w-]+\.[\w.-]+/g, "[email]")
|
|
127
128
|
.replace(new RegExp(INTERNATIONAL_PHONE, "g"), "[number]")
|
package/dist/runs/run-context.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isContentKey } from "@wirecat/cli-base/standards";
|
|
2
2
|
import { BARE_PHONE, INTERNATIONAL_PHONE } from "./error-detail.js";
|
|
3
|
+
import { secretNamed, withoutSecrets } from "./secrets.js";
|
|
3
4
|
export const REDACTED = "[redacted]";
|
|
4
5
|
/**
|
|
5
6
|
* The agent is read from what each one sets in the processes it starts: Claude Code `CLAUDECODE` and
|
|
@@ -49,26 +50,6 @@ export const secretInput = (input) => {
|
|
|
49
50
|
secretInputs.add(input);
|
|
50
51
|
return input;
|
|
51
52
|
};
|
|
52
|
-
const SECRET_WORDS = new Set([
|
|
53
|
-
"authorization",
|
|
54
|
-
"cookie",
|
|
55
|
-
"credential",
|
|
56
|
-
"credentials",
|
|
57
|
-
"passphrase",
|
|
58
|
-
"password",
|
|
59
|
-
"secret",
|
|
60
|
-
"token",
|
|
61
|
-
"apikey",
|
|
62
|
-
]);
|
|
63
|
-
// Undeclared flags carry no mark, so the name is the only signal; CLIs take secrets at a prompt (WC-SECR-5) and this
|
|
64
|
-
// catches the one typed anyway.
|
|
65
|
-
const secretNamed = (name) => {
|
|
66
|
-
const words = name
|
|
67
|
-
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
68
|
-
.split(/[\s_.-]+/)
|
|
69
|
-
.map((word) => word.toLowerCase());
|
|
70
|
-
return words.some((word, at) => SECRET_WORDS.has(word) || (word === "api" && words[at + 1] === "key"));
|
|
71
|
-
};
|
|
72
53
|
// A password inside a URL, or one unbroken run of 40 token characters, is a credential wherever it was typed.
|
|
73
54
|
const secretShaped = (word) => /:\/\/[^/@\s:]+:[^/@\s]+@/.test(word) || /(^|[^A-Za-z0-9_-])[A-Za-z0-9_-]{40,}($|[^A-Za-z0-9_-])/.test(word);
|
|
74
55
|
// Messaging CLIs promise a phone number reaches no log, so it goes even from text the record otherwise keeps.
|
|
@@ -88,7 +69,7 @@ export const maskArgv = (program, words, { keep = false } = {}) => {
|
|
|
88
69
|
let positional = 0;
|
|
89
70
|
let profileSeen = false;
|
|
90
71
|
let lost = false;
|
|
91
|
-
const raw = (word) => (keep && !secretShaped(word) ? withoutPhones(word) : REDACTED);
|
|
72
|
+
const raw = (word) => (keep && !secretShaped(word) ? withoutPhones(withoutSecrets(word, REDACTED)) : REDACTED);
|
|
92
73
|
const keptValue = (name, value, input) => keep
|
|
93
74
|
? (input && secretInputs.has(input)) || secretNamed(name)
|
|
94
75
|
? REDACTED
|
|
@@ -151,7 +132,7 @@ export const maskArgv = (program, words, { keep = false } = {}) => {
|
|
|
151
132
|
}
|
|
152
133
|
if (current === program && !profileSeen) {
|
|
153
134
|
profileSeen = true;
|
|
154
|
-
argv.push(keep ? raw(word) : PROFILE.test(word) && withoutPhones(word) === word ? word : REDACTED);
|
|
135
|
+
argv.push(keep ? raw(word) : PROFILE.test(word) && withoutPhones(withoutSecrets(word)) === word ? word : REDACTED);
|
|
155
136
|
continue;
|
|
156
137
|
}
|
|
157
138
|
if (current.commands.length > 0 && current.registeredArguments.length === 0) {
|
|
@@ -171,7 +152,7 @@ export const maskArgv = (program, words, { keep = false } = {}) => {
|
|
|
171
152
|
const NUMBER = /^-?\d+(\.\d+)?$/;
|
|
172
153
|
const PROFILE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
173
154
|
/** A mistyped command word is kept so the typo can be seen; anything shaped like a sentence or an address is not. */
|
|
174
|
-
const commandLike = (word) =>
|
|
155
|
+
const commandLike = (word) => /^[a-z][a-z0-9-]{0,31}$/i.test(word) && withoutSecrets(word) === word ? word : REDACTED;
|
|
175
156
|
const maskedValue = (name, value, choices) => {
|
|
176
157
|
if (isContentKey(name))
|
|
177
158
|
return REDACTED;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const secretNamed: (name: string) => boolean;
|
|
2
|
+
/**
|
|
3
|
+
* Whether a run of token characters is a random string rather than words: long, mixed case, high Shannon entropy,
|
|
4
|
+
* cut into short pieces (`Kz9vQ2mX`) where a name is cut into words (`InlineQueryResultMpeg4Gif`).
|
|
5
|
+
*/
|
|
6
|
+
export declare const looksRandom: (run: string) => boolean;
|
|
7
|
+
/**
|
|
8
|
+
* Text with each secret inside it replaced by `mark` and the words around it kept: a token of a known vendor format,
|
|
9
|
+
* a private key, a `Bearer`/`Basic` credential, the value of a secret-named `name=value` or `name: value`, and a
|
|
10
|
+
* random-looking string of 20 or more printable characters (`looksRandom`).
|
|
11
|
+
*/
|
|
12
|
+
export declare const withoutSecrets: (text: string, mark?: string) => string;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const SECRET_WORDS = new Set([
|
|
2
|
+
"authorization",
|
|
3
|
+
"cookie",
|
|
4
|
+
"credential",
|
|
5
|
+
"credentials",
|
|
6
|
+
"passphrase",
|
|
7
|
+
"password",
|
|
8
|
+
"secret",
|
|
9
|
+
"token",
|
|
10
|
+
"apikey",
|
|
11
|
+
]);
|
|
12
|
+
// Undeclared flags carry no mark, so the name is the only signal; CLIs take secrets at a prompt (WC-SECR-5) and this
|
|
13
|
+
// catches the one typed anyway.
|
|
14
|
+
export const secretNamed = (name) => {
|
|
15
|
+
const words = name
|
|
16
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
17
|
+
.split(/[\s_.-]+/)
|
|
18
|
+
.map((word) => word.toLowerCase());
|
|
19
|
+
return words.some((word, at) => SECRET_WORDS.has(word) || (word === "api" && words[at + 1] === "key"));
|
|
20
|
+
};
|
|
21
|
+
const START = "(?<![A-Za-z0-9_-])";
|
|
22
|
+
const END = "(?![A-Za-z0-9_-])";
|
|
23
|
+
// Vendors print these prefixes so scanners can find their keys; a Stripe `pk_` key is public by design.
|
|
24
|
+
const KNOWN_TOKENS = new RegExp(`${[
|
|
25
|
+
"gh[pousr]_[A-Za-z0-9]{36,}",
|
|
26
|
+
"github_pat_[A-Za-z0-9_]{22,}",
|
|
27
|
+
"sk-[A-Za-z0-9_-]{20,}",
|
|
28
|
+
"xox[abprs]-[A-Za-z0-9-]{10,}",
|
|
29
|
+
"(?:AKIA|ASIA)[A-Z0-9]{16}",
|
|
30
|
+
"AIza[A-Za-z0-9_-]{35}",
|
|
31
|
+
"(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{10,}",
|
|
32
|
+
"npm_[A-Za-z0-9]{36}",
|
|
33
|
+
"eyJ[A-Za-z0-9_-]{5,}\\.eyJ[A-Za-z0-9_-]{5,}\\.[A-Za-z0-9_-]*",
|
|
34
|
+
]
|
|
35
|
+
.map((pattern) => `${START}${pattern}${END}`)
|
|
36
|
+
.join("|")}|(?<!\\d)\\d{8,10}:[A-Za-z0-9_-]{35}${END}`, "g");
|
|
37
|
+
const PRIVATE_KEY = /-----BEGIN[A-Z ]* PRIVATE KEY-----[\s\S]*?(?:-----END[A-Z ]* PRIVATE KEY-----|$)/g;
|
|
38
|
+
// Capitalised, as in a header, and not followed by a plain word: "the Basic plan" is somebody's words.
|
|
39
|
+
const SCHEME = /(?<![A-Za-z])(Bearer|Basic)(\s+)(?![A-Z]?[a-z]+(?![A-Za-z0-9]))[A-Za-z0-9._~+/=-]{8,}/g;
|
|
40
|
+
const NAMED_PAIR = /(?<![A-Za-z0-9])([A-Za-z0-9_.-]*(?:token|password|passphrase|secret|credential|cookie|authorization|api[_-]?key)[A-Za-z0-9_.-]*)(["']?\s*[:=]\s*["']?)((?:Bearer\s+|Basic\s+)?)([^\s"'&,;]+)/gi;
|
|
41
|
+
/**
|
|
42
|
+
* Whether a run of token characters is a random string rather than words: long, mixed case, high Shannon entropy,
|
|
43
|
+
* cut into short pieces (`Kz9vQ2mX`) where a name is cut into words (`InlineQueryResultMpeg4Gif`).
|
|
44
|
+
*/
|
|
45
|
+
export const looksRandom = (run) => {
|
|
46
|
+
if (run.length < 20)
|
|
47
|
+
return false;
|
|
48
|
+
const classes = [/[a-z]/, /[A-Z]/, /\d/, /[^A-Za-z0-9]/].filter((kind) => kind.test(run)).length;
|
|
49
|
+
if (classes < 3)
|
|
50
|
+
return false;
|
|
51
|
+
let switches = 0;
|
|
52
|
+
for (let at = 1; at < run.length; at++) {
|
|
53
|
+
const [before, after] = [run[at - 1], run[at]];
|
|
54
|
+
if ((/[a-z]/.test(before) && /[A-Z]/.test(after)) || (/[A-Z]/.test(before) && /[a-z]/.test(after)))
|
|
55
|
+
switches += 1;
|
|
56
|
+
}
|
|
57
|
+
if (switches / run.length < 0.15)
|
|
58
|
+
return false;
|
|
59
|
+
const pieces = run.match(/[A-Z]?[a-z]+|[A-Z]+(?![a-z])|\d+/g) ?? [];
|
|
60
|
+
if (pieces.join("").length / Math.max(pieces.length, 1) >= 2.75)
|
|
61
|
+
return false;
|
|
62
|
+
return entropy(run) >= 3.5;
|
|
63
|
+
};
|
|
64
|
+
const entropy = (text) => {
|
|
65
|
+
const counts = new Map();
|
|
66
|
+
for (const char of text)
|
|
67
|
+
counts.set(char, (counts.get(char) ?? 0) + 1);
|
|
68
|
+
let bits = 0;
|
|
69
|
+
for (const count of counts.values())
|
|
70
|
+
bits -= (count / text.length) * Math.log2(count / text.length);
|
|
71
|
+
return bits;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Text with each secret inside it replaced by `mark` and the words around it kept: a token of a known vendor format,
|
|
75
|
+
* a private key, a `Bearer`/`Basic` credential, the value of a secret-named `name=value` or `name: value`, and a
|
|
76
|
+
* random-looking string of 20 or more printable characters (`looksRandom`).
|
|
77
|
+
*/
|
|
78
|
+
export const withoutSecrets = (text, mark = "[redacted]") => text
|
|
79
|
+
.replace(PRIVATE_KEY, mark)
|
|
80
|
+
.replace(KNOWN_TOKENS, mark)
|
|
81
|
+
.replace(SCHEME, `$1$2${mark}`)
|
|
82
|
+
.replace(NAMED_PAIR, (whole, name, separator, scheme) => secretNamed(name) ? `${name}${separator}${scheme}${mark}` : whole)
|
|
83
|
+
.replace(/[!-~]{20,}/g, (token) =>
|
|
84
|
+
// A URL or a path is somebody's address, not a key; a credential in one is caught by the rules above.
|
|
85
|
+
token.includes("://") || /^(?:\/|~\/|\.\.?\/)/.test(token)
|
|
86
|
+
? token
|
|
87
|
+
: token.replace(RUN, (run) => (looksRandom(run) ? mark : run)));
|
|
88
|
+
const RUN = /(?<![A-Za-z0-9_+/-])[A-Za-z0-9_+/-]{20,}/g;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Option } from "commander";
|
|
2
|
+
/**
|
|
3
|
+
* Opt-in, because a read nobody answers hangs: an agent running `session start` in a shell must get the refusal it
|
|
4
|
+
* gets today, and only a caller that holds stdin open and answers each `needs` line (WireCat Desktop) passes it.
|
|
5
|
+
*/
|
|
6
|
+
export declare const answerStdinOption: () => Option;
|
|
7
|
+
export interface Need {
|
|
8
|
+
/** What is asked, for the caller to pick a field: `phone`, `code`, `password`, `api_id`, `api_hash`, `token`, `redirect`… */
|
|
9
|
+
field: string;
|
|
10
|
+
/** Shown masked and never echoed. */
|
|
11
|
+
secret: boolean;
|
|
12
|
+
prompt: string;
|
|
13
|
+
hint?: string | undefined;
|
|
14
|
+
}
|
|
15
|
+
export interface SignInStepsOptions {
|
|
16
|
+
/** Writes one whole line to the diagnostic stream; stdout keeps only the answer. */
|
|
17
|
+
diagnostic: (line: string) => void;
|
|
18
|
+
/** Machine output (`--json`, `--jsonl`): only then are the steps printed. */
|
|
19
|
+
json: boolean;
|
|
20
|
+
/** `--answer-stdin`. */
|
|
21
|
+
answers: boolean;
|
|
22
|
+
input?: NodeJS.ReadableStream;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* A sign-in told as JSON lines on the diagnostic stream, for a window to draw instead of a terminal: `open_url`,
|
|
26
|
+
* `pairing_code`, `progress`, and `needs`, whose answer is one line on stdin. A QR code is `loginQr`'s `qr` line.
|
|
27
|
+
*/
|
|
28
|
+
export interface SignInSteps {
|
|
29
|
+
readonly json: boolean;
|
|
30
|
+
/** Whether `ask` reads answers from stdin; tools keep their own terminal prompt otherwise. */
|
|
31
|
+
readonly answers: boolean;
|
|
32
|
+
openUrl: (url: string, purpose: string) => void;
|
|
33
|
+
pairingCode: (code: string, where: string, expiresAt?: Date) => void;
|
|
34
|
+
progress: (step: string, message: string) => void;
|
|
35
|
+
/** A `signal` that aborts withdraws the question: the caller got its answer another way (a browser callback). */
|
|
36
|
+
ask: (need: Need, signal?: AbortSignal) => Promise<string>;
|
|
37
|
+
}
|
|
38
|
+
export declare const signInSteps: ({ diagnostic, json, answers, input }: SignInStepsOptions) => SignInSteps;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { StringDecoder } from "node:string_decoder";
|
|
2
|
+
import { CliError } from "@wirecat/cli-base";
|
|
3
|
+
import { Option } from "commander";
|
|
4
|
+
import { MAX_SECRET_INPUT } from "../cli/input-policy.js";
|
|
5
|
+
/**
|
|
6
|
+
* Opt-in, because a read nobody answers hangs: an agent running `session start` in a shell must get the refusal it
|
|
7
|
+
* gets today, and only a caller that holds stdin open and answers each `needs` line (WireCat Desktop) passes it.
|
|
8
|
+
*/
|
|
9
|
+
export const answerStdinOption = () => new Option("--answer-stdin", "with --json and no terminal: print each question as a `needs` line on stderr and read its answer, one line, from stdin");
|
|
10
|
+
export const signInSteps = ({ diagnostic, json, answers, input = process.stdin }) => {
|
|
11
|
+
if (answers && !json)
|
|
12
|
+
throw new CliError("validation_error", "--answer-stdin needs --json");
|
|
13
|
+
const emit = (event) => {
|
|
14
|
+
if (json)
|
|
15
|
+
diagnostic(JSON.stringify(event));
|
|
16
|
+
};
|
|
17
|
+
const next = answers ? lineReader(input) : undefined;
|
|
18
|
+
return {
|
|
19
|
+
json,
|
|
20
|
+
answers,
|
|
21
|
+
openUrl: (url, purpose) => emit({ event: "open_url", url, purpose }),
|
|
22
|
+
pairingCode: (code, where, expiresAt) => emit({ event: "pairing_code", code, where, expires_at: expiresAt?.toISOString() ?? null }),
|
|
23
|
+
progress: (step, message) => emit({ event: "progress", step, message }),
|
|
24
|
+
ask: async ({ field, secret, prompt, hint }, signal) => {
|
|
25
|
+
if (!next)
|
|
26
|
+
throw new CliError("validation_error", `the ${prompt} must be typed at a terminal`, {
|
|
27
|
+
reason: "input.required",
|
|
28
|
+
});
|
|
29
|
+
signal?.throwIfAborted();
|
|
30
|
+
emit({ event: "needs", field, secret, prompt, hint: hint ?? null });
|
|
31
|
+
return await next(signal);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* One reader for the whole run, so a second answer that arrived with the first is not lost. It reads only while a
|
|
37
|
+
* question waits and pauses stdin between them, so a pipe the caller keeps open does not hold the process after the
|
|
38
|
+
* last answer.
|
|
39
|
+
*/
|
|
40
|
+
const lineReader = (input) => {
|
|
41
|
+
const decoder = new StringDecoder("utf8");
|
|
42
|
+
let buffered = "";
|
|
43
|
+
let ended = false;
|
|
44
|
+
return (signal) => new Promise((resolve, reject) => {
|
|
45
|
+
const cleanup = () => {
|
|
46
|
+
input.off("data", data).off("end", end).off("error", fail);
|
|
47
|
+
signal?.removeEventListener("abort", aborted);
|
|
48
|
+
input.pause();
|
|
49
|
+
// A paused pipe still holds the event loop; the tool must end once its work is done.
|
|
50
|
+
input.unref?.();
|
|
51
|
+
};
|
|
52
|
+
const fail = (error) => {
|
|
53
|
+
cleanup();
|
|
54
|
+
reject(error);
|
|
55
|
+
};
|
|
56
|
+
const take = () => {
|
|
57
|
+
const at = buffered.indexOf("\n");
|
|
58
|
+
if (at < 0)
|
|
59
|
+
return false;
|
|
60
|
+
const line = buffered.slice(0, at).replace(/\r$/, "");
|
|
61
|
+
buffered = buffered.slice(at + 1);
|
|
62
|
+
cleanup();
|
|
63
|
+
resolve(line.trim());
|
|
64
|
+
return true;
|
|
65
|
+
};
|
|
66
|
+
const data = (chunk) => {
|
|
67
|
+
buffered += typeof chunk === "string" ? chunk : decoder.write(chunk);
|
|
68
|
+
if (take())
|
|
69
|
+
return;
|
|
70
|
+
if (buffered.length > MAX_SECRET_INPUT)
|
|
71
|
+
fail(new CliError("validation_error", "the answer exceeds its byte limit", { reason: "input.too-large" }));
|
|
72
|
+
};
|
|
73
|
+
const end = () => {
|
|
74
|
+
ended = true;
|
|
75
|
+
fail(new CliError("cancelled", "the input closed before the answer came", { reason: "input.closed" }));
|
|
76
|
+
};
|
|
77
|
+
const aborted = () => fail(signal?.reason ?? new CliError("cancelled", "cancelled"));
|
|
78
|
+
if (take())
|
|
79
|
+
return;
|
|
80
|
+
if (ended)
|
|
81
|
+
return end();
|
|
82
|
+
input.on("data", data).once("end", end).once("error", fail);
|
|
83
|
+
signal?.addEventListener("abort", aborted, { once: true });
|
|
84
|
+
input.ref?.();
|
|
85
|
+
input.resume();
|
|
86
|
+
});
|
|
87
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wirecat/cli-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.443.0",
|
|
4
4
|
"description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Viacheslav Ptsarev",
|