Skip to content

Commit 39de397

Browse files
authored
fix(coding-agents): keep a long-lived host's credential live, and say which one it used (#3600) (#3606)
* fix(coding-agents): keep a long-lived host's credential live, and say which one it used (#3600) `HindsightClient` copied `apiToken` at construction and never re-read it, so a host that outlives its credential — dsh, Cline, Kilo, Prime Agent, opencode, the MCP server — kept signing with a key the operator had already replaced. Enabling auth or rotating the key mid-session 401'd every call until the whole host restarted, while `hindsight_diagnose` re-read the file and reported the situation as healthy. The one-shot hook binaries were immune, which is why the same machine showed working hooks alongside dead in-session tools. The credential is now resolved through a provider on a 401 and the request replayed once, but only if the re-resolved token actually CHANGED — a genuinely wrong key still surfaces as one 401 rather than doubling every failing request. The happy path never touches the filesystem. All three fetch paths go through one signing helper. `reflect` and the drain poll fetched directly, so a recovery wired into `req()` alone would have left them failing forever. Both #3600 and the two drifts below come from the same shape: five hosts each carried their own copy of loadConfig -> deriveBankId -> applyBankConfig -> new HindsightClient. So the fix is one shared builder (core/host-client.ts) rather than a sixth line pasted into each. Hoisting it fixes two settings that had already gone missing that way: - dsh and Prime Agent never passed `maxParallelRetains`, so both silently ignored it and always used the default 10. - dsh never passed the directory to `applyBankConfig`, so `optInOnly` was not enforced there at all: an unapproved repo still got a bank. `hindsight_diagnose` now reports the credential IN USE next to the one on disk (booleans only, never the value), resolved through the same pipeline the host used — including a per-bank `banks.<id>.apiToken`, which a bare loadConfig() comparison would have reported as a permanent false mismatch. Without this the drift stays invisible to the one tool whose purpose is to explain it. A 401 also now says whether a credential was even sent. The server answers identically for "no key" and "wrong key"; only the client knows which it was. Behaviour change worth naming: `disabled: true` now wins uniformly. It already did for every host except the MCP server, which applied the `banks.<id>` section first and so could be re-enabled per bank; `optInOnly`/`optInPaths` is the supported way to run memory in only some projects. Resolution also stops before bank derivation when disabled, since that shells out to git and the disabled path exists to be a zero-overhead baseline. Reported with a verified local patch and a full root-cause analysis by @allenliang2022 in #3600; this implements that approach. * docs(coding-agents): say when a config change takes effect, and that the token is the exception Nothing in the README or the skill said when an edit to ~/.hindsight/coding-agent.json actually applies — and the answer differs per host: a hook harness re-reads the file on every invocation and picks a change up on the next prompt, a persistent plugin holds it for the life of the agent process, and the MCP server for the session. That gap got worse, not better, with the credential fix: the apiToken row now says it is picked up without a restart, which reads as "config is live" unless the rule it is an exception to is written down somewhere.
1 parent efef4fa commit 39de397

16 files changed

Lines changed: 554 additions & 104 deletions

File tree

hindsight-docs/docs-integrations/coding-agents.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,26 @@ The map-valued settings (`mapPathToBank`, `harnesses`, `banks`, `retainMetadata`
302302
per-key branching doesn't survive flattening into one variable. `maxParallelRetains` is available
303303
as `HINDSIGHT_MAX_PARALLEL_RETAINS` for containers and CI.
304304

305+
### When a change takes effect
306+
307+
Config is read when a process starts — the file is not watched — so when an edit applies depends on
308+
what reads it:
309+
310+
| host | reads the file | an edit applies |
311+
| ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | -------------------------- |
312+
| hook harnesses (Claude Code, Codex CLI, Cursor CLI, GitHub Copilot CLI, Grok Build, Antigravity CLI, Devin) | once per hook invocation — each hook is its own short-lived process | on your next prompt |
313+
| persistent plugins (opencode, Kilo CLI, Cline CLI, Prime Agent, DeepSeek Harness) | once per workspace, when the host loads the plugin | after restarting the agent |
314+
| the MCP server behind the `hindsight_*` tools | once at startup | in your next session |
315+
316+
`apiToken` is the exception. Every host re-reads it when the server rejects a request, so enabling
317+
authentication or rotating the key is picked up on the next call with nothing to restart —
318+
otherwise a rotation would leave a long-running agent failing every memory call until it was
319+
restarted. Everything else follows the table: `apiUrl`, `disabled`, bank routing, `gitIngest`, and
320+
the survey and knowledge-page settings.
321+
322+
`hindsight_diagnose` reports both sides of that gap — what the file says now, and what the running
323+
client is actually using.
324+
305325
### Opt-in only
306326

307327
By default every project gets memory — that is what makes the plugin zero-setup. If you would
@@ -349,7 +369,7 @@ hook by Codex...), so one shared config serves several agents side by side:
349369
| field | default | meaning |
350370
| ----------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
351371
| `apiUrl` | `https://api.hindsight.vectorize.io` | Hindsight API base URL (set to `http://localhost:8888` for a local server) |
352-
| `apiToken` || bearer token (Hindsight Cloud) |
372+
| `apiToken` || bearer token (Hindsight Cloud). Picked up without restarting the agent: a long-lived host re-reads it after a rejected request, so enabling auth or rotating the key mid-session recovers on the next call |
353373
| `bankId` || **explicit static bank**; unset ⇒ per-repo dynamic resolution (below) |
354374
| `dynamicBankId` | dynamic iff no `bankId` | force dynamic (`true`) or static (`false`) resolution |
355375
| `bankIdTemplate` | `"coding-agent::{gitProject}"` | dynamic bank id format; the default makes every agent share one bank per repo |

hindsight-integrations/coding-agents/README.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,26 @@ The map-valued settings (`mapPathToBank`, `harnesses`, `banks`, `retainMetadata`
295295
per-key branching doesn't survive flattening into one variable. `maxParallelRetains` is available
296296
as `HINDSIGHT_MAX_PARALLEL_RETAINS` for containers and CI.
297297

298+
### When a change takes effect
299+
300+
Config is read when a process starts — the file is not watched — so when an edit applies depends on
301+
what reads it:
302+
303+
| host | reads the file | an edit applies |
304+
| ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | -------------------------- |
305+
| hook harnesses (Claude Code, Codex CLI, Cursor CLI, GitHub Copilot CLI, Grok Build, Antigravity CLI, Devin) | once per hook invocation — each hook is its own short-lived process | on your next prompt |
306+
| persistent plugins (opencode, Kilo CLI, Cline CLI, Prime Agent, DeepSeek Harness) | once per workspace, when the host loads the plugin | after restarting the agent |
307+
| the MCP server behind the `hindsight_*` tools | once at startup | in your next session |
308+
309+
`apiToken` is the exception. Every host re-reads it when the server rejects a request, so enabling
310+
authentication or rotating the key is picked up on the next call with nothing to restart —
311+
otherwise a rotation would leave a long-running agent failing every memory call until it was
312+
restarted. Everything else follows the table: `apiUrl`, `disabled`, bank routing, `gitIngest`, and
313+
the survey and knowledge-page settings.
314+
315+
`hindsight_diagnose` reports both sides of that gap — what the file says now, and what the running
316+
client is actually using.
317+
298318
### Opt-in only
299319

300320
By default every project gets memory — that is what makes the plugin zero-setup. If you would
@@ -342,7 +362,7 @@ hook by Codex...), so one shared config serves several agents side by side:
342362
| field | default | meaning |
343363
| ----------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
344364
| `apiUrl` | `https://api.hindsight.vectorize.io` | Hindsight API base URL (set to `http://localhost:8888` for a local server) |
345-
| `apiToken` || bearer token (Hindsight Cloud) |
365+
| `apiToken` || bearer token (Hindsight Cloud). Picked up without restarting the agent: a long-lived host re-reads it after a rejected request, so enabling auth or rotating the key mid-session recovers on the next call |
346366
| `bankId` || **explicit static bank**; unset ⇒ per-repo dynamic resolution (below) |
347367
| `dynamicBankId` | dynamic iff no `bankId` | force dynamic (`true`) or static (`false`) resolution |
348368
| `bankIdTemplate` | `"coding-agent::{gitProject}"` | dynamic bank id format; the default makes every agent share one bank per repo |

hindsight-integrations/coding-agents/skill/SKILL.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@ created with), `autoSeed`/`seedLimit` (true/300),
8888
`codebaseSurvey`/`surveyModel`/`surveyBudgetUsd` (true/haiku/2), `surveyRefreshCommits` (0=off),
8989
`logLevel` ("info").
9090

91+
Config is read at process start, not watched: a hook harness picks an edit up on the next prompt, a
92+
persistent plugin (opencode, Kilo, Cline, Prime Agent, dsh) only after the agent restarts, and the
93+
MCP server in the next session. `apiToken` is the exception — re-read whenever the server rejects a
94+
request, so rotating it needs no restart. `hindsight_diagnose` reports the file's token and the
95+
running client's separately, which is how you tell a stale credential from a wrong one.
96+
9197
Blacklist a whole directory tree: map it to one bank and disable that bank —
9298
`"mapPathToBank": {"~/scratch": "scratch"}` + `"banks": {"scratch": {"disabled": true}}`.
9399

hindsight-integrations/coding-agents/src/cline.ts

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,7 @@
66
* `beforeModel` hook can, so it is the only path that can provide the same reflect/page injection
77
* guarantee as every other Hindsight harness. The memory behaviour itself stays in RuntimeCore.
88
*/
9-
import { applyBankConfig, loadConfig } from "./core/config";
10-
import { deriveBankId } from "./core/bank";
11-
import { HindsightClient } from "./core/hindsight";
9+
import { resolveHostMemory } from "./core/host-client";
1210
import { RuntimeCore } from "./core/runtime";
1311
import type { TransportTurn } from "./core/chat";
1412
import { diag } from "./core/diag";
@@ -187,23 +185,10 @@ export function createClineHooks(
187185
}
188186

189187
function createRuntime(workspaceRoot: string | undefined): RuntimeCore | undefined {
190-
let cfg = loadConfig({ harness: HARNESS });
191-
if (cfg.disabled) return undefined;
192-
const resolved = applyBankConfig(
193-
cfg,
194-
deriveBankId(cfg, workspaceRoot || process.cwd(), HARNESS),
195-
workspaceRoot || process.cwd()
196-
);
197-
cfg = resolved.cfg;
198-
if (cfg.disabled) return undefined;
199-
const client = new HindsightClient({
200-
apiUrl: cfg.apiUrl,
201-
apiToken: cfg.apiToken,
202-
bank: resolved.bankId,
203-
maxParallelRetains: cfg.maxParallelRetains,
204-
observationScopes: cfg.observationScopes,
205-
});
206-
return new RuntimeCore(client, resolved.bankId, cfg, HARNESS, workspaceRoot || process.cwd());
188+
const dir = workspaceRoot || process.cwd();
189+
const { cfg, bankId, client } = resolveHostMemory(HARNESS, dir);
190+
if (cfg.disabled) return undefined; // global switch, per-bank opt-out or optInOnly
191+
return new RuntimeCore(client, bankId, cfg, HARNESS, dir);
207192
}
208193

209194
const plugin: ClinePlugin = {

hindsight-integrations/coding-agents/src/core/daemon.test.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,16 @@ describe("every harness entrypoint reaches a daemon", () => {
129129
"mcp-server.ts": "child of a hook harness whose SessionStart ensured the daemon first",
130130
"core/hook.ts":
131131
"the prompt path, deliberately: a cold start outlives the hook budget and stalls the turn",
132+
"core/host-client.ts":
133+
"the shared builder itself, not an entrypoint — its CALLERS are the request origins this checks",
132134
};
133135

136+
/** A module is a request origin if it constructs a client directly OR asks the shared builder
137+
* (core/host-client.ts) for one — the long-lived hosts do the latter, and reading only for
138+
* `new HindsightClient(` would silently stop checking every one of them. */
139+
const buildsClient = (src: string): boolean =>
140+
src.includes("new HindsightClient(") || src.includes("resolveHostMemory(");
141+
134142
function sourceFiles(dir: string, prefix = ""): string[] {
135143
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
136144
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
@@ -144,7 +152,7 @@ describe("every harness entrypoint reaches a daemon", () => {
144152
const unreached = sourceFiles(SRC).filter((rel) => {
145153
if (rel in EXEMPT) return false;
146154
const src = readFileSync(join(SRC, rel), "utf8");
147-
if (!src.includes("new HindsightClient(")) return false;
155+
if (!buildsClient(src)) return false;
148156
// RuntimeCore is the persistent-plugin hosts' shared lifecycle; it ensures at both points.
149157
return !src.includes("ensureDaemon") && !src.includes("RuntimeCore");
150158
});
@@ -154,7 +162,7 @@ describe("every harness entrypoint reaches a daemon", () => {
154162
// An exemption for a file that no longer builds a client is a stale claim about live code.
155163
it("keeps no exemption for a module that stopped building a client", () => {
156164
const stale = Object.keys(EXEMPT).filter(
157-
(rel) => !readFileSync(join(SRC, rel), "utf8").includes("new HindsightClient(")
165+
(rel) => !buildsClient(readFileSync(join(SRC, rel), "utf8"))
158166
);
159167
expect(stale).toEqual([]);
160168
});

0 commit comments

Comments
 (0)