@mnemom/mnemom 0.17.32-next.4 → 0.17.33
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/dist/commands/agent-fuel.d.ts +9 -0
- package/dist/commands/agent-fuel.js +36 -2
- package/dist/commands/agent-run.d.ts +50 -0
- package/dist/commands/agent-run.js +221 -0
- package/dist/commands/agent-setup.d.ts +8 -3
- package/dist/commands/agent-setup.js +32 -9
- package/dist/commands/agent-share-fixture.d.ts +3 -1
- package/dist/commands/agent-share-fixture.js +11 -7
- package/dist/commands/agent-share-render.d.ts +23 -0
- package/dist/commands/agent-share-render.js +72 -0
- package/dist/commands/agent-share.d.ts +15 -2
- package/dist/commands/agent-share.js +30 -9
- package/dist/commands/agent.d.ts +19 -4
- package/dist/commands/agent.js +33 -13
- package/dist/index.js +20 -0
- package/dist/lib/api.js +17 -1
- package/dist/lib/auth.d.ts +6 -0
- package/dist/lib/auth.js +161 -14
- package/dist/lib/experimental.d.ts +1 -1
- package/dist/lib/experimental.js +13 -0
- package/dist/lib/oauth.d.ts +7 -0
- package/dist/lib/oauth.js +13 -0
- package/dist/lib/runner/bundle.d.ts +129 -0
- package/dist/lib/runner/bundle.js +319 -0
- package/dist/lib/runner/contract-header.d.ts +46 -0
- package/dist/lib/runner/contract-header.js +85 -0
- package/dist/lib/runner/executor-client.d.ts +71 -0
- package/dist/lib/runner/executor-client.js +97 -0
- package/dist/lib/runner/job.d.ts +70 -0
- package/dist/lib/runner/job.js +463 -0
- package/dist/lib/runner/loop.d.ts +45 -0
- package/dist/lib/runner/loop.js +104 -0
- package/dist/lib/runner/oidc.d.ts +38 -0
- package/dist/lib/runner/oidc.js +89 -0
- package/dist/lib/runner/options.d.ts +41 -0
- package/dist/lib/runner/options.js +47 -0
- package/dist/lib/runner/outcome.d.ts +47 -0
- package/dist/lib/runner/outcome.js +89 -0
- package/dist/lib/runner/phases.d.ts +49 -0
- package/dist/lib/runner/phases.js +99 -0
- package/dist/lib/runner/proxy.d.ts +45 -0
- package/dist/lib/runner/proxy.js +153 -0
- package/dist/lib/runner/settings.d.ts +31 -0
- package/dist/lib/runner/settings.js +44 -0
- package/dist/lib/statusline-feed.js +10 -11
- package/dist/lib/statusline.d.ts +6 -0
- package/dist/lib/statusline.js +19 -9
- package/dist/rc-proxy.mjs +69 -5
- package/package.json +2 -1
|
@@ -43,9 +43,15 @@ export interface RenderedLinePreview {
|
|
|
43
43
|
coverage?: string;
|
|
44
44
|
kind: string;
|
|
45
45
|
}
|
|
46
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* The aip `renderLines` contract, injected. Resolves to the lines the card may show; REJECTS
|
|
48
|
+
* (with the renderer's reasons) when the card is not verifiable or breaks claim-lint, in which
|
|
49
|
+
* case no link is printed. `period` asks for the period-card surface (reverts always shown).
|
|
50
|
+
*/
|
|
47
51
|
export interface LineRenderer {
|
|
48
|
-
renderLines(card: unknown
|
|
52
|
+
renderLines(card: unknown, opts?: {
|
|
53
|
+
period?: boolean;
|
|
54
|
+
}): RenderedLinePreview[] | Promise<RenderedLinePreview[]>;
|
|
49
55
|
}
|
|
50
56
|
export interface StatementSource {
|
|
51
57
|
listLines(): Promise<CandidateLine[]>;
|
|
@@ -56,6 +62,11 @@ export interface CardIssuer {
|
|
|
56
62
|
}
|
|
57
63
|
export interface AgentShareDeps {
|
|
58
64
|
source: StatementSource;
|
|
65
|
+
/**
|
|
66
|
+
* Optional claim-lint screen (aip `lintRendering`) run on each candidate before it is
|
|
67
|
+
* offered; returns a reason when the line must not be shared. Word lists live in aip only.
|
|
68
|
+
*/
|
|
69
|
+
screen?: (line: SharedCardLine) => Promise<string | null> | string | null;
|
|
59
70
|
issuer: CardIssuer;
|
|
60
71
|
renderer: LineRenderer;
|
|
61
72
|
/** Interactive picker: returns the chosen line ids. */
|
|
@@ -66,6 +77,8 @@ export interface AgentShareDeps {
|
|
|
66
77
|
err: (line: string) => void;
|
|
67
78
|
}
|
|
68
79
|
export interface AgentShareFlags {
|
|
80
|
+
/** Period card: every offered revert line is included, and a card without one is refused. */
|
|
81
|
+
period?: boolean;
|
|
69
82
|
lines?: string;
|
|
70
83
|
list?: boolean;
|
|
71
84
|
json?: boolean;
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { createInterface } from "node:readline/promises";
|
|
18
18
|
import { getWebsiteUrl } from "../lib/config.js";
|
|
19
19
|
import { fmt } from "../lib/format.js";
|
|
20
|
+
import { aipLineRenderer, aipLineScreen } from "./agent-share-render.js";
|
|
20
21
|
export const AGENT_SHARE_ENV = "MNEMOM_AGENT_SHARE";
|
|
21
22
|
export function agentShareEnabled(env = process.env) {
|
|
22
23
|
return env[AGENT_SHARE_ENV] === "1";
|
|
@@ -132,10 +133,16 @@ export async function agentShareCommand(flags, deps, env = process.env) {
|
|
|
132
133
|
const refused = [];
|
|
133
134
|
for (const c of candidates) {
|
|
134
135
|
const r = refuseReason(c);
|
|
135
|
-
if (r)
|
|
136
|
+
if (r) {
|
|
136
137
|
refused.push(r);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const shared = toSharedLine(c);
|
|
141
|
+
const lint = deps.screen ? await deps.screen(shared) : null;
|
|
142
|
+
if (lint)
|
|
143
|
+
refused.push({ id: shared.id, reason: "estimate", detail: lint });
|
|
137
144
|
else
|
|
138
|
-
offered.push(
|
|
145
|
+
offered.push(shared);
|
|
139
146
|
}
|
|
140
147
|
if (flags.list) {
|
|
141
148
|
if (flags.json) {
|
|
@@ -175,6 +182,15 @@ export async function agentShareCommand(flags, deps, env = process.env) {
|
|
|
175
182
|
deps.err(fmt.error("No lines picked."));
|
|
176
183
|
return 1;
|
|
177
184
|
}
|
|
185
|
+
if (flags.period) {
|
|
186
|
+
for (const l of offered)
|
|
187
|
+
if (l.kind === "revert" && !ids.includes(l.id))
|
|
188
|
+
ids.push(l.id);
|
|
189
|
+
if (!offered.some((l) => l.kind === "revert")) {
|
|
190
|
+
deps.err(fmt.error("A period card always shows reverts, and none are available to share."));
|
|
191
|
+
return 1;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
178
194
|
const chosen = [];
|
|
179
195
|
for (const id of ids) {
|
|
180
196
|
const r = refused.find((x) => x.id === id);
|
|
@@ -202,13 +218,22 @@ export async function agentShareCommand(flags, deps, env = process.env) {
|
|
|
202
218
|
deps.err(fmt.error(`Refusing to share: ${problem}.`));
|
|
203
219
|
return 1;
|
|
204
220
|
}
|
|
221
|
+
// The renderer is the gate: nothing is linked unless aip renders the card (verified, lint clean).
|
|
222
|
+
let rendered;
|
|
223
|
+
try {
|
|
224
|
+
rendered = await deps.renderer.renderLines(card, { period: flags.period === true });
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
deps.err(fmt.error(`Refusing to share: ${error.message}.`));
|
|
228
|
+
return 1;
|
|
229
|
+
}
|
|
205
230
|
const link = buildShareLink(card, deps.websiteBase());
|
|
206
231
|
if (flags.json) {
|
|
207
232
|
deps.out(JSON.stringify({ link, lines: chosen }, null, 2));
|
|
208
233
|
return 0;
|
|
209
234
|
}
|
|
210
235
|
deps.out("Your card:");
|
|
211
|
-
for (const r of
|
|
236
|
+
for (const r of rendered) {
|
|
212
237
|
deps.out(` ${r.text}${r.coverage ? ` (${r.coverage})` : ""}`);
|
|
213
238
|
}
|
|
214
239
|
deps.out("");
|
|
@@ -224,12 +249,8 @@ const notConnected = () => {
|
|
|
224
249
|
export const liveAgentShareDeps = {
|
|
225
250
|
source: { listLines: async () => notConnected() },
|
|
226
251
|
issuer: { issue: async () => notConnected() },
|
|
227
|
-
renderer:
|
|
228
|
-
|
|
229
|
-
text: `${l.label}: ${l.value}`,
|
|
230
|
-
kind: l.kind,
|
|
231
|
-
})),
|
|
232
|
-
},
|
|
252
|
+
renderer: aipLineRenderer(undefined),
|
|
253
|
+
screen: aipLineScreen,
|
|
233
254
|
pick: async (lines) => {
|
|
234
255
|
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
235
256
|
try {
|
package/dist/commands/agent.d.ts
CHANGED
|
@@ -417,15 +417,30 @@ export interface BaseUrlProxyOptions {
|
|
|
417
417
|
*/
|
|
418
418
|
export declare function startBaseUrlProxy(door: string, done: (baseUrl: string | null, recordStatePath?: string) => void, opts?: BaseUrlProxyOptions): void;
|
|
419
419
|
/**
|
|
420
|
-
* The
|
|
421
|
-
* Mnemom key rides line 2 on BOTH auth modes when the launch has one — the proxy
|
|
422
|
-
* x-mnemom-api-key either way; a subscription launch always has one, an api-key
|
|
423
|
-
* when this machine stores one or the launch pins.
|
|
420
|
+
* The four lines the Remote Control proxy reads on stdin (anthropic, mnemom, openai, proxy
|
|
421
|
+
* credential). The Mnemom key rides line 2 on BOTH auth modes when the launch has one — the proxy
|
|
422
|
+
* forwards it as x-mnemom-api-key either way; a subscription launch always has one, an api-key
|
|
423
|
+
* launch has one when this machine stores one or the launch pins. Line 4 is this session's proxy
|
|
424
|
+
* credential ({@link newRcProxyCredential}). Never logged.
|
|
424
425
|
*/
|
|
425
426
|
export declare function rcProxyStdin(ctx: {
|
|
426
427
|
anthropicKey?: string;
|
|
427
428
|
mnemomKey?: string;
|
|
429
|
+
proxyCredential: string;
|
|
428
430
|
}): string;
|
|
431
|
+
/**
|
|
432
|
+
* MNE-9233: a fresh per-session credential for the Remote Control proxy. The proxy injects the
|
|
433
|
+
* owner's keys and its loopback port is reachable by every local process, so it serves only a
|
|
434
|
+
* caller that presents this value as `Proxy-Authorization: Basic` (rc-proxy.mjs). 32 random bytes,
|
|
435
|
+
* hex: URL-safe in the HTTPS_PROXY userinfo without escaping.
|
|
436
|
+
*/
|
|
437
|
+
export declare function newRcProxyCredential(): string;
|
|
438
|
+
/**
|
|
439
|
+
* The HTTPS_PROXY value for the Remote Control child: the proxy's loopback port, with this
|
|
440
|
+
* session's credential as the userinfo. Claude Code sends it as `Proxy-Authorization` on CONNECT
|
|
441
|
+
* and on absolute-form requests. The user name must match rc-proxy.mjs's PROXY_USER.
|
|
442
|
+
*/
|
|
443
|
+
export declare function rcProxyUrl(port: string, proxyCredential: string): string;
|
|
429
444
|
/**
|
|
430
445
|
* The agent id a launch pins, or undefined for a name launch: `--agent` when it is a full
|
|
431
446
|
* `mnm-<uuid>` (any case), else launcher.toml `agent_id` (only when no `--agent` was given —
|
package/dist/commands/agent.js
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import { spawn, spawnSync } from "node:child_process";
|
|
29
29
|
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, renameSync, } from "node:fs";
|
|
30
|
-
import { randomUUID } from "node:crypto";
|
|
30
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
31
31
|
import { tmpdir, homedir, hostname } from "node:os";
|
|
32
32
|
import { basename, join, dirname } from "node:path";
|
|
33
33
|
import { fileURLToPath } from "node:url";
|
|
@@ -1620,13 +1620,31 @@ export function startBaseUrlProxy(door, done, opts = {}) {
|
|
|
1620
1620
|
// Remote Control launch (terminal RC + headless server) — behind the bundled proxy
|
|
1621
1621
|
// ───────────────────────────────────────────────────────────────────────────
|
|
1622
1622
|
/**
|
|
1623
|
-
* The
|
|
1624
|
-
* Mnemom key rides line 2 on BOTH auth modes when the launch has one — the proxy
|
|
1625
|
-
* x-mnemom-api-key either way; a subscription launch always has one, an api-key
|
|
1626
|
-
* when this machine stores one or the launch pins.
|
|
1623
|
+
* The four lines the Remote Control proxy reads on stdin (anthropic, mnemom, openai, proxy
|
|
1624
|
+
* credential). The Mnemom key rides line 2 on BOTH auth modes when the launch has one — the proxy
|
|
1625
|
+
* forwards it as x-mnemom-api-key either way; a subscription launch always has one, an api-key
|
|
1626
|
+
* launch has one when this machine stores one or the launch pins. Line 4 is this session's proxy
|
|
1627
|
+
* credential ({@link newRcProxyCredential}). Never logged.
|
|
1627
1628
|
*/
|
|
1628
1629
|
export function rcProxyStdin(ctx) {
|
|
1629
|
-
return `${ctx.anthropicKey ?? ""}\n${ctx.mnemomKey ?? ""}\n\n`;
|
|
1630
|
+
return `${ctx.anthropicKey ?? ""}\n${ctx.mnemomKey ?? ""}\n\n${ctx.proxyCredential}\n`;
|
|
1631
|
+
}
|
|
1632
|
+
/**
|
|
1633
|
+
* MNE-9233: a fresh per-session credential for the Remote Control proxy. The proxy injects the
|
|
1634
|
+
* owner's keys and its loopback port is reachable by every local process, so it serves only a
|
|
1635
|
+
* caller that presents this value as `Proxy-Authorization: Basic` (rc-proxy.mjs). 32 random bytes,
|
|
1636
|
+
* hex: URL-safe in the HTTPS_PROXY userinfo without escaping.
|
|
1637
|
+
*/
|
|
1638
|
+
export function newRcProxyCredential() {
|
|
1639
|
+
return randomBytes(32).toString("hex");
|
|
1640
|
+
}
|
|
1641
|
+
/**
|
|
1642
|
+
* The HTTPS_PROXY value for the Remote Control child: the proxy's loopback port, with this
|
|
1643
|
+
* session's credential as the userinfo. Claude Code sends it as `Proxy-Authorization` on CONNECT
|
|
1644
|
+
* and on absolute-form requests. The user name must match rc-proxy.mjs's PROXY_USER.
|
|
1645
|
+
*/
|
|
1646
|
+
export function rcProxyUrl(port, proxyCredential) {
|
|
1647
|
+
return `http://mnemom:${proxyCredential}@127.0.0.1:${port}`;
|
|
1630
1648
|
}
|
|
1631
1649
|
/**
|
|
1632
1650
|
* The agent id a launch pins, or undefined for a name launch: `--agent` when it is a full
|
|
@@ -1652,6 +1670,8 @@ function launchRemoteControl(ctx) {
|
|
|
1652
1670
|
}
|
|
1653
1671
|
}
|
|
1654
1672
|
const proxyScript = resolveProxyScript();
|
|
1673
|
+
// MNE-9233: only the child launched below may use this session's proxy (stdin line 4 + HTTPS_PROXY).
|
|
1674
|
+
const proxyCredential = newRcProxyCredential();
|
|
1655
1675
|
const rcDir = mkdtempSync(join(rcStateRoot(), "mnemom-code-rc."));
|
|
1656
1676
|
writeFileSync(join(rcDir, "owner.pid"), `${process.pid}\n`, { mode: 0o600 }); // rc-state.mjs sweep
|
|
1657
1677
|
const proxyLog = process.env.MNEMOM_AGENT_RC_PROXY_LOG || join(rcDir, "proxy.log");
|
|
@@ -1718,9 +1738,9 @@ function launchRemoteControl(ctx) {
|
|
|
1718
1738
|
stopped = true;
|
|
1719
1739
|
stopProxyAndTempDir();
|
|
1720
1740
|
});
|
|
1721
|
-
// Secrets on stdin —
|
|
1722
|
-
// Anthropic key, plus this machine's Mnemom key when it has one (the key principal;
|
|
1723
|
-
// with a pin); a subscription launch sends the Mnemom key only (the identity anchor — the
|
|
1741
|
+
// Secrets on stdin — four lines (anthropic, mnemom, openai, proxy credential). An api-key launch
|
|
1742
|
+
// sends the Anthropic key, plus this machine's Mnemom key when it has one (the key principal;
|
|
1743
|
+
// always with a pin); a subscription launch sends the Mnemom key only (the identity anchor — the
|
|
1724
1744
|
// user's OAuth bearer is what pays). openai is always empty on this Anthropic-only door.
|
|
1725
1745
|
// A proxy that exits before reading them turns this write into an EPIPE; that is
|
|
1726
1746
|
// not a failure of its own — the exited-before-READY check below refuses the
|
|
@@ -1731,7 +1751,7 @@ function launchRemoteControl(ctx) {
|
|
|
1731
1751
|
say(`mnemom agent: could not hand the secrets to the Remote Control proxy: ${err.message}`);
|
|
1732
1752
|
void onExit().finally(() => process.exit(1));
|
|
1733
1753
|
});
|
|
1734
|
-
proxy.stdin?.end(rcProxyStdin(ctx));
|
|
1754
|
+
proxy.stdin?.end(rcProxyStdin({ ...ctx, proxyCredential }));
|
|
1735
1755
|
let stdoutBuf = "";
|
|
1736
1756
|
const start = Date.now();
|
|
1737
1757
|
const tryStart = () => {
|
|
@@ -1748,7 +1768,7 @@ function launchRemoteControl(ctx) {
|
|
|
1748
1768
|
void onExit().finally(() => process.exit(1));
|
|
1749
1769
|
return;
|
|
1750
1770
|
}
|
|
1751
|
-
spawnRcCli({ ...ctx, port, ca, onExit });
|
|
1771
|
+
spawnRcCli({ ...ctx, port, ca, proxyCredential, onExit });
|
|
1752
1772
|
return;
|
|
1753
1773
|
}
|
|
1754
1774
|
if (proxy.exitCode !== null) {
|
|
@@ -1805,8 +1825,8 @@ function spawnRcCli(ctx) {
|
|
|
1805
1825
|
// env. The RC gate forwards the Mnemom identity via the proxy's own auth plane, never from
|
|
1806
1826
|
// this child's env, so there is no legitimate use for these vars here either.
|
|
1807
1827
|
stripOwnerCredentialEnv(childEnv);
|
|
1808
|
-
childEnv.HTTPS_PROXY =
|
|
1809
|
-
childEnv.https_proxy =
|
|
1828
|
+
childEnv.HTTPS_PROXY = rcProxyUrl(ctx.port, ctx.proxyCredential);
|
|
1829
|
+
childEnv.https_proxy = childEnv.HTTPS_PROXY;
|
|
1810
1830
|
childEnv.NODE_EXTRA_CA_CERTS = ctx.ca;
|
|
1811
1831
|
childEnv[SESSION_NAME_ENV] = rcName;
|
|
1812
1832
|
// Autocompact guard (lib/autocompact-guard.ts). The interactive shape gets the --settings layer
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { agentShareFixtureEnabled, fixtureAgentShareParts, } from "./commands/ag
|
|
|
19
19
|
import { agentSessionsCommand, agentShowCommand, liveSessionDeps, } from "./commands/agent-sessions.js";
|
|
20
20
|
import { agentGoalPinCommand, agentGoalResetCommand, agentGoalShowCommand, liveGoalDeps, } from "./commands/agent-goal.js";
|
|
21
21
|
import { agentFuelCommand, liveFuelDeps } from "./commands/agent-fuel.js";
|
|
22
|
+
import { runAgentRun } from "./commands/agent-run.js";
|
|
22
23
|
import { orgListCommand, orgShowCommand, orgUseCommand } from "./commands/org.js";
|
|
23
24
|
import { teamListCommand, teamShowCommand, teamTemplateCommand, teamPreviewComposeCommand, teamAdminGrantCommand, teamAdminRevokeCommand, teamAdminListCommand, teamCoverageCommand, } from "./commands/team.js";
|
|
24
25
|
import { advisoriesListCommand, advisoriesShowCommand } from "./commands/advisories.js";
|
|
@@ -339,6 +340,8 @@ const agentCmd = program
|
|
|
339
340
|
configAgentId: cfg.agentId,
|
|
340
341
|
}),
|
|
341
342
|
governance,
|
|
343
|
+
// Setup provisions for the launch, so it authenticates the way the launch will.
|
|
344
|
+
auth: opts.auth,
|
|
342
345
|
});
|
|
343
346
|
return;
|
|
344
347
|
}
|
|
@@ -919,6 +922,7 @@ agentCmd
|
|
|
919
922
|
.command("share", { hidden: true })
|
|
920
923
|
.description("Build a shareable card from lines of your signed statements and print the share-page link (card in the URL fragment; nothing is posted). Needs MNEMOM_AGENT_SHARE=1")
|
|
921
924
|
.option("--lines <ids>", "Comma-separated line ids to include (skips the picker)")
|
|
925
|
+
.option("--period", "Period card: always include reverts (refused if none are available)")
|
|
922
926
|
.option("--list", "List the lines available to share and exit")
|
|
923
927
|
.option("--json", "Print JSON instead of text")
|
|
924
928
|
.action(async (opts) => {
|
|
@@ -1019,6 +1023,22 @@ agentCmd
|
|
|
1019
1023
|
process.exit(1);
|
|
1020
1024
|
}
|
|
1021
1025
|
});
|
|
1026
|
+
// `mnemom agent run --watch|--once` — the thin runner (ruling 34.12; Doors W-L13): claim served-phase
|
|
1027
|
+
// jobs from the org's ready pool and run their signed phases. Exit 0 done/no_job, 1 failed,
|
|
1028
|
+
// 2 usage, 3 refused.
|
|
1029
|
+
agentCmd
|
|
1030
|
+
.command("run", { hidden: hiddenUnless("dispatch") })
|
|
1031
|
+
.description("EXPERIMENTAL (`mnemom experimental enable dispatch`): run served-phase jobs from your org's pool. " +
|
|
1032
|
+
"The executor key comes from MNEMOM_EXECUTOR_KEY (or --oidc in GitHub Actions); each job prints one outcome line")
|
|
1033
|
+
.option("--watch", "Keep claiming jobs until SIGTERM or SIGINT (the container form)")
|
|
1034
|
+
.option("--once", "Run at most one job, then exit (the CI form)")
|
|
1035
|
+
.option("--fail-mode <mode>", "closed (default) or open; also MNEMOM_RUNNER_FAIL_MODE")
|
|
1036
|
+
.option("--json", "Print only outcome lines on stdout; logs go to stderr")
|
|
1037
|
+
.option("--oidc", "With --once: exchange the GitHub Actions ID token for a one-job key")
|
|
1038
|
+
.hook("preAction", () => requireExperimental("`mnemom agent run`", "dispatch"))
|
|
1039
|
+
.action(async (opts) => {
|
|
1040
|
+
process.exitCode = await runAgentRun(opts);
|
|
1041
|
+
});
|
|
1022
1042
|
// `mnemom agent fuel <file>` — the fuel step (Doors W3.2): one confirmation seals a batch of job
|
|
1023
1043
|
// contracts through the batch-seal endpoint and commits them to the repo.
|
|
1024
1044
|
agentCmd
|
package/dist/lib/api.js
CHANGED
|
@@ -212,7 +212,23 @@ export async function getAgent(id) {
|
|
|
212
212
|
*/
|
|
213
213
|
export async function listAgents() {
|
|
214
214
|
const data = await gFetch("/v1/agents?limit=100", {}, { fallback: "Failed to list agents" });
|
|
215
|
-
return data.agents ?? [];
|
|
215
|
+
return (data.agents ?? []).map(toListItem);
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* An org-scoped API key gets its org's fleet from `GET /v1/agents` (`get_org_agent_fleet`), whose
|
|
219
|
+
* rows carry `agent_id`/`agent_name`; a personal key gets `id`/`name`. Read both as one shape.
|
|
220
|
+
*/
|
|
221
|
+
function toListItem(a) {
|
|
222
|
+
if (!("agent_id" in a))
|
|
223
|
+
return a;
|
|
224
|
+
return {
|
|
225
|
+
id: a.agent_id,
|
|
226
|
+
name: a.agent_name ?? null,
|
|
227
|
+
email: a.owner_email ?? null,
|
|
228
|
+
created_at: a.created_at,
|
|
229
|
+
last_seen: a.last_seen ?? null,
|
|
230
|
+
containment_status: a.containment_status ?? null,
|
|
231
|
+
};
|
|
216
232
|
}
|
|
217
233
|
/**
|
|
218
234
|
* Every agent the caller OWNS, across all their orgs — `GET /v1/agents?scope=all`: rows whose
|
package/dist/lib/auth.d.ts
CHANGED
|
@@ -67,6 +67,12 @@ export declare function getAccessToken(): Promise<string | null>;
|
|
|
67
67
|
* actual exp, or server-side revocation). Force a refresh and retry once.
|
|
68
68
|
*/
|
|
69
69
|
export declare function forceRefreshAccessToken(): Promise<string | null>;
|
|
70
|
+
/**
|
|
71
|
+
* True when auth.json holds a session whose access token has expired. After
|
|
72
|
+
* getAccessToken() returned null this means the refresh was rejected (or was
|
|
73
|
+
* impossible), so the remedy is `mnemom login`, not creating an account.
|
|
74
|
+
*/
|
|
75
|
+
export declare function hasExpiredStoredSession(): boolean;
|
|
70
76
|
/**
|
|
71
77
|
* Get a valid access token or exit with a helpful message.
|
|
72
78
|
*/
|
package/dist/lib/auth.js
CHANGED
|
@@ -15,11 +15,24 @@
|
|
|
15
15
|
import * as fs from "node:fs";
|
|
16
16
|
import * as path from "node:path";
|
|
17
17
|
import { MNEMOM_DIR } from "./config.js";
|
|
18
|
-
import { loginWithLoopback, loginWithDevice, refreshTokens as oauthRefreshTokens, } from "./oauth.js";
|
|
18
|
+
import { loginWithLoopback, loginWithDevice, refreshTokens as oauthRefreshTokens, refreshWorstCaseMs, } from "./oauth.js";
|
|
19
19
|
// ============================================================================
|
|
20
20
|
// Auth Store (persisted to ~/.mnemom/auth.json)
|
|
21
21
|
// ============================================================================
|
|
22
22
|
const AUTH_FILE = path.join(MNEMOM_DIR, "auth.json");
|
|
23
|
+
/**
|
|
24
|
+
* Held while one process refreshes the stored tokens (MNE-9232). The AS rotates
|
|
25
|
+
* refresh tokens, so two processes refreshing with the same one race: the first
|
|
26
|
+
* wins and the second gets invalid_grant. The lock serialises them, and the
|
|
27
|
+
* waiter re-reads auth.json under it and picks up the winner's tokens.
|
|
28
|
+
*/
|
|
29
|
+
const AUTH_LOCK_FILE = `${AUTH_FILE}.lock`;
|
|
30
|
+
const AUTH_LOCK_POLL_MS = 100;
|
|
31
|
+
/** A stored access token this close to expiry (seconds) is treated as expired. */
|
|
32
|
+
const EXPIRY_BUFFER_S = 60;
|
|
33
|
+
function nowSeconds() {
|
|
34
|
+
return Math.floor(Date.now() / 1000);
|
|
35
|
+
}
|
|
23
36
|
function loadAuthStore() {
|
|
24
37
|
try {
|
|
25
38
|
if (!fs.existsSync(AUTH_FILE))
|
|
@@ -112,9 +125,7 @@ export async function getAccessToken() {
|
|
|
112
125
|
const auth = getAuthInfo();
|
|
113
126
|
if (!auth)
|
|
114
127
|
return null;
|
|
115
|
-
|
|
116
|
-
const now = Math.floor(Date.now() / 1000);
|
|
117
|
-
if (auth.expiresAt > now + 60) {
|
|
128
|
+
if (auth.expiresAt > nowSeconds() + EXPIRY_BUFFER_S) {
|
|
118
129
|
return auth.accessToken;
|
|
119
130
|
}
|
|
120
131
|
// Auto-refresh. A *thrown* error from refreshStoredTokens() (transient
|
|
@@ -152,6 +163,15 @@ export async function forceRefreshAccessToken() {
|
|
|
152
163
|
const refreshed = await refreshStoredTokens(auth);
|
|
153
164
|
return refreshed?.accessToken ?? null;
|
|
154
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* True when auth.json holds a session whose access token has expired. After
|
|
168
|
+
* getAccessToken() returned null this means the refresh was rejected (or was
|
|
169
|
+
* impossible), so the remedy is `mnemom login`, not creating an account.
|
|
170
|
+
*/
|
|
171
|
+
export function hasExpiredStoredSession() {
|
|
172
|
+
const auth = getAuthInfo();
|
|
173
|
+
return auth !== null && auth.expiresAt <= nowSeconds() + EXPIRY_BUFFER_S;
|
|
174
|
+
}
|
|
155
175
|
/**
|
|
156
176
|
* Get a valid access token or exit with a helpful message.
|
|
157
177
|
*/
|
|
@@ -232,11 +252,113 @@ export async function loginWithDeviceFlow() {
|
|
|
232
252
|
// ============================================================================
|
|
233
253
|
// Token refresh (OAuth refresh_token grant)
|
|
234
254
|
// ============================================================================
|
|
255
|
+
/** True when `pid` names a running process (EPERM: it runs as another user). */
|
|
256
|
+
function pidAlive(pid) {
|
|
257
|
+
try {
|
|
258
|
+
process.kill(pid, 0);
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
catch (err) {
|
|
262
|
+
return err.code === "EPERM";
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* A lock is stale when its holder has exited or it has been held longer than a
|
|
267
|
+
* refresh can take. Age and holder are read through one descriptor so both
|
|
268
|
+
* describe the same lock file. A lock that vanished first is not stale: the
|
|
269
|
+
* next create attempt decides.
|
|
270
|
+
*/
|
|
271
|
+
function authLockIsStale(staleMs) {
|
|
272
|
+
let mtimeMs;
|
|
273
|
+
let holder;
|
|
274
|
+
let fd;
|
|
275
|
+
try {
|
|
276
|
+
fd = fs.openSync(AUTH_LOCK_FILE, "r");
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
mtimeMs = fs.fstatSync(fd).mtimeMs;
|
|
283
|
+
holder = fs.readFileSync(fd, "utf-8");
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
finally {
|
|
289
|
+
fs.closeSync(fd);
|
|
290
|
+
}
|
|
291
|
+
if (Date.now() - mtimeMs >= staleMs)
|
|
292
|
+
return true;
|
|
293
|
+
const pid = Number(holder.trim());
|
|
294
|
+
return Number.isInteger(pid) && pid > 0 && !pidAlive(pid);
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Take the refresh lock with O_EXCL (the update.lock pattern in
|
|
298
|
+
* lib/update/state.ts), waiting while another live process holds it. A stale
|
|
299
|
+
* lock is taken over. Waiting past the longest a refresh can take throws, which
|
|
300
|
+
* callers treat as a transient failure, never as revoked credentials.
|
|
301
|
+
*
|
|
302
|
+
* Two waiters can both judge one lock stale and both take it over. That only
|
|
303
|
+
* puts back the pre-lock race, which the invalid_grant re-read in
|
|
304
|
+
* refreshStoredTokens() still recovers from.
|
|
305
|
+
*/
|
|
306
|
+
async function acquireAuthLock() {
|
|
307
|
+
const staleMs = refreshWorstCaseMs();
|
|
308
|
+
const deadline = Date.now() + staleMs;
|
|
309
|
+
const owner = String(process.pid);
|
|
310
|
+
for (;;) {
|
|
311
|
+
try {
|
|
312
|
+
fs.mkdirSync(MNEMOM_DIR, { recursive: true, mode: 0o700 });
|
|
313
|
+
fs.writeFileSync(AUTH_LOCK_FILE, owner, { flag: "wx", mode: 0o600 });
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
if (err.code !== "EEXIST")
|
|
318
|
+
throw err;
|
|
319
|
+
}
|
|
320
|
+
if (authLockIsStale(staleMs)) {
|
|
321
|
+
fs.rmSync(AUTH_LOCK_FILE, { force: true });
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (Date.now() >= deadline) {
|
|
325
|
+
throw new Error(`Timed out waiting for another mnemom process to refresh the Mnemom session ` +
|
|
326
|
+
`(${AUTH_LOCK_FILE}). Re-run the command.`);
|
|
327
|
+
}
|
|
328
|
+
await new Promise((resolve) => setTimeout(resolve, AUTH_LOCK_POLL_MS));
|
|
329
|
+
}
|
|
330
|
+
return () => {
|
|
331
|
+
// Remove the lock only while it is still ours: if this process stalled past
|
|
332
|
+
// staleMs another process may have taken it over.
|
|
333
|
+
try {
|
|
334
|
+
if (fs.readFileSync(AUTH_LOCK_FILE, "utf-8") === owner) {
|
|
335
|
+
fs.rmSync(AUTH_LOCK_FILE, { force: true });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
/* already gone */
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
/** True when `candidate` holds a different, unexpired access token than `seen`. */
|
|
344
|
+
function isNewerSession(candidate, seen) {
|
|
345
|
+
return (candidate.accessToken !== seen.accessToken &&
|
|
346
|
+
candidate.expiresAt > nowSeconds() + EXPIRY_BUFFER_S);
|
|
347
|
+
}
|
|
235
348
|
/**
|
|
236
349
|
* Refresh the stored tokens via the OAuth refresh_token grant. Requires both a
|
|
237
350
|
* refresh token and the client_id they were issued to; returns null (rather
|
|
238
351
|
* than throwing) when refresh isn't possible so callers degrade to re-login.
|
|
239
352
|
*
|
|
353
|
+
* Runs under AUTH_LOCK_FILE and works from auth.json as it is once the lock is
|
|
354
|
+
* held, not from the caller's earlier read (MNE-9232):
|
|
355
|
+
* - another process already refreshed (a different, unexpired access token
|
|
356
|
+
* on disk) → return its tokens, no network call;
|
|
357
|
+
* - the session was removed (`mnemom logout`) → null, never resurrected;
|
|
358
|
+
* - invalid_grant/invalid_client → re-read once; if a writer that does not
|
|
359
|
+
* take this lock (an older CLI) stored a different refresh token meanwhile,
|
|
360
|
+
* use its session or refresh once with its refresh token.
|
|
361
|
+
*
|
|
240
362
|
* Note: oauthRefreshTokens() returns null ONLY on a definitive revoke
|
|
241
363
|
* (invalid_grant/invalid_client) or missing credentials; on transient
|
|
242
364
|
* exhaustion (repeated 5xx / network faults) or any other non-revoke 4xx it
|
|
@@ -250,14 +372,39 @@ export async function loginWithDeviceFlow() {
|
|
|
250
372
|
async function refreshStoredTokens(auth) {
|
|
251
373
|
if (!auth.refreshToken || !auth.clientId)
|
|
252
374
|
return null;
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
375
|
+
const release = await acquireAuthLock();
|
|
376
|
+
try {
|
|
377
|
+
const current = getAuthInfo();
|
|
378
|
+
if (current && isNewerSession(current, auth))
|
|
379
|
+
return current;
|
|
380
|
+
if (!current?.refreshToken || !current.clientId)
|
|
381
|
+
return null;
|
|
382
|
+
let base = current;
|
|
383
|
+
let clientId = current.clientId;
|
|
384
|
+
let refreshed = await oauthRefreshTokens(current.refreshToken, current.clientId);
|
|
385
|
+
if (!refreshed) {
|
|
386
|
+
const latest = getAuthInfo();
|
|
387
|
+
if (!latest?.refreshToken || !latest.clientId)
|
|
388
|
+
return null;
|
|
389
|
+
if (latest.refreshToken === current.refreshToken)
|
|
390
|
+
return null;
|
|
391
|
+
if (isNewerSession(latest, current))
|
|
392
|
+
return latest;
|
|
393
|
+
base = latest;
|
|
394
|
+
clientId = latest.clientId;
|
|
395
|
+
refreshed = await oauthRefreshTokens(latest.refreshToken, latest.clientId);
|
|
396
|
+
if (!refreshed)
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
const stored = fromOAuthTokens(refreshed, clientId);
|
|
400
|
+
// Preserve any legacy identity fields so whoami/status keep working across a
|
|
401
|
+
// refresh of a token set that still carries them.
|
|
402
|
+
stored.userId = base.userId;
|
|
403
|
+
stored.email = base.email;
|
|
404
|
+
saveAuthTokens(stored);
|
|
405
|
+
return stored;
|
|
406
|
+
}
|
|
407
|
+
finally {
|
|
408
|
+
release();
|
|
409
|
+
}
|
|
263
410
|
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* Everything here is pure or dependency-injected; index.ts and the command
|
|
13
13
|
* modules are the thin drivers.
|
|
14
14
|
*/
|
|
15
|
-
export type ExperimentalId = "context-handoff" | "slack" | "implicit" | "measured-savings" | "record" | "fuel" | "durable";
|
|
15
|
+
export type ExperimentalId = "context-handoff" | "slack" | "implicit" | "measured-savings" | "record" | "fuel" | "durable" | "dispatch";
|
|
16
16
|
/**
|
|
17
17
|
* Features that used to be experimental and are now on by default for everyone. Their ids are
|
|
18
18
|
* no longer in the registry, but `mnemom experimental enable|disable <id>` still accepts them
|
package/dist/lib/experimental.js
CHANGED
|
@@ -120,6 +120,19 @@ export const EXPERIMENTAL_FEATURES = [
|
|
|
120
120
|
env: [],
|
|
121
121
|
},
|
|
122
122
|
},
|
|
123
|
+
{
|
|
124
|
+
// The thin runner (Doors W-L13, ruling 34.12 (4)). Off: `agent run` exits 2 with the gate
|
|
125
|
+
// pointer before reading any credential.
|
|
126
|
+
id: "dispatch",
|
|
127
|
+
summary: "The thin runner: `mnemom agent run --watch|--once` claims served-phase jobs and runs them",
|
|
128
|
+
since: "0.17.32",
|
|
129
|
+
gates: {
|
|
130
|
+
flags: [],
|
|
131
|
+
configKeys: [],
|
|
132
|
+
commands: ["mnemom agent run"],
|
|
133
|
+
env: [],
|
|
134
|
+
},
|
|
135
|
+
},
|
|
123
136
|
];
|
|
124
137
|
export const EXPERIMENTAL_IDS = EXPERIMENTAL_FEATURES.map((f) => f.id);
|
|
125
138
|
export const EXPERIMENTAL_ENV = "MNEMOM_EXPERIMENTAL";
|
package/dist/lib/oauth.d.ts
CHANGED
|
@@ -26,6 +26,13 @@
|
|
|
26
26
|
* positive integer (capped at 5 min), else the default.
|
|
27
27
|
*/
|
|
28
28
|
export declare function oauthHttpTimeoutMs(env?: NodeJS.ProcessEnv): number;
|
|
29
|
+
/**
|
|
30
|
+
* The longest one refreshTokens() call can legitimately take (ms): discovery
|
|
31
|
+
* plus REFRESH_MAX_ATTEMPTS token requests, each at the per-request deadline,
|
|
32
|
+
* plus the backoff sleeps between attempts. lib/auth.ts uses it to bound how
|
|
33
|
+
* long a process waits on another process's refresh lock (MNE-9232).
|
|
34
|
+
*/
|
|
35
|
+
export declare function refreshWorstCaseMs(env?: NodeJS.ProcessEnv): number;
|
|
29
36
|
/** The name carried by an error thrown when an AS round-trip hit its deadline. */
|
|
30
37
|
export declare const OAUTH_TIMEOUT_ERROR = "OAuthTimeoutError";
|
|
31
38
|
export interface AuthServerMetadata {
|
package/dist/lib/oauth.js
CHANGED
|
@@ -86,6 +86,19 @@ export function oauthHttpTimeoutMs(env = process.env) {
|
|
|
86
86
|
}
|
|
87
87
|
return OAUTH_HTTP_TIMEOUT_MS;
|
|
88
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* The longest one refreshTokens() call can legitimately take (ms): discovery
|
|
91
|
+
* plus REFRESH_MAX_ATTEMPTS token requests, each at the per-request deadline,
|
|
92
|
+
* plus the backoff sleeps between attempts. lib/auth.ts uses it to bound how
|
|
93
|
+
* long a process waits on another process's refresh lock (MNE-9232).
|
|
94
|
+
*/
|
|
95
|
+
export function refreshWorstCaseMs(env = process.env) {
|
|
96
|
+
let backoff = 0;
|
|
97
|
+
for (let attempt = 0; attempt < REFRESH_MAX_ATTEMPTS - 1; attempt++) {
|
|
98
|
+
backoff += REFRESH_BACKOFF_BASE_MS * 2 ** attempt;
|
|
99
|
+
}
|
|
100
|
+
return oauthHttpTimeoutMs(env) * (1 + REFRESH_MAX_ATTEMPTS) + backoff;
|
|
101
|
+
}
|
|
89
102
|
/** The name carried by an error thrown when an AS round-trip hit its deadline. */
|
|
90
103
|
export const OAUTH_TIMEOUT_ERROR = "OAuthTimeoutError";
|
|
91
104
|
function isTimeout(err) {
|