@songsid/agend 2.1.4-beta.25 → 2.1.4-beta.26

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.
@@ -10,7 +10,7 @@ export interface InstanceStatusData {
10
10
  costCents: number;
11
11
  }
12
12
  export interface AlertData {
13
- type: "hang" | "cost_warn" | "cost_limit" | "schedule_deferred" | "rotation" | "cancel" | "interactive_prompt" | "exit_restart" | "clear_confirm" | "tip";
13
+ type: "hang" | "cost_warn" | "cost_limit" | "schedule_deferred" | "rotation" | "cancel" | "interactive_prompt" | "exit_restart" | "clear_confirm" | "tip" | "login";
14
14
  instanceName: string;
15
15
  message: string;
16
16
  choices?: Choice[];
@@ -93,6 +93,29 @@ export interface FleetContext {
93
93
  promptTip?(generalName: string, adapter: import("./channel/types.js").ChannelAdapter, chatId: string, threadId?: string): Promise<"posted" | "empty" | "unavailable">;
94
94
  /** Persistently enable advanced tips without waiting for the dismissal threshold. */
95
95
  unlockAdvancedTips?(userId: string): boolean;
96
+ /** `/login` backend chooser buttons. Caller enforces fleet-admin. */
97
+ promptLoginBackends?(chat: {
98
+ adapter: import("./channel/types.js").ChannelAdapter;
99
+ adapterId: string;
100
+ chatId: string;
101
+ threadId?: string;
102
+ }): Promise<void>;
103
+ /**
104
+ * Start a remote login session for one backend; returns a status line, or
105
+ * null when a still-valid-auth confirmation prompt was posted instead.
106
+ */
107
+ startLoginSession?(backend: string, chat: {
108
+ adapter: import("./channel/types.js").ChannelAdapter;
109
+ adapterId: string;
110
+ chatId: string;
111
+ threadId?: string;
112
+ }, opts?: {
113
+ skipAuthCheck?: boolean;
114
+ }): Promise<string | null>;
115
+ /** Paste admin-supplied text (auth code / start URL) into the login window. */
116
+ loginSubmitInput?(text: string): Promise<string>;
117
+ /** Abort the active login session. */
118
+ cancelLoginSession?(): Promise<string>;
96
119
  /** Human-readable effective model for an instance (resolves inherited defaults). */
97
120
  modelDisplayForInstance?(name: string): string;
98
121
  /**
@@ -811,6 +811,52 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
811
811
  private isDeliveryEpochCurrent;
812
812
  /** Invalidate work queued before a user cancel and wake idle-gate waiters. */
813
813
  private cancelPendingDeliveries;
814
+ private activeLogin;
815
+ /** Post the backend chooser for a bare `/login`. Caller enforces admin. */
816
+ promptLoginBackends(chat: {
817
+ adapter: ChannelAdapter;
818
+ adapterId: string;
819
+ chatId: string;
820
+ threadId?: string;
821
+ }): Promise<void>;
822
+ /**
823
+ * Start a login session for one backend. Caller enforces admin.
824
+ * Returns a status line to post, or null when a confirmation prompt was
825
+ * posted instead (auth still valid — see the pre-check below).
826
+ */
827
+ startLoginSession(backendArg: string, chat: {
828
+ adapter: ChannelAdapter;
829
+ adapterId: string;
830
+ chatId: string;
831
+ threadId?: string;
832
+ }, opts?: {
833
+ skipAuthCheck?: boolean;
834
+ }): Promise<string | null>;
835
+ /** Create the login window and session (pre-check already settled). */
836
+ private launchLoginSession;
837
+ /** `/login code <text>` — paste admin-supplied text into the login window. */
838
+ loginSubmitInput(text: string): Promise<string>;
839
+ /** `/login cancel` — abort the active session and remove its window. */
840
+ cancelLoginSession(): Promise<string>;
841
+ /**
842
+ * The URL (+ code) is a live credential: whoever completes it binds THEIR
843
+ * account to this fleet's CLI. Telegram gets an HTML spoiler (same treatment
844
+ * as the dashboard token); other adapters get plain text with the warning.
845
+ */
846
+ private sendLoginSecret;
847
+ /**
848
+ * After a successful login: wake the paused instances of that backend AND
849
+ * restart the running ones — a live CLI holds the old token in memory and
850
+ * only re-reads credentials on process start (a paused instance's CLI is
851
+ * already dead, so waking it respawns with the new token for free).
852
+ */
853
+ private recoverBackendInstances;
854
+ /** Backend chooser button → start that backend's login session. */
855
+ private handleLoginBackendSelect;
856
+ /** Re-login confirmation (auth pre-check said credentials still work). */
857
+ private handleLoginConfirm;
858
+ /** Kiro provider button → drive the CLI's arrow-key selector. */
859
+ private handleLoginMenuSelect;
814
860
  queueMirrorMessage(text: string): void;
815
861
  /** Push an SSE event to all connected Web UI clients. */
816
862
  emitSseEvent(event: string, data: unknown): void;
@@ -44,6 +44,8 @@ import { outboundHandlers } from "./outbound-handlers.js";
44
44
  import { handleWebRequest, broadcastSseEvent } from "./web-api.js";
45
45
  import { handleViewRequest, isViewPath } from "./view-api.js";
46
46
  import { handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
47
+ import { LOGIN_FLOWS, LOGIN_BACKEND_ALIASES, checkAuthStatus } from "./login-flows.js";
48
+ import { LoginSession } from "./login-manager.js";
47
49
  import { handleSettingsRequest } from "./settings-api.js";
48
50
  import { setLocale, detectLocale, getLocale, t } from "./locale.js";
49
51
  import { handleAgentRequest } from "./agent-endpoint.js";
@@ -209,6 +211,9 @@ const HANG_CALLBACK_PREFIX = "hang:";
209
211
  const CLEAR_CONFIRM_CALLBACK_PREFIX = "clear-confirm:";
210
212
  const TIP_DISMISS_CALLBACK_PREFIX = "tip-dismiss:";
211
213
  const TIP_UNLOCK_CALLBACK_PREFIX = "tip-unlock:";
214
+ const LOGIN_CALLBACK_PREFIX = "login:";
215
+ const LOGIN_MENU_CALLBACK_PREFIX = "login-menu:";
216
+ const LOGIN_CONFIRM_CALLBACK_PREFIX = "login-confirm:";
212
217
  const CLEAR_CONFIRM_TIMEOUT_MS = 15_000;
213
218
  /** Default lifetime for long-lived nonce prompts (clear overrides this to 15s). */
214
219
  const NONCE_BUTTON_TIMEOUT_MS = 15 * 60_000;
@@ -2364,6 +2369,12 @@ export class FleetManager {
2364
2369
  return;
2365
2370
  if (await this.handleTipUnlock(data, adapterId, this.adapter ?? undefined))
2366
2371
  return;
2372
+ if (await this.handleLoginBackendSelect(data, adapterId, this.adapter ?? undefined))
2373
+ return;
2374
+ if (await this.handleLoginMenuSelect(data, adapterId, this.adapter ?? undefined))
2375
+ return;
2376
+ if (await this.handleLoginConfirm(data, adapterId, this.adapter ?? undefined))
2377
+ return;
2367
2378
  if (await this.handleClearConfirmation(data, adapterId, this.adapter ?? undefined))
2368
2379
  return;
2369
2380
  if (await this.handleExitRestartPrompt(data, adapterId, this.adapter ?? undefined))
@@ -2737,6 +2748,12 @@ export class FleetManager {
2737
2748
  return;
2738
2749
  if (await this.handleTipUnlock(data, adapterId, adapter))
2739
2750
  return;
2751
+ if (await this.handleLoginBackendSelect(data, adapterId, adapter))
2752
+ return;
2753
+ if (await this.handleLoginMenuSelect(data, adapterId, adapter))
2754
+ return;
2755
+ if (await this.handleLoginConfirm(data, adapterId, adapter))
2756
+ return;
2740
2757
  if (await this.handleClearConfirmation(data, adapterId, adapter))
2741
2758
  return;
2742
2759
  if (await this.handleExitRestartPrompt(data, adapterId, adapter))
@@ -6175,6 +6192,261 @@ export class FleetManager {
6175
6192
  check();
6176
6193
  return next;
6177
6194
  }
6195
+ // ── Remote CLI login (`/login`) ──────────────────────────────────────────
6196
+ //
6197
+ // One session at a time, in a dedicated tmux window — never an instance
6198
+ // pane. Credentials are per-backend (codex homes symlink auth.json to the
6199
+ // shared ~/.codex; the other CLIs use one real home), so a single sign-in
6200
+ // repairs every instance of that backend, and instance delivery, pane-state
6201
+ // detection, tool progress, and mcp_proxy_reply never observe login output.
6202
+ activeLogin = null;
6203
+ /** Post the backend chooser for a bare `/login`. Caller enforces admin. */
6204
+ async promptLoginBackends(chat) {
6205
+ const configured = new Set();
6206
+ for (const [, config] of Object.entries(this.fleetConfig?.instances ?? {})) {
6207
+ configured.add(config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
6208
+ }
6209
+ const choices = Object.keys(LOGIN_FLOWS)
6210
+ .filter(backend => configured.size === 0 || configured.has(backend))
6211
+ .map(backend => ({ action: backend, label: backend }));
6212
+ if (choices.length === 0) {
6213
+ await chat.adapter.sendText(chat.chatId, t("login.unsupported", [...configured].join(", ")), { threadId: chat.threadId });
6214
+ return;
6215
+ }
6216
+ await this.postNonceButtonPrompt({
6217
+ prefix: LOGIN_CALLBACK_PREFIX,
6218
+ alertType: "login",
6219
+ instanceName: "login",
6220
+ adapter: chat.adapter,
6221
+ adapterId: chat.adapterId,
6222
+ chatId: chat.chatId,
6223
+ threadId: chat.threadId,
6224
+ message: t("login.choose_backend"),
6225
+ choices,
6226
+ expiredText: t("buttons.stale"),
6227
+ });
6228
+ }
6229
+ /**
6230
+ * Start a login session for one backend. Caller enforces admin.
6231
+ * Returns a status line to post, or null when a confirmation prompt was
6232
+ * posted instead (auth still valid — see the pre-check below).
6233
+ */
6234
+ async startLoginSession(backendArg, chat, opts = {}) {
6235
+ const backend = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
6236
+ const flow = LOGIN_FLOWS[backend];
6237
+ if (!flow)
6238
+ return t("login.unsupported", backendArg);
6239
+ if (this.activeLogin)
6240
+ return t("login.busy", this.activeLogin.backend);
6241
+ // Token-free pre-check (5s cap): re-login while auth still works is
6242
+ // usually a mistake, so it needs a confirmed click. An invalid OR
6243
+ // uncertain result (timeout, missing binary) proceeds straight to login —
6244
+ // an unreliable probe must never block the re-login the admin asked for.
6245
+ if (!opts.skipAuthCheck && flow.authCheck) {
6246
+ const status = await checkAuthStatus(flow.authCheck);
6247
+ if (status === "valid") {
6248
+ await this.postNonceButtonPrompt({
6249
+ prefix: LOGIN_CONFIRM_CALLBACK_PREFIX,
6250
+ alertType: "login",
6251
+ instanceName: backend,
6252
+ adapter: chat.adapter,
6253
+ adapterId: chat.adapterId,
6254
+ chatId: chat.chatId,
6255
+ threadId: chat.threadId,
6256
+ message: t("login.still_valid", backend),
6257
+ choices: [
6258
+ { action: "go", label: t("login.relogin_go") },
6259
+ { action: "cancel", label: t("login.relogin_cancel") },
6260
+ ],
6261
+ expiredText: t("buttons.stale"),
6262
+ });
6263
+ return null;
6264
+ }
6265
+ }
6266
+ return this.launchLoginSession(flow, backend, chat);
6267
+ }
6268
+ /** Create the login window and session (pre-check already settled). */
6269
+ async launchLoginSession(flow, backend, chat) {
6270
+ if (this.activeLogin)
6271
+ return t("login.busy", this.activeLogin.backend);
6272
+ const sessionName = getTmuxSession();
6273
+ await TmuxManager.ensureSession(sessionName);
6274
+ const tmux = new TmuxManager(sessionName, "");
6275
+ const session = new LoginSession(flow, tmux, {
6276
+ onMenu: async (options) => {
6277
+ await this.postNonceButtonPrompt({
6278
+ prefix: LOGIN_MENU_CALLBACK_PREFIX,
6279
+ alertType: "login",
6280
+ instanceName: backend,
6281
+ adapter: chat.adapter,
6282
+ adapterId: chat.adapterId,
6283
+ chatId: chat.chatId,
6284
+ threadId: chat.threadId,
6285
+ message: t("login.choose_provider"),
6286
+ choices: options.map((label, index) => ({ action: String(index), label })),
6287
+ expiredText: t("buttons.stale"),
6288
+ });
6289
+ },
6290
+ onAuthHint: async (url, code) => {
6291
+ await this.sendLoginSecret(chat, backend, url, code);
6292
+ },
6293
+ onNeedInput: async (promptExcerpt) => {
6294
+ await chat.adapter.sendText(chat.chatId, t("login.need_input", backend, promptExcerpt), { threadId: chat.threadId }).catch(() => { });
6295
+ },
6296
+ onDone: async ({ ok, detail }) => {
6297
+ this.activeLogin = null;
6298
+ let text;
6299
+ if (ok) {
6300
+ const { woken, restarted } = await this.recoverBackendInstances(backend);
6301
+ const none = t("login.none");
6302
+ text = t("login.success", backend, woken.length ? woken.join(", ") : none, restarted.length ? restarted.join(", ") : none);
6303
+ }
6304
+ else if (detail === "cancelled") {
6305
+ text = t("login.cancelled", backend);
6306
+ }
6307
+ else {
6308
+ text = t("login.failed", backend, detail);
6309
+ }
6310
+ await chat.adapter.sendText(chat.chatId, text, { threadId: chat.threadId }).catch(() => { });
6311
+ },
6312
+ }, this.logger);
6313
+ // Claim the slot before the first await so two admins racing /login cannot
6314
+ // both create windows; release on startup failure.
6315
+ this.activeLogin = { session, backend, chat };
6316
+ try {
6317
+ await session.start();
6318
+ }
6319
+ catch (err) {
6320
+ this.activeLogin = null;
6321
+ return t("login.failed", backend, err.message);
6322
+ }
6323
+ return t("login.started", backend);
6324
+ }
6325
+ /** `/login code <text>` — paste admin-supplied text into the login window. */
6326
+ async loginSubmitInput(text) {
6327
+ if (!this.activeLogin)
6328
+ return t("login.no_session");
6329
+ const ok = await this.activeLogin.session.submitInput(text);
6330
+ return ok ? t("login.input_sent") : t("login.input_failed");
6331
+ }
6332
+ /** `/login cancel` — abort the active session and remove its window. */
6333
+ async cancelLoginSession() {
6334
+ if (!this.activeLogin)
6335
+ return t("login.no_session");
6336
+ const backend = this.activeLogin.backend;
6337
+ await this.activeLogin.session.cancel();
6338
+ return t("login.cancelled", backend);
6339
+ }
6340
+ /**
6341
+ * The URL (+ code) is a live credential: whoever completes it binds THEIR
6342
+ * account to this fleet's CLI. Telegram gets an HTML spoiler (same treatment
6343
+ * as the dashboard token); other adapters get plain text with the warning.
6344
+ */
6345
+ async sendLoginSecret(chat, backend, url, code) {
6346
+ const codeLine = code ? `\n${t("login.auth_code", code)}` : "";
6347
+ try {
6348
+ if (chat.adapter.type === "telegram") {
6349
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
6350
+ await chat.adapter.sendText(chat.chatId, `${esc(t("login.auth_hint", backend))}\n<tg-spoiler>${esc(url)}${esc(codeLine)}</tg-spoiler>`, { threadId: chat.threadId, format: "html" });
6351
+ }
6352
+ else {
6353
+ await chat.adapter.sendText(chat.chatId, `${t("login.auth_hint", backend)}\n${url}${codeLine}`, { threadId: chat.threadId });
6354
+ }
6355
+ }
6356
+ catch (err) {
6357
+ this.logger.warn({ err: err.message, backend }, "Failed to deliver login URL");
6358
+ }
6359
+ }
6360
+ /**
6361
+ * After a successful login: wake the paused instances of that backend AND
6362
+ * restart the running ones — a live CLI holds the old token in memory and
6363
+ * only re-reads credentials on process start (a paused instance's CLI is
6364
+ * already dead, so waking it respawns with the new token for free).
6365
+ */
6366
+ async recoverBackendInstances(backend) {
6367
+ const woken = [];
6368
+ const restarted = [];
6369
+ for (const [name, config] of Object.entries(this.fleetConfig?.instances ?? {})) {
6370
+ const effective = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
6371
+ if (effective !== backend)
6372
+ continue;
6373
+ const status = this.getInstanceStatus(name);
6374
+ try {
6375
+ if (status === "paused") {
6376
+ if (this.daemons.has(name))
6377
+ await this.lifecycle.wake(name, 30_000);
6378
+ else
6379
+ await this.startPersistedPausedInstance(name);
6380
+ woken.push(name);
6381
+ }
6382
+ else if (status === "running") {
6383
+ await this.restartSingleInstance(name);
6384
+ restarted.push(name);
6385
+ }
6386
+ }
6387
+ catch (err) {
6388
+ this.logger.warn({ err: err.message, name, status }, "Post-login recovery failed");
6389
+ }
6390
+ }
6391
+ return { woken, restarted };
6392
+ }
6393
+ /** Backend chooser button → start that backend's login session. */
6394
+ async handleLoginBackendSelect(data, callbackAdapterId, receivingAdapter) {
6395
+ const claimed = this.consumeNonceCallback(LOGIN_CALLBACK_PREFIX, /^login:([0-9a-f]+):([a-z][a-z-]*)$/, data, callbackAdapterId, receivingAdapter);
6396
+ if (claimed === null)
6397
+ return false;
6398
+ if (claimed === "consumed")
6399
+ return true;
6400
+ const { entry, action: backend } = claimed;
6401
+ await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", backend));
6402
+ const text = await this.startLoginSession(backend, {
6403
+ adapter: entry.adapter,
6404
+ adapterId: entry.adapterId,
6405
+ chatId: entry.chatId,
6406
+ threadId: entry.threadId,
6407
+ });
6408
+ if (text)
6409
+ await entry.adapter.sendText(entry.chatId, text, { threadId: entry.threadId }).catch(() => { });
6410
+ return true;
6411
+ }
6412
+ /** Re-login confirmation (auth pre-check said credentials still work). */
6413
+ async handleLoginConfirm(data, callbackAdapterId, receivingAdapter) {
6414
+ const claimed = this.consumeNonceCallback(LOGIN_CONFIRM_CALLBACK_PREFIX, /^login-confirm:([0-9a-f]+):(go|cancel)$/, data, callbackAdapterId, receivingAdapter);
6415
+ if (claimed === null)
6416
+ return false;
6417
+ if (claimed === "consumed")
6418
+ return true;
6419
+ const { entry, action } = claimed;
6420
+ const backend = entry.instanceName;
6421
+ if (action === "cancel") {
6422
+ await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.cancelled", backend));
6423
+ return true;
6424
+ }
6425
+ await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", backend));
6426
+ const text = await this.startLoginSession(backend, {
6427
+ adapter: entry.adapter,
6428
+ adapterId: entry.adapterId,
6429
+ chatId: entry.chatId,
6430
+ threadId: entry.threadId,
6431
+ }, { skipAuthCheck: true });
6432
+ if (text)
6433
+ await entry.adapter.sendText(entry.chatId, text, { threadId: entry.threadId }).catch(() => { });
6434
+ return true;
6435
+ }
6436
+ /** Kiro provider button → drive the CLI's arrow-key selector. */
6437
+ async handleLoginMenuSelect(data, callbackAdapterId, receivingAdapter) {
6438
+ const claimed = this.consumeNonceCallback(LOGIN_MENU_CALLBACK_PREFIX, /^login-menu:([0-9a-f]+):(\d)$/, data, callbackAdapterId, receivingAdapter);
6439
+ if (claimed === null)
6440
+ return false;
6441
+ if (claimed === "consumed")
6442
+ return true;
6443
+ const { entry, action } = claimed;
6444
+ const index = Number(action);
6445
+ const label = this.activeLogin?.session.flow.menu?.options[index] ?? action;
6446
+ const ok = await this.activeLogin?.session.selectMenuOption(index) ?? false;
6447
+ await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, ok ? t("login.provider_selected", label) : t("login.no_session"));
6448
+ return true;
6449
+ }
6178
6450
  queueMirrorMessage(text) {
6179
6451
  const mirrorTopicId = this.fleetConfig?.channel?.mirror_topic_id;
6180
6452
  if (mirrorTopicId == null || !this.adapter)