@zgeoff/atc 2.15.0 → 2.15.1
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/package.json +2 -1
- package/src/agents/agent-adapter.ts +3 -38
- package/src/agents/build-hook-settings.ts +1 -1
- package/src/agents/claude-adapter.ts +3 -3
- package/src/agents/codex-adapter.ts +3 -3
- package/src/agents/gateway-adapter.ts +3 -3
- package/src/agents/grok-adapter.ts +3 -3
- package/src/agents/start-claude-headless-run.ts +1 -1
- package/src/client/boot-daemon.ts +2 -2
- package/src/client/collect-agent-picks.ts +1 -1
- package/src/client/daemon-client.ts +2 -1
- package/src/client/format-overlay-agent-mark.ts +1 -1
- package/src/client/index.ts +4 -2
- package/src/client/pick-tab-target.ts +2 -2
- package/src/client/spawn-picker.ts +1 -1
- package/src/client/to-mirror-session.ts +3 -3
- package/src/client/ui.ts +3 -3
- package/src/daemon/build-message-trail-entry.ts +1 -1
- package/src/daemon/build-report-trail-entry.ts +1 -1
- package/src/daemon/build-scoped-context.ts +1 -1
- package/src/daemon/build-session-message-event.ts +1 -1
- package/src/daemon/daemon-connection.ts +4 -237
- package/src/daemon/daemon-context.ts +240 -0
- package/src/daemon/daemon.ts +9 -5
- package/src/daemon/execution-provider.ts +12 -0
- package/src/daemon/hooks.ts +1 -7
- package/src/daemon/idempotency-ledger.ts +78 -18
- package/src/daemon/imp-harness.ts +41 -0
- package/src/daemon/local-pty-provider.ts +45 -0
- package/src/daemon/parse-hook-line.ts +1 -1
- package/src/daemon/sessions.ts +116 -104
- package/src/daemon/start-headless-turn.ts +3 -3
- package/src/daemon/start-session-bridge.ts +2 -2
- package/src/mcp/collect-clients.ts +1 -1
- package/src/mcp/collect-grants.ts +1 -1
- package/src/mcp/find-client-name.ts +1 -1
- package/src/mcp/mcp-auth-schema.ts +37 -0
- package/src/mcp/open-mcp-auth.ts +2 -2
- package/src/mcp/reconnecting-caller.ts +15 -6
- package/src/mcp/remove-client.ts +1 -1
- package/src/mcp/revoke-grant.ts +1 -1
- package/src/mcp/types.ts +0 -38
- package/src/mcp-http-server.ts +2 -1
- package/src/protocol/adapter-event.ts +25 -0
- package/src/protocol/count-session-states.ts +16 -0
- package/src/protocol/daemon-channel.ts +17 -0
- package/src/protocol/hook-event.ts +11 -0
- package/src/protocol/pinned-group-key.ts +2 -0
- package/src/protocol/session-state.ts +5 -0
- package/src/protocol/sort-grouped-session-views.ts +29 -0
- package/src/protocol/sort-session-views.ts +41 -0
- package/src/protocol/sortable-session-view.ts +13 -0
- package/src/shared/agent-id.ts +6 -0
- package/src/shared/collect-gateways.ts +1 -1
- package/src/shared/to-agent-id.ts +11 -0
- package/src/store/fleet-entry.ts +2 -2
- package/src/store/state-store.ts +5 -4
- /package/src/{store → shared}/bun-sqlite-driver.ts +0 -0
- /package/src/{agents → shared}/truncate-detail.ts +0 -0
- /package/src/{daemon → shared}/truncate-summary.ts +0 -0
package/src/daemon/sessions.ts
CHANGED
|
@@ -1,21 +1,25 @@
|
|
|
1
1
|
import { writeFileSync } from 'node:fs';
|
|
2
2
|
import type {
|
|
3
|
-
AdapterEvent,
|
|
4
3
|
AgentAdapter,
|
|
5
|
-
AgentID,
|
|
6
4
|
SpawnOptions,
|
|
7
5
|
SpawnOverrides,
|
|
8
6
|
SpawnPlan,
|
|
9
7
|
} from '../agents/agent-adapter';
|
|
10
|
-
import {
|
|
8
|
+
import type { AdapterEvent } from '../protocol/adapter-event';
|
|
9
|
+
import { countSessionStates } from '../protocol/count-session-states';
|
|
11
10
|
import { DaemonError } from '../protocol/daemon-error';
|
|
11
|
+
import type { HookEvent } from '../protocol/hook-event';
|
|
12
12
|
import type { ErrorCode } from '../protocol/protocol';
|
|
13
|
+
import type { SessionState } from '../protocol/session-state';
|
|
14
|
+
import { sortSessionViews } from '../protocol/sort-session-views';
|
|
15
|
+
import type { AgentID } from '../shared/agent-id';
|
|
13
16
|
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
14
17
|
import type { TargetConfigError } from '../shared/collect-targets';
|
|
15
18
|
import { socketPath, statusFile } from '../shared/config';
|
|
16
19
|
import type { DaemonID } from '../shared/daemon-id';
|
|
17
20
|
import { resolveRepoRoot } from '../shared/resolve-repo-root';
|
|
18
21
|
import type { SessionID } from '../shared/session-id';
|
|
22
|
+
import { truncateDetail } from '../shared/truncate-detail';
|
|
19
23
|
import { truncateToBytes } from '../shared/truncate-to-bytes';
|
|
20
24
|
import type { FleetEntry, FleetEntryUpdate, FleetStore } from '../store/fleet-entry';
|
|
21
25
|
import type { SessionWorkspace } from '../store/workspace-materialization';
|
|
@@ -31,14 +35,11 @@ import type {
|
|
|
31
35
|
HarnessRelay,
|
|
32
36
|
} from './execution-provider';
|
|
33
37
|
import { findExecutionRefusal } from './find-execution-refusal';
|
|
34
|
-
import type { HookEvent } from './hooks';
|
|
35
38
|
import type { BridgeBinding } from './is-binding-current';
|
|
36
39
|
import { LocalPTYProvider } from './local-pty-provider';
|
|
37
40
|
import { mintSessionID } from './mint-session-id';
|
|
38
41
|
import { pickSessionState } from './pick-session-state';
|
|
39
42
|
|
|
40
|
-
export type SessionState = 'running' | 'needs_you' | 'done' | 'exited';
|
|
41
|
-
|
|
42
43
|
export type SessionEventKind = 'added' | 'state' | 'renamed' | 'removed';
|
|
43
44
|
|
|
44
45
|
// The over-the-wire view of a session: everything but the harness handle,
|
|
@@ -181,6 +182,10 @@ interface MaterializedSpawn {
|
|
|
181
182
|
// stored identity ran on.
|
|
182
183
|
const LOCAL_TARGET_IDENTITY = buildTargetIdentity('local-pty', {});
|
|
183
184
|
|
|
185
|
+
// How long a failed spawn's rollback waits for the killed process to exit,
|
|
186
|
+
// once after its kill and once more after a forced kill.
|
|
187
|
+
const FAILED_SPAWN_EXIT_WAIT_MS = 2000;
|
|
188
|
+
|
|
184
189
|
// The target a session runs on and the identity it is bound to there; null
|
|
185
190
|
// binds a new session to the target as it stands now.
|
|
186
191
|
interface TargetBinding {
|
|
@@ -236,6 +241,13 @@ export class SessionManager {
|
|
|
236
241
|
// write deletes; each stays here until a write carrying it lands.
|
|
237
242
|
private readonly removedIDs = new Set<SessionID>();
|
|
238
243
|
|
|
244
|
+
// Failed spawns whose rollback is running, and those whose killed process
|
|
245
|
+
// it could not confirm gone, each with the harness it ran in, so no revive
|
|
246
|
+
// starts a second harness while the first may still run.
|
|
247
|
+
private readonly rollingBack = new Set<SessionID>();
|
|
248
|
+
|
|
249
|
+
private readonly unconfirmedKills = new Map<SessionID, HarnessHandle>();
|
|
250
|
+
|
|
239
251
|
// The epoch the next harness start or attach takes, unique across every
|
|
240
252
|
// session this manager holds.
|
|
241
253
|
private nextBridgeEpoch = 1;
|
|
@@ -475,6 +487,12 @@ export class SessionManager {
|
|
|
475
487
|
return null;
|
|
476
488
|
}
|
|
477
489
|
|
|
490
|
+
const settled = await this.isRollbackSettled(id);
|
|
491
|
+
|
|
492
|
+
if (!settled) {
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
|
|
478
496
|
const adapter = this.findAdapter(s.agent);
|
|
479
497
|
|
|
480
498
|
if (adapter === null) {
|
|
@@ -498,8 +516,14 @@ export class SessionManager {
|
|
|
498
516
|
this.adopting.delete(id);
|
|
499
517
|
}
|
|
500
518
|
|
|
501
|
-
// A kill
|
|
502
|
-
|
|
519
|
+
// A kill, a second adopt, or a failed spawn's rollback can land while
|
|
520
|
+
// the host wakes.
|
|
521
|
+
if (
|
|
522
|
+
s.pty !== null ||
|
|
523
|
+
!this.sessions.includes(s) ||
|
|
524
|
+
this.rollingBack.has(id) ||
|
|
525
|
+
this.unconfirmedKills.has(id)
|
|
526
|
+
) {
|
|
503
527
|
return null;
|
|
504
528
|
}
|
|
505
529
|
|
|
@@ -893,8 +917,11 @@ export class SessionManager {
|
|
|
893
917
|
|
|
894
918
|
// Takes back a spawn that failed after its process started: the process
|
|
895
919
|
// dies and the session leaves the list and the fleet, so the spawn leaves
|
|
896
|
-
// nothing behind. It resolves only once the
|
|
897
|
-
// durable, and throws when the kill
|
|
920
|
+
// nothing behind. It resolves only once the process has exited and the
|
|
921
|
+
// fleet without the session is durable, and throws when the kill, the
|
|
922
|
+
// exit, or that write cannot be confirmed. A session whose exit is not
|
|
923
|
+
// confirmed stays listed, so the spawn's caller can still find it, and
|
|
924
|
+
// no revive starts it until its process is found gone. A session that
|
|
898
925
|
// never registered is left alone.
|
|
899
926
|
async removeFailedSpawn(id: SessionID): Promise<void> {
|
|
900
927
|
const s = this.sessions.find((x) => x.id === id);
|
|
@@ -903,13 +930,87 @@ export class SessionManager {
|
|
|
903
930
|
return;
|
|
904
931
|
}
|
|
905
932
|
|
|
906
|
-
s.pty
|
|
907
|
-
s.pty = null;
|
|
933
|
+
const pty = s.pty;
|
|
908
934
|
|
|
909
|
-
this.
|
|
910
|
-
this.emitChange();
|
|
935
|
+
this.rollingBack.add(id);
|
|
911
936
|
|
|
912
|
-
|
|
937
|
+
try {
|
|
938
|
+
if (pty !== null) {
|
|
939
|
+
await this.killFailedSpawn(id, pty);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// A harness other than the killed one would outlive the rollback.
|
|
943
|
+
if (s.pty !== null && s.pty !== pty) {
|
|
944
|
+
throw new Error(`session ${id} started another harness while its spawn rolled back`);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
s.pty = null;
|
|
948
|
+
|
|
949
|
+
this.remove(s);
|
|
950
|
+
this.emitChange();
|
|
951
|
+
|
|
952
|
+
await this.writeFleet();
|
|
953
|
+
} finally {
|
|
954
|
+
this.rollingBack.delete(id);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// Kills a failed spawn's harness and waits for its exit. A kill that
|
|
959
|
+
// throws or an exit it cannot confirm leaves the harness recorded as
|
|
960
|
+
// possibly running, which blocks revives of the session.
|
|
961
|
+
private async killFailedSpawn(id: SessionID, pty: HarnessHandle): Promise<void> {
|
|
962
|
+
this.unconfirmedKills.set(id, pty);
|
|
963
|
+
pty.kill();
|
|
964
|
+
|
|
965
|
+
const exited = await this.waitForKilledExit(pty);
|
|
966
|
+
|
|
967
|
+
if (!exited) {
|
|
968
|
+
throw new Error(`session ${id} did not exit after its kill`);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
this.unconfirmedKills.delete(id);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// Waits for a killed harness to exit, ending it with a signal it cannot
|
|
975
|
+
// ignore when the first wait runs out and its provider can send one.
|
|
976
|
+
private async waitForKilledExit(pty: HarnessHandle): Promise<boolean> {
|
|
977
|
+
const exited = await pty.waitForExit(FAILED_SPAWN_EXIT_WAIT_MS);
|
|
978
|
+
|
|
979
|
+
if (exited) {
|
|
980
|
+
return true;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
if (pty.killForced === undefined) {
|
|
984
|
+
return false;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
pty.killForced();
|
|
988
|
+
|
|
989
|
+
return pty.waitForExit(FAILED_SPAWN_EXIT_WAIT_MS);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// Whether a failed spawn's rollback leaves the session free to revive:
|
|
993
|
+
// none is running, and any process it killed is gone now. A session whose
|
|
994
|
+
// killed process may still run stays blocked until a later check finds it
|
|
995
|
+
// gone.
|
|
996
|
+
private async isRollbackSettled(id: SessionID): Promise<boolean> {
|
|
997
|
+
if (this.rollingBack.has(id)) {
|
|
998
|
+
return false;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
const killed = this.unconfirmedKills.get(id);
|
|
1002
|
+
|
|
1003
|
+
if (killed !== undefined) {
|
|
1004
|
+
const gone = await killed.waitForExit(0);
|
|
1005
|
+
|
|
1006
|
+
if (!gone) {
|
|
1007
|
+
return false;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
this.unconfirmedKills.delete(id);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
return true;
|
|
913
1014
|
}
|
|
914
1015
|
|
|
915
1016
|
collectDescriptors(): SessionDescriptor[] {
|
|
@@ -1482,95 +1583,6 @@ export class SessionManager {
|
|
|
1482
1583
|
}
|
|
1483
1584
|
}
|
|
1484
1585
|
|
|
1485
|
-
export function countSessionStates(
|
|
1486
|
-
list: readonly { readonly state: SessionState }[],
|
|
1487
|
-
): Record<SessionState, number> {
|
|
1488
|
-
const c = { needs_you: 0, running: 0, done: 0, exited: 0 };
|
|
1489
|
-
|
|
1490
|
-
for (const s of list) {
|
|
1491
|
-
c[s.state]++;
|
|
1492
|
-
}
|
|
1493
|
-
|
|
1494
|
-
return c;
|
|
1495
|
-
}
|
|
1496
|
-
|
|
1497
|
-
interface SortableSessionView {
|
|
1498
|
-
readonly id: string;
|
|
1499
|
-
readonly parent: string | null;
|
|
1500
|
-
readonly state: SessionState;
|
|
1501
|
-
readonly pinned: boolean;
|
|
1502
|
-
readonly lastAttachedAt: number;
|
|
1503
|
-
readonly createdAt: number;
|
|
1504
|
-
}
|
|
1505
|
-
|
|
1506
|
-
// Overlay order: pinned sessions first in most-recently-attached order, then
|
|
1507
|
-
// everyone else by urgency — who needs you, finished turns, busy, dead —
|
|
1508
|
-
// with most-recently-attached breaking ties inside each state. A
|
|
1509
|
-
// sub-session sits directly under its parent, ranked among its siblings
|
|
1510
|
-
// alone, so its attention never moves the parent's row; a sub-session whose
|
|
1511
|
-
// parent is not listed ranks as a top-level row.
|
|
1512
|
-
export function sortSessionViews<T extends SortableSessionView>(list: readonly T[]): T[] {
|
|
1513
|
-
const rank: Record<SessionState, number> = {
|
|
1514
|
-
needs_you: 0,
|
|
1515
|
-
done: 1,
|
|
1516
|
-
running: 2,
|
|
1517
|
-
exited: 3,
|
|
1518
|
-
};
|
|
1519
|
-
|
|
1520
|
-
const ranked = [...list].toSorted((a, b) => {
|
|
1521
|
-
if (a.pinned !== b.pinned) {
|
|
1522
|
-
return a.pinned ? -1 : 1;
|
|
1523
|
-
}
|
|
1524
|
-
|
|
1525
|
-
const recency = b.lastAttachedAt - a.lastAttachedAt || b.createdAt - a.createdAt;
|
|
1526
|
-
|
|
1527
|
-
return a.pinned ? recency : rank[a.state] - rank[b.state] || recency;
|
|
1528
|
-
});
|
|
1529
|
-
|
|
1530
|
-
const listed = new Set(ranked.map((s) => s.id));
|
|
1531
|
-
|
|
1532
|
-
const sorted: T[] = [];
|
|
1533
|
-
|
|
1534
|
-
for (const s of ranked) {
|
|
1535
|
-
if (s.parent !== null && listed.has(s.parent)) {
|
|
1536
|
-
continue;
|
|
1537
|
-
}
|
|
1538
|
-
|
|
1539
|
-
sorted.push(s, ...ranked.filter((child) => child.parent === s.id));
|
|
1540
|
-
}
|
|
1541
|
-
|
|
1542
|
-
return sorted;
|
|
1543
|
-
}
|
|
1544
|
-
|
|
1545
|
-
// Never a filesystem path, so a repository can't collide with it.
|
|
1546
|
-
export const PINNED_GROUP_KEY = ' pinned';
|
|
1547
|
-
|
|
1548
|
-
// Overlay display order for the grouped view: the flat sort with each
|
|
1549
|
-
// repository's sessions pulled together at the position of its best-ranked
|
|
1550
|
-
// member, so the renderer's adjacency-based headers appear once per group.
|
|
1551
|
-
// Pinned sessions form their own leading group. A sub-session keys by its
|
|
1552
|
-
// parent, so a set never splits across groups.
|
|
1553
|
-
export function sortGroupedSessionViews<
|
|
1554
|
-
T extends SortableSessionView & { readonly repoRoot: string },
|
|
1555
|
-
>(list: readonly T[]): T[] {
|
|
1556
|
-
const byID = new Map(list.map((s) => [s.id, s]));
|
|
1557
|
-
const buckets = new Map<string, T[]>();
|
|
1558
|
-
|
|
1559
|
-
for (const s of sortSessionViews(list)) {
|
|
1560
|
-
const owner = (s.parent === null ? undefined : byID.get(s.parent)) ?? s;
|
|
1561
|
-
const key = owner.pinned ? PINNED_GROUP_KEY : owner.repoRoot;
|
|
1562
|
-
const bucket = buckets.get(key);
|
|
1563
|
-
|
|
1564
|
-
if (bucket === undefined) {
|
|
1565
|
-
buckets.set(key, [s]);
|
|
1566
|
-
} else {
|
|
1567
|
-
bucket.push(s);
|
|
1568
|
-
}
|
|
1569
|
-
}
|
|
1570
|
-
|
|
1571
|
-
return [...buckets.values()].flat();
|
|
1572
|
-
}
|
|
1573
|
-
|
|
1574
1586
|
function buildLifecycle(s: Session): SessionLifecycle {
|
|
1575
1587
|
return buildSessionLifecycle({
|
|
1576
1588
|
desired: s.desired,
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { AdapterEvent } from '../
|
|
2
|
-
import { truncateDetail } from '../agents/truncate-detail';
|
|
1
|
+
import type { AdapterEvent } from '../protocol/adapter-event';
|
|
3
2
|
import type { SessionID } from '../shared/session-id';
|
|
3
|
+
import { truncateDetail } from '../shared/truncate-detail';
|
|
4
|
+
import { truncateSummary } from '../shared/truncate-summary';
|
|
4
5
|
import type { SessionRuntime } from './session-runtime';
|
|
5
6
|
import type { SessionManager } from './sessions';
|
|
6
|
-
import { truncateSummary } from './truncate-summary';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Starts one headless turn for a session: finds its adapter's headless
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import type { HookEvent } from '../protocol/hook-event';
|
|
2
3
|
import type { EventMsg } from '../protocol/protocol';
|
|
3
4
|
import type { MessageID } from '../shared/message-id';
|
|
4
5
|
import type { SessionID } from '../shared/session-id';
|
|
5
6
|
import { toMessageID } from '../shared/to-message-id';
|
|
6
7
|
import type { MessageRecord } from '../store/message-record';
|
|
7
|
-
import type { TapClient } from './daemon-
|
|
8
|
+
import type { TapClient } from './daemon-context';
|
|
8
9
|
import type { HarnessRelay } from './execution-provider';
|
|
9
|
-
import type { HookEvent } from './hooks';
|
|
10
10
|
import { isBindingCurrent } from './is-binding-current';
|
|
11
11
|
import type { BridgeBinding } from './is-binding-current';
|
|
12
12
|
import { parseHookLine } from './parse-hook-line';
|
|
@@ -2,7 +2,7 @@ import type { Kysely } from 'kysely';
|
|
|
2
2
|
import { GRANT_SCOPES } from '../shared/grant-scope';
|
|
3
3
|
import type { GrantScope } from '../shared/grant-scope';
|
|
4
4
|
import { collectJSONStrings } from './collect-json-strings';
|
|
5
|
-
import type { MCPAuthSchema } from './
|
|
5
|
+
import type { MCPAuthSchema } from './mcp-auth-schema';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* One authorization a client holds: every refresh token rotated from one
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The columns of the authorization server's database that atc reads and
|
|
3
|
+
* writes itself. better-auth owns the tables and their migrations; dates are
|
|
4
|
+
* ISO 8601 strings and string lists are JSON arrays.
|
|
5
|
+
*/
|
|
6
|
+
export interface MCPAuthSchema {
|
|
7
|
+
readonly oauthClient: {
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly clientId: string;
|
|
10
|
+
readonly name: string | null;
|
|
11
|
+
readonly redirectUris: string;
|
|
12
|
+
readonly createdAt: string | null;
|
|
13
|
+
};
|
|
14
|
+
readonly oauthAccessToken: {
|
|
15
|
+
readonly id: string;
|
|
16
|
+
readonly token: string;
|
|
17
|
+
readonly clientId: string;
|
|
18
|
+
readonly sessionId: string | null;
|
|
19
|
+
readonly authorizationCodeId: string | null;
|
|
20
|
+
};
|
|
21
|
+
readonly oauthRefreshToken: {
|
|
22
|
+
readonly id: string;
|
|
23
|
+
readonly clientId: string;
|
|
24
|
+
readonly sessionId: string | null;
|
|
25
|
+
readonly authorizationCodeId: string | null;
|
|
26
|
+
readonly scopes: string;
|
|
27
|
+
readonly createdAt: string;
|
|
28
|
+
readonly expiresAt: string;
|
|
29
|
+
readonly revoked: string | null;
|
|
30
|
+
};
|
|
31
|
+
readonly oauthConsent: { readonly id: string; readonly clientId: string };
|
|
32
|
+
readonly oauthResource: { readonly identifier: string };
|
|
33
|
+
readonly session: { readonly id: string; readonly expiresAt: string };
|
|
34
|
+
|
|
35
|
+
// When each grant last reached /mcp, keyed by the grant's authorization code id.
|
|
36
|
+
readonly atc_grant_use: { readonly grant_id: string; readonly last_used_at: string };
|
|
37
|
+
}
|
package/src/mcp/open-mcp-auth.ts
CHANGED
|
@@ -8,11 +8,11 @@ import { betterAuth } from 'better-auth';
|
|
|
8
8
|
import type { BetterAuthPlugin } from 'better-auth';
|
|
9
9
|
import { getMigrations } from 'better-auth/db/migration';
|
|
10
10
|
import { Kysely, SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler, sql } from 'kysely';
|
|
11
|
+
import { BunSqliteDriver } from '../shared/bun-sqlite-driver';
|
|
11
12
|
import { GRANT_SCOPES } from '../shared/grant-scope';
|
|
12
|
-
import { BunSqliteDriver } from '../store/bun-sqlite-driver';
|
|
13
13
|
import { buildOwnerPlugin } from './build-owner-plugin';
|
|
14
14
|
import { deriveTokenHash } from './derive-token-hash';
|
|
15
|
-
import type { MCPAuthSchema } from './
|
|
15
|
+
import type { MCPAuthSchema } from './mcp-auth-schema';
|
|
16
16
|
|
|
17
17
|
interface MCPAuthOptions {
|
|
18
18
|
readonly dbPath: string;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import {
|
|
2
|
+
import type { DaemonChannel } from '../protocol/daemon-channel';
|
|
3
3
|
import type { DaemonFeature } from '../protocol/daemon-features';
|
|
4
4
|
import { parseDaemonFeatures } from '../protocol/parse-daemon-features';
|
|
5
5
|
import { requireDaemonFeatures } from './require-daemon-features';
|
|
@@ -30,7 +30,7 @@ const KEYED_METHODS: ReadonlyMap<string, DaemonFeature> = new Map<string, Daemon
|
|
|
30
30
|
|
|
31
31
|
// One handshaken connection and the features its daemon announced.
|
|
32
32
|
interface DaemonConnection {
|
|
33
|
-
readonly client:
|
|
33
|
+
readonly client: DaemonChannel;
|
|
34
34
|
readonly features: ReadonlySet<DaemonFeature>;
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -48,13 +48,22 @@ export class ReconnectingCaller implements FleetCaller {
|
|
|
48
48
|
|
|
49
49
|
private readonly build: string;
|
|
50
50
|
|
|
51
|
+
private readonly openChannel: (socketPath: string) => Promise<DaemonChannel>;
|
|
52
|
+
|
|
51
53
|
private client: Promise<DaemonConnection> | null = null;
|
|
52
54
|
|
|
53
|
-
private readonly closed = new WeakSet<
|
|
55
|
+
private readonly closed = new WeakSet<DaemonChannel>();
|
|
54
56
|
|
|
55
|
-
|
|
57
|
+
// The last argument connects to the daemon at a socket path; each channel
|
|
58
|
+
// it returns is handshaken here before a request rides it.
|
|
59
|
+
constructor(
|
|
60
|
+
socketPath: string,
|
|
61
|
+
build: string,
|
|
62
|
+
openChannel: (socketPath: string) => Promise<DaemonChannel>,
|
|
63
|
+
) {
|
|
56
64
|
this.socketPath = socketPath;
|
|
57
65
|
this.build = build;
|
|
66
|
+
this.openChannel = openChannel;
|
|
58
67
|
}
|
|
59
68
|
|
|
60
69
|
// The required features are checked against each connection right before
|
|
@@ -141,10 +150,10 @@ export class ReconnectingCaller implements FleetCaller {
|
|
|
141
150
|
}
|
|
142
151
|
};
|
|
143
152
|
|
|
144
|
-
let client:
|
|
153
|
+
let client: DaemonChannel;
|
|
145
154
|
|
|
146
155
|
try {
|
|
147
|
-
client = await
|
|
156
|
+
client = await this.openChannel(this.socketPath);
|
|
148
157
|
} catch (error) {
|
|
149
158
|
resetClient();
|
|
150
159
|
throw error;
|
package/src/mcp/remove-client.ts
CHANGED
package/src/mcp/revoke-grant.ts
CHANGED
package/src/mcp/types.ts
CHANGED
|
@@ -25,44 +25,6 @@ export interface ToolContext {
|
|
|
25
25
|
readonly sender: { readonly kind: 'fixed' | 'default'; readonly name: string };
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
/**
|
|
29
|
-
* The columns of the authorization server's database that atc reads and
|
|
30
|
-
* writes itself. better-auth owns the tables and their migrations; dates are
|
|
31
|
-
* ISO 8601 strings and string lists are JSON arrays.
|
|
32
|
-
*/
|
|
33
|
-
export interface MCPAuthSchema {
|
|
34
|
-
readonly oauthClient: {
|
|
35
|
-
readonly id: string;
|
|
36
|
-
readonly clientId: string;
|
|
37
|
-
readonly name: string | null;
|
|
38
|
-
readonly redirectUris: string;
|
|
39
|
-
readonly createdAt: string | null;
|
|
40
|
-
};
|
|
41
|
-
readonly oauthAccessToken: {
|
|
42
|
-
readonly id: string;
|
|
43
|
-
readonly token: string;
|
|
44
|
-
readonly clientId: string;
|
|
45
|
-
readonly sessionId: string | null;
|
|
46
|
-
readonly authorizationCodeId: string | null;
|
|
47
|
-
};
|
|
48
|
-
readonly oauthRefreshToken: {
|
|
49
|
-
readonly id: string;
|
|
50
|
-
readonly clientId: string;
|
|
51
|
-
readonly sessionId: string | null;
|
|
52
|
-
readonly authorizationCodeId: string | null;
|
|
53
|
-
readonly scopes: string;
|
|
54
|
-
readonly createdAt: string;
|
|
55
|
-
readonly expiresAt: string;
|
|
56
|
-
readonly revoked: string | null;
|
|
57
|
-
};
|
|
58
|
-
readonly oauthConsent: { readonly id: string; readonly clientId: string };
|
|
59
|
-
readonly oauthResource: { readonly identifier: string };
|
|
60
|
-
readonly session: { readonly id: string; readonly expiresAt: string };
|
|
61
|
-
|
|
62
|
-
// When each grant last reached /mcp, keyed by the grant's authorization code id.
|
|
63
|
-
readonly atc_grant_use: { readonly grant_id: string; readonly last_used_at: string };
|
|
64
|
-
}
|
|
65
|
-
|
|
66
28
|
/**
|
|
67
29
|
* The authorization server's store: the better-auth instance and the
|
|
68
30
|
* database under it.
|
package/src/mcp-http-server.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mkdirSync } from 'node:fs';
|
|
2
2
|
import { bootDaemonClient } from './client/boot-daemon';
|
|
3
|
+
import { DaemonClient } from './client/daemon-client';
|
|
3
4
|
import { collectClients } from './mcp/collect-clients';
|
|
4
5
|
import { openMCPAuth } from './mcp/open-mcp-auth';
|
|
5
6
|
import { ReconnectingCaller } from './mcp/reconnecting-caller';
|
|
@@ -27,7 +28,7 @@ export async function runMCPHTTPServer(build: string, flags: MCPHTTPFlags): Prom
|
|
|
27
28
|
|
|
28
29
|
mkdirSync(stateDir, { recursive: true });
|
|
29
30
|
|
|
30
|
-
const caller = new ReconnectingCaller(boot.socketPath, build);
|
|
31
|
+
const caller = new ReconnectingCaller(boot.socketPath, build, (path) => DaemonClient.open(path));
|
|
31
32
|
|
|
32
33
|
const server = await startMCPHTTPServer({
|
|
33
34
|
caller,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What an adapter makes of one hook event: the agent-neutral kind and the
|
|
5
|
+
* fields the daemon and the store act on.
|
|
6
|
+
*/
|
|
7
|
+
export interface AdapterEvent {
|
|
8
|
+
kind: 'started' | 'needs-input' | 'turn-done' | 'prompt-submitted' | 'ended' | 'heartbeat';
|
|
9
|
+
agentSessionID?: AgentSessionID;
|
|
10
|
+
message?: string;
|
|
11
|
+
|
|
12
|
+
// Fuller activity text than message: what the agent last said or was
|
|
13
|
+
// asked, for briefing. Bounded by the adapter.
|
|
14
|
+
detail?: string;
|
|
15
|
+
|
|
16
|
+
// Opaque handle the adapter can later pull a session name from.
|
|
17
|
+
nameSource?: string;
|
|
18
|
+
|
|
19
|
+
// Claude resume-existence path. Distinct from nameSource: a naming
|
|
20
|
+
// handle is not a resume gate.
|
|
21
|
+
transcriptSource?: string;
|
|
22
|
+
|
|
23
|
+
// The agent's whole final message for a finished turn; detail holds a bounded preview of it.
|
|
24
|
+
result?: string;
|
|
25
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { SessionState } from './session-state';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How many of the sessions are in each state.
|
|
5
|
+
*/
|
|
6
|
+
export function countSessionStates(
|
|
7
|
+
list: readonly { readonly state: SessionState }[],
|
|
8
|
+
): Record<SessionState, number> {
|
|
9
|
+
const c = { needs_you: 0, running: 0, done: 0, exited: 0 };
|
|
10
|
+
|
|
11
|
+
for (const s of list) {
|
|
12
|
+
c[s.state]++;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return c;
|
|
16
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One open protocol connection to a daemon, as a caller that does not own
|
|
3
|
+
* the transport drives it: the handshake, correlated requests, a callback
|
|
4
|
+
* for when the connection ends, and closing it.
|
|
5
|
+
*/
|
|
6
|
+
export interface DaemonChannel {
|
|
7
|
+
onClose: () => void;
|
|
8
|
+
readonly sendHello: (build: string) => Promise<Readonly<Record<string, unknown>>>;
|
|
9
|
+
|
|
10
|
+
// A request with a principal acts as that principal.
|
|
11
|
+
readonly sendRequest: (
|
|
12
|
+
m: string,
|
|
13
|
+
p?: Readonly<Record<string, unknown>>,
|
|
14
|
+
as?: string,
|
|
15
|
+
) => Promise<Readonly<Record<string, unknown>>>;
|
|
16
|
+
readonly stop: () => void;
|
|
17
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SessionID } from '../shared/session-id';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One line a hook reporter sends: the atc session it reports on, the agent's
|
|
5
|
+
* hook event name, and the hook's payload as the agent gave it.
|
|
6
|
+
*/
|
|
7
|
+
export interface HookEvent {
|
|
8
|
+
atcId: SessionID;
|
|
9
|
+
event: string;
|
|
10
|
+
payload: Record<string, unknown>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { PINNED_GROUP_KEY } from './pinned-group-key';
|
|
2
|
+
import { sortSessionViews } from './sort-session-views';
|
|
3
|
+
import type { SortableSessionView } from './sortable-session-view';
|
|
4
|
+
|
|
5
|
+
// Overlay display order for the grouped view: the flat sort with each
|
|
6
|
+
// repository's sessions pulled together at the position of its best-ranked
|
|
7
|
+
// member, so the renderer's adjacency-based headers appear once per group.
|
|
8
|
+
// Pinned sessions form their own leading group. A sub-session keys by its
|
|
9
|
+
// parent, so a set never splits across groups.
|
|
10
|
+
export function sortGroupedSessionViews<
|
|
11
|
+
T extends SortableSessionView & { readonly repoRoot: string },
|
|
12
|
+
>(list: readonly T[]): T[] {
|
|
13
|
+
const byID = new Map(list.map((s) => [s.id, s]));
|
|
14
|
+
const buckets = new Map<string, T[]>();
|
|
15
|
+
|
|
16
|
+
for (const s of sortSessionViews(list)) {
|
|
17
|
+
const owner = (s.parent === null ? undefined : byID.get(s.parent)) ?? s;
|
|
18
|
+
const key = owner.pinned ? PINNED_GROUP_KEY : owner.repoRoot;
|
|
19
|
+
const bucket = buckets.get(key);
|
|
20
|
+
|
|
21
|
+
if (bucket === undefined) {
|
|
22
|
+
buckets.set(key, [s]);
|
|
23
|
+
} else {
|
|
24
|
+
bucket.push(s);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return [...buckets.values()].flat();
|
|
29
|
+
}
|