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

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,102 @@ 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
+ const MAC_EXPECT_TIMEOUT = 124;
24
+ const MAC_EXPECT_SCRIPT_BODY = `
25
+ set timeout 5
26
+ log_user 0
27
+ if {[gets stdin secret] < 0} { exit ${MAC_EXPECT_FAILURE} }
28
+ set prompt_count 0
29
+ spawn -noecho $zhihu_command {*}$zhihu_args
30
+ expect {
31
+ "password data for new item:" {
32
+ if {$prompt_count != 0} { exit ${MAC_EXPECT_FAILURE} }
33
+ set prompt_count 1
34
+ send -- "$secret\\r"
35
+ exp_continue
36
+ }
37
+ "retype password for new item:" {
38
+ if {$prompt_count != 1} { exit ${MAC_EXPECT_FAILURE} }
39
+ set prompt_count 2
40
+ send -- "$secret\\r"
41
+ exp_continue
42
+ }
43
+ timeout { exit ${MAC_EXPECT_TIMEOUT} }
44
+ eof {}
45
+ }
46
+ if {[catch {wait} wait_result] != 0} { exit ${MAC_EXPECT_FAILURE} }
47
+ if {[llength $wait_result] == 4
48
+ && [string is integer -strict [lindex $wait_result 2]]
49
+ && [string is integer -strict [lindex $wait_result 3]]} {
50
+ if {[lindex $wait_result 2] != 0} { exit ${MAC_EXPECT_FAILURE} }
51
+ set exit_status [lindex $wait_result 3]
52
+ if {$exit_status == 0 && $prompt_count != 2} { exit ${MAC_EXPECT_FAILURE} }
53
+ exit $exit_status
54
+ }
55
+ exit ${MAC_EXPECT_FAILURE}
56
+ `;
57
+ function commandEnvironment() {
58
+ const allowedEnvironmentKeys = new Set([
59
+ "HOME",
60
+ "PATH",
61
+ "LANG",
62
+ "LC_ALL",
63
+ "LC_CTYPE",
64
+ "DBUS_SESSION_BUS_ADDRESS",
65
+ "XDG_RUNTIME_DIR",
66
+ "XDG_CONFIG_HOME",
67
+ "DISPLAY",
68
+ ]);
69
+ return Object.fromEntries(Object.entries(process.env).filter(([key]) => allowedEnvironmentKeys.has(key) || key.startsWith("LC_")));
70
+ }
71
+ function isMacSecurityWrite(command, args) {
72
+ return process.platform === "darwin"
73
+ && command === MAC_SECURITY_EXECUTABLE
74
+ && args[0] === "add-generic-password"
75
+ && args.at(-1) === "-w";
76
+ }
77
+ function tclQuote(value) {
78
+ return `"${value
79
+ .replaceAll("\\", "\\\\")
80
+ .replaceAll("\"", "\\\"")
81
+ .replaceAll("$", "\\$")
82
+ .replaceAll("[", "\\[")
83
+ .replaceAll("]", "\\]")
84
+ .replaceAll("\r", "\\r")
85
+ .replaceAll("\n", "\\n")}"`;
86
+ }
87
+ function macSecurityExpectArgs(command, args) {
88
+ const commandLiteral = tclQuote(command);
89
+ const argsLiteral = args.map(tclQuote).join(" ");
90
+ const script = `set zhihu_command ${commandLiteral}\nset zhihu_args [list ${argsLiteral}]${MAC_EXPECT_SCRIPT_BODY}`;
91
+ return ["-c", script];
92
+ }
93
+ /**
94
+ * Remove an input value from command output before it can reach diagnostics.
95
+ */
96
+ function removeInputFromOutput(output, input) {
97
+ if (!input)
98
+ return output;
99
+ const candidates = [input, input.endsWith("\n") ? input.slice(0, -1) : input];
100
+ return candidates.reduce((value, candidate) => candidate ? value.split(candidate).join("") : value, output);
101
+ }
102
+ /**
103
+ * Spawn a fixed executable without putting secret input in argv/env.
104
+ */
21
105
  export const processCommandRunner = (command, args, options = {}) => new Promise((resolve, reject) => {
106
+ const useMacSecurityExpect = isMacSecurityWrite(command, args);
107
+ const executable = useMacSecurityExpect ? MAC_EXPECT_EXECUTABLE : command;
108
+ const executableArgs = useMacSecurityExpect ? macSecurityExpectArgs(command, args) : [...args];
22
109
  let child;
23
110
  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], {
111
+ child = spawn(executable, executableArgs, {
37
112
  shell: false,
38
113
  stdio: ["pipe", "pipe", "pipe"],
39
- env: environment,
114
+ env: commandEnvironment(),
115
+ detached: false,
40
116
  });
41
117
  }
42
118
  catch {
@@ -46,36 +122,80 @@ export const processCommandRunner = (command, args, options = {}) => new Promise
46
122
  let stdout = "";
47
123
  let stderr = "";
48
124
  let timer;
49
- const finish = (result) => {
125
+ let settled = false;
126
+ let inputSent = false;
127
+ let terminated = false;
128
+ const clearMainTimer = () => {
50
129
  if (timer)
51
130
  clearTimeout(timer);
52
- resolve(result);
131
+ };
132
+ const terminateChild = () => {
133
+ if (terminated)
134
+ return;
135
+ terminated = true;
136
+ try {
137
+ child.kill();
138
+ }
139
+ catch {
140
+ // Cleanup is best effort; do not turn a child race into an uncaught
141
+ // stream error.
142
+ }
143
+ };
144
+ const finish = (result) => {
145
+ if (settled)
146
+ return;
147
+ settled = true;
148
+ clearMainTimer();
149
+ const cleanStdout = removeInputFromOutput(stdout, options.input);
150
+ const cleanStderr = removeInputFromOutput(stderr, options.input);
151
+ resolve({ ...result, stdout: cleanStdout, stderr: cleanStderr });
152
+ };
153
+ const failUnavailable = (message) => {
154
+ if (settled)
155
+ return;
156
+ terminateChild();
157
+ settled = true;
158
+ clearMainTimer();
159
+ reject(new CredentialStoreError("CREDENTIAL_STORE_UNAVAILABLE", message));
160
+ };
161
+ const sendInput = () => {
162
+ if (inputSent || settled)
163
+ return;
164
+ inputSent = true;
165
+ try {
166
+ if (options.input !== undefined) {
167
+ // For macOS `security ... -w`, the expect helper reads the single
168
+ // logical credential input and answers each prompt on its PTY.
169
+ // The input is still only written to the child stream, never argv/env.
170
+ child.stdin.write(options.input);
171
+ }
172
+ child.stdin.end();
173
+ }
174
+ catch {
175
+ failUnavailable("System credential store is unavailable");
176
+ }
53
177
  };
54
178
  child.stdout.on("data", (chunk) => {
55
179
  stdout += chunk.toString();
56
180
  if (stdout.length > 16384)
57
- child.kill();
181
+ terminateChild();
58
182
  });
59
183
  child.stderr.on("data", (chunk) => {
60
184
  stderr += chunk.toString();
61
185
  if (stderr.length > 16384)
62
- child.kill();
63
- });
64
- child.once("error", () => {
65
- if (timer)
66
- clearTimeout(timer);
67
- reject(new CredentialStoreError("CREDENTIAL_STORE_UNAVAILABLE", "System credential store is unavailable"));
186
+ terminateChild();
68
187
  });
188
+ // Keep listeners attached after settlement so a late EPIPE from a child
189
+ // that exited during the handshake cannot become an uncaught error.
190
+ child.stdin.on("error", () => failUnavailable("System credential store is unavailable"));
191
+ child.on("error", () => failUnavailable("System credential store is unavailable"));
69
192
  child.once("close", (code) => finish({ code: code ?? 1, stdout, stderr }));
70
193
  if (options.timeoutMs) {
71
194
  timer = setTimeout(() => {
72
- child.kill();
73
- reject(new CredentialStoreError("CREDENTIAL_STORE_UNAVAILABLE", "System credential store timed out"));
195
+ failUnavailable("System credential store timed out");
74
196
  }, options.timeoutMs);
75
197
  }
76
- if (options.input !== undefined)
77
- child.stdin.write(options.input);
78
- child.stdin.end();
198
+ sendInput();
79
199
  });
80
200
  function missingResult(result) {
81
201
  return result.code !== 0 && !result.stdout.trim() && !result.stderr.trim();
@@ -158,6 +278,9 @@ export class SecureCredentialStore {
158
278
  try {
159
279
  const command = this.command();
160
280
  const result = await this.runner(command.executable, command.writeArgs, {
281
+ // The process runner passes this single logical credential to the
282
+ // fixed macOS Expect helper, which answers both security prompts in
283
+ // the child PTY without placing the secret in argv or the environment.
161
284
  input: `${credential}\n`,
162
285
  timeoutMs: 5000,
163
286
  });