agentus 0.0.0-stage → 0.2.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/LICENSE +202 -0
- package/NOTICE +11 -0
- package/README.md +476 -2
- package/README.zh-CN.md +396 -0
- package/bin/agentus.mjs +147 -0
- package/docs/android-notify-contract.md +658 -0
- package/docs/dev/01-dev-loop.md +186 -0
- package/docs/dev/02-verify.md +237 -0
- package/docs/dev/03-release.md +218 -0
- package/docs/dev/04-recover.md +199 -0
- package/docs/dev/05-agent-driven.md +114 -0
- package/docs/dev/README.md +65 -0
- package/docs/installation.md +366 -0
- package/docs/installation.zh-CN.md +350 -0
- package/docs/refs/aionui-acp-rules.md +46 -0
- package/docs/refs/hermes-studio-ui-ideas.md +191 -0
- package/docs/transcript-folding.md +139 -0
- package/package.json +76 -5
- package/packages/server/mcp/plan-server.mjs +217 -0
- package/packages/server/mock/agent.mjs +693 -0
- package/packages/server/package.json +22 -0
- package/packages/server/src/acp/backends.ts +196 -0
- package/packages/server/src/acp/errors.ts +46 -0
- package/packages/server/src/acp/registry.ts +579 -0
- package/packages/server/src/acp/session-manager.ts +1868 -0
- package/packages/server/src/auth.ts +688 -0
- package/packages/server/src/dashscope.ts +235 -0
- package/packages/server/src/fs.ts +379 -0
- package/packages/server/src/hotwords.ts +132 -0
- package/packages/server/src/index.ts +1486 -0
- package/packages/server/src/notify/center.ts +994 -0
- package/packages/server/src/notify/types.ts +222 -0
- package/packages/server/src/plan/plan.ts +126 -0
- package/packages/server/src/pty-helper.py +135 -0
- package/packages/server/src/settings.ts +469 -0
- package/packages/server/src/store/export.ts +209 -0
- package/packages/server/src/store/store.ts +1238 -0
- package/packages/server/src/term.ts +201 -0
- package/packages/server/src/title.ts +109 -0
- package/packages/server/src/voice.ts +251 -0
- package/packages/shared/package.json +14 -0
- package/packages/shared/src/index.ts +271 -0
- package/packages/web/dist/assets/index-D667PX2c.css +32 -0
- package/packages/web/dist/assets/index-d9NzwPFu.js +283 -0
- package/packages/web/dist/coding-agents/hermes.png +0 -0
- package/packages/web/dist/coding-agents/qoder.svg +5 -0
- package/packages/web/dist/icons/agentus-icon-inverse.svg +301 -0
- package/packages/web/dist/icons/agentus-icon.svg +301 -0
- package/packages/web/dist/icons/agentus-mark-inverse.svg +301 -0
- package/packages/web/dist/icons/agentus-mark.svg +301 -0
- package/packages/web/dist/icons/apple-touch-icon-180.png +0 -0
- package/packages/web/dist/icons/favicon.ico +0 -0
- package/packages/web/dist/icons/icon-192.png +0 -0
- package/packages/web/dist/icons/icon-512.png +0 -0
- package/packages/web/dist/icons/icon-maskable-512.png +0 -0
- package/packages/web/dist/index.html +30 -0
- package/packages/web/dist/manifest.webmanifest +17 -0
- package/packages/web/dist/sw.js +61 -0
- package/scripts/make-cert.sh +101 -0
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
// Agentus auth — one operator, two credentials.
|
|
2
|
+
//
|
|
3
|
+
// 1. session cookie : HttpOnly + SameSite=Lax, HMAC-SHA256 signed, minted by
|
|
4
|
+
// POST /api/auth/login. This is what the browser carries.
|
|
5
|
+
// 2. machine token : 32-byte hex in <DATA_DIR>/auth.token (0600), accepted as
|
|
6
|
+
// `Authorization: Bearer <t>` or `?token=<t>`. Scripts,
|
|
7
|
+
// launchers and CI use this; no password on disk.
|
|
8
|
+
//
|
|
9
|
+
// Why a cookie and not the bearer-in-localStorage that hermes-studio's client uses
|
|
10
|
+
// (studied, not copied — packages/server/src/middleware/user-auth.ts): the cockpit
|
|
11
|
+
// spawns processes that can write files, so an XSS-readable token is a bad trade.
|
|
12
|
+
// An HttpOnly cookie is unreadable from JS and rides along on the WS handshake for
|
|
13
|
+
// free. Scripts still get the bearer path, so nothing becomes un-scriptable.
|
|
14
|
+
//
|
|
15
|
+
// Threat model: this gates a LAN-exposed service that can execute agent commands in
|
|
16
|
+
// arbitrary working directories. It is a lock on the shed, not a bank vault — no
|
|
17
|
+
// TLS by default (a bare LAN IP cannot carry a trusted cert), so treat the operator
|
|
18
|
+
// password as LAN-trust, and keep the default off the network.
|
|
19
|
+
import { createHash, createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
|
|
23
|
+
const COOKIE_NAME = "agentus_session";
|
|
24
|
+
const DEFAULT_USERNAME = "admin";
|
|
25
|
+
const DEFAULT_PASSWORD = "123456";
|
|
26
|
+
const SESSION_VERSION = "v1";
|
|
27
|
+
|
|
28
|
+
export interface AuthPrincipal {
|
|
29
|
+
kind: "session" | "token";
|
|
30
|
+
username: string;
|
|
31
|
+
expiresAt: number | null;
|
|
32
|
+
/** session id — lets the sessions list mark which row is the caller */
|
|
33
|
+
jti?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AuthStatus {
|
|
37
|
+
enabled: boolean;
|
|
38
|
+
username: string;
|
|
39
|
+
usingDefaultPassword: boolean;
|
|
40
|
+
/** where the username/password in play came from */
|
|
41
|
+
source: "saved" | "env" | "default";
|
|
42
|
+
/** live sessions in the registry (advisory count for the boot log) */
|
|
43
|
+
sessions: number;
|
|
44
|
+
/** ISO path of the machine token, so the boot log can point scripts at it */
|
|
45
|
+
tokenFile: string | null;
|
|
46
|
+
sessionTtlMs: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface LimiterEntry {
|
|
50
|
+
fails: number;
|
|
51
|
+
lockedUntil: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const limiter = new Map<string, LimiterEntry>();
|
|
55
|
+
|
|
56
|
+
function envNum(name: string, fallback: number): number {
|
|
57
|
+
const raw = Number(process.env[name]);
|
|
58
|
+
return Number.isFinite(raw) && raw > 0 ? raw : fallback;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** `AGENTUS_AUTH=off` opens the cockpit (local hacking only) — it says so loudly. */
|
|
62
|
+
export function authEnabled(): boolean {
|
|
63
|
+
const v = String(process.env.AGENTUS_AUTH ?? "on").trim().toLowerCase();
|
|
64
|
+
return !(v === "off" || v === "0" || v === "false" || v === "no");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function configuredUsername(): string {
|
|
68
|
+
return stored?.username ?? (String(process.env.AGENTUS_USERNAME ?? DEFAULT_USERNAME).trim() || DEFAULT_USERNAME);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function configuredPassword(): string {
|
|
72
|
+
return process.env.AGENTUS_PASSWORD ?? DEFAULT_PASSWORD;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function usesDefaultPassword(): boolean {
|
|
76
|
+
return !stored && !process.env.AGENTUS_PASSWORD && !process.env.AGENTUS_PASSWORD_HASH;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Where the credentials in play come from — the settings page names the source, the
|
|
80
|
+
* same way the context-window popover names where its number came from. */
|
|
81
|
+
export function credentialSource(): "saved" | "env" | "default" {
|
|
82
|
+
if (stored) return "saved";
|
|
83
|
+
return process.env.AGENTUS_PASSWORD || process.env.AGENTUS_PASSWORD_HASH ? "env" : "default";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Bumped on every credential change and carried in the session payload, so a password
|
|
87
|
+
* change invalidates every cookie that was minted before it. */
|
|
88
|
+
export function credentialEpoch(): number {
|
|
89
|
+
return stored?.epoch ?? 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** scrypt:<salt>:<hex> — the shape hermes-studio writes, and what AGENTUS_PASSWORD_HASH takes. */
|
|
93
|
+
export function hashPassword(password: string): string {
|
|
94
|
+
const salt = randomBytes(16).toString("hex");
|
|
95
|
+
return `scrypt:${salt}:${scryptSync(password, salt, 32).toString("hex")}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const MIN_PASSWORD_LEN = 6;
|
|
99
|
+
export const MIN_USERNAME_LEN = 2;
|
|
100
|
+
|
|
101
|
+
interface StoredCreds {
|
|
102
|
+
username: string;
|
|
103
|
+
/** scrypt:<salt>:<hex> — never the password itself */
|
|
104
|
+
hash: string;
|
|
105
|
+
epoch: number;
|
|
106
|
+
updatedAt: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let stored: StoredCreds | null = null;
|
|
110
|
+
let credsFile: string | null = null;
|
|
111
|
+
|
|
112
|
+
function readStored(file: string): StoredCreds | null {
|
|
113
|
+
try {
|
|
114
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf8")) as Partial<StoredCreds>;
|
|
115
|
+
if (!raw.username || !raw.hash || !String(raw.hash).startsWith("scrypt:")) return null;
|
|
116
|
+
return {
|
|
117
|
+
username: String(raw.username).trim(),
|
|
118
|
+
hash: String(raw.hash),
|
|
119
|
+
epoch: Number(raw.epoch ?? 1) || 1,
|
|
120
|
+
updatedAt: Number(raw.updatedAt ?? 0),
|
|
121
|
+
};
|
|
122
|
+
} catch { return null; }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export type CredentialResult =
|
|
126
|
+
| { ok: true; username: string; epoch: number }
|
|
127
|
+
| { ok: false; status: number; error: string };
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Change the operator's credentials. The current password is required even though the
|
|
131
|
+
* caller already holds a session: a session can be stolen, and this is the one action
|
|
132
|
+
* that hands the whole cockpit over. On success every existing cookie stops validating
|
|
133
|
+
* (the epoch moves) — the caller gets a fresh one from the endpoint.
|
|
134
|
+
*/
|
|
135
|
+
export function changeCredentials(input: { currentPassword: string; username?: string; newPassword?: string }): CredentialResult {
|
|
136
|
+
if (!verifyCredentials(configuredUsername(), String(input.currentPassword ?? ""))) {
|
|
137
|
+
return { ok: false, status: 401, error: "current password is not right" };
|
|
138
|
+
}
|
|
139
|
+
if (input.username === undefined && input.newPassword === undefined) {
|
|
140
|
+
return { ok: false, status: 400, error: "nothing to change" };
|
|
141
|
+
}
|
|
142
|
+
const nextUser = input.username === undefined ? configuredUsername() : String(input.username).trim();
|
|
143
|
+
if (nextUser.length < MIN_USERNAME_LEN || nextUser.length > 32 || /\s/.test(nextUser)) {
|
|
144
|
+
return { ok: false, status: 400, error: `username must be ${MIN_USERNAME_LEN}-32 characters with no spaces` };
|
|
145
|
+
}
|
|
146
|
+
const newPass = input.newPassword === undefined ? null : String(input.newPassword);
|
|
147
|
+
if (newPass !== null && newPass.length < MIN_PASSWORD_LEN) {
|
|
148
|
+
return { ok: false, status: 400, error: `password must be at least ${MIN_PASSWORD_LEN} characters` };
|
|
149
|
+
}
|
|
150
|
+
if (!credsFile) return { ok: false, status: 500, error: "credentials are not initialised" };
|
|
151
|
+
const next: StoredCreds = {
|
|
152
|
+
username: nextUser,
|
|
153
|
+
// a username-only change has to pin today's password as a hash, or the env value
|
|
154
|
+
// would silently come back into play
|
|
155
|
+
hash: newPass === null ? (stored?.hash ?? hashPassword(configuredPassword())) : hashPassword(newPass),
|
|
156
|
+
epoch: (stored?.epoch ?? 0) + 1,
|
|
157
|
+
updatedAt: Date.now(),
|
|
158
|
+
};
|
|
159
|
+
fs.mkdirSync(path.dirname(credsFile), { recursive: true });
|
|
160
|
+
fs.writeFileSync(credsFile, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
|
161
|
+
stored = next;
|
|
162
|
+
return { ok: true, username: next.username, epoch: next.epoch };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function sessionTtlMs(): number {
|
|
166
|
+
// NB: sessions carry a JWT-style `exp` stamped in whole SECONDS, so a TTL below
|
|
167
|
+
// 1s can expire in the same second it was issued. Anything sane (hours/days) is fine.
|
|
168
|
+
return envNum("AGENTUS_SESSION_TTL_MS", 1000 * 60 * 60 * 24 * 7); // 7 days
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---- key material -----------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
let secret: Buffer | null = null;
|
|
174
|
+
let tokenFile: string | null = null;
|
|
175
|
+
let machineToken: string | null = null;
|
|
176
|
+
|
|
177
|
+
function readOrCreate(file: string, bytes: number): string {
|
|
178
|
+
try {
|
|
179
|
+
const v = fs.readFileSync(file, "utf8").trim();
|
|
180
|
+
if (v) return v;
|
|
181
|
+
} catch { /* missing or unreadable => mint a new one */ }
|
|
182
|
+
const value = randomBytes(bytes).toString("hex");
|
|
183
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
184
|
+
fs.writeFileSync(file, `${value}\n`, { mode: 0o600 });
|
|
185
|
+
return value;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Load key material once at boot. Both files are 0600 and live next to the store,
|
|
190
|
+
* so a restart keeps existing sessions valid (no surprise logout) and the machine
|
|
191
|
+
* token stays stable for scripts.
|
|
192
|
+
*/
|
|
193
|
+
export function initAuth(dataDir: string): AuthStatus {
|
|
194
|
+
// Credentials the operator changed in the UI win over the environment; the env pair
|
|
195
|
+
// stays the bootstrap, exactly like voice/theme settings (settings.json > .env).
|
|
196
|
+
credsFile = path.join(dataDir, "credentials.json");
|
|
197
|
+
stored = readStored(credsFile);
|
|
198
|
+
sessionsFile = path.join(dataDir, "sessions.json");
|
|
199
|
+
adoptingLegacy = !fs.existsSync(sessionsFile);
|
|
200
|
+
records = readRecords(sessionsFile);
|
|
201
|
+
const secretRaw = process.env.AGENTUS_AUTH_SECRET?.trim() || readOrCreate(path.join(dataDir, "auth.secret"), 32);
|
|
202
|
+
secret = Buffer.from(secretRaw, "utf8");
|
|
203
|
+
// Exported so WS-upgrade code (a different module, same process) can verify too.
|
|
204
|
+
const envToken = process.env.AGENTUS_AUTH_TOKEN?.trim();
|
|
205
|
+
if (envToken) {
|
|
206
|
+
tokenFile = null;
|
|
207
|
+
machineToken = envToken;
|
|
208
|
+
} else {
|
|
209
|
+
tokenFile = path.join(dataDir, "auth.token");
|
|
210
|
+
machineToken = readOrCreate(tokenFile, 32);
|
|
211
|
+
}
|
|
212
|
+
return status();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function status(): AuthStatus {
|
|
216
|
+
return {
|
|
217
|
+
enabled: authEnabled(),
|
|
218
|
+
username: configuredUsername(),
|
|
219
|
+
usingDefaultPassword: usesDefaultPassword(),
|
|
220
|
+
source: credentialSource(),
|
|
221
|
+
sessions: records.length,
|
|
222
|
+
tokenFile,
|
|
223
|
+
sessionTtlMs: sessionTtlMs(),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function hmac(input: string): string {
|
|
228
|
+
return createHmac("sha256", secret ?? Buffer.from("uninitialised")).update(input).digest("base64url");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function safeEqual(a: string, b: string): boolean {
|
|
232
|
+
const left = Buffer.from(a);
|
|
233
|
+
const right = Buffer.from(b);
|
|
234
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** scrypt compare against `scrypt:<salt>:<hex>` (same on-disk shape hermes-studio uses). */
|
|
238
|
+
function verifyScrypt(password: string, stored: string): boolean {
|
|
239
|
+
const [scheme, salt, expectedHex] = stored.split(":");
|
|
240
|
+
if (scheme !== "scrypt" || !salt || !expectedHex) return false;
|
|
241
|
+
const expected = Buffer.from(expectedHex, "hex");
|
|
242
|
+
if (expected.length === 0) return false;
|
|
243
|
+
const actual = scryptSync(password, salt, expected.length);
|
|
244
|
+
return timingSafeEqual(actual, expected);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function verifyCredentials(username: string, password: string): boolean {
|
|
248
|
+
// Both sides are hashed before comparison: equal lengths (so timingSafeEqual is
|
|
249
|
+
// usable) and no plaintext string identity check anywhere on the path.
|
|
250
|
+
const userOk = safeEqual(
|
|
251
|
+
createHash("sha256").update(String(username)).digest("hex"),
|
|
252
|
+
createHash("sha256").update(configuredUsername()).digest("hex"),
|
|
253
|
+
);
|
|
254
|
+
// saved (UI-set) > AGENTUS_PASSWORD_HASH > AGENTUS_PASSWORD > built-in default
|
|
255
|
+
const hashEnv = process.env.AGENTUS_PASSWORD_HASH?.trim();
|
|
256
|
+
const hash = stored?.hash ?? hashEnv;
|
|
257
|
+
const passOk = hash ? verifyScrypt(String(password), hash) : safeEqual(
|
|
258
|
+
createHash("sha256").update(String(password)).digest("hex"),
|
|
259
|
+
createHash("sha256").update(configuredPassword()).digest("hex"),
|
|
260
|
+
);
|
|
261
|
+
return userOk && passOk;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---- session tokens ---------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
interface SessionBody {
|
|
267
|
+
u: string;
|
|
268
|
+
iat: number;
|
|
269
|
+
exp: number;
|
|
270
|
+
/** credential epoch: a password change moves it, so old cookies stop validating */
|
|
271
|
+
e: number;
|
|
272
|
+
/** session id — needed so "log out" can actually revoke, not just clear the cookie */
|
|
273
|
+
jti: string;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Revoked session ids (in memory: a restart forgets, which also invalidates nothing —
|
|
277
|
+
* every cookie still has to pass signature+expiry on its own). */
|
|
278
|
+
const revoked = new Set<string>();
|
|
279
|
+
const REVOKED_CAP = 1000;
|
|
280
|
+
|
|
281
|
+
export function issueSession(
|
|
282
|
+
username: string,
|
|
283
|
+
now = Date.now(),
|
|
284
|
+
origin: { ip?: string; ua?: string } = {},
|
|
285
|
+
): { value: string; expiresAt: number; jti: string } {
|
|
286
|
+
const exp = now + sessionTtlMs();
|
|
287
|
+
const payload: SessionBody = {
|
|
288
|
+
u: username,
|
|
289
|
+
iat: Math.floor(now / 1000),
|
|
290
|
+
exp: Math.floor(exp / 1000),
|
|
291
|
+
e: credentialEpoch(),
|
|
292
|
+
jti: randomBytes(8).toString("hex"),
|
|
293
|
+
};
|
|
294
|
+
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
295
|
+
// Recorded so the operator can SEE the devices holding a session and kick one. The
|
|
296
|
+
// index is advisory for reads but authoritative for existence: a token whose jti is
|
|
297
|
+
// not in the registry is refused (that is what makes "log out everywhere" real).
|
|
298
|
+
rememberSession({
|
|
299
|
+
jti: payload.jti,
|
|
300
|
+
username,
|
|
301
|
+
ip: String(origin.ip ?? ""),
|
|
302
|
+
ua: String(origin.ua ?? "").slice(0, 240),
|
|
303
|
+
iat: now,
|
|
304
|
+
exp,
|
|
305
|
+
lastSeen: now,
|
|
306
|
+
});
|
|
307
|
+
return { value: `${SESSION_VERSION}.${body}.${hmac(body)}`, expiresAt: exp, jti: payload.jti };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** signature + expiry, ignoring revocation (revoke itself has to be able to parse) */
|
|
311
|
+
function parseSession(value: string | undefined | null, now = Date.now()): SessionBody | null {
|
|
312
|
+
if (!value) return null;
|
|
313
|
+
const parts = value.split(".");
|
|
314
|
+
if (parts.length !== 3 || parts[0] !== SESSION_VERSION) return null;
|
|
315
|
+
const [, body, sig] = parts;
|
|
316
|
+
if (!safeEqual(sig, hmac(body))) return null;
|
|
317
|
+
try {
|
|
318
|
+
const parsed = JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as Partial<SessionBody>;
|
|
319
|
+
if (!parsed.u || !parsed.exp || !parsed.jti) return null;
|
|
320
|
+
if (Math.floor(now / 1000) >= parsed.exp) return null;
|
|
321
|
+
if (!safeEqual(String(parsed.u), configuredUsername())) return null; // username changed => old cookie dies
|
|
322
|
+
if (Number(parsed.e ?? 0) !== credentialEpoch()) return null; // password changed => same
|
|
323
|
+
return parsed as SessionBody;
|
|
324
|
+
} catch {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function verifySessionValue(value: string | undefined | null, now = Date.now()): SessionBody | null {
|
|
330
|
+
const body = parseSession(value, now);
|
|
331
|
+
if (!body) return null;
|
|
332
|
+
if (revoked.has(body.jti)) return null;
|
|
333
|
+
if (!sessionKnown(body.jti)) {
|
|
334
|
+
// First boot after this feature shipped: the registry did not exist yet, so a cookie
|
|
335
|
+
// that still passes signature+expiry is adopted instead of logging every device out.
|
|
336
|
+
if (!adoptingLegacy) return null;
|
|
337
|
+
rememberSession({ jti: body.jti, username: body.u, ip: "", ua: "", iat: body.iat * 1000, exp: body.exp * 1000, lastSeen: now });
|
|
338
|
+
adoptingLegacy = false;
|
|
339
|
+
} else {
|
|
340
|
+
noteSessionSeen(body.jti, now);
|
|
341
|
+
}
|
|
342
|
+
return body;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Logout with teeth: without this the stateless cookie would keep working until exp,
|
|
346
|
+
* so "sign out" would only hide the UI. */
|
|
347
|
+
export function revokeSession(value: string | undefined | null, now = Date.now()): boolean {
|
|
348
|
+
const body = parseSession(value, now);
|
|
349
|
+
if (!body) return false;
|
|
350
|
+
forgetSession(body.jti);
|
|
351
|
+
rememberRevoked(body.jti);
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** unbounded growth in a long-lived process is a slow leak; keep the recent tail */
|
|
356
|
+
function rememberRevoked(jti: string): void {
|
|
357
|
+
revoked.add(jti);
|
|
358
|
+
if (revoked.size > REVOKED_CAP) {
|
|
359
|
+
const first = revoked.values().next().value;
|
|
360
|
+
if (first) revoked.delete(first);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ---- the session registry: who is signed in, and from where ------------------
|
|
365
|
+
//
|
|
366
|
+
// Sessions are stateless (HMAC + expiry), which is exactly why they are invisible: a
|
|
367
|
+
// stolen cookie or a phone left logged in on a train cannot be seen, let alone revoked.
|
|
368
|
+
// This registry is the index the operator looks at and kicks from. It is advisory for
|
|
369
|
+
// reads, authoritative for existence — a valid-looking cookie whose jti is absent is
|
|
370
|
+
// refused, so "log out everywhere" survives a restart.
|
|
371
|
+
//
|
|
372
|
+
// Left over from hermes-studio (studied, not copied): their AccountSettings lists locked
|
|
373
|
+
// IPs and hands you an unlock button. We have had a login limiter since M1 with no way to
|
|
374
|
+
// look at it — same gap, same fix.
|
|
375
|
+
|
|
376
|
+
interface SessionRecord {
|
|
377
|
+
jti: string;
|
|
378
|
+
username: string;
|
|
379
|
+
ip: string;
|
|
380
|
+
ua: string;
|
|
381
|
+
iat: number;
|
|
382
|
+
exp: number;
|
|
383
|
+
lastSeen: number;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
let sessionsFile: string | null = null;
|
|
387
|
+
let records: SessionRecord[] = [];
|
|
388
|
+
/** true only until the registry exists on disk: valid cookies from before the upgrade
|
|
389
|
+
* are adopted once instead of logging every device out. */
|
|
390
|
+
let adoptingLegacy = true;
|
|
391
|
+
let lastFlush = 0;
|
|
392
|
+
|
|
393
|
+
function readRecords(file: string): SessionRecord[] {
|
|
394
|
+
try {
|
|
395
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf8")) as SessionRecord[];
|
|
396
|
+
if (!Array.isArray(raw)) return [];
|
|
397
|
+
return raw.filter((r) => r && typeof r.jti === "string" && Number(r.exp) > 0);
|
|
398
|
+
} catch { return []; }
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function flushSessions(force = false): void {
|
|
402
|
+
if (!sessionsFile) return;
|
|
403
|
+
const now = Date.now();
|
|
404
|
+
if (!force && now - lastFlush < 20000) return; // lastSeen ticks are not worth a write each
|
|
405
|
+
lastFlush = now;
|
|
406
|
+
records = records.filter((r) => r.exp > now);
|
|
407
|
+
fs.mkdirSync(path.dirname(sessionsFile), { recursive: true });
|
|
408
|
+
fs.writeFileSync(sessionsFile, `${JSON.stringify(records)}
|
|
409
|
+
`, { mode: 0o600 });
|
|
410
|
+
adoptingLegacy = false;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function rememberSession(r: SessionRecord): void {
|
|
414
|
+
records = records.filter((x) => x.jti !== r.jti);
|
|
415
|
+
records.push(r);
|
|
416
|
+
flushSessions(true);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function sessionKnown(jti: string): boolean {
|
|
420
|
+
return records.some((r) => r.jti === jti);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function noteSessionSeen(jti: string, now: number): void {
|
|
424
|
+
const r = records.find((x) => x.jti === jti);
|
|
425
|
+
if (!r) return;
|
|
426
|
+
r.lastSeen = now;
|
|
427
|
+
flushSessions();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function forgetSession(jti: string): void {
|
|
431
|
+
const before = records.length;
|
|
432
|
+
records = records.filter((r) => r.jti !== jti);
|
|
433
|
+
if (records.length !== before) flushSessions(true);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export interface SessionView {
|
|
437
|
+
jti: string;
|
|
438
|
+
username: string;
|
|
439
|
+
ip: string;
|
|
440
|
+
ua: string;
|
|
441
|
+
issuedAt: number;
|
|
442
|
+
expiresAt: number;
|
|
443
|
+
lastSeen: number;
|
|
444
|
+
current: boolean;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export function listSessions(currentJti?: string, now = Date.now()): SessionView[] {
|
|
448
|
+
return records
|
|
449
|
+
.filter((r) => r.exp > now && !revoked.has(r.jti))
|
|
450
|
+
.map((r) => ({
|
|
451
|
+
jti: r.jti,
|
|
452
|
+
username: r.username,
|
|
453
|
+
ip: r.ip,
|
|
454
|
+
ua: r.ua,
|
|
455
|
+
issuedAt: r.iat,
|
|
456
|
+
expiresAt: r.exp,
|
|
457
|
+
lastSeen: r.lastSeen,
|
|
458
|
+
current: Boolean(currentJti) && r.jti === currentJti,
|
|
459
|
+
}))
|
|
460
|
+
.sort((a, b) => Number(b.current) - Number(a.current) || b.lastSeen - a.lastSeen);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Kill one device's session. Returns false when the id is not a live session. */
|
|
464
|
+
export function revokeSessionById(jti: string): boolean {
|
|
465
|
+
if (!jti || !sessionKnown(jti)) return false;
|
|
466
|
+
forgetSession(jti);
|
|
467
|
+
rememberRevoked(jti);
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Log out everywhere except the caller. Returns how many sessions were dropped. */
|
|
472
|
+
export function revokeOtherSessions(keepJti: string | undefined): number {
|
|
473
|
+
const now = Date.now();
|
|
474
|
+
const victims = records.filter((r) => r.jti !== keepJti && r.exp > now);
|
|
475
|
+
for (const v of victims) {
|
|
476
|
+
forgetSession(v.jti);
|
|
477
|
+
rememberRevoked(v.jti);
|
|
478
|
+
}
|
|
479
|
+
return victims.length;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ---- login-failure locks, visible and liftable ------------------------------
|
|
483
|
+
|
|
484
|
+
export interface IpLockView {
|
|
485
|
+
ip: string;
|
|
486
|
+
fails: number;
|
|
487
|
+
locked: boolean;
|
|
488
|
+
retryAfterMs: number;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export function listIpLocks(now = Date.now()): IpLockView[] {
|
|
492
|
+
const out: IpLockView[] = [];
|
|
493
|
+
for (const [ip, e] of limiter) {
|
|
494
|
+
const locked = e.lockedUntil > now;
|
|
495
|
+
if (!locked && e.fails === 0) continue;
|
|
496
|
+
out.push({ ip, fails: e.fails, locked, retryAfterMs: locked ? e.lockedUntil - now : 0 });
|
|
497
|
+
}
|
|
498
|
+
return out.sort((a, b) => Number(b.locked) - Number(a.locked) || b.fails - a.fails);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Lift a lock (or clear a half-spent failure count). False when the IP was not known. */
|
|
502
|
+
export function unlockIp(ip: string): boolean {
|
|
503
|
+
return limiter.delete(ip);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export function unlockAllIps(): number {
|
|
507
|
+
const n = limiter.size;
|
|
508
|
+
limiter.clear();
|
|
509
|
+
return n;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export function verifyMachineToken(value: string | undefined | null): boolean {
|
|
513
|
+
if (!value || !machineToken || !authEnabled()) return false;
|
|
514
|
+
return safeEqual(String(value), machineToken);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// ---- request plumbing -------------------------------------------------------
|
|
518
|
+
|
|
519
|
+
export function parseCookies(header: string | undefined): Record<string, string> {
|
|
520
|
+
const out: Record<string, string> = {};
|
|
521
|
+
for (const part of String(header ?? "").split(";")) {
|
|
522
|
+
const idx = part.indexOf("=");
|
|
523
|
+
if (idx < 1) continue;
|
|
524
|
+
const k = part.slice(0, idx).trim();
|
|
525
|
+
const v = part.slice(idx + 1).trim();
|
|
526
|
+
if (k) out[k] = decodeURIComponent(v);
|
|
527
|
+
}
|
|
528
|
+
return out;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function bearer(header: string | string[] | undefined): string {
|
|
532
|
+
const raw = Array.isArray(header) ? header[0] : header;
|
|
533
|
+
const s = String(raw ?? "");
|
|
534
|
+
return s.toLowerCase().startsWith("bearer ") ? s.slice(7).trim() : "";
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Resolve the caller. Returns null when unauthenticated (caller decides the status
|
|
539
|
+
* code). Disabled auth => an anonymous operator, so local dev stays frictionless.
|
|
540
|
+
*/
|
|
541
|
+
export function authenticate(
|
|
542
|
+
headers: Record<string, string | string[] | undefined>,
|
|
543
|
+
url: URL,
|
|
544
|
+
now = Date.now(),
|
|
545
|
+
): AuthPrincipal | null {
|
|
546
|
+
const st = status();
|
|
547
|
+
if (!st.enabled) return { kind: "token", username: st.username, expiresAt: null };
|
|
548
|
+
|
|
549
|
+
const token = bearer(headers.authorization) || url.searchParams.get("token") || "";
|
|
550
|
+
if (verifyMachineToken(token)) return { kind: "token", username: st.username, expiresAt: null };
|
|
551
|
+
|
|
552
|
+
const session = verifySessionValue(parseCookies(String(headers.cookie ?? ""))[COOKIE_NAME], now);
|
|
553
|
+
if (session) return { kind: "session", username: session.u, expiresAt: session.exp * 1000, jti: session.jti };
|
|
554
|
+
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** `/api/auth/*` is reachable unauthenticated; the SPA shell and assets are public.
|
|
559
|
+
* `/api/version` too: it is one build hash, and the page that most needs it is the one whose
|
|
560
|
+
* session has already expired (a WebView left open for days) — the "this page is old code"
|
|
561
|
+
* banner must not depend on being logged in. */
|
|
562
|
+
const PUBLIC_API = new Set(["/api/auth/login", "/api/auth/me", "/api/auth/logout", "/api/version"]);
|
|
563
|
+
|
|
564
|
+
export function requiresAuth(pathname: string): boolean {
|
|
565
|
+
if (!pathname.startsWith("/api/")) return false; // shell, assets, /healthz
|
|
566
|
+
return !PUBLIC_API.has(pathname);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export function isSecureRequest(headers: Record<string, string | string[] | undefined>): boolean {
|
|
570
|
+
const proto = String(headers["x-forwarded-proto"] ?? "").split(",")[0].trim().toLowerCase();
|
|
571
|
+
return proto === "https";
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
export function sessionCookie(value: string, maxAgeSec: number, secure: boolean): string {
|
|
575
|
+
return [
|
|
576
|
+
`${COOKIE_NAME}=${encodeURIComponent(value)}`,
|
|
577
|
+
"Path=/",
|
|
578
|
+
"HttpOnly",
|
|
579
|
+
"SameSite=Lax",
|
|
580
|
+
`Max-Age=${maxAgeSec}`,
|
|
581
|
+
// Only over HTTPS: with a bare LAN IP there is no cert, and a Secure cookie
|
|
582
|
+
// would simply never be stored (looks exactly like "login does nothing").
|
|
583
|
+
secure ? "Secure" : "",
|
|
584
|
+
].filter(Boolean).join("; ");
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
export function clearedCookie(secure: boolean): string {
|
|
588
|
+
return sessionCookie("", 0, secure);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ---- login rate limit -------------------------------------------------------
|
|
592
|
+
|
|
593
|
+
/** 5 bad attempts from one IP => 30s lockout. Env-tunable so tests need not sleep. */
|
|
594
|
+
export function loginLimits(): { maxFails: number; lockMs: number } {
|
|
595
|
+
return { maxFails: envNum("AGENTUS_LOGIN_MAX_FAILS", 5), lockMs: envNum("AGENTUS_LOGIN_LOCK_MS", 30_000) };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
export function loginAllowed(ip: string, now = Date.now()): { allowed: boolean; retryAfterMs: number } {
|
|
599
|
+
const e = limiter.get(ip);
|
|
600
|
+
if (!e) return { allowed: true, retryAfterMs: 0 };
|
|
601
|
+
if (e.lockedUntil > now) return { allowed: false, retryAfterMs: e.lockedUntil - now };
|
|
602
|
+
return { allowed: true, retryAfterMs: 0 };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
export function recordLoginFailure(ip: string, now = Date.now()): void {
|
|
606
|
+
const { maxFails, lockMs } = loginLimits();
|
|
607
|
+
const e = limiter.get(ip) ?? { fails: 0, lockedUntil: 0 };
|
|
608
|
+
e.fails += 1;
|
|
609
|
+
if (e.fails >= maxFails) {
|
|
610
|
+
e.lockedUntil = now + lockMs;
|
|
611
|
+
e.fails = 0;
|
|
612
|
+
}
|
|
613
|
+
limiter.set(ip, e);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export function recordLoginSuccess(ip: string): void {
|
|
617
|
+
limiter.delete(ip);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** test hook: QA drives lockout behaviour without waiting for real time */
|
|
621
|
+
export function resetLoginLimiter(): void {
|
|
622
|
+
limiter.clear();
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export const AUTH_COOKIE_NAME = COOKIE_NAME;
|
|
626
|
+
export const AUTH_DEFAULT_USERNAME = DEFAULT_USERNAME;
|
|
627
|
+
export const AUTH_DEFAULT_PASSWORD = DEFAULT_PASSWORD;
|
|
628
|
+
|
|
629
|
+
// ---- HTTP Basic (the outer lock) --------------------------------------------
|
|
630
|
+
//
|
|
631
|
+
// Why this exists at all: SakuraFrp's per-tunnel `auth_pass` turned out to gate HTTP
|
|
632
|
+
// with a 200 "authorise your IP" page rather than a 401 challenge — unscriptable, and
|
|
633
|
+
// invisible to curl. So the outer lock lives here: a standard Basic challenge in front
|
|
634
|
+
// of everything, which any tunnel, proxy or phone browser honours.
|
|
635
|
+
//
|
|
636
|
+
// AGENTUS_BASIC_AUTH forms:
|
|
637
|
+
// user:pass -> both must match
|
|
638
|
+
// :pass -> password only; ANY (or empty) username is accepted
|
|
639
|
+
// pass -> same as ":pass"
|
|
640
|
+
// Unset means no Basic layer (LAN/local default).
|
|
641
|
+
//
|
|
642
|
+
// The password-only form exists because HTTP Basic *always* asks for a username
|
|
643
|
+
// (RFC 7617 puts "user:pass" in the header), but a single-operator service has no
|
|
644
|
+
// use for one — typing any name, or leaving it blank, is friction with no security
|
|
645
|
+
// value. Note the trap this replaces: a naive parser that required a non-empty
|
|
646
|
+
// username would treat ":pass" as malformed and silently turn the lock OFF.
|
|
647
|
+
|
|
648
|
+
export function basicAuthConfig(): { user: string; pass: string } | null {
|
|
649
|
+
const raw = process.env.AGENTUS_BASIC_AUTH?.trim();
|
|
650
|
+
if (!raw) return null;
|
|
651
|
+
const idx = raw.indexOf(":");
|
|
652
|
+
const cfg = idx < 0 ? { user: "", pass: raw } : { user: raw.slice(0, idx), pass: raw.slice(idx + 1) };
|
|
653
|
+
return cfg.pass ? cfg : null; // a password is mandatory; an empty one locks nobody out usefully
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
export function verifyBasic(header: string | string[] | undefined): boolean {
|
|
657
|
+
const cfg = basicAuthConfig();
|
|
658
|
+
if (!cfg) return true;
|
|
659
|
+
const raw = Array.isArray(header) ? header[0] : header;
|
|
660
|
+
const value = String(raw ?? "");
|
|
661
|
+
if (!value.toLowerCase().startsWith("basic ")) return false;
|
|
662
|
+
let decoded = "";
|
|
663
|
+
try {
|
|
664
|
+
decoded = Buffer.from(value.slice(6).trim(), "base64").toString("utf8");
|
|
665
|
+
} catch {
|
|
666
|
+
return false;
|
|
667
|
+
}
|
|
668
|
+
const idx = decoded.indexOf(":");
|
|
669
|
+
if (idx < 0) return false;
|
|
670
|
+
const givenUser = decoded.slice(0, idx);
|
|
671
|
+
const givenPass = decoded.slice(idx + 1);
|
|
672
|
+
const passOk = safeEqual(digest(givenPass), digest(cfg.pass));
|
|
673
|
+
// no username configured => username is not part of the secret
|
|
674
|
+
const userOk = cfg.user === "" ? true : safeEqual(digest(givenUser), digest(cfg.user));
|
|
675
|
+
return userOk && passOk;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function digest(value: string): string {
|
|
679
|
+
return createHash("sha256").update(value).digest("hex");
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** 401 + the challenge. Without WWW-Authenticate a browser never asks. */
|
|
683
|
+
export function basicChallenge(): Record<string, string> {
|
|
684
|
+
return {
|
|
685
|
+
"www-authenticate": 'Basic realm="Agentus", charset="UTF-8"',
|
|
686
|
+
"content-type": "text/plain; charset=utf-8",
|
|
687
|
+
};
|
|
688
|
+
}
|