@mnemom/mnemom 0.17.31-next.1 → 0.17.32-next.0
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/base-url-proxy.mjs +30 -0
- package/dist/commands/agent-fuel.d.ts +75 -0
- package/dist/commands/agent-fuel.js +298 -0
- package/dist/commands/agent-implicit-notice.d.ts +31 -0
- package/dist/commands/agent-implicit-notice.js +73 -0
- package/dist/commands/agent-session.d.ts +2 -1
- package/dist/commands/agent-session.js +2 -1
- package/dist/commands/agent.d.ts +52 -1
- package/dist/commands/agent.js +111 -26
- package/dist/commands/auth.js +20 -0
- package/dist/commands/status.js +1 -1
- package/dist/commands/update.js +3 -1
- package/dist/commands/validate.d.ts +1 -1
- package/dist/commands/validate.js +1 -1
- package/dist/index.js +51 -12
- package/dist/lib/agent.d.ts +32 -19
- package/dist/lib/agent.js +34 -32
- package/dist/lib/api.d.ts +53 -0
- package/dist/lib/api.js +31 -10
- package/dist/lib/canonical-json.d.ts +12 -0
- package/dist/lib/canonical-json.js +22 -0
- package/dist/lib/conduct-receipt.d.ts +81 -0
- package/dist/lib/conduct-receipt.js +177 -0
- package/dist/lib/contract-hash.d.ts +61 -0
- package/dist/lib/contract-hash.js +126 -0
- package/dist/lib/experimental.d.ts +10 -1
- package/dist/lib/experimental.js +35 -1
- package/dist/lib/oauth.d.ts +32 -1
- package/dist/lib/oauth.js +157 -44
- package/dist/lib/statusline-bin.d.ts +2 -2
- package/dist/lib/statusline-bin.js +4 -7
- package/dist/lib/statusline-feed.d.ts +33 -0
- package/dist/lib/statusline-feed.js +68 -0
- package/dist/lib/statusline.d.ts +39 -88
- package/dist/lib/statusline.js +58 -250
- package/dist/lib/update/decide.d.ts +22 -3
- package/dist/lib/update/decide.js +35 -12
- package/dist/lib/update/doctor.js +5 -1
- package/dist/lib/update/run.d.ts +2 -0
- package/dist/lib/update/run.js +22 -8
- package/package.json +4 -4
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contract's full sha256 and seal-time check, as the gateway computes them (Doors W3.5).
|
|
3
|
+
*
|
|
4
|
+
* Callers that hash contracts outside the gateway (the fuel step's CLI verb and the thin runner,
|
|
5
|
+
* each in its own change) use this module. It computes a contract's hash, its committed file's
|
|
6
|
+
* bytes and its repo path; writing the file is the caller's job. It ports the gateway's
|
|
7
|
+
* `normaliseContractObject` / `checkContractObject` / `canonicalHash` (gateway/src/goal-contract.ts). The full sha256 is the digest whose first 16
|
|
8
|
+
* hex characters are the gateway's `x-mnemom-contract-hash`: sha256 of the canonical JSON of the
|
|
9
|
+
* normalised contract, with a default `"nudge"` mode dropped and `"enforce"` kept. The 8 KB
|
|
10
|
+
* limit is measured, like the gateway's, on the canonical JSON with the mode still present.
|
|
11
|
+
*
|
|
12
|
+
* `gateway/test/fixtures/contract-sha256-parity.json` pins the gateway's own outputs; the CLI's
|
|
13
|
+
* parity suite and mnemom-api's (`src/contract-step/hash.ts`) read the same cases.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { canonicalJson } from "./canonical-json.js";
|
|
17
|
+
/** The gateway's `CONTRACT_JSON_MAX`: bytes of canonical JSON of the normalised contract. */
|
|
18
|
+
export const CONTRACT_JSON_MAX = 8_192;
|
|
19
|
+
/** The gateway's `STATEMENT_MAX` (its `USER_CLIP`), in characters. */
|
|
20
|
+
export const STATEMENT_MAX = 4_000;
|
|
21
|
+
const MODES = new Set(["nudge", "enforce"]);
|
|
22
|
+
function stringArray(value) {
|
|
23
|
+
if (!Array.isArray(value))
|
|
24
|
+
return [];
|
|
25
|
+
return value.filter((v) => typeof v === "string" && v.trim().length > 0);
|
|
26
|
+
}
|
|
27
|
+
function positiveFinite(value) {
|
|
28
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
|
|
29
|
+
}
|
|
30
|
+
/** The gateway's `normaliseContractObject`: null without a non-empty statement. Mode defaults to "nudge". */
|
|
31
|
+
export function normaliseContract(obj) {
|
|
32
|
+
const statement = typeof obj.statement === "string" ? obj.statement.trim() : "";
|
|
33
|
+
if (!statement)
|
|
34
|
+
return null;
|
|
35
|
+
const contract = {
|
|
36
|
+
statement,
|
|
37
|
+
requirements: stringArray(obj.requirements),
|
|
38
|
+
allowed_paths: stringArray(obj.allowed_paths),
|
|
39
|
+
forbidden_paths: stringArray(obj.forbidden_paths),
|
|
40
|
+
};
|
|
41
|
+
if (typeof obj.goal_id === "string" && obj.goal_id.trim())
|
|
42
|
+
contract.goal_id = obj.goal_id.trim();
|
|
43
|
+
const maxTurns = positiveFinite(obj.max_turns);
|
|
44
|
+
if (maxTurns !== undefined)
|
|
45
|
+
contract.max_turns = maxTurns;
|
|
46
|
+
const budgetUsd = positiveFinite(obj.budget_usd);
|
|
47
|
+
if (budgetUsd !== undefined)
|
|
48
|
+
contract.budget_usd = budgetUsd;
|
|
49
|
+
const stallTurns = positiveFinite(obj.stall_turns);
|
|
50
|
+
if (stallTurns !== undefined)
|
|
51
|
+
contract.stall_turns = stallTurns;
|
|
52
|
+
const timeLimitS = positiveFinite(obj.time_limit_s);
|
|
53
|
+
if (timeLimitS !== undefined)
|
|
54
|
+
contract.time_limit_s = timeLimitS;
|
|
55
|
+
contract.mode = MODES.has(obj.mode) ? obj.mode : "nudge";
|
|
56
|
+
return contract;
|
|
57
|
+
}
|
|
58
|
+
/** The gateway's seal-time gate (`checkContractObject`), with the same reasons. */
|
|
59
|
+
export function checkContract(value, label = "contract") {
|
|
60
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
61
|
+
return { ok: false, reason: "unparseable", message: `${label} must be a JSON object` };
|
|
62
|
+
}
|
|
63
|
+
const obj = value;
|
|
64
|
+
const contract = normaliseContract(obj);
|
|
65
|
+
if (!contract) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
reason: "statement_missing",
|
|
69
|
+
message: `${label} needs a non-empty "statement"`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (contract.statement.length > STATEMENT_MAX) {
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
reason: "statement_too_long",
|
|
76
|
+
message: `"statement" is ${contract.statement.length} characters; the maximum is ${STATEMENT_MAX}`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (obj.mode !== undefined && !MODES.has(obj.mode)) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
reason: "mode_invalid",
|
|
83
|
+
message: `"mode" must be "nudge" or "enforce" (got ${JSON.stringify(obj.mode)})`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const bytes = contractSizeBytes(contract);
|
|
87
|
+
if (bytes > CONTRACT_JSON_MAX) {
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
reason: "contract_too_large",
|
|
91
|
+
message: `the contract is ${bytes} bytes of canonical JSON; the maximum is ${CONTRACT_JSON_MAX}`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return { ok: true, contract };
|
|
95
|
+
}
|
|
96
|
+
/** Bytes the 8 KB limit measures: canonical JSON of the normalised contract, mode included. */
|
|
97
|
+
export function contractSizeBytes(contract) {
|
|
98
|
+
return Buffer.byteLength(canonicalJson(contract), "utf8");
|
|
99
|
+
}
|
|
100
|
+
/** The hash form: a default "nudge" mode dropped, "enforce" kept (the gateway's `canonicalHash`). */
|
|
101
|
+
export function hashForm(contract) {
|
|
102
|
+
const form = { ...contract };
|
|
103
|
+
if ((contract.mode ?? "nudge") === "nudge")
|
|
104
|
+
delete form.mode;
|
|
105
|
+
return form;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The committed contract file's exact bytes: canonical JSON of the hash form, no trailing newline,
|
|
109
|
+
* so sha256 of the file equals `contractSha256`.
|
|
110
|
+
*/
|
|
111
|
+
export function contractFileText(contract) {
|
|
112
|
+
return canonicalJson(hashForm(contract));
|
|
113
|
+
}
|
|
114
|
+
/** Full lowercase-hex sha256. Its first 16 characters are the gateway's `x-mnemom-contract-hash`. */
|
|
115
|
+
export function contractSha256(contract) {
|
|
116
|
+
return createHash("sha256").update(contractFileText(contract), "utf8").digest("hex");
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Where a sealed contract is committed in the repo: content-addressed by its full sha256. Throws
|
|
120
|
+
* on anything but 64 lowercase hex characters, so the path can never leave `.mnemom/contracts/`.
|
|
121
|
+
*/
|
|
122
|
+
export function contractRepoPath(sha256) {
|
|
123
|
+
if (!/^[0-9a-f]{64}$/.test(sha256))
|
|
124
|
+
throw new Error("contract sha256 must be 64 lowercase hex characters");
|
|
125
|
+
return `.mnemom/contracts/${sha256}.json`;
|
|
126
|
+
}
|
|
@@ -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";
|
|
15
|
+
export type ExperimentalId = "context-handoff" | "slack" | "implicit" | "measured-savings" | "record" | "fuel";
|
|
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
|
|
@@ -63,6 +63,15 @@ export declare function resolveExperimental(opts: {
|
|
|
63
63
|
stored: ExperimentalPrefs | undefined;
|
|
64
64
|
env: Record<string, string | undefined>;
|
|
65
65
|
}): ExperimentalState;
|
|
66
|
+
/**
|
|
67
|
+
* Features a prerelease (`-next.N`) build makes available without `mnemom experimental
|
|
68
|
+
* enable`: door one's T16 turns implicit goals on by default on `next` (decisions 12 and
|
|
69
|
+
* 22), so its flag, its launcher.toml value and `mnemom agent goal` are not refused there.
|
|
70
|
+
* Stable is unchanged.
|
|
71
|
+
*/
|
|
72
|
+
export declare const PRERELEASE_AVAILABLE: readonly ExperimentalId[];
|
|
73
|
+
/** The features whose flags, keys and commands are usable: the enabled ones, plus PRERELEASE_AVAILABLE on a prerelease. */
|
|
74
|
+
export declare function availableFeatures(enabled: readonly ExperimentalId[], prerelease: boolean): ExperimentalId[];
|
|
66
75
|
/** The one-line stderr notice for a launch with experimental features on; null when none are. */
|
|
67
76
|
export declare function experimentalNotice(enabled: readonly ExperimentalId[]): string | null;
|
|
68
77
|
/** The error for using an experimental flag or command while its feature is off. */
|
package/dist/lib/experimental.js
CHANGED
|
@@ -75,7 +75,7 @@ export const EXPERIMENTAL_FEATURES = [
|
|
|
75
75
|
{
|
|
76
76
|
// No flag, launcher.toml key or env of its own: `mnemom experimental enable` is the switch.
|
|
77
77
|
id: "measured-savings",
|
|
78
|
-
summary: "The
|
|
78
|
+
summary: "The gateway measures each request's saving into the response usage (the status bar never shows it)",
|
|
79
79
|
since: "0.17.16",
|
|
80
80
|
gates: {
|
|
81
81
|
flags: [],
|
|
@@ -84,6 +84,29 @@ export const EXPERIMENTAL_FEATURES = [
|
|
|
84
84
|
env: [],
|
|
85
85
|
},
|
|
86
86
|
},
|
|
87
|
+
{
|
|
88
|
+
// No flag, launcher.toml key or env of its own: `mnemom experimental enable` is the switch.
|
|
89
|
+
id: "record",
|
|
90
|
+
summary: "Print the conduct receipt at exit: the session's measured model cost and μ, or the record link",
|
|
91
|
+
since: "0.17.32",
|
|
92
|
+
gates: {
|
|
93
|
+
flags: [],
|
|
94
|
+
configKeys: [],
|
|
95
|
+
commands: [],
|
|
96
|
+
env: [],
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
id: "fuel",
|
|
101
|
+
summary: "The fuel step: `mnemom agent fuel <file>` seals a batch of job contracts with one confirmation and commits them to the repo",
|
|
102
|
+
since: "0.17.32",
|
|
103
|
+
gates: {
|
|
104
|
+
flags: [],
|
|
105
|
+
configKeys: [],
|
|
106
|
+
commands: ["mnemom agent fuel"],
|
|
107
|
+
env: [],
|
|
108
|
+
},
|
|
109
|
+
},
|
|
87
110
|
];
|
|
88
111
|
export const EXPERIMENTAL_IDS = EXPERIMENTAL_FEATURES.map((f) => f.id);
|
|
89
112
|
export const EXPERIMENTAL_ENV = "MNEMOM_EXPERIMENTAL";
|
|
@@ -126,6 +149,17 @@ export function resolveExperimental(opts) {
|
|
|
126
149
|
unknown: stored.filter((t) => !isExperimentalId(t)),
|
|
127
150
|
};
|
|
128
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* Features a prerelease (`-next.N`) build makes available without `mnemom experimental
|
|
154
|
+
* enable`: door one's T16 turns implicit goals on by default on `next` (decisions 12 and
|
|
155
|
+
* 22), so its flag, its launcher.toml value and `mnemom agent goal` are not refused there.
|
|
156
|
+
* Stable is unchanged.
|
|
157
|
+
*/
|
|
158
|
+
export const PRERELEASE_AVAILABLE = ["implicit"];
|
|
159
|
+
/** The features whose flags, keys and commands are usable: the enabled ones, plus PRERELEASE_AVAILABLE on a prerelease. */
|
|
160
|
+
export function availableFeatures(enabled, prerelease) {
|
|
161
|
+
return normalize(prerelease ? [...enabled, ...PRERELEASE_AVAILABLE] : enabled);
|
|
162
|
+
}
|
|
129
163
|
/** The one-line stderr notice for a launch with experimental features on; null when none are. */
|
|
130
164
|
export function experimentalNotice(enabled) {
|
|
131
165
|
if (enabled.length === 0)
|
package/dist/lib/oauth.d.ts
CHANGED
|
@@ -42,7 +42,8 @@ export interface AuthServerMetadata {
|
|
|
42
42
|
}
|
|
43
43
|
/**
|
|
44
44
|
* Fetch (and process-cache) the AS metadata document. We resolve it relative to
|
|
45
|
-
* the active API base URL so
|
|
45
|
+
* the active API base URL so local (and any non-default `Environment`) points
|
|
46
|
+
* at its own AS.
|
|
46
47
|
*/
|
|
47
48
|
export declare function discover(): Promise<AuthServerMetadata>;
|
|
48
49
|
/** Reset the process-level discovery cache (used by tests). */
|
|
@@ -89,10 +90,35 @@ export interface LoopbackResult {
|
|
|
89
90
|
* scoped tokens plus the client_id that was used (so the caller can persist it
|
|
90
91
|
* for refresh). `openUrl` is injectable so tests can drive the flow without
|
|
91
92
|
* spawning a real browser.
|
|
93
|
+
*
|
|
94
|
+
* Cross-device completion: when the AS advertises a device authorization
|
|
95
|
+
* endpoint, this also starts an RFC 8628 device authorization for the same
|
|
96
|
+
* client and prints its code with the verification URI. The login then
|
|
97
|
+
* finishes on whichever comes first: the loopback redirect (same machine) or an
|
|
98
|
+
* approval of the device code from any browser (e.g. the phone that opened the
|
|
99
|
+
* sign-up confirmation email), where the person types the code from this
|
|
100
|
+
* terminal. The code is never put in the authorize URL or any other link, so
|
|
101
|
+
* nothing that opens in a browser can approve this login without the person
|
|
102
|
+
* typing it. Tokens only ever come back to this process: the loopback code
|
|
103
|
+
* needs our PKCE verifier and the device grant needs our device_code. If the
|
|
104
|
+
* device authorization cannot be started, the login is exactly the loopback
|
|
105
|
+
* flow it always was.
|
|
92
106
|
*/
|
|
93
107
|
export declare function loginWithLoopback(openUrl?: (url: string) => void, opts?: {
|
|
94
108
|
inviteToken?: string;
|
|
109
|
+
sleep?: (ms: number) => Promise<void>;
|
|
95
110
|
}): Promise<LoopbackResult>;
|
|
111
|
+
/** How long the loopback listener waits while a device code is live. */
|
|
112
|
+
export declare function crossDeviceWaitMs(expiresInSec: number): number;
|
|
113
|
+
/**
|
|
114
|
+
* Settle with whichever login path finishes first.
|
|
115
|
+
* - The loopback path settles the login either way, exactly as before: a
|
|
116
|
+
* denial, a state mismatch or the timeout in the browser ends it.
|
|
117
|
+
* - The device path ends it on success, or when the person explicitly denies
|
|
118
|
+
* the device code. Any other device failure (expiry, a transport error)
|
|
119
|
+
* only drops that path and the loopback wait continues.
|
|
120
|
+
*/
|
|
121
|
+
export declare function firstLogin(viaLoopback: Promise<OAuthTokens>, viaDevice: Promise<OAuthTokens> | null): Promise<OAuthTokens>;
|
|
96
122
|
export interface DeviceResult {
|
|
97
123
|
tokens: OAuthTokens;
|
|
98
124
|
clientId: string;
|
|
@@ -169,6 +195,11 @@ export declare function browserOpenerFor(platform: NodeJS.Platform, href: string
|
|
|
169
195
|
* Returns whether a launch was attempted.
|
|
170
196
|
*/
|
|
171
197
|
export declare function openBrowser(url: string): boolean;
|
|
198
|
+
/**
|
|
199
|
+
* setTimeout-based sleep that resolves early when `signal` aborts, so an
|
|
200
|
+
* abandoned device poll does not hold the process open after login finishes.
|
|
201
|
+
*/
|
|
202
|
+
export declare function abortableSleep(ms: number, signal: AbortSignal): Promise<void>;
|
|
172
203
|
/**
|
|
173
204
|
* Await `promise` while emitting a heartbeat tick every `intervalMs`, so a long
|
|
174
205
|
* silent wait (a browser sign-in round-trip) doesn't look hung. `onTick` is
|
package/dist/lib/oauth.js
CHANGED
|
@@ -35,6 +35,12 @@ const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
|
35
35
|
// Loopback login waits at most this long for the browser round-trip before
|
|
36
36
|
// giving up and freeing the port.
|
|
37
37
|
const LOOPBACK_TIMEOUT_MS = 5 * 60 * 1000;
|
|
38
|
+
// Browser login also starts a device authorization (RFC 8628) when the AS offers
|
|
39
|
+
// one, so the login can be approved from ANOTHER device: someone who signs up in
|
|
40
|
+
// the browser and confirms the email on their phone. While that is live the
|
|
41
|
+
// loopback listener waits as long as the device code is valid, capped here, so
|
|
42
|
+
// the port is never left open indefinitely.
|
|
43
|
+
const CROSS_DEVICE_MAX_WAIT_MS = 15 * 60 * 1000;
|
|
38
44
|
// Cadence for the "…still waiting for you to finish signing in" heartbeat that
|
|
39
45
|
// both interactive login poll loops emit, so a multi-minute browser sign-in
|
|
40
46
|
// doesn't read as a hung CLI (matches the card-write retry tick in try-me).
|
|
@@ -111,7 +117,8 @@ async function oauthFetch(what, url, init = {}) {
|
|
|
111
117
|
let cachedMetadata = null;
|
|
112
118
|
/**
|
|
113
119
|
* Fetch (and process-cache) the AS metadata document. We resolve it relative to
|
|
114
|
-
* the active API base URL so
|
|
120
|
+
* the active API base URL so local (and any non-default `Environment`) points
|
|
121
|
+
* at its own AS.
|
|
115
122
|
*/
|
|
116
123
|
export async function discover() {
|
|
117
124
|
if (cachedMetadata)
|
|
@@ -208,46 +215,120 @@ function tokensFromResponse(data) {
|
|
|
208
215
|
* scoped tokens plus the client_id that was used (so the caller can persist it
|
|
209
216
|
* for refresh). `openUrl` is injectable so tests can drive the flow without
|
|
210
217
|
* spawning a real browser.
|
|
218
|
+
*
|
|
219
|
+
* Cross-device completion: when the AS advertises a device authorization
|
|
220
|
+
* endpoint, this also starts an RFC 8628 device authorization for the same
|
|
221
|
+
* client and prints its code with the verification URI. The login then
|
|
222
|
+
* finishes on whichever comes first: the loopback redirect (same machine) or an
|
|
223
|
+
* approval of the device code from any browser (e.g. the phone that opened the
|
|
224
|
+
* sign-up confirmation email), where the person types the code from this
|
|
225
|
+
* terminal. The code is never put in the authorize URL or any other link, so
|
|
226
|
+
* nothing that opens in a browser can approve this login without the person
|
|
227
|
+
* typing it. Tokens only ever come back to this process: the loopback code
|
|
228
|
+
* needs our PKCE verifier and the device grant needs our device_code. If the
|
|
229
|
+
* device authorization cannot be started, the login is exactly the loopback
|
|
230
|
+
* flow it always was.
|
|
211
231
|
*/
|
|
212
232
|
export async function loginWithLoopback(openUrl = openBrowser, opts = {}) {
|
|
213
233
|
const meta = await discover();
|
|
214
234
|
const pkce = generatePkce();
|
|
215
235
|
const state = crypto.randomBytes(16).toString("hex");
|
|
216
|
-
const { port, codePromise, close } = await startLoopbackServer(state);
|
|
236
|
+
const { port, codePromise, close, setTimeoutMs } = await startLoopbackServer(state);
|
|
217
237
|
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
218
|
-
|
|
219
|
-
// requires exact redirect_uri matching, and the loopback port is ephemeral,
|
|
220
|
-
// so we register per-login (cheap, public client, no secret).
|
|
221
|
-
const clientId = await registerClient([redirectUri]);
|
|
222
|
-
const authUrl = new URL(meta.authorization_endpoint);
|
|
223
|
-
authUrl.searchParams.set("response_type", "code");
|
|
224
|
-
authUrl.searchParams.set("client_id", clientId);
|
|
225
|
-
authUrl.searchParams.set("redirect_uri", redirectUri);
|
|
226
|
-
authUrl.searchParams.set("scope", negotiateScopes(meta));
|
|
227
|
-
authUrl.searchParams.set("state", state);
|
|
228
|
-
authUrl.searchParams.set("code_challenge", pkce.challenge);
|
|
229
|
-
authUrl.searchParams.set("code_challenge_method", pkce.method);
|
|
230
|
-
// A Mnemom Agent invitation (`mnemom agent --invite`) rides along so a NEW
|
|
231
|
-
// invitee who signs up in this browser round-trip skips the confirmation
|
|
232
|
-
// email: the AS ignores the param but bounces a signed-out browser to /login
|
|
233
|
-
// with this exact URL as return_to, and sign-up forwards the token to the api.
|
|
234
|
-
if (opts.inviteToken)
|
|
235
|
-
authUrl.searchParams.set("invite_token", opts.inviteToken);
|
|
236
|
-
console.log("Opening browser to authenticate...");
|
|
237
|
-
console.log(`If the browser doesn't open, visit:\n ${authUrl.toString()}\n`);
|
|
238
|
-
openUrl(authUrl.toString());
|
|
239
|
-
console.log("Waiting for authentication...");
|
|
238
|
+
const stopPolling = new AbortController();
|
|
240
239
|
try {
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
|
|
240
|
+
// Register a client bound to the exact loopback redirect URI. OAuth 2.1
|
|
241
|
+
// requires exact redirect_uri matching, and the loopback port is ephemeral,
|
|
242
|
+
// so we register per-login (cheap, public client, no secret). The same
|
|
243
|
+
// client also runs the device grant, so either path refreshes the same way.
|
|
244
|
+
const clientId = await registerClient([redirectUri]);
|
|
245
|
+
const device = await startCrossDeviceAuthorization(meta, clientId);
|
|
246
|
+
const authUrl = new URL(meta.authorization_endpoint);
|
|
247
|
+
authUrl.searchParams.set("response_type", "code");
|
|
248
|
+
authUrl.searchParams.set("client_id", clientId);
|
|
249
|
+
authUrl.searchParams.set("redirect_uri", redirectUri);
|
|
250
|
+
authUrl.searchParams.set("scope", negotiateScopes(meta));
|
|
251
|
+
authUrl.searchParams.set("state", state);
|
|
252
|
+
authUrl.searchParams.set("code_challenge", pkce.challenge);
|
|
253
|
+
authUrl.searchParams.set("code_challenge_method", pkce.method);
|
|
254
|
+
// A Mnemom Agent invitation (`mnemom agent --invite`) rides along so a NEW
|
|
255
|
+
// invitee who signs up in this browser round-trip skips the confirmation
|
|
256
|
+
// email: the AS ignores the param but bounces a signed-out browser to /login
|
|
257
|
+
// with this exact URL as return_to, and sign-up forwards the token to the api.
|
|
258
|
+
if (opts.inviteToken)
|
|
259
|
+
authUrl.searchParams.set("invite_token", opts.inviteToken);
|
|
260
|
+
console.log("Opening browser to authenticate...");
|
|
261
|
+
console.log(`If the browser doesn't open, visit:\n ${authUrl.toString()}\n`);
|
|
262
|
+
if (device) {
|
|
263
|
+
setTimeoutMs(crossDeviceWaitMs(device.expires_in));
|
|
264
|
+
// The plain verification_uri, never verification_uri_complete: the person
|
|
265
|
+
// types the code there, so a link alone can never approve this login.
|
|
266
|
+
console.log(`Confirming on another device? Open ${device.verification_uri} and enter: ${device.user_code}\n`);
|
|
267
|
+
}
|
|
268
|
+
openUrl(authUrl.toString());
|
|
269
|
+
console.log("Waiting for authentication...");
|
|
270
|
+
const viaLoopback = codePromise.then((code) => exchangeCode(meta, clientId, code, pkce.verifier, redirectUri));
|
|
271
|
+
const viaDevice = device
|
|
272
|
+
? pollDeviceToken(meta, clientId, device, opts.sleep ?? ((ms) => abortableSleep(ms, stopPolling.signal)),
|
|
273
|
+
// The loopback wait below already prints the heartbeat.
|
|
274
|
+
() => { }, stopPolling.signal)
|
|
275
|
+
: null;
|
|
276
|
+
// Heartbeat while we wait — otherwise the sign-in round-trip is dead
|
|
277
|
+
// silent and reads as a hung CLI.
|
|
278
|
+
const tokens = await withHeartbeat(firstLogin(viaLoopback, viaDevice), LOGIN_HEARTBEAT_MS, (s) => console.log(`…still waiting for you to finish signing in (${s}s)`));
|
|
245
279
|
return { tokens, clientId };
|
|
246
280
|
}
|
|
247
281
|
finally {
|
|
282
|
+
stopPolling.abort();
|
|
248
283
|
close();
|
|
249
284
|
}
|
|
250
285
|
}
|
|
286
|
+
/**
|
|
287
|
+
* Start the device authorization that lets a browser login be approved from
|
|
288
|
+
* another device. Best-effort: an AS without the endpoint, or any failure,
|
|
289
|
+
* returns null and the login carries on as loopback only.
|
|
290
|
+
*/
|
|
291
|
+
async function startCrossDeviceAuthorization(meta, clientId) {
|
|
292
|
+
if (!meta.device_authorization_endpoint)
|
|
293
|
+
return null;
|
|
294
|
+
try {
|
|
295
|
+
const authz = await requestDeviceAuthorization(meta.device_authorization_endpoint, meta, clientId);
|
|
296
|
+
if (typeof authz.device_code !== "string" ||
|
|
297
|
+
typeof authz.user_code !== "string" ||
|
|
298
|
+
typeof authz.verification_uri !== "string" ||
|
|
299
|
+
typeof authz.expires_in !== "number") {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
return authz;
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
/** How long the loopback listener waits while a device code is live. */
|
|
309
|
+
export function crossDeviceWaitMs(expiresInSec) {
|
|
310
|
+
const ms = Number.isFinite(expiresInSec) ? expiresInSec * 1000 : 0;
|
|
311
|
+
return Math.min(Math.max(ms, LOOPBACK_TIMEOUT_MS), CROSS_DEVICE_MAX_WAIT_MS);
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Settle with whichever login path finishes first.
|
|
315
|
+
* - The loopback path settles the login either way, exactly as before: a
|
|
316
|
+
* denial, a state mismatch or the timeout in the browser ends it.
|
|
317
|
+
* - The device path ends it on success, or when the person explicitly denies
|
|
318
|
+
* the device code. Any other device failure (expiry, a transport error)
|
|
319
|
+
* only drops that path and the loopback wait continues.
|
|
320
|
+
*/
|
|
321
|
+
export function firstLogin(viaLoopback, viaDevice) {
|
|
322
|
+
if (!viaDevice)
|
|
323
|
+
return viaLoopback;
|
|
324
|
+
return new Promise((resolve, reject) => {
|
|
325
|
+
viaLoopback.then(resolve, reject);
|
|
326
|
+
viaDevice.then(resolve, (err) => {
|
|
327
|
+
if (err instanceof Error && err.message === DEVICE_ACCESS_DENIED)
|
|
328
|
+
reject(err);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
}
|
|
251
332
|
/**
|
|
252
333
|
* Start a loopback HTTP listener that captures the OAuth redirect
|
|
253
334
|
* (GET /callback?code=...&state=...). Resolves with the authorization code once
|
|
@@ -293,13 +374,18 @@ function startLoopbackServer(expectedState) {
|
|
|
293
374
|
return new Promise((resolve) => {
|
|
294
375
|
server.listen(0, "127.0.0.1", () => {
|
|
295
376
|
const port = server.address().port;
|
|
296
|
-
const
|
|
377
|
+
const giveUp = () => {
|
|
297
378
|
rejectCode(new Error("Login timed out. Please try again."));
|
|
298
379
|
server.close();
|
|
299
|
-
}
|
|
380
|
+
};
|
|
381
|
+
let timeout = setTimeout(giveUp, LOOPBACK_TIMEOUT_MS);
|
|
300
382
|
resolve({
|
|
301
383
|
port,
|
|
302
384
|
codePromise,
|
|
385
|
+
setTimeoutMs: (ms) => {
|
|
386
|
+
clearTimeout(timeout);
|
|
387
|
+
timeout = setTimeout(giveUp, ms);
|
|
388
|
+
},
|
|
303
389
|
close: () => {
|
|
304
390
|
clearTimeout(timeout);
|
|
305
391
|
server.close();
|
|
@@ -358,18 +444,7 @@ export async function loginWithDevice(opts) {
|
|
|
358
444
|
// clients"). Register a loopback placeholder — it satisfies validation and is
|
|
359
445
|
// never used, since the device grant never redirects.
|
|
360
446
|
const clientId = await registerClient(["http://127.0.0.1/callback"]);
|
|
361
|
-
const
|
|
362
|
-
method: "POST",
|
|
363
|
-
headers: {
|
|
364
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
365
|
-
Accept: "application/json",
|
|
366
|
-
},
|
|
367
|
-
body: new URLSearchParams({ client_id: clientId, scope: negotiateScopes(meta) }),
|
|
368
|
-
});
|
|
369
|
-
if (!authzRes.ok) {
|
|
370
|
-
throw new Error(`Device authorization failed: ${await oauthError(authzRes)}`);
|
|
371
|
-
}
|
|
372
|
-
const authz = (await authzRes.json());
|
|
447
|
+
const authz = await requestDeviceAuthorization(meta.device_authorization_endpoint, meta, clientId);
|
|
373
448
|
display("");
|
|
374
449
|
display("To authenticate, visit:");
|
|
375
450
|
display(` ${authz.verification_uri}`);
|
|
@@ -385,7 +460,26 @@ export async function loginWithDevice(opts) {
|
|
|
385
460
|
const tokens = await pollDeviceToken(meta, clientId, authz, sleep, display);
|
|
386
461
|
return { tokens, clientId };
|
|
387
462
|
}
|
|
388
|
-
|
|
463
|
+
/** POST the device authorization request (RFC 8628 §3.1) for `clientId`. */
|
|
464
|
+
async function requestDeviceAuthorization(endpoint, meta, clientId) {
|
|
465
|
+
const authzRes = await oauthFetch("Device authorization at", endpoint, {
|
|
466
|
+
method: "POST",
|
|
467
|
+
headers: {
|
|
468
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
469
|
+
Accept: "application/json",
|
|
470
|
+
},
|
|
471
|
+
body: new URLSearchParams({ client_id: clientId, scope: negotiateScopes(meta) }),
|
|
472
|
+
});
|
|
473
|
+
if (!authzRes.ok) {
|
|
474
|
+
throw new Error(`Device authorization failed: ${await oauthError(authzRes)}`);
|
|
475
|
+
}
|
|
476
|
+
return (await authzRes.json());
|
|
477
|
+
}
|
|
478
|
+
/** The message a device-code denial rejects with (matched by firstLogin). */
|
|
479
|
+
const DEVICE_ACCESS_DENIED = "Authorization was denied.";
|
|
480
|
+
/** Thrown into an abandoned device poll once the other login path has won. */
|
|
481
|
+
const POLL_STOPPED = "Device polling stopped.";
|
|
482
|
+
async function pollDeviceToken(meta, clientId, authz, sleep, display, signal) {
|
|
389
483
|
// RFC 8628 §3.5: default interval is 5s if the server omits it; on slow_down
|
|
390
484
|
// we increase the interval by 5s and keep that as the new minimum.
|
|
391
485
|
let intervalMs = (authz.interval ?? 5) * 1000;
|
|
@@ -399,6 +493,8 @@ async function pollDeviceToken(meta, clientId, authz, sleep, display) {
|
|
|
399
493
|
throw new Error("Device authorization expired before approval. Please try again.");
|
|
400
494
|
}
|
|
401
495
|
await sleep(intervalMs);
|
|
496
|
+
if (signal?.aborted)
|
|
497
|
+
throw new Error(POLL_STOPPED);
|
|
402
498
|
waitedMs += intervalMs;
|
|
403
499
|
let res;
|
|
404
500
|
try {
|
|
@@ -437,7 +533,7 @@ async function pollDeviceToken(meta, clientId, authz, sleep, display) {
|
|
|
437
533
|
case "expired_token":
|
|
438
534
|
throw new Error("Device authorization expired before approval. Please try again.");
|
|
439
535
|
case "access_denied":
|
|
440
|
-
throw new Error(
|
|
536
|
+
throw new Error(DEVICE_ACCESS_DENIED);
|
|
441
537
|
default:
|
|
442
538
|
throw new Error(`Device authorization failed: ${body.error ?? `HTTP ${res.status}`}` +
|
|
443
539
|
(body.error_description ? ` — ${body.error_description}` : ""));
|
|
@@ -623,6 +719,23 @@ export function openBrowser(url) {
|
|
|
623
719
|
function defaultSleep(ms) {
|
|
624
720
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
625
721
|
}
|
|
722
|
+
/**
|
|
723
|
+
* setTimeout-based sleep that resolves early when `signal` aborts, so an
|
|
724
|
+
* abandoned device poll does not hold the process open after login finishes.
|
|
725
|
+
*/
|
|
726
|
+
export function abortableSleep(ms, signal) {
|
|
727
|
+
return new Promise((resolve) => {
|
|
728
|
+
if (signal.aborted)
|
|
729
|
+
return resolve();
|
|
730
|
+
const done = () => {
|
|
731
|
+
clearTimeout(timer);
|
|
732
|
+
signal.removeEventListener("abort", done);
|
|
733
|
+
resolve();
|
|
734
|
+
};
|
|
735
|
+
const timer = setTimeout(done, ms);
|
|
736
|
+
signal.addEventListener("abort", done, { once: true });
|
|
737
|
+
});
|
|
738
|
+
}
|
|
626
739
|
/**
|
|
627
740
|
* Await `promise` while emitting a heartbeat tick every `intervalMs`, so a long
|
|
628
741
|
* silent wait (a browser sign-in round-trip) doesn't look hung. `onTick` is
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Entry for the `mnemom agent` status-bar
|
|
3
|
-
* on every status update, so it imports only the
|
|
2
|
+
* Entry for the `mnemom agent` status-bar segment (lib/statusline.ts). Claude Code runs it
|
|
3
|
+
* on every status update, so it imports only the segment — not the whole CLI.
|
|
4
4
|
*/
|
|
5
5
|
export {};
|
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Entry for the `mnemom agent` status-bar
|
|
3
|
-
* on every status update, so it imports only the
|
|
2
|
+
* Entry for the `mnemom agent` status-bar segment (lib/statusline.ts). Claude Code runs it
|
|
3
|
+
* on every status update, so it imports only the segment — not the whole CLI.
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
6
|
-
import { join } from "node:path";
|
|
7
|
-
import { INNER_ENV, measuredMode, render } from "./statusline.js";
|
|
5
|
+
import { FEED_ENV, INNER_ENV, render } from "./statusline.js";
|
|
8
6
|
const chunks = [];
|
|
9
7
|
process.stdin.on("data", (c) => chunks.push(c));
|
|
10
8
|
process.stdin.on("end", () => {
|
|
11
9
|
const line = render(Buffer.concat(chunks).toString("utf8"), {
|
|
12
|
-
stateDir: join(homedir(), ".mnemom", "statusline"),
|
|
13
10
|
inner: process.env[INNER_ENV] || undefined,
|
|
14
|
-
|
|
11
|
+
feed: process.env[FEED_ENV] || undefined,
|
|
15
12
|
});
|
|
16
13
|
process.stdout.write(`${line}\n`);
|
|
17
14
|
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The launcher side of the fed status line (W1.4, decision 14), prerelease only.
|
|
3
|
+
*
|
|
4
|
+
* The local base-URL proxy records the gateway's `x-mnemom-lineage-key` for this session in
|
|
5
|
+
* its record-state file (the one the conduct receipt reads). While Claude Code runs, the launcher reads Record's accumulator totals for that
|
|
6
|
+
* lineage every few seconds and writes them to the feed file the status-line bin reads
|
|
7
|
+
* (lib/statusline.ts). The bin never touches the network.
|
|
8
|
+
*
|
|
9
|
+
* Fail open everywhere: no lineage yet, a dark route (404), a non-billing role (403) or any
|
|
10
|
+
* error leaves the feed as it was, so the status line stays "mnemom" until fed.
|
|
11
|
+
*/
|
|
12
|
+
import { type RecordTotals } from "./api.js";
|
|
13
|
+
/** How often the launcher re-reads the totals. */
|
|
14
|
+
export declare const FEED_INTERVAL_MS = 10000;
|
|
15
|
+
export interface FeedOptions {
|
|
16
|
+
/** The proxy's record-state JSON (MNEMOM_AGENT_BU_RECORD_STATE), carrying `lineage_key`. */
|
|
17
|
+
recordStateFile: string;
|
|
18
|
+
/** Read by the status-line bin (MNEMOM_STATUSLINE_FEED). */
|
|
19
|
+
feedFile: string;
|
|
20
|
+
orgId: string;
|
|
21
|
+
intervalMs?: number;
|
|
22
|
+
/** Test seam; the real read is getRecordTotals (throws on any non-200). */
|
|
23
|
+
fetchTotals?: (orgId: string, lineageKey: string) => Promise<RecordTotals | null>;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The lineage key the proxy recorded, or null when there is none (yet) or it is not the
|
|
27
|
+
* gateway's `cid:`/`cr:` shape (the proxy's own check is looser; this read is exact).
|
|
28
|
+
*/
|
|
29
|
+
export declare function readLineage(path: string): string | null;
|
|
30
|
+
/** One poll: read the lineage, fetch its totals, write the feed. Never throws. */
|
|
31
|
+
export declare function feedOnce(o: FeedOptions): Promise<boolean>;
|
|
32
|
+
/** Poll until stopped. The timer is unref'd, so it never holds the launcher open. */
|
|
33
|
+
export declare function startFeed(o: FeedOptions): () => void;
|