@patimweb/pi-zep 1.0.0 → 1.0.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/index.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * PHP endpoint instead of the paid ZEP REST API.
6
6
  *
7
7
  * Tools:
8
+ * - zep_doctor: diagnose config, session and reachability without spending a login attempt
8
9
  * - zep_setup: store userid/password/Mandant
9
10
  * - zep_login: log in (password, or adopt a browser tab session)
10
11
  * - zep_status: show config, log in, prove the session works
@@ -26,6 +27,7 @@
26
27
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
27
28
 
28
29
  import { loadConfig } from "./src/config.ts";
30
+ import { ZepDoctorTool } from "./src/tools/zep-doctor.ts";
29
31
  import { ZepSetupTool } from "./src/tools/zep-setup.ts";
30
32
  import { ZepLoginTool } from "./src/tools/zep-login.ts";
31
33
  import { ZepStatusTool } from "./src/tools/zep-status.ts";
@@ -38,6 +40,7 @@ import { ZepDeleteTool } from "./src/tools/zep-delete.ts";
38
40
  export default function (pi: ExtensionAPI) {
39
41
  loadConfig();
40
42
 
43
+ pi.registerTool(ZepDoctorTool);
41
44
  pi.registerTool(ZepSetupTool);
42
45
  pi.registerTool(ZepLoginTool);
43
46
  pi.registerTool(ZepStatusTool);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patimweb/pi-zep",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "ZEP time-tracking for pi coding agent — book project times (Projektzeiten) on zep-online.de over plain HTTP, without the paid ZEP API",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -51,6 +51,14 @@ export class ZepError extends Error {
51
51
  }
52
52
  }
53
53
 
54
+ export interface ZepPing {
55
+ readonly status: number;
56
+ /** The login form rendered, i.e. the server is up and reachable. */
57
+ readonly loginPageOk: boolean;
58
+ /** login.php bounced us into the app, i.e. we are already authenticated. */
59
+ readonly redirectedToApp: boolean;
60
+ }
61
+
54
62
  export interface ZepClientOptions {
55
63
  /** Called whenever a fresh session was established, so it can be cached. */
56
64
  readonly onSession?: (session: ZepSession) => void;
@@ -117,6 +125,17 @@ export class ZepClient {
117
125
  * where `noch` is the remaining attempt count before a lockout.
118
126
  */
119
127
  async login(): Promise<LoginResult> {
128
+ // Refuse before touching the network. Without this guard an absent
129
+ // password becomes the literal string "undefined" in the form body, which
130
+ // ZEP counts as a real failed attempt - and the account is locked after 5.
131
+ if (!this.account.password) {
132
+ throw new ZepError(
133
+ `No password stored for ZEP user "${this.account.userid}". Add a "password" to the ` +
134
+ "profile in ~/.pi/zep-config.json (or run zep_setup). Refusing to send an empty " +
135
+ "password, because ZEP locks the account after 5 failed attempts.",
136
+ );
137
+ }
138
+
120
139
  this.cookies.clear();
121
140
  this.clientsessid = null;
122
141
  this.requesttoken = null;
@@ -131,9 +150,16 @@ export class ZepClient {
131
150
  login: "Anmelden",
132
151
  }).toString();
133
152
 
153
+ // Match the browser form POST exactly: same-origin form submissions carry
154
+ // Referer and Origin, and ZEP's login handler is the pickiest endpoint.
155
+ const loginUrl = `${this.root}/view/login.php`;
134
156
  const res = await this.raw("/view/login.php", {
135
157
  method: "POST",
136
- headers: { "content-type": "application/x-www-form-urlencoded" },
158
+ headers: {
159
+ "content-type": "application/x-www-form-urlencoded",
160
+ referer: loginUrl,
161
+ origin: this.root,
162
+ },
137
163
  body,
138
164
  });
139
165
 
@@ -190,6 +216,22 @@ export class ZepClient {
190
216
  return this.lastLogin;
191
217
  }
192
218
 
219
+ /**
220
+ * Reachability probe used by zep_doctor. Read-only: it fetches the login
221
+ * page and reports what came back. Never call it on a client that holds a
222
+ * live session - priming a new PHPSESSID would replace the working cookie.
223
+ */
224
+ async ping(): Promise<ZepPing> {
225
+ const res = await this.raw("/view/login.php", { method: "GET" });
226
+ const body = await res.text();
227
+ const location = res.headers.get("location") ?? "";
228
+ return {
229
+ status: res.status,
230
+ loginPageOk: /id="login-form"/.test(body),
231
+ redirectedToApp: /index\.php/.test(location) && !/action=login/.test(location),
232
+ };
233
+ }
234
+
193
235
  /** Log in only when there is no usable session yet. */
194
236
  async ensureLogin(): Promise<void> {
195
237
  if (this.hasSession) return;
@@ -229,9 +271,9 @@ export class ZepClient {
229
271
  const headers = new Headers(rest.headers ?? {});
230
272
  headers.set("user-agent", USER_AGENT);
231
273
  headers.set("accept-language", "de-DE,de;q=0.9,en;q=0.8");
232
- if (rest.method && rest.method !== "GET" && !headers.has("x-requested-with")) {
233
- headers.set("x-requested-with", "XMLHttpRequest");
234
- }
274
+ // Deliberately no automatic X-Requested-With here: the browser only sends
275
+ // it on ajax.php calls, never on the login form POST. Sending it on the
276
+ // login made the request diverge from the real form.
235
277
  if (this.cookies.size > 0) {
236
278
  headers.set(
237
279
  "cookie",
package/src/config.ts CHANGED
@@ -10,14 +10,20 @@
10
10
  * on every call.
11
11
  */
12
12
 
13
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
14
14
  import { homedir } from "node:os";
15
15
  import { dirname, join } from "node:path";
16
16
 
17
17
  import type { ZepAccount, ZepConfigFile, ZepSession } from "./types.ts";
18
18
 
19
- const CONFIG_PATH = join(homedir(), ".pi", "zep-config.json");
20
- const SESSION_PATH = join(homedir(), ".pi", "zep-session.json");
19
+ /** Resolved on every call, so honouring $HOME works and tests can relocate it. */
20
+ function configFile(): string {
21
+ return join(homedir(), ".pi", "zep-config.json");
22
+ }
23
+
24
+ function sessionFile(): string {
25
+ return join(homedir(), ".pi", "zep-session.json");
26
+ }
21
27
 
22
28
  export const DEFAULT_BASE_URL = "https://zep-online.de";
23
29
  export const DEFAULT_MANDANT = "zepneoimpulse";
@@ -28,6 +34,19 @@ let config: { profiles: Record<string, ZepAccount>; activeProfile: string | null
28
34
  };
29
35
  let sessions: Record<string, ZepSession> = {};
30
36
 
37
+ // mtimes of the last read, so refreshConfig() can tell whether the files
38
+ // changed on disk. -1 means "never read".
39
+ let configMtime = -1;
40
+ let sessionMtime = -1;
41
+
42
+ function mtimeOf(path: string): number {
43
+ try {
44
+ return statSync(path).mtimeMs;
45
+ } catch {
46
+ return -1;
47
+ }
48
+ }
49
+
31
50
  function writePrivate(path: string, data: unknown): void {
32
51
  const dir = dirname(path);
33
52
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -49,12 +68,31 @@ function readJson<T>(path: string, fallback: T): T {
49
68
  }
50
69
 
51
70
  export function loadConfig(): void {
52
- const loaded = readJson<ZepConfigFile>(CONFIG_PATH, { profiles: {}, activeProfile: null });
71
+ const loaded = readJson<ZepConfigFile>(configFile(), { profiles: {}, activeProfile: null });
53
72
  config = {
54
73
  profiles: loaded?.profiles ?? {},
55
74
  activeProfile: loaded?.activeProfile ?? null,
56
75
  };
57
- sessions = readJson<Record<string, ZepSession>>(SESSION_PATH, {});
76
+ sessions = readJson<Record<string, ZepSession>>(sessionFile(), {});
77
+ configMtime = mtimeOf(configFile());
78
+ sessionMtime = mtimeOf(sessionFile());
79
+ }
80
+
81
+ /**
82
+ * Re-read the files when they changed on disk.
83
+ *
84
+ * pi loads an extension once per session, so without this a config file that
85
+ * the user wrote by hand (the documented alternative to zep_setup) is only
86
+ * picked up after a restart - the tools would keep reporting
87
+ * "No ZEP profile configured" while a perfectly valid file sits on disk.
88
+ *
89
+ * Cheap: two stat() calls in the common case where nothing changed.
90
+ */
91
+ export function refreshConfig(): void {
92
+ const nextConfig = mtimeOf(configFile());
93
+ const nextSession = mtimeOf(sessionFile());
94
+ if (nextConfig === configMtime && nextSession === sessionMtime) return;
95
+ loadConfig();
58
96
  }
59
97
 
60
98
  export function getConfig(): ZepConfigFile {
@@ -102,14 +140,14 @@ export function getProfile(name?: string): ZepAccount {
102
140
  export function saveProfile(name: string, account: ZepAccount): void {
103
141
  config.profiles[name] = account;
104
142
  if (!config.activeProfile) config.activeProfile = name;
105
- writePrivate(CONFIG_PATH, config);
143
+ writePrivate(configFile(), config);
106
144
  }
107
145
 
108
146
  export function updateProfile(name: string, patch: Partial<ZepAccount>): void {
109
147
  const existing = config.profiles[name];
110
148
  if (!existing) return;
111
149
  config.profiles[name] = { ...existing, ...patch };
112
- writePrivate(CONFIG_PATH, config);
150
+ writePrivate(configFile(), config);
113
151
  }
114
152
 
115
153
  export function useProfile(name: string): void {
@@ -117,7 +155,7 @@ export function useProfile(name: string): void {
117
155
  throw new Error(`ZEP profile "${name}" not found.`);
118
156
  }
119
157
  config.activeProfile = name;
120
- writePrivate(CONFIG_PATH, config);
158
+ writePrivate(configFile(), config);
121
159
  }
122
160
 
123
161
  export function deleteProfile(name: string): void {
@@ -126,16 +164,16 @@ export function deleteProfile(name: string): void {
126
164
  if (config.activeProfile === name) {
127
165
  config.activeProfile = listProfiles()[0] ?? null;
128
166
  }
129
- writePrivate(CONFIG_PATH, config);
130
- writePrivate(SESSION_PATH, sessions);
167
+ writePrivate(configFile(), config);
168
+ writePrivate(sessionFile(), sessions);
131
169
  }
132
170
 
133
171
  export function configPath(): string {
134
- return CONFIG_PATH;
172
+ return configFile();
135
173
  }
136
174
 
137
175
  export function sessionPath(): string {
138
- return SESSION_PATH;
176
+ return sessionFile();
139
177
  }
140
178
 
141
179
  // ------------------------------------------------------------------- sessions
@@ -146,10 +184,10 @@ export function getSession(profileName: string): ZepSession | undefined {
146
184
 
147
185
  export function saveSession(profileName: string, session: ZepSession): void {
148
186
  sessions[profileName] = session;
149
- writePrivate(SESSION_PATH, sessions);
187
+ writePrivate(sessionFile(), sessions);
150
188
  }
151
189
 
152
190
  export function clearSession(profileName: string): void {
153
191
  delete sessions[profileName];
154
- writePrivate(SESSION_PATH, sessions);
192
+ writePrivate(sessionFile(), sessions);
155
193
  }
package/src/session.ts CHANGED
@@ -6,7 +6,14 @@
6
6
  * after 5 failed password attempts.
7
7
  */
8
8
 
9
- import { clearSession, getActiveProfileName, getProfile, getSession, saveSession } from "./config.ts";
9
+ import {
10
+ clearSession,
11
+ getActiveProfileName,
12
+ getProfile,
13
+ getSession,
14
+ refreshConfig,
15
+ saveSession,
16
+ } from "./config.ts";
10
17
  import { ZepClient, ZepError } from "./clients/zep-client.ts";
11
18
 
12
19
  export interface ClientHandle {
@@ -17,6 +24,7 @@ export interface ClientHandle {
17
24
  }
18
25
 
19
26
  export async function createClient(profileName?: string): Promise<ClientHandle> {
27
+ refreshConfig();
20
28
  const name = profileName ?? getActiveProfileName();
21
29
  if (!name) {
22
30
  throw new ZepError("No ZEP profile configured. Call zep_setup first.");
@@ -62,6 +70,7 @@ export async function createClient(profileName?: string): Promise<ClientHandle>
62
70
  * Used by zep_login force=true.
63
71
  */
64
72
  export async function loginProfile(profileName: string): Promise<ZepClient> {
73
+ refreshConfig();
65
74
  const account = getProfile(profileName);
66
75
  clearSession(profileName);
67
76
 
@@ -0,0 +1,297 @@
1
+ /**
2
+ * zep_doctor -- diagnose the ZEP setup without spending a login attempt.
3
+ *
4
+ * Why this exists: every login attempt costs one of only five tries before
5
+ * ZEP locks the account, and a rejected login says only "Benutzername oder
6
+ * Kennwort falsch" - it cannot distinguish a wrong userid from a wrong
7
+ * password, nor from a config file that carries a trailing space. This tool
8
+ * checks everything that can be checked for free, and performs the real
9
+ * credential check only when explicitly asked for.
10
+ */
11
+
12
+ import { existsSync, statSync } from "node:fs";
13
+ import { Type } from "typebox";
14
+
15
+ import {
16
+ configPath,
17
+ getActiveProfileName,
18
+ getProfile,
19
+ getSession,
20
+ listProfiles,
21
+ refreshConfig,
22
+ sessionPath,
23
+ } from "../config.ts";
24
+ import { ZepClient } from "../clients/zep-client.ts";
25
+ import { errorResult, textResult, type ToolResult } from "./shared.ts";
26
+
27
+ interface Check {
28
+ readonly name: string;
29
+ readonly status: "ok" | "warn" | "fail" | "skip";
30
+ readonly detail: string;
31
+ /** What to do about it, when it is not "ok". */
32
+ readonly hint?: string;
33
+ }
34
+
35
+ const ICON = { ok: "OK ", warn: "WARN", fail: "FAIL", skip: "SKIP" } as const;
36
+
37
+ function render(checks: ReadonlyArray<Check>): string {
38
+ return checks
39
+ .map((c) => {
40
+ const head = `${ICON[c.status]} ${c.name}: ${c.detail}`;
41
+ return c.hint ? `${head}\n -> ${c.hint}` : head;
42
+ })
43
+ .join("\n");
44
+ }
45
+
46
+ export const ZepDoctorTool = {
47
+ name: "zep_doctor",
48
+ label: "ZEP Doctor",
49
+ description:
50
+ "Check the ZEP configuration and connectivity before booking: config file, permissions, profile fields, password hygiene (whitespace is the usual cause of 'Benutzername oder Kennwort falsch'), cached session, and reachability of the server. Uses no login attempt unless checkCredentials is set, and reports how many password tries are left.",
51
+ parameters: Type.Object({
52
+ profile: Type.Optional(
53
+ Type.String({ description: "ZEP profile to check. Defaults to the active profile." }),
54
+ ),
55
+ checkCredentials: Type.Optional(
56
+ Type.Boolean({
57
+ description:
58
+ "Also perform ONE real login attempt to verify userid and password. This consumes one of the 5 tries before ZEP locks the account, so it is off by default.",
59
+ }),
60
+ ),
61
+ }),
62
+
63
+ async execute(
64
+ _toolCallId: string,
65
+ params: { profile?: string; checkCredentials?: boolean },
66
+ ): Promise<ToolResult> {
67
+ // Re-read from disk: the doctor is exactly the tool you run right after
68
+ // editing the config by hand.
69
+ refreshConfig();
70
+ const checks: Check[] = [];
71
+
72
+ // ------------------------------------------------------------ config file
73
+ const path = configPath();
74
+ if (!existsSync(path)) {
75
+ checks.push({
76
+ name: "config file",
77
+ status: "fail",
78
+ detail: `${path} does not exist`,
79
+ hint: "Run zep_setup, or write the file by hand (profiles.<name>.userid/password, activeProfile).",
80
+ });
81
+ return errorResult(render(checks), { ok: false });
82
+ }
83
+
84
+ const mode = statSync(path).mode & 0o777;
85
+ checks.push({
86
+ name: "config file",
87
+ status: mode === 0o600 ? "ok" : "warn",
88
+ detail: `${path} (mode ${mode.toString(8)})`,
89
+ ...(mode === 0o600
90
+ ? {}
91
+ : { hint: `chmod 600 ${path} - the file holds a password in clear text.` }),
92
+ });
93
+
94
+ const profiles = listProfiles();
95
+ const name = params.profile ?? getActiveProfileName();
96
+ if (!name) {
97
+ checks.push({
98
+ name: "active profile",
99
+ status: "fail",
100
+ detail: `none set (known: ${profiles.join(", ") || "none"})`,
101
+ hint: 'Set "activeProfile" in the config file, or run zep_profile action=use.',
102
+ });
103
+ return errorResult(render(checks), { ok: false });
104
+ }
105
+
106
+ let account;
107
+ try {
108
+ account = getProfile(name);
109
+ } catch (error) {
110
+ checks.push({
111
+ name: `profile "${name}"`,
112
+ status: "fail",
113
+ detail: error instanceof Error ? error.message : String(error),
114
+ hint: `Known profiles: ${profiles.join(", ") || "none"}`,
115
+ });
116
+ return errorResult(render(checks), { ok: false });
117
+ }
118
+ checks.push({ name: "profile", status: "ok", detail: `"${name}" (active)` });
119
+
120
+ // --------------------------------------------------------------- fields
121
+ const missing = (["baseUrl", "mandant", "userid", "password"] as const).filter(
122
+ (field) => !account[field],
123
+ );
124
+ checks.push(
125
+ missing.length === 0
126
+ ? { name: "profile fields", status: "ok", detail: "baseUrl, mandant, userid, password" }
127
+ : {
128
+ name: "profile fields",
129
+ status: "fail",
130
+ detail: `missing or empty: ${missing.join(", ")}`,
131
+ hint: 'Add the field to the profile in the config file, or re-run zep_setup.',
132
+ },
133
+ );
134
+
135
+ checks.push({
136
+ name: "target",
137
+ status: "ok",
138
+ detail: `${account.baseUrl}/${account.mandant} as ${account.userid}`,
139
+ });
140
+
141
+ // ------------------------------------------------- password hygiene
142
+ const raw = account.password ?? "";
143
+ const hygiene = passwordShapeIssues(raw);
144
+
145
+ checks.push(
146
+ hygiene.length === 0
147
+ ? { name: "password shape", status: "ok", detail: `${raw.length} characters, no whitespace` }
148
+ : {
149
+ name: "password shape",
150
+ status: "fail",
151
+ detail: hygiene.join("; "),
152
+ hint: "Fix the stored password. ZEP reports all of these as 'Benutzername oder Kennwort falsch'.",
153
+ },
154
+ );
155
+
156
+ // --------------------------------------------------------- session cache
157
+ const cached = getSession(name);
158
+ let liveSession: ZepClient | null = null;
159
+
160
+ if (!cached) {
161
+ checks.push({
162
+ name: "cached session",
163
+ status: "skip",
164
+ detail: `${sessionPath()} has no entry for "${name}"`,
165
+ hint: "A login is needed once; after that the session is reused.",
166
+ });
167
+ } else {
168
+ const client = new ZepClient(account, {});
169
+ client.applySession(cached);
170
+ const alive = await client.probe();
171
+ checks.push({
172
+ name: "cached session",
173
+ status: alive ? "ok" : "warn",
174
+ detail: `CLIENTSESSID ${cached.clientsessid} saved ${cached.savedAt} - ${
175
+ alive ? "still accepted" : "no longer accepted"
176
+ }`,
177
+ ...(alive ? {} : { hint: "It expired; the next call performs a fresh login." }),
178
+ });
179
+ if (alive) liveSession = client;
180
+ }
181
+
182
+ // ------------------------------------------------------------ reachability
183
+ // A dedicated client: ping() primes a fresh PHPSESSID, which must not
184
+ // overwrite the one belonging to a live session.
185
+ try {
186
+ const ping = await new ZepClient(account, {}).ping();
187
+ checks.push({
188
+ name: "reachability",
189
+ status: ping.loginPageOk || ping.redirectedToApp ? "ok" : "warn",
190
+ detail: `GET /view/login.php -> HTTP ${ping.status}, login form ${
191
+ ping.loginPageOk ? "present" : "absent"
192
+ }${ping.redirectedToApp ? ", redirects into the app (already authenticated)" : ""}`,
193
+ ...(ping.loginPageOk || ping.redirectedToApp
194
+ ? {}
195
+ : { hint: "Unexpected response; a proxy or maintenance page may be in the way." }),
196
+ });
197
+ } catch (error) {
198
+ checks.push({
199
+ name: "reachability",
200
+ status: "fail",
201
+ detail: error instanceof Error ? error.message : String(error),
202
+ hint: "Check network/proxy. Override baseUrl per profile if a mirror is used.",
203
+ });
204
+ }
205
+
206
+ // --------------------------------------------------------- live session
207
+ if (liveSession) {
208
+ try {
209
+ const { form } = await liveSession.loadForm();
210
+ checks.push({
211
+ name: "booking form",
212
+ status: "ok",
213
+ detail: `${form.projects.length} projects, ${form.vorgaenge.length} Vorgänge for the selected project`,
214
+ });
215
+ } catch (error) {
216
+ checks.push({
217
+ name: "booking form",
218
+ status: "fail",
219
+ detail: error instanceof Error ? error.message : String(error),
220
+ });
221
+ }
222
+ }
223
+
224
+ // ------------------------------------------------------ credential check
225
+ if (params.checkCredentials) {
226
+ const client = new ZepClient(account, {});
227
+ try {
228
+ const result = await client.login();
229
+ checks.push({
230
+ name: "credential check",
231
+ status: result.ok ? "ok" : "fail",
232
+ detail: result.ok
233
+ ? `login accepted (CLIENTSESSID ${result.clientsessid})`
234
+ : `${result.message ?? "rejected"}${
235
+ result.attemptsLeft !== undefined ? ` - ${result.attemptsLeft} tries left` : ""
236
+ }`,
237
+ ...(result.ok
238
+ ? {}
239
+ : {
240
+ hint:
241
+ "ZEP cannot tell 'wrong userid' from 'wrong password'. Verify both; do NOT retry blindly - the account locks after 5.",
242
+ }),
243
+ });
244
+ } catch (error) {
245
+ checks.push({
246
+ name: "credential check",
247
+ status: "fail",
248
+ detail: error instanceof Error ? error.message : String(error),
249
+ });
250
+ }
251
+ } else {
252
+ checks.push({
253
+ name: "credential check",
254
+ status: "skip",
255
+ detail: "not attempted (costs one of 5 tries before lockout)",
256
+ hint: "Re-run with checkCredentials=true only when the stored userid/password are confirmed.",
257
+ });
258
+ }
259
+
260
+ const text = render(checks);
261
+ const failed = checks.some((c) => c.status === "fail");
262
+ const warnOrFail = failed || checks.some((c) => c.status === "warn");
263
+
264
+ const verdict = failed
265
+ ? "\n\nVerdict: something must be fixed before booking will work."
266
+ : warnOrFail
267
+ ? "\n\nVerdict: usable, with warnings above."
268
+ : "\n\nVerdict: everything checks out.";
269
+
270
+ return warnOrFail
271
+ ? errorResult(text + verdict, { ok: !failed, checks })
272
+ : textResult(text + verdict, { ok: true, checks });
273
+ },
274
+ };
275
+
276
+ /**
277
+ * Problems with a stored password that ZEP would all report as
278
+ * "Benutzername oder Kennwort falsch" - i.e. silently. Pure, so it is testable
279
+ * without a config file.
280
+ */
281
+ export function passwordShapeIssues(raw: string): string[] {
282
+ const issues: string[] = [];
283
+ if (raw !== raw.trim()) issues.push("leading/trailing whitespace (copy-paste!)");
284
+ if (/\s/.test(raw.trim())) issues.push("contains an inner whitespace character");
285
+ if (raw === "undefined" || raw === "null") issues.push(`literally the string "${raw}"`);
286
+ if (raw.length < 6) issues.push(`suspiciously short (${raw.length} chars)`);
287
+ // `!` starts history expansion in an interactive bash. A password written
288
+ // through a shell command can silently lose everything from the `!` on -
289
+ // and ZEP then only says "wrong password".
290
+ if (raw.includes("!")) {
291
+ issues.push(
292
+ `contains "!" (${raw.length} chars) - in an interactive shell "!" starts history expansion, ` +
293
+ "so a password written via a command line may be truncated at that position",
294
+ );
295
+ }
296
+ return issues;
297
+ }
@@ -10,6 +10,7 @@ import {
10
10
  getActiveProfileName,
11
11
  listProfiles,
12
12
  useProfile,
13
+ refreshConfig,
13
14
  } from "../config.ts";
14
15
  import { errorResult, textResult, type ToolResult } from "./shared.ts";
15
16
 
@@ -31,6 +32,7 @@ export const ZepProfileTool = {
31
32
  _toolCallId: string,
32
33
  params: { action?: string; name?: string },
33
34
  ): Promise<ToolResult> {
35
+ refreshConfig();
34
36
  const action = params.action ?? "list";
35
37
  try {
36
38
  if (action === "list") {
@@ -4,7 +4,13 @@
4
4
 
5
5
  import { Type } from "typebox";
6
6
 
7
- import { configPath, getActiveProfileName, listProfiles, sessionPath } from "../config.ts";
7
+ import {
8
+ configPath,
9
+ getActiveProfileName,
10
+ listProfiles,
11
+ refreshConfig,
12
+ sessionPath,
13
+ } from "../config.ts";
8
14
  import { ZepClient } from "../clients/zep-client.ts";
9
15
  import { createClient, ZepError } from "../session.ts";
10
16
  import { formatFormData } from "../formatting/formatters.ts";
@@ -35,6 +41,7 @@ export const ZepStatusTool = {
35
41
  _toolCallId: string,
36
42
  params: { profile?: string; probe?: boolean; relogin?: boolean },
37
43
  ): Promise<ToolResult> {
44
+ refreshConfig();
38
45
  const lines: string[] = [];
39
46
  const profiles = listProfiles();
40
47