@luckypiggy/zhihu-ai-cli 0.1.0-test.1 → 0.2.1-test.5

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.
@@ -17,26 +17,113 @@ function safeProfile(profile) {
17
17
  }
18
18
  return profile;
19
19
  }
20
- /** Spawn a fixed executable without a shell and without putting secret input in argv/env. */
20
+ const MAC_SECURITY_EXECUTABLE = "/usr/bin/security";
21
+ const MAC_EXPECT_EXECUTABLE = "/usr/bin/expect";
22
+ const MAC_EXPECT_FAILURE = 70;
23
+ /** Reserved exit status of the fixed macOS Expect helper. Other children may also exit 124. */
24
+ export const MAC_EXPECT_TIMEOUT = 124;
25
+ /** Read, delete, availability, and Linux writes keep the original five-second bound. */
26
+ export const CREDENTIAL_COMMAND_TIMEOUT_MS = 5000;
27
+ /**
28
+ * Bounded Expect wait for the whole macOS Keychain handshake. Both prompts
29
+ * share this clock because exp_continue does not restart it.
30
+ */
31
+ export const MAC_KEYCHAIN_WRITE_EXPECT_TIMEOUT_SECONDS = 15;
32
+ /** Lets Expect exit and the child be reaped after its own timeout. */
33
+ export const MAC_KEYCHAIN_WRITE_CLEANUP_MARGIN_MS = 2000;
34
+ export const MAC_KEYCHAIN_WRITE_PROCESS_TIMEOUT_MS = MAC_KEYCHAIN_WRITE_EXPECT_TIMEOUT_SECONDS * 1000 + MAC_KEYCHAIN_WRITE_CLEANUP_MARGIN_MS;
35
+ const MAC_EXPECT_SCRIPT_BODY = `
36
+ set timeout ${MAC_KEYCHAIN_WRITE_EXPECT_TIMEOUT_SECONDS}
37
+ log_user 0
38
+ if {[gets stdin secret] < 0} { exit ${MAC_EXPECT_FAILURE} }
39
+ set prompt_count 0
40
+ spawn -noecho $zhihu_command {*}$zhihu_args
41
+ expect {
42
+ "password data for new item:" {
43
+ if {$prompt_count != 0} { exit ${MAC_EXPECT_FAILURE} }
44
+ set prompt_count 1
45
+ send -- "$secret\\r"
46
+ exp_continue -continue_timer
47
+ }
48
+ "retype password for new item:" {
49
+ if {$prompt_count != 1} { exit ${MAC_EXPECT_FAILURE} }
50
+ set prompt_count 2
51
+ send -- "$secret\\r"
52
+ exp_continue -continue_timer
53
+ }
54
+ timeout { exit ${MAC_EXPECT_TIMEOUT} }
55
+ eof {}
56
+ }
57
+ if {[catch {wait} wait_result] != 0} { exit ${MAC_EXPECT_FAILURE} }
58
+ if {[llength $wait_result] == 4
59
+ && [string is integer -strict [lindex $wait_result 2]]
60
+ && [string is integer -strict [lindex $wait_result 3]]} {
61
+ if {[lindex $wait_result 2] != 0} { exit ${MAC_EXPECT_FAILURE} }
62
+ set exit_status [lindex $wait_result 3]
63
+ if {$exit_status == 0 && $prompt_count != 2} { exit ${MAC_EXPECT_FAILURE} }
64
+ exit $exit_status
65
+ }
66
+ exit ${MAC_EXPECT_FAILURE}
67
+ `;
68
+ function commandEnvironment() {
69
+ const allowedEnvironmentKeys = new Set([
70
+ "HOME",
71
+ "PATH",
72
+ "LANG",
73
+ "LC_ALL",
74
+ "LC_CTYPE",
75
+ "DBUS_SESSION_BUS_ADDRESS",
76
+ "XDG_RUNTIME_DIR",
77
+ "XDG_CONFIG_HOME",
78
+ "DISPLAY",
79
+ ]);
80
+ return Object.fromEntries(Object.entries(process.env).filter(([key]) => allowedEnvironmentKeys.has(key) || key.startsWith("LC_")));
81
+ }
82
+ function isMacSecurityWrite(command, args) {
83
+ return process.platform === "darwin"
84
+ && command === MAC_SECURITY_EXECUTABLE
85
+ && args[0] === "add-generic-password"
86
+ && args.at(-1) === "-w";
87
+ }
88
+ function tclQuote(value) {
89
+ return `"${value
90
+ .replaceAll("\\", "\\\\")
91
+ .replaceAll("\"", "\\\"")
92
+ .replaceAll("$", "\\$")
93
+ .replaceAll("[", "\\[")
94
+ .replaceAll("]", "\\]")
95
+ .replaceAll("\r", "\\r")
96
+ .replaceAll("\n", "\\n")}"`;
97
+ }
98
+ function macSecurityExpectArgs(command, args) {
99
+ const commandLiteral = tclQuote(command);
100
+ const argsLiteral = args.map(tclQuote).join(" ");
101
+ const script = `set zhihu_command ${commandLiteral}\nset zhihu_args [list ${argsLiteral}]${MAC_EXPECT_SCRIPT_BODY}`;
102
+ return ["-c", script];
103
+ }
104
+ /**
105
+ * Remove an input value from command output before it can reach diagnostics.
106
+ */
107
+ function removeInputFromOutput(output, input) {
108
+ if (!input)
109
+ return output;
110
+ const candidates = [input, input.endsWith("\n") ? input.slice(0, -1) : input];
111
+ return candidates.reduce((value, candidate) => candidate ? value.split(candidate).join("") : value, output);
112
+ }
113
+ /**
114
+ * Spawn a fixed executable without putting secret input in argv/env.
115
+ */
21
116
  export const processCommandRunner = (command, args, options = {}) => new Promise((resolve, reject) => {
117
+ const useMacSecurityExpect = isMacSecurityWrite(command, args);
118
+ const executable = useMacSecurityExpect ? MAC_EXPECT_EXECUTABLE : command;
119
+ const executableArgs = useMacSecurityExpect ? macSecurityExpectArgs(command, args) : [...args];
22
120
  let child;
23
121
  try {
24
- const allowedEnvironmentKeys = new Set([
25
- "HOME",
26
- "PATH",
27
- "LANG",
28
- "LC_ALL",
29
- "LC_CTYPE",
30
- "DBUS_SESSION_BUS_ADDRESS",
31
- "XDG_RUNTIME_DIR",
32
- "XDG_CONFIG_HOME",
33
- "DISPLAY",
34
- ]);
35
- const environment = Object.fromEntries(Object.entries(process.env).filter(([key]) => allowedEnvironmentKeys.has(key) || key.startsWith("LC_")));
36
- child = spawn(command, [...args], {
122
+ child = spawn(executable, executableArgs, {
37
123
  shell: false,
38
124
  stdio: ["pipe", "pipe", "pipe"],
39
- env: environment,
125
+ env: commandEnvironment(),
126
+ detached: false,
40
127
  });
41
128
  }
42
129
  catch {
@@ -46,36 +133,87 @@ export const processCommandRunner = (command, args, options = {}) => new Promise
46
133
  let stdout = "";
47
134
  let stderr = "";
48
135
  let timer;
49
- const finish = (result) => {
136
+ let settled = false;
137
+ let inputSent = false;
138
+ let terminated = false;
139
+ const clearMainTimer = () => {
50
140
  if (timer)
51
141
  clearTimeout(timer);
52
- resolve(result);
142
+ };
143
+ const terminateChild = () => {
144
+ if (terminated)
145
+ return;
146
+ terminated = true;
147
+ try {
148
+ child.kill();
149
+ }
150
+ catch {
151
+ // Cleanup is best effort; do not turn a child race into an uncaught
152
+ // stream error.
153
+ }
154
+ };
155
+ const finish = (result) => {
156
+ if (settled)
157
+ return;
158
+ settled = true;
159
+ clearMainTimer();
160
+ const cleanStdout = removeInputFromOutput(stdout, options.input);
161
+ const cleanStderr = removeInputFromOutput(stderr, options.input);
162
+ resolve({ ...result, stdout: cleanStdout, stderr: cleanStderr });
163
+ };
164
+ const failUnavailable = (message) => {
165
+ if (settled)
166
+ return;
167
+ settled = true;
168
+ terminateChild();
169
+ clearMainTimer();
170
+ reject(new CredentialStoreError("CREDENTIAL_STORE_UNAVAILABLE", message));
171
+ };
172
+ const sendInput = () => {
173
+ if (inputSent || settled)
174
+ return;
175
+ inputSent = true;
176
+ try {
177
+ if (options.input !== undefined) {
178
+ // For macOS `security ... -w`, the expect helper reads the single
179
+ // logical credential input and answers each prompt on its PTY.
180
+ // The input is still only written to the child stream, never argv/env.
181
+ child.stdin.write(options.input);
182
+ }
183
+ child.stdin.end();
184
+ }
185
+ catch {
186
+ failUnavailable("System credential store is unavailable");
187
+ }
53
188
  };
54
189
  child.stdout.on("data", (chunk) => {
55
190
  stdout += chunk.toString();
56
191
  if (stdout.length > 16384)
57
- child.kill();
192
+ terminateChild();
58
193
  });
59
194
  child.stderr.on("data", (chunk) => {
60
195
  stderr += chunk.toString();
61
196
  if (stderr.length > 16384)
62
- child.kill();
197
+ terminateChild();
63
198
  });
64
- child.once("error", () => {
65
- if (timer)
66
- clearTimeout(timer);
67
- reject(new CredentialStoreError("CREDENTIAL_STORE_UNAVAILABLE", "System credential store is unavailable"));
199
+ // Keep listeners attached after settlement so a late EPIPE from a child
200
+ // that exited during the handshake cannot become an uncaught error.
201
+ child.stdin.on("error", () => failUnavailable("System credential store is unavailable"));
202
+ child.on("error", () => failUnavailable("System credential store is unavailable"));
203
+ child.once("close", (code) => {
204
+ const status = code ?? 1;
205
+ if (useMacSecurityExpect && status === MAC_EXPECT_TIMEOUT) {
206
+ failUnavailable("System credential store timed out");
207
+ return;
208
+ }
209
+ finish({ code: status, stdout, stderr });
68
210
  });
69
- child.once("close", (code) => finish({ code: code ?? 1, stdout, stderr }));
70
211
  if (options.timeoutMs) {
71
212
  timer = setTimeout(() => {
72
- child.kill();
73
- reject(new CredentialStoreError("CREDENTIAL_STORE_UNAVAILABLE", "System credential store timed out"));
213
+ failUnavailable("System credential store timed out");
74
214
  }, options.timeoutMs);
75
215
  }
76
- if (options.input !== undefined)
77
- child.stdin.write(options.input);
78
- child.stdin.end();
216
+ sendInput();
79
217
  });
80
218
  function missingResult(result) {
81
219
  return result.code !== 0 && !result.stdout.trim() && !result.stderr.trim();
@@ -118,7 +256,7 @@ export class SecureCredentialStore {
118
256
  async available() {
119
257
  try {
120
258
  const command = this.command();
121
- const result = await this.runner(command.executable, command.checkArgs, { timeoutMs: 5000 });
259
+ const result = await this.runner(command.executable, command.checkArgs, { timeoutMs: CREDENTIAL_COMMAND_TIMEOUT_MS });
122
260
  if (result.code !== 0)
123
261
  throw new CredentialStoreError("CREDENTIAL_STORE_UNAVAILABLE", "System credential store is unavailable");
124
262
  return true;
@@ -132,7 +270,7 @@ export class SecureCredentialStore {
132
270
  async get() {
133
271
  try {
134
272
  const command = this.command();
135
- const result = await this.runner(command.executable, command.readArgs, { timeoutMs: 5000 });
273
+ const result = await this.runner(command.executable, command.readArgs, { timeoutMs: CREDENTIAL_COMMAND_TIMEOUT_MS });
136
274
  if (result.code !== 0) {
137
275
  const diagnostic = result.stderr.toLowerCase();
138
276
  if (missingResult(result) || diagnostic.includes("could not be found") || diagnostic.includes("no such secret") || diagnostic.includes("not found"))
@@ -158,8 +296,13 @@ export class SecureCredentialStore {
158
296
  try {
159
297
  const command = this.command();
160
298
  const result = await this.runner(command.executable, command.writeArgs, {
299
+ // The process runner passes this single logical credential to the
300
+ // fixed macOS Expect helper, which answers both security prompts in
301
+ // the child PTY without placing the secret in argv or the environment.
161
302
  input: `${credential}\n`,
162
- timeoutMs: 5000,
303
+ timeoutMs: this.platform === "darwin"
304
+ ? MAC_KEYCHAIN_WRITE_PROCESS_TIMEOUT_MS
305
+ : CREDENTIAL_COMMAND_TIMEOUT_MS,
163
306
  });
164
307
  if (result.code !== 0)
165
308
  throw new CredentialStoreError("CREDENTIAL_STORE_ERROR", "Unable to write system credential");
@@ -173,7 +316,7 @@ export class SecureCredentialStore {
173
316
  async delete() {
174
317
  try {
175
318
  const command = this.command();
176
- const result = await this.runner(command.executable, command.deleteArgs, { timeoutMs: 5000 });
319
+ const result = await this.runner(command.executable, command.deleteArgs, { timeoutMs: CREDENTIAL_COMMAND_TIMEOUT_MS });
177
320
  const diagnostic = result.stderr.toLowerCase();
178
321
  if (result.code !== 0 && !missingResult(result) && !diagnostic.includes("could not be found") && !diagnostic.includes("no such secret") && !diagnostic.includes("not found")) {
179
322
  throw new CredentialStoreError("CREDENTIAL_STORE_ERROR", "Unable to delete system credential");