@haven_ai/connect 0.0.0-dev.202609040858.f4467bb → 0.0.0-dev.202609041540.2214a02
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/README.md +80 -20
- package/dist/cli.cjs +267 -40
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +268 -41
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +267 -40
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +88 -4
- package/dist/index.d.ts +88 -4
- package/dist/index.js +267 -40
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -92,7 +92,8 @@ already-running host, which is why the restart guidance below matters.
|
|
|
92
92
|
|
|
93
93
|
## Retiring an old agent directory
|
|
94
94
|
|
|
95
|
-
Re-running setup creates a NEW agent and retires nothing
|
|
95
|
+
Re-running setup without `--replace` creates a NEW agent and retires nothing
|
|
96
|
+
(with `--replace`, see [Running setup again](#running-setup-again)). Long-lived MCP hosts
|
|
96
97
|
(gateways, TUI workers, editors, desktop apps) load their MCP wiring once, at
|
|
97
98
|
process start — a host started before your latest setup keeps spawning the OLD
|
|
98
99
|
agent's signer path forever, and when that directory is later removed the spawn
|
|
@@ -173,7 +174,9 @@ dashboard whenever ready and verify later with the read-only `haven_get_agent`
|
|
|
173
174
|
tool. The object includes runtime/topology status,
|
|
174
175
|
probe result, activation and next-action guidance, approval state/expiry (null
|
|
175
176
|
when the backend does not provide an approval expiry), the two
|
|
176
|
-
read-only verification tools, `hosted_mcp_url`,
|
|
177
|
+
read-only verification tools, `hosted_mcp_url`, `superseded_agent_ids`, and —
|
|
178
|
+
on a run that replaced existing wiring — `superseded_agents_retired_locally`
|
|
179
|
+
with `retired_agent_ids`. It
|
|
177
180
|
contains no API key, private key, credential
|
|
178
181
|
contents, full credential paths, or full delegate address. The same redacted
|
|
179
182
|
object is available to library callers as `runConnect(...).outcome`; the older
|
|
@@ -186,11 +189,26 @@ mismatch. It is non-secret: the same string goes into your own MCP config file,
|
|
|
186
189
|
and the API key travels beside it in a header.
|
|
187
190
|
|
|
188
191
|
`superseded_agent_ids` lists the other agent directories on this machine. A
|
|
189
|
-
re-run mints a NEW agent and retires nothing, so those
|
|
190
|
-
live API and signing keys — revoke them on the Haven
|
|
191
|
-
replace them. Empty on a clean first run; an empty
|
|
192
|
-
since a scan that cannot read the credential root also
|
|
193
|
-
failing a completed setup.
|
|
192
|
+
re-run mints a NEW agent, and without `--replace` retires nothing, so those
|
|
193
|
+
older agents still hold live API and signing keys — revoke them on the Haven
|
|
194
|
+
agent page if you meant to replace them. Empty on a clean first run; an empty
|
|
195
|
+
list is not a guarantee, since a scan that cannot read the credential root also
|
|
196
|
+
yields one rather than failing a completed setup. On a `--replace` run, `retired_agent_ids` names the
|
|
197
|
+
directories that were actually tombstoned and had their local key files removed
|
|
198
|
+
— **the collision set only**, never the whole `superseded_agent_ids` list, which
|
|
199
|
+
also names named agents that coexist with the replaced bare pair and are
|
|
200
|
+
untouched — and `superseded_agents_retired_locally` is `true` when that
|
|
201
|
+
retirement covered every collision entry (`false`: the install ended with an
|
|
202
|
+
error code and the retirement was skipped, or one entry failed). Both fields
|
|
203
|
+
are absent on any other run and say nothing about the backend, where nothing is
|
|
204
|
+
revoked.
|
|
205
|
+
|
|
206
|
+
A `wiring_collision` refusal (a non-interactive bare setup over a live
|
|
207
|
+
previous agent, #2551) carries `error.superseded_agent_ids` and
|
|
208
|
+
`error.suggested_name` — the ids the human needs in order to decide, and a
|
|
209
|
+
valid `--name` the run can be re-issued with — with `next_action:
|
|
210
|
+
relay_wiring_collision_to_user`. It is a relay, not a retry hint: do not append
|
|
211
|
+
`--replace` or `--name` on the agent's own initiative.
|
|
194
212
|
|
|
195
213
|
For a recoverable install, configuration, probe, consent, or manual-runtime
|
|
196
214
|
condition, inspect `error.code` and `error.next_action`, then follow the safe
|
|
@@ -247,6 +265,17 @@ adding a security boundary. (The local-signer tool-exposure
|
|
|
247
265
|
acknowledgement is a separate, machine-checkable consent about what the local
|
|
248
266
|
MCP tools expose, not a registration gate.)
|
|
249
267
|
|
|
268
|
+
The one question Connect does put before registration is not about the
|
|
269
|
+
agent being created but about the one already here: a **wiring collision**
|
|
270
|
+
([#2551](https://github.com/d-hinders/Haven-AI/issues/2551)). When the bare
|
|
271
|
+
`haven` / `haven-signer` pair on this machine already belongs to a different
|
|
272
|
+
agent whose directory still holds a live key, proceeding would silently
|
|
273
|
+
re-point that pair — the state `--doctor` reports as a still-spend-capable
|
|
274
|
+
`superseded` directory. That choice (replace it, or install alongside under a
|
|
275
|
+
name) belongs to the user, and it is asked *before* the key is minted or the
|
|
276
|
+
agent registered so that declining leaves no orphaned `pending_approval` agent
|
|
277
|
+
behind. See [Running setup again](#running-setup-again).
|
|
278
|
+
|
|
250
279
|
## Running setup again
|
|
251
280
|
|
|
252
281
|
Each setup prompt is one-time and each successful run creates a **new** agent
|
|
@@ -264,17 +293,44 @@ already-configured machine behaves as follows (characterized in
|
|
|
264
293
|
credentials into its own directory under `~/.haven/agents/<agent-id>/`,
|
|
265
294
|
alongside the previous agent's directory, which stays byte-identical.
|
|
266
295
|
Nothing is rotated, revoked, or deleted locally.
|
|
267
|
-
- **
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
296
|
+
- **A bare re-run over a live previous agent is a decision, not a default
|
|
297
|
+
([#2551](https://github.com/d-hinders/Haven-AI/issues/2551)).** Before
|
|
298
|
+
minting a key or registering, Connect scans the credential root for a
|
|
299
|
+
bare-pair directory that still holds a usable key — the same reading
|
|
300
|
+
`--doctor` classifies as `wired` or `superseded`; `retired`, `orphaned`,
|
|
301
|
+
`parked` and **named** directories never count. If it finds one:
|
|
302
|
+
- an **interactive terminal** is asked to choose — **replace** (below) or
|
|
303
|
+
**install alongside** under a name Connect proposes from the agent's
|
|
304
|
+
display name (e.g. `payment-agent`), collision-checked against the
|
|
305
|
+
directories already there;
|
|
306
|
+
- a **non-interactive run** (`--json`, CI, an agent tool call, a pipe)
|
|
307
|
+
refuses with `wiring_collision` — nothing written, token still unused —
|
|
308
|
+
naming the superseded agent ids and the two flags that resolve it. The
|
|
309
|
+
refusal is written as a **relay instruction**: an agent running the
|
|
310
|
+
dashboard's command may append only `--json` (and `--runtime` after a
|
|
311
|
+
runtime refusal), so it must hand the choice to its user rather than add
|
|
312
|
+
a flag itself, and re-run only with the flag the user picks.
|
|
313
|
+
- **`--replace`** is the unattended answer "yes, replace". `--name <slug>`
|
|
314
|
+
installs alongside. Passing both is a usage error — they contradict.
|
|
315
|
+
- **Replacing re-points the bare pair, then retires the previous directory
|
|
316
|
+
locally.** Connect owns the `haven` and `haven-signer` entries (and the
|
|
317
|
+
managed Codex/Hermes equivalents) and re-points them at the new agent's
|
|
318
|
+
credentials; unrelated MCP servers and configuration are preserved. Once
|
|
319
|
+
the runtime install has actually completed, each superseded directory is
|
|
320
|
+
tombstoned and its local key files removed — the same teardown `--unwire`
|
|
321
|
+
performs — so `--doctor` reads it as `retired` rather than still
|
|
322
|
+
spend-capable. If the install ends with an error code the retirement is
|
|
323
|
+
**skipped**, because the old wiring may still be the only working one; the
|
|
324
|
+
outcome's `superseded_agents_retired_locally` says which happened and
|
|
325
|
+
`retired_agent_ids` names exactly the directories it reached. With
|
|
272
326
|
`--name`, each agent owns its own suffixed pair and they coexist; see
|
|
273
327
|
[Running several agents in one runtime](#running-several-agents-in-one-runtime).
|
|
274
|
-
- **The previous agent is not revoked by a re-run
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
328
|
+
- **The previous agent is not revoked by a re-run — with or without
|
|
329
|
+
`--replace`.** Local retirement is local: its authority remains whatever
|
|
330
|
+
its on-chain rules and the Haven agent page say. Revoke agents you no
|
|
331
|
+
longer use from the Haven dashboard. Connect never calls revoke — that
|
|
332
|
+
route is owner-authenticated, and an agent credential revoking a sibling
|
|
333
|
+
agent would be an agent editing its own authority.
|
|
278
334
|
- **A re-run never overwrites an existing credential file.** A write that would
|
|
279
335
|
collide with an existing `identity.json`/`signer.json`/`agent.json` is
|
|
280
336
|
refused outright (and a partially failed write rolls itself back), so a
|
|
@@ -401,10 +457,14 @@ Add `--json` for a machine-readable report. No secret material is ever
|
|
|
401
457
|
printed.
|
|
402
458
|
|
|
403
459
|
`--doctor` also probes every OTHER agent credential directory it did not
|
|
404
|
-
select (#1688). A re-run of setup mints a NEW agent and
|
|
405
|
-
a directory from a previous setup
|
|
406
|
-
authenticates — meaning any host that started
|
|
407
|
-
spending as the agent you believe you replaced.
|
|
460
|
+
select (#1688). A re-run of setup mints a NEW agent and, unless it ran with
|
|
461
|
+
`--replace` (#2551), retires nothing, so a directory from a previous setup
|
|
462
|
+
can hold an API key that still authenticates — meaning any host that started
|
|
463
|
+
before the re-run keeps spending as the agent you believe you replaced. Since
|
|
464
|
+
#2551 a bare setup no longer reaches that state silently: it asks at a
|
|
465
|
+
terminal and refuses everywhere else, so a `superseded` directory now means
|
|
466
|
+
someone chose it — a `--replace` whose install failed, an older connector, or
|
|
467
|
+
a directory this scan could not classify. A superseded directory
|
|
408
468
|
whose key is still live is a FAILING check naming the agent id, with the
|
|
409
469
|
repair spelled out: revoke it on the Haven agent page, then remove the
|
|
410
470
|
directory. An already-revoked one reports as informational; an unreachable
|
package/dist/cli.cjs
CHANGED
|
@@ -535,9 +535,9 @@ var init_runtime_manifest = __esm({
|
|
|
535
535
|
mcpPackage: "@haven_ai/mcp",
|
|
536
536
|
mcpVersion: mcp.MCP_VERSION,
|
|
537
537
|
sdkPackage: "@haven_ai/sdk",
|
|
538
|
-
sdkVersion: "0.0.0-dev.
|
|
538
|
+
sdkVersion: "0.0.0-dev.202609041540.2214a02",
|
|
539
539
|
signerPackage: "@haven_ai/signer",
|
|
540
|
-
signerVersion: "0.0.0-dev.
|
|
540
|
+
signerVersion: "0.0.0-dev.202609041540.2214a02",
|
|
541
541
|
// Sourced from the SDK, never a literal (#1161). This field read '20.0.0'
|
|
542
542
|
// while every package's `engines` said `>=24` and the docs said `>=24.0.0`,
|
|
543
543
|
// so the guard that was supposed to enforce the floor waved Node v23 through
|
|
@@ -2913,14 +2913,6 @@ var init_runtime_install = __esm({
|
|
|
2913
2913
|
}
|
|
2914
2914
|
});
|
|
2915
2915
|
|
|
2916
|
-
// src/rekey-messages.ts
|
|
2917
|
-
var REKEY_FINISH_NEEDS_API_KEY;
|
|
2918
|
-
var init_rekey_messages = __esm({
|
|
2919
|
-
"src/rekey-messages.ts"() {
|
|
2920
|
-
REKEY_FINISH_NEEDS_API_KEY = "--rekey-finish needs --api-key <key> \u2014 the one the Haven agent page showed once.";
|
|
2921
|
-
}
|
|
2922
|
-
});
|
|
2923
|
-
|
|
2924
2916
|
// src/tombstone.ts
|
|
2925
2917
|
var tombstone_exports = {};
|
|
2926
2918
|
__export(tombstone_exports, {
|
|
@@ -3038,6 +3030,9 @@ var init_tombstone = __esm({
|
|
|
3038
3030
|
// src/unwire.ts
|
|
3039
3031
|
var unwire_exports = {};
|
|
3040
3032
|
__export(unwire_exports, {
|
|
3033
|
+
readIdentityFile: () => readIdentityFile,
|
|
3034
|
+
teardownLocalKeyMaterial: () => teardownLocalKeyMaterial,
|
|
3035
|
+
tombstoneDirectoryIfAbsent: () => tombstoneDirectoryIfAbsent,
|
|
3041
3036
|
unwireAgent: () => unwireAgent
|
|
3042
3037
|
});
|
|
3043
3038
|
function identityAt(directory) {
|
|
@@ -3077,18 +3072,13 @@ async function unwireAgent(input) {
|
|
|
3077
3072
|
const slug = input.slug ?? sidecar?.server_name;
|
|
3078
3073
|
const names = serverNamesFor(slug);
|
|
3079
3074
|
const runtimes = [];
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
replacedBy: input.replacedBy,
|
|
3088
|
-
tombstonesDir: input.tombstonesDir
|
|
3089
|
-
});
|
|
3090
|
-
tombstoned = true;
|
|
3091
|
-
}
|
|
3075
|
+
const tombstoned = await tombstoneDirectoryIfAbsent({
|
|
3076
|
+
directory: input.directory,
|
|
3077
|
+
agentId,
|
|
3078
|
+
reason: input.reason ?? "unwired via --unwire",
|
|
3079
|
+
replacedBy: input.replacedBy,
|
|
3080
|
+
tombstonesDir: input.tombstonesDir
|
|
3081
|
+
});
|
|
3092
3082
|
for (const model of RUNTIMES) {
|
|
3093
3083
|
const path = runtimeConfigPathFor(model.runtime, homeDir);
|
|
3094
3084
|
if (path === null) continue;
|
|
@@ -3173,16 +3163,27 @@ async function unwireAgent(input) {
|
|
|
3173
3163
|
}
|
|
3174
3164
|
}
|
|
3175
3165
|
}
|
|
3166
|
+
await teardownLocalKeyMaterial(input.directory, identity);
|
|
3167
|
+
return { directory: input.directory, agentId, slug, tombstoned, runtimes };
|
|
3168
|
+
}
|
|
3169
|
+
async function tombstoneDirectoryIfAbsent(input) {
|
|
3170
|
+
if (await readOptionalText(path.join(input.directory, TOMBSTONE_FILENAME)) !== null) return false;
|
|
3171
|
+
await writeAgentTombstone(input);
|
|
3172
|
+
return true;
|
|
3173
|
+
}
|
|
3174
|
+
async function teardownLocalKeyMaterial(directory, identity) {
|
|
3176
3175
|
await Promise.all([
|
|
3177
|
-
promises.rm(path.join(
|
|
3178
|
-
promises.rm(path.join(
|
|
3176
|
+
promises.rm(path.join(directory, "signer.json"), { force: true }),
|
|
3177
|
+
promises.rm(path.join(directory, REKEY_PENDING_FILENAME), { force: true })
|
|
3179
3178
|
]);
|
|
3180
3179
|
if (identity && identity.api_key !== void 0) {
|
|
3181
3180
|
const { api_key: _dropped, ...rest } = identity;
|
|
3182
|
-
await promises.writeFile(path.join(
|
|
3181
|
+
await promises.writeFile(path.join(directory, "identity.json"), `${JSON.stringify(rest, null, 2)}
|
|
3183
3182
|
`, { mode: 384 });
|
|
3184
3183
|
}
|
|
3185
|
-
|
|
3184
|
+
}
|
|
3185
|
+
async function readIdentityFile(directory) {
|
|
3186
|
+
return identityAt(directory);
|
|
3186
3187
|
}
|
|
3187
3188
|
var RUNTIMES;
|
|
3188
3189
|
var init_unwire = __esm({
|
|
@@ -3203,6 +3204,14 @@ var init_unwire = __esm({
|
|
|
3203
3204
|
}
|
|
3204
3205
|
});
|
|
3205
3206
|
|
|
3207
|
+
// src/rekey-messages.ts
|
|
3208
|
+
var REKEY_FINISH_NEEDS_API_KEY;
|
|
3209
|
+
var init_rekey_messages = __esm({
|
|
3210
|
+
"src/rekey-messages.ts"() {
|
|
3211
|
+
REKEY_FINISH_NEEDS_API_KEY = "--rekey-finish needs --api-key <key> \u2014 the one the Haven agent page showed once.";
|
|
3212
|
+
}
|
|
3213
|
+
});
|
|
3214
|
+
|
|
3206
3215
|
// src/rekey.ts
|
|
3207
3216
|
var rekey_exports = {};
|
|
3208
3217
|
__export(rekey_exports, {
|
|
@@ -4267,10 +4276,119 @@ function defaultPromptIo() {
|
|
|
4267
4276
|
};
|
|
4268
4277
|
}
|
|
4269
4278
|
|
|
4279
|
+
// src/wiring-collision.ts
|
|
4280
|
+
init_server_names();
|
|
4281
|
+
init_signer_runtime();
|
|
4282
|
+
init_storage();
|
|
4283
|
+
init_tombstone();
|
|
4284
|
+
async function detectWiringCollision(input) {
|
|
4285
|
+
const root = defaultCredentialRoot(input.credentialsDir);
|
|
4286
|
+
let entries = [];
|
|
4287
|
+
try {
|
|
4288
|
+
entries = await promises.readdir(root);
|
|
4289
|
+
} catch {
|
|
4290
|
+
return null;
|
|
4291
|
+
}
|
|
4292
|
+
const superseded = [];
|
|
4293
|
+
const taken = /* @__PURE__ */ new Set();
|
|
4294
|
+
for (const entry of entries) {
|
|
4295
|
+
const directory = path.join(root, entry);
|
|
4296
|
+
let identityRaw;
|
|
4297
|
+
try {
|
|
4298
|
+
identityRaw = await promises.readFile(path.join(directory, "identity.json"), "utf8");
|
|
4299
|
+
} catch {
|
|
4300
|
+
continue;
|
|
4301
|
+
}
|
|
4302
|
+
taken.add(entry);
|
|
4303
|
+
let identity;
|
|
4304
|
+
try {
|
|
4305
|
+
identity = JSON.parse(identityRaw);
|
|
4306
|
+
} catch {
|
|
4307
|
+
identity = void 0;
|
|
4308
|
+
}
|
|
4309
|
+
if (!identity?.api_key) continue;
|
|
4310
|
+
if (await pathExists2(path.join(directory, TOMBSTONE_FILENAME))) continue;
|
|
4311
|
+
const sidecar = await readRuntimeSidecar(directory);
|
|
4312
|
+
if (sidecar?.server_name) continue;
|
|
4313
|
+
superseded.push({ directory, agentId: identity.agent_id ?? entry });
|
|
4314
|
+
}
|
|
4315
|
+
if (superseded.length === 0) return null;
|
|
4316
|
+
return {
|
|
4317
|
+
superseded,
|
|
4318
|
+
suggestedServerName: proposeServerSlug(input.agentName, taken)
|
|
4319
|
+
};
|
|
4320
|
+
}
|
|
4321
|
+
function proposeServerSlug(agentName, taken) {
|
|
4322
|
+
let base = agentName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-").slice(0, 32).replace(/-+$/g, "");
|
|
4323
|
+
if (!slugIsValid(base)) base = "agent";
|
|
4324
|
+
if (!taken.has(base)) return base;
|
|
4325
|
+
for (let n = 2; n < 1e3; n += 1) {
|
|
4326
|
+
const suffix = `-${n}`;
|
|
4327
|
+
const candidate = `${base.slice(0, 32 - suffix.length).replace(/-+$/g, "")}${suffix}`;
|
|
4328
|
+
if (slugIsValid(candidate) && !taken.has(candidate)) return candidate;
|
|
4329
|
+
}
|
|
4330
|
+
throw new Error(`Could not propose an unused server name for ${JSON.stringify(agentName)}.`);
|
|
4331
|
+
}
|
|
4332
|
+
function slugIsValid(slug) {
|
|
4333
|
+
try {
|
|
4334
|
+
assertValidServerSlug(slug);
|
|
4335
|
+
return true;
|
|
4336
|
+
} catch {
|
|
4337
|
+
return false;
|
|
4338
|
+
}
|
|
4339
|
+
}
|
|
4340
|
+
var MAX_PROMPT_ATTEMPTS2 = 3;
|
|
4341
|
+
async function promptWiringCollisionResolution(collision, agentName, io) {
|
|
4342
|
+
const ids = collision.superseded.map((entry) => entry.agentId).join(", ");
|
|
4343
|
+
io.write(`This machine is already wired to a Haven agent with a live key: ${ids}.
|
|
4344
|
+
`);
|
|
4345
|
+
io.write(`Setting up "${agentName}" on the bare haven / haven-signer pair would replace that wiring.
|
|
4346
|
+
`);
|
|
4347
|
+
io.write(" r) replace \u2014 re-point haven / haven-signer at the new agent and retire the previous directory locally\n");
|
|
4348
|
+
io.write(" (tombstoned, local key files removed; you still revoke it on the Haven agent page)\n");
|
|
4349
|
+
io.write(` a) alongside \u2014 install as a named agent (suggested: ${collision.suggestedServerName}) with its own
|
|
4350
|
+
`);
|
|
4351
|
+
io.write(` haven-<name> / haven-signer-<name> pair, leaving the current wiring untouched
|
|
4352
|
+
`);
|
|
4353
|
+
io.write(" q) quit \u2014 nothing is written and the setup token stays unused\n");
|
|
4354
|
+
for (let attempt = 0; attempt < MAX_PROMPT_ATTEMPTS2; attempt += 1) {
|
|
4355
|
+
const answer = await io.question("Replace, install alongside, or quit? [r/a/q]: ");
|
|
4356
|
+
if (answer === null) return { action: "abort" };
|
|
4357
|
+
const trimmed = answer.trim().toLowerCase();
|
|
4358
|
+
if (trimmed === "r" || trimmed === "replace") return { action: "replace" };
|
|
4359
|
+
if (trimmed === "q" || trimmed === "quit") return { action: "abort" };
|
|
4360
|
+
if (trimmed === "a" || trimmed === "alongside") {
|
|
4361
|
+
const typed = await io.question(`Server name [${collision.suggestedServerName}]: `);
|
|
4362
|
+
if (typed === null) return { action: "abort" };
|
|
4363
|
+
const serverName = typed.trim() === "" ? collision.suggestedServerName : typed.trim();
|
|
4364
|
+
try {
|
|
4365
|
+
assertValidServerSlug(serverName);
|
|
4366
|
+
} catch (err) {
|
|
4367
|
+
io.write(`${err instanceof Error ? err.message : String(err)}
|
|
4368
|
+
`);
|
|
4369
|
+
continue;
|
|
4370
|
+
}
|
|
4371
|
+
return { action: "alongside", serverName };
|
|
4372
|
+
}
|
|
4373
|
+
io.write(`"${trimmed}" is not one of r, a, q.
|
|
4374
|
+
`);
|
|
4375
|
+
}
|
|
4376
|
+
return { action: "abort" };
|
|
4377
|
+
}
|
|
4378
|
+
async function pathExists2(path) {
|
|
4379
|
+
try {
|
|
4380
|
+
await promises.stat(path);
|
|
4381
|
+
return true;
|
|
4382
|
+
} catch {
|
|
4383
|
+
return false;
|
|
4384
|
+
}
|
|
4385
|
+
}
|
|
4386
|
+
|
|
4270
4387
|
// src/runtime.ts
|
|
4388
|
+
init_unwire();
|
|
4271
4389
|
init_local_mcp_runtime();
|
|
4272
4390
|
init_runtime_manifest();
|
|
4273
|
-
var CONNECTOR_VERSION = "0.0.0-dev.
|
|
4391
|
+
var CONNECTOR_VERSION = "0.0.0-dev.202609041540.2214a02";
|
|
4274
4392
|
var CONNECT_OUTCOME_SCHEMA_VERSION = 1;
|
|
4275
4393
|
var failureOutcomesByError = /* @__PURE__ */ new WeakMap();
|
|
4276
4394
|
function failureOutcomeFor(runtimeHint, error) {
|
|
@@ -4375,6 +4493,28 @@ async function executeConnect(options, deps, trace) {
|
|
|
4375
4493
|
await assertServerSlugAvailable(options.serverName, options.credentialsDir);
|
|
4376
4494
|
}
|
|
4377
4495
|
log("Checked local credential storage \u2014 all clear.");
|
|
4496
|
+
let serverName = options.serverName;
|
|
4497
|
+
let replacing;
|
|
4498
|
+
if (!serverName) {
|
|
4499
|
+
const collision = await detectWiringCollision({
|
|
4500
|
+
credentialsDir: options.credentialsDir,
|
|
4501
|
+
agentName: setup.agent.name
|
|
4502
|
+
});
|
|
4503
|
+
if (collision) {
|
|
4504
|
+
const resolution = await resolveWiringCollision(collision, setup.agent.name, options, deps);
|
|
4505
|
+
if (resolution.action === "replace") {
|
|
4506
|
+
replacing = collision;
|
|
4507
|
+
log(
|
|
4508
|
+
`Replacing the existing haven / haven-signer wiring (previous agent(s): ${supersededIds(collision)}). The previous directory is retired locally once the new wiring is written; nothing is revoked.`
|
|
4509
|
+
);
|
|
4510
|
+
} else {
|
|
4511
|
+
serverName = resolution.serverName;
|
|
4512
|
+
assertValidServerSlug(serverName);
|
|
4513
|
+
await assertServerSlugAvailable(serverName, options.credentialsDir);
|
|
4514
|
+
log(`Installing alongside the existing wiring as a named agent: ${serverNamesFor(serverName).hosted} / ${serverNamesFor(serverName).signer}.`);
|
|
4515
|
+
}
|
|
4516
|
+
}
|
|
4517
|
+
}
|
|
4378
4518
|
const localKey = generateKey();
|
|
4379
4519
|
const localApiKey = generateLocalApiKey();
|
|
4380
4520
|
log("Minting a fresh signing key and API key \u2014 both stay on this machine.");
|
|
@@ -4395,7 +4535,7 @@ async function executeConnect(options, deps, trace) {
|
|
|
4395
4535
|
// the dashboard can name it. Derived here rather than sent as the raw
|
|
4396
4536
|
// slug — `serverNamesFor` is the one place the naming rule lives, and
|
|
4397
4537
|
// the hosted name is what a user pastes into an MCP config.
|
|
4398
|
-
mcpServerName: serverNamesFor(
|
|
4538
|
+
mcpServerName: serverNamesFor(serverName).hosted,
|
|
4399
4539
|
connectorContext: {
|
|
4400
4540
|
environment_label: options.environmentLabel ?? "Local workspace",
|
|
4401
4541
|
config_target: installCapabilities.canWriteRuntimeConfig ? "agent runtime MCP config" : "local credential files"
|
|
@@ -4417,7 +4557,7 @@ async function executeConnect(options, deps, trace) {
|
|
|
4417
4557
|
const credentialPaths = await writeCredentials({
|
|
4418
4558
|
baseDir: options.credentialsDir,
|
|
4419
4559
|
agentId: registration.agent_id,
|
|
4420
|
-
serverName
|
|
4560
|
+
serverName,
|
|
4421
4561
|
apiKey: localApiKey,
|
|
4422
4562
|
delegateKey: localKey.privateKey,
|
|
4423
4563
|
delegateAddress: localKey.address,
|
|
@@ -4456,7 +4596,7 @@ async function executeConnect(options, deps, trace) {
|
|
|
4456
4596
|
ackSigner: options.ackSigner,
|
|
4457
4597
|
ackLocalTools: options.ackLocalTools,
|
|
4458
4598
|
localMcp: options.localMcp,
|
|
4459
|
-
serverName
|
|
4599
|
+
serverName
|
|
4460
4600
|
}, {
|
|
4461
4601
|
onProgress: log,
|
|
4462
4602
|
// #1543: report "runtime configured" the moment the config write settles,
|
|
@@ -4495,10 +4635,43 @@ async function executeConnect(options, deps, trace) {
|
|
|
4495
4635
|
} else {
|
|
4496
4636
|
log("Haven setup on this machine is complete.");
|
|
4497
4637
|
}
|
|
4638
|
+
let supersededAgentsRetiredLocally;
|
|
4639
|
+
const retiredAgentIds = [];
|
|
4640
|
+
if (replacing) {
|
|
4641
|
+
if (runtimeInstall.errorCode) {
|
|
4642
|
+
supersededAgentsRetiredLocally = false;
|
|
4643
|
+
log(
|
|
4644
|
+
`Previous agent(s) ${supersededIds(replacing)} were NOT retired: the runtime install did not complete, so their wiring may still be the only working one. Resolve the install, then retire them with ${RERUN_HINT} --unwire <dir>.`
|
|
4645
|
+
);
|
|
4646
|
+
} else {
|
|
4647
|
+
supersededAgentsRetiredLocally = true;
|
|
4648
|
+
for (const entry of replacing.superseded) {
|
|
4649
|
+
try {
|
|
4650
|
+
await tombstoneDirectoryIfAbsent({
|
|
4651
|
+
directory: entry.directory,
|
|
4652
|
+
agentId: entry.agentId,
|
|
4653
|
+
reason: "replaced by a new setup (--replace)",
|
|
4654
|
+
replacedBy: registration.agent_id
|
|
4655
|
+
});
|
|
4656
|
+
await teardownLocalKeyMaterial(entry.directory, await readIdentityFile(entry.directory));
|
|
4657
|
+
retiredAgentIds.push(entry.agentId);
|
|
4658
|
+
log(`Retired previous agent ${entry.agentId} locally: tombstoned, local key files removed.`);
|
|
4659
|
+
} catch (err) {
|
|
4660
|
+
supersededAgentsRetiredLocally = false;
|
|
4661
|
+
log(`Could not retire previous agent ${entry.agentId} locally: ${err instanceof Error ? err.message : String(err)}`);
|
|
4662
|
+
}
|
|
4663
|
+
}
|
|
4664
|
+
}
|
|
4665
|
+
}
|
|
4498
4666
|
let supersededAgentIds = [];
|
|
4499
4667
|
try {
|
|
4500
4668
|
supersededAgentIds = await listOtherAgentIds(options.credentialsDir, credentialPaths.directory);
|
|
4501
|
-
if (
|
|
4669
|
+
if (supersededAgentsRetiredLocally === true) {
|
|
4670
|
+
log("");
|
|
4671
|
+
log(
|
|
4672
|
+
`Replaced: previous agent(s) ${supersededIds(replacing)} are retired on this machine but NOT revoked \u2014 revoke them on the Haven agent page, then restart EVERY long-lived host (gateways, TUI workers, editors): each holds the MCP wiring snapshot from its own start time, and the tombstone speaks only when a stale host next probes the old path.`
|
|
4673
|
+
);
|
|
4674
|
+
} else if (supersededAgentIds.length > 0) {
|
|
4502
4675
|
log("");
|
|
4503
4676
|
log(
|
|
4504
4677
|
`Heads-up: this setup created a NEW agent. Your previous agent(s) \u2014 ${supersededAgentIds.join(", ")} \u2014 still exist with their own keys, and any host that was already running keeps acting as them.`
|
|
@@ -4542,6 +4715,8 @@ async function executeConnect(options, deps, trace) {
|
|
|
4542
4715
|
delegateAddress: registration.delegate_address,
|
|
4543
4716
|
hostedMcpUrl: registration.hosted_mcp_url,
|
|
4544
4717
|
supersededAgentIds,
|
|
4718
|
+
supersededAgentsRetiredLocally,
|
|
4719
|
+
...replacing ? { retiredAgentIds } : {},
|
|
4545
4720
|
setupChallengeExpiresAt: setup.challenge.expires_at,
|
|
4546
4721
|
approvalRequired: registration.agent_status === "pending_approval"
|
|
4547
4722
|
});
|
|
@@ -4594,6 +4769,8 @@ function completionOutcome(input) {
|
|
|
4594
4769
|
// agents" is a fact a caller needs, and an omitted key would be
|
|
4595
4770
|
// indistinguishable from an older connector that never reported it.
|
|
4596
4771
|
superseded_agent_ids: input.supersededAgentIds ?? [],
|
|
4772
|
+
...input.supersededAgentsRetiredLocally !== void 0 ? { superseded_agents_retired_locally: input.supersededAgentsRetiredLocally } : {},
|
|
4773
|
+
...input.retiredAgentIds ? { retired_agent_ids: input.retiredAgentIds } : {},
|
|
4597
4774
|
...input.setupChallengeExpiresAt ? { setup_challenge_expires_at: input.setupChallengeExpiresAt } : {},
|
|
4598
4775
|
...runtimeInstall.errorCode ? { error: { code: runtimeInstall.errorCode, next_action: nextAction2 } } : {}
|
|
4599
4776
|
};
|
|
@@ -4615,10 +4792,38 @@ function installedClientProse(hint) {
|
|
|
4615
4792
|
return `Haven can see these agent clients installed here, likeliest first: ${found.join(", ")}.${suggestion} `;
|
|
4616
4793
|
}
|
|
4617
4794
|
function runtimeSelectionPrompt(options, deps) {
|
|
4618
|
-
if (options
|
|
4619
|
-
if (!(deps.isTty ?? Boolean(process.stdin.isTTY))) return void 0;
|
|
4795
|
+
if (!interactivePromptAllowed(options, deps)) return void 0;
|
|
4620
4796
|
return deps.promptRuntime ?? (() => resolveRuntimeByInstalledClientPrompt());
|
|
4621
4797
|
}
|
|
4798
|
+
function interactivePromptAllowed(options, deps) {
|
|
4799
|
+
if (options.interactive !== true) return false;
|
|
4800
|
+
return deps.isTty ?? Boolean(process.stdin.isTTY);
|
|
4801
|
+
}
|
|
4802
|
+
function supersededIds(collision) {
|
|
4803
|
+
return collision.superseded.map((entry) => entry.agentId).join(", ");
|
|
4804
|
+
}
|
|
4805
|
+
async function resolveWiringCollision(collision, agentName, options, deps) {
|
|
4806
|
+
if (options.replaceExistingWiring) return { action: "replace" };
|
|
4807
|
+
if (interactivePromptAllowed(options, deps)) {
|
|
4808
|
+
const prompt = deps.promptWiringCollision ?? ((c, name) => promptWiringCollisionResolution(c, name, defaultPromptIo()));
|
|
4809
|
+
const resolution = await prompt(collision, agentName);
|
|
4810
|
+
if (resolution.action === "abort") {
|
|
4811
|
+
throw new ConnectError(
|
|
4812
|
+
"wiring_collision_declined",
|
|
4813
|
+
`Setup stopped at your request: this machine is already wired to ${supersededIds(collision)} and you chose neither to replace that wiring nor to install alongside it. Nothing was written: no agent was created, no credentials were stored, and the Haven setup token is still unused. Run the setup command again with --replace to re-point haven / haven-signer at the new agent, or with --name <slug> (e.g. --name ${collision.suggestedServerName}) to install alongside.`,
|
|
4814
|
+
"rerun_connect_with_replace_or_name",
|
|
4815
|
+
{ supersededAgentIds: collision.superseded.map((e) => e.agentId), suggestedServerName: collision.suggestedServerName }
|
|
4816
|
+
);
|
|
4817
|
+
}
|
|
4818
|
+
return resolution;
|
|
4819
|
+
}
|
|
4820
|
+
throw new ConnectError(
|
|
4821
|
+
"wiring_collision",
|
|
4822
|
+
`This machine is already wired to a Haven agent with a live key (${supersededIds(collision)}), and setting up "${agentName}" on the bare haven / haven-signer pair would replace that wiring. Nothing was written and the Haven setup token is still unused. If you are an AI agent running this command: do NOT add a flag yourself \u2014 relay this to your user and stop. Your user decides: REPLACE the existing wiring (re-run the same command with --replace added, which re-points the pair at the new agent and retires the previous directory locally \u2014 they still revoke the old agent on the Haven agent page), or install ALONGSIDE it (re-run with --name <slug> added, e.g. --name ${collision.suggestedServerName}, which gives the new agent its own haven-<slug> / haven-signer-<slug> pair). Re-run only with the flag your user chooses.`,
|
|
4823
|
+
"relay_wiring_collision_to_user",
|
|
4824
|
+
{ supersededAgentIds: collision.superseded.map((e) => e.agentId), suggestedServerName: collision.suggestedServerName }
|
|
4825
|
+
);
|
|
4826
|
+
}
|
|
4622
4827
|
function failedConnectOutcome(runtimeHint, error) {
|
|
4623
4828
|
const message = error instanceof Error ? error.message : "";
|
|
4624
4829
|
const code = error instanceof ConnectError ? error.code : /Node\.js >=/i.test(message) ? "unsupported_node_version" : /setup challenge.*expired|expired or invalid/i.test(message) ? "setup_challenge_expired_or_invalid" : /only available for Claude Code and Codex/i.test(message) ? "local_mcp_unsupported_runtime" : "connect_failed";
|
|
@@ -4649,7 +4854,9 @@ function failedConnectOutcome(runtimeHint, error) {
|
|
|
4649
4854
|
...error instanceof ConnectError && message ? { message: redactForAutomation(message) } : {},
|
|
4650
4855
|
...error instanceof ConnectError && error.details.allowedRuntimes ? { allowed_runtimes: error.details.allowedRuntimes } : {},
|
|
4651
4856
|
...error instanceof ConnectError && error.details.installedClients?.length ? { installed_clients: error.details.installedClients } : {},
|
|
4652
|
-
...error instanceof ConnectError && error.details.suggestedRuntime ? { suggested_runtime: error.details.suggestedRuntime } : {}
|
|
4857
|
+
...error instanceof ConnectError && error.details.suggestedRuntime ? { suggested_runtime: error.details.suggestedRuntime } : {},
|
|
4858
|
+
...error instanceof ConnectError && error.details.supersededAgentIds ? { superseded_agent_ids: error.details.supersededAgentIds } : {},
|
|
4859
|
+
...error instanceof ConnectError && error.details.suggestedServerName ? { suggested_name: error.details.suggestedServerName } : {}
|
|
4653
4860
|
}
|
|
4654
4861
|
};
|
|
4655
4862
|
}
|
|
@@ -4886,6 +5093,7 @@ function parseArgs(argv, env = process.env) {
|
|
|
4886
5093
|
let tombstoneReplacedBy;
|
|
4887
5094
|
let unwire;
|
|
4888
5095
|
let unwireDir;
|
|
5096
|
+
let replace = false;
|
|
4889
5097
|
for (let i = 0; i < argv.length; i += 1) {
|
|
4890
5098
|
const arg = argv[i];
|
|
4891
5099
|
if (arg === "--help" || arg === "-h") {
|
|
@@ -4925,6 +5133,8 @@ function parseArgs(argv, env = process.env) {
|
|
|
4925
5133
|
options.runtimeForce = requireValue(argv, ++i, arg);
|
|
4926
5134
|
} else if (arg === "--credentials-dir") {
|
|
4927
5135
|
options.credentialsDir = requireValue(argv, ++i, arg);
|
|
5136
|
+
} else if (arg === "--replace") {
|
|
5137
|
+
replace = true;
|
|
4928
5138
|
} else if (arg === "--name") {
|
|
4929
5139
|
options.serverName = requireValue(argv, ++i, arg);
|
|
4930
5140
|
assertValidServerSlug(options.serverName);
|
|
@@ -4950,6 +5160,17 @@ function parseArgs(argv, env = process.env) {
|
|
|
4950
5160
|
if (help) {
|
|
4951
5161
|
return { options, help, json, doctor, repair, tombstone, rekey };
|
|
4952
5162
|
}
|
|
5163
|
+
if (replace) {
|
|
5164
|
+
if (rekeyPhase || tombstoneDir || unwire || doctor || repair) {
|
|
5165
|
+
throw new Error("--replace belongs to a --setup run: it says what to do when the bare haven / haven-signer pair is already wired to another agent.");
|
|
5166
|
+
}
|
|
5167
|
+
if (options.serverName) {
|
|
5168
|
+
throw new Error(
|
|
5169
|
+
"--replace and --name contradict each other: --replace overwrites the bare haven / haven-signer wiring, --name installs alongside it under its own pair. Pass one of them."
|
|
5170
|
+
);
|
|
5171
|
+
}
|
|
5172
|
+
options.replaceExistingWiring = true;
|
|
5173
|
+
}
|
|
4953
5174
|
if (rekey) {
|
|
4954
5175
|
if (options.setupToken) {
|
|
4955
5176
|
throw new Error("--rekey replaces an existing agent's key; it does not take --setup. Drop one of them.");
|
|
@@ -5028,6 +5249,12 @@ function helpText() {
|
|
|
5028
5249
|
" --runtime-force <name> Escape hatch: use exactly this runtime, ignoring environment detection.",
|
|
5029
5250
|
" --credentials-dir <path> Credential directory fallback. Defaults to ~/.haven/agents.",
|
|
5030
5251
|
" --environment-label <text> Non-sensitive label shown in Haven setup review.",
|
|
5252
|
+
" --replace When this machine is already wired to a different Haven agent on the bare",
|
|
5253
|
+
" haven / haven-signer pair, re-point that pair at the new agent and retire the",
|
|
5254
|
+
" previous agent directory locally (tombstoned, local key files removed).",
|
|
5255
|
+
" Nothing is revoked \u2014 revoke the old agent on the Haven agent page. Without",
|
|
5256
|
+
" this flag a non-interactive run REFUSES such a collision (wiring_collision)",
|
|
5257
|
+
" and an interactive terminal is asked; --name installs alongside instead.",
|
|
5031
5258
|
" --name <slug> Wiring slug for a NAMED agent: writes haven-<slug> / haven-signer-<slug>",
|
|
5032
5259
|
" MCP entries and stores credentials at ~/.haven/agents/<slug>/, so several",
|
|
5033
5260
|
" agents can run side by side in one runtime. 1-32 lowercase letters, digits,",
|
|
@@ -5131,13 +5358,13 @@ async function runCli(argv, io = {
|
|
|
5131
5358
|
}
|
|
5132
5359
|
if (parsed.tombstone) {
|
|
5133
5360
|
const { writeAgentTombstone: writeAgentTombstone2 } = await Promise.resolve().then(() => (init_tombstone(), tombstone_exports));
|
|
5134
|
-
const { readFile:
|
|
5135
|
-
const { join:
|
|
5361
|
+
const { readFile: readFile14 } = await import('fs/promises');
|
|
5362
|
+
const { join: join12 } = await import('path');
|
|
5136
5363
|
try {
|
|
5137
5364
|
let agentId = "unknown";
|
|
5138
5365
|
try {
|
|
5139
5366
|
const identity = JSON.parse(
|
|
5140
|
-
await
|
|
5367
|
+
await readFile14(join12(parsed.tombstone.directory, "identity.json"), "utf8")
|
|
5141
5368
|
);
|
|
5142
5369
|
agentId = identity.agent_id ?? "unknown";
|
|
5143
5370
|
} catch {
|
|
@@ -5176,10 +5403,10 @@ async function runCli(argv, io = {
|
|
|
5176
5403
|
if (parsed.unwire) {
|
|
5177
5404
|
const { unwireAgent: unwireAgent2 } = await Promise.resolve().then(() => (init_unwire(), unwire_exports));
|
|
5178
5405
|
const { homedir: homedir10 } = await import('os');
|
|
5179
|
-
const { join:
|
|
5406
|
+
const { join: join12 } = await import('path');
|
|
5180
5407
|
const homeDir = homedir10();
|
|
5181
|
-
const root = parsed.options.credentialsDir ??
|
|
5182
|
-
const directory = parsed.unwireDir ?? (parsed.options.serverName ?
|
|
5408
|
+
const root = parsed.options.credentialsDir ?? join12(homeDir, ".haven", "agents");
|
|
5409
|
+
const directory = parsed.unwireDir ?? (parsed.options.serverName ? join12(root, parsed.options.serverName) : root);
|
|
5183
5410
|
try {
|
|
5184
5411
|
const result = await unwireAgent2({
|
|
5185
5412
|
directory,
|