@zgeoff/atc 2.15.1 → 2.16.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/package.json +2 -2
- package/src/agents/build-hook-settings.ts +12 -3
- package/src/agents/print-codex-hook-file.ts +1 -1
- package/src/agents/print-grok-hook-file.ts +1 -1
- package/src/cli.ts +15 -4
- package/src/daemon/broker-authority-error.ts +41 -0
- package/src/daemon/build-fleet-events.ts +10 -4
- package/src/daemon/build-grant-from-fleet-entry.ts +14 -0
- package/src/daemon/build-report-view.ts +10 -4
- package/src/daemon/build-scoped-context.ts +63 -76
- package/src/daemon/collect-secret-rule-mismatches.ts +83 -0
- package/src/daemon/daemon-connection.ts +202 -51
- package/src/daemon/daemon-context.ts +63 -12
- package/src/daemon/daemon.ts +258 -90
- package/src/daemon/drain-inbox.ts +85 -0
- package/src/daemon/imp-client-port.ts +75 -3
- package/src/daemon/imp-port.ts +60 -0
- package/src/daemon/is-imp-name-allowed.ts +38 -0
- package/src/daemon/is-own-hook-event.ts +19 -0
- package/src/daemon/is-tree-in-reach.ts +32 -0
- package/src/daemon/parse-hook-line.ts +3 -0
- package/src/daemon/session-runtime.ts +6 -0
- package/src/daemon/sessions.ts +62 -22
- package/src/daemon/tap-registry.ts +43 -5
- package/src/daemon/verify-broker-authority.ts +52 -0
- package/src/daemon/verify-cleanup-authority.ts +44 -0
- package/src/daemon/verify-token-imp-authority.ts +74 -0
- package/src/hook-report.ts +6 -4
- package/src/protocol/hook-event.ts +6 -2
- package/src/statusline.ts +8 -6
- package/src/store/run-migrations.ts +55 -4
- package/src/store/state-store.ts +44 -9
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { PROTOCOL_V } from '../protocol/protocol';
|
|
2
|
+
import type { SessionID } from '../shared/session-id';
|
|
3
|
+
import type { MessageOwner } from '../store/message-owner';
|
|
4
|
+
import type { MessageRecord } from '../store/message-record';
|
|
5
|
+
import type { TapClient } from './daemon-context';
|
|
6
|
+
import type { TapGeneration, TapRegistry } from './tap-registry';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What a drain reads from: the taps, the owner a linked tap reads messages
|
|
10
|
+
* under (the session's atc id and agent session id, or null for a session
|
|
11
|
+
* the daemon does not hold), and the store's pending messages for an owner.
|
|
12
|
+
*/
|
|
13
|
+
export interface InboxSource {
|
|
14
|
+
readonly taps: TapRegistry<TapClient>;
|
|
15
|
+
readonly findLinkedOwner: (sessionID: SessionID) => MessageOwner | null;
|
|
16
|
+
readonly collectPendingMessages: (owner: MessageOwner) => Promise<MessageRecord[]>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Hands the session's tap the oldest pending message it has not taken. The
|
|
21
|
+
* backlog is read before anything is sent, so a tap's ok response is always
|
|
22
|
+
* queued ahead of its first message, and one message goes per call: the
|
|
23
|
+
* tap's ack calls it again, so the backlog never outgrows the connection's
|
|
24
|
+
* outbound queue. The drain is bound to the tap it found before the read: a
|
|
25
|
+
* tap another attach made while the read waited gets nothing from it.
|
|
26
|
+
*/
|
|
27
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- the drain claims deliveries in the tap registry it is given
|
|
28
|
+
export async function drainInbox(sessionID: SessionID, source: InboxSource): Promise<void> {
|
|
29
|
+
const linkedOwner = source.findLinkedOwner(sessionID);
|
|
30
|
+
const tap = source.taps.findTap(sessionID);
|
|
31
|
+
|
|
32
|
+
if (linkedOwner === null || tap === null) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// A tap a principal holds takes only the messages sent to this atc id:
|
|
37
|
+
// another session that shares the agent session id may be out of its
|
|
38
|
+
// reach.
|
|
39
|
+
const owner = tap.linked ? linkedOwner : { atcID: linkedOwner.atcID };
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const pending = await source.collectPendingMessages(owner);
|
|
43
|
+
|
|
44
|
+
for (const record of pending) {
|
|
45
|
+
if (sendInboxMessage(sessionID, record, tap, source.taps)) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} catch {}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Hands one pending message to the tap the drain was made for, once, and
|
|
53
|
+
// reports whether it did. An unlinked tap takes only a message sent to the
|
|
54
|
+
// session's own atc id. The event goes to the tap connection alone: it never
|
|
55
|
+
// reaches other clients, the events socket, or hooks.
|
|
56
|
+
function sendInboxMessage(
|
|
57
|
+
sessionID: SessionID,
|
|
58
|
+
record: MessageRecord,
|
|
59
|
+
tap: TapGeneration,
|
|
60
|
+
|
|
61
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- a delivery claims the message in the registry
|
|
62
|
+
taps: TapRegistry<TapClient>,
|
|
63
|
+
): boolean {
|
|
64
|
+
if (!tap.linked && record.atcID !== sessionID) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const client = taps.claimDelivery(sessionID, record.id, tap.generation);
|
|
69
|
+
|
|
70
|
+
if (client === null) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
client.sendEvent({
|
|
75
|
+
v: PROTOCOL_V,
|
|
76
|
+
ev: 'InboxMessage',
|
|
77
|
+
s: sessionID,
|
|
78
|
+
message: record.id,
|
|
79
|
+
from: record.from,
|
|
80
|
+
text: record.text,
|
|
81
|
+
sentAt: record.sentAt,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { createImpClient, openExecSession, openReverseForward } from '@zgeoff/imp-client';
|
|
2
2
|
import type { ExecOutcome, ImpClient } from '@zgeoff/imp-client';
|
|
3
|
+
import { isRecord } from '../shared/report';
|
|
3
4
|
import type {
|
|
4
5
|
ImpCommand,
|
|
5
6
|
ImpCommandResult,
|
|
6
7
|
ImpCreateSpec,
|
|
7
8
|
ImpFeatures,
|
|
9
|
+
ImpIdentity,
|
|
8
10
|
ImpLease,
|
|
9
11
|
ImpPort,
|
|
10
12
|
ImpRelayConnection,
|
|
11
13
|
ImpReverseForward,
|
|
14
|
+
ImpSecret,
|
|
12
15
|
ImpSessionConnection,
|
|
13
16
|
ImpSessionHandlers,
|
|
14
17
|
ImpSessionOutcome,
|
|
@@ -48,11 +51,79 @@ export class ImpClientPort implements ImpPort {
|
|
|
48
51
|
this.readToken = options.readToken;
|
|
49
52
|
}
|
|
50
53
|
|
|
51
|
-
//
|
|
54
|
+
// A flag counts only as a literal true: an impd from before a flag has
|
|
55
|
+
// it false, and so does one that sends anything else in its place.
|
|
52
56
|
readonly readFeatures = async (): Promise<ImpFeatures> => {
|
|
53
57
|
const info = await this.tryCall((client) => client.system.info());
|
|
54
58
|
|
|
55
|
-
|
|
59
|
+
const features: Readonly<Record<string, unknown>> | undefined = info.features;
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
sessionOffsets: features?.['sessionOffsets'] === true,
|
|
63
|
+
leases: features?.['leases'] === true,
|
|
64
|
+
grantableTokens: features?.['grantableTokens'] === true,
|
|
65
|
+
secretRebind: features?.['secretRebind'] === true,
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// An impd from before grantable lists sends none, which grants nothing.
|
|
70
|
+
readonly readIdentity = async (): Promise<ImpIdentity> => {
|
|
71
|
+
const identity = await this.tryCall((client) => client.tokens.whoami());
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
kind: identity.kind,
|
|
75
|
+
name: identity.name,
|
|
76
|
+
scope: identity.scope,
|
|
77
|
+
imps: identity.imps === null ? null : [...identity.imps],
|
|
78
|
+
grantable: [...(identity.grantable ?? [])],
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
readonly readSecrets = async (): Promise<readonly ImpSecret[]> => {
|
|
83
|
+
const secrets = await this.tryCall((client) => client.secrets.list());
|
|
84
|
+
|
|
85
|
+
return secrets.map((secret) => ({
|
|
86
|
+
name: secret.name,
|
|
87
|
+
kind: secret.kind,
|
|
88
|
+
rules: secret.rules.map((rule) => ({
|
|
89
|
+
host: rule.host,
|
|
90
|
+
header: rule.header,
|
|
91
|
+
scheme: rule.scheme,
|
|
92
|
+
...(rule.user === undefined ? {} : { user: rule.user }),
|
|
93
|
+
})),
|
|
94
|
+
imps: [...secret.imps],
|
|
95
|
+
}));
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
readonly readGrants = async (name: string): Promise<readonly string[]> => {
|
|
99
|
+
const grants = await this.tryCall((client) => client.grants.list({ name }));
|
|
100
|
+
|
|
101
|
+
return [...grants];
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
readonly createGrant = async (name: string, secret: string): Promise<void> => {
|
|
105
|
+
await this.tryCall((client) => client.grants.add({ name, secret }));
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// A grant impd no longer holds is no grant to revoke; a missing imp or
|
|
109
|
+
// secret still rejects.
|
|
110
|
+
readonly removeGrant = async (name: string, secret: string): Promise<boolean> => {
|
|
111
|
+
try {
|
|
112
|
+
await this.tryCall((client) => client.grants.delete({ name, secret }));
|
|
113
|
+
|
|
114
|
+
return true;
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (
|
|
117
|
+
error instanceof ImpPortError &&
|
|
118
|
+
error.code === 'NOT_FOUND' &&
|
|
119
|
+
isRecord(error.data) &&
|
|
120
|
+
error.data['kind'] === 'grant'
|
|
121
|
+
) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
56
127
|
};
|
|
57
128
|
|
|
58
129
|
readonly readImp = async (name: string): Promise<ImpView | null> => {
|
|
@@ -60,6 +131,7 @@ export class ImpClientPort implements ImpPort {
|
|
|
60
131
|
const imp = await this.tryCall((client) => client.imps.get({ name }));
|
|
61
132
|
|
|
62
133
|
return {
|
|
134
|
+
id: imp.id,
|
|
63
135
|
name: imp.name,
|
|
64
136
|
state: imp.state,
|
|
65
137
|
leases: (imp.leases?.leases ?? []).map((lease) => toLease(lease)),
|
|
@@ -83,7 +155,7 @@ export class ImpClientPort implements ImpPort {
|
|
|
83
155
|
}),
|
|
84
156
|
);
|
|
85
157
|
|
|
86
|
-
return { name: imp.name, state: imp.state, leases: [], otherLeaseCount: 0 };
|
|
158
|
+
return { id: imp.id, name: imp.name, state: imp.state, leases: [], otherLeaseCount: 0 };
|
|
87
159
|
};
|
|
88
160
|
|
|
89
161
|
readonly acquireLease = async (
|
package/src/daemon/imp-port.ts
CHANGED
|
@@ -9,6 +9,23 @@ export interface ImpPort {
|
|
|
9
9
|
// impd's capability flags; an impd without them has neither.
|
|
10
10
|
readonly readFeatures: () => Promise<ImpFeatures>;
|
|
11
11
|
|
|
12
|
+
// The caller's own identity: its scope, the imps it may reach, and the
|
|
13
|
+
// secrets it may grant.
|
|
14
|
+
readonly readIdentity: () => Promise<ImpIdentity>;
|
|
15
|
+
|
|
16
|
+
// Every secret impd holds, with its rules and never its value.
|
|
17
|
+
readonly readSecrets: () => Promise<readonly ImpSecret[]>;
|
|
18
|
+
|
|
19
|
+
// The names of the secrets granted to an imp.
|
|
20
|
+
readonly readGrants: (name: string) => Promise<readonly string[]>;
|
|
21
|
+
|
|
22
|
+
// Grants a secret to an imp; granting one it already holds changes
|
|
23
|
+
// nothing, and nothing in the result distinguishes the two.
|
|
24
|
+
readonly createGrant: (name: string, secret: string) => Promise<void>;
|
|
25
|
+
|
|
26
|
+
// Revokes a secret from an imp, and reports whether impd held the grant.
|
|
27
|
+
readonly removeGrant: (name: string, secret: string) => Promise<boolean>;
|
|
28
|
+
|
|
12
29
|
// The imp under a name, or null when impd holds none.
|
|
13
30
|
readonly readImp: (name: string) => Promise<ImpView | null>;
|
|
14
31
|
readonly createImp: (spec: ImpCreateSpec) => Promise<ImpView>;
|
|
@@ -51,11 +68,54 @@ export interface ImpPort {
|
|
|
51
68
|
export interface ImpFeatures {
|
|
52
69
|
readonly sessionOffsets: boolean;
|
|
53
70
|
readonly leases: boolean;
|
|
71
|
+
|
|
72
|
+
// Tokens limited to some imps may grant a list of secrets to them.
|
|
73
|
+
readonly grantableTokens: boolean;
|
|
74
|
+
|
|
75
|
+
// A rebound or recreated secret drops its grants and leaves a token's
|
|
76
|
+
// list of grantable secrets behind.
|
|
77
|
+
readonly secretRebind: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
type ImpScope = 'read' | 'exec' | 'manage';
|
|
81
|
+
|
|
82
|
+
export interface ImpIdentity {
|
|
83
|
+
readonly kind: 'token' | 'ssh' | 'tailnet' | 'dashboard';
|
|
84
|
+
readonly name: string;
|
|
85
|
+
readonly scope: ImpScope;
|
|
86
|
+
|
|
87
|
+
// Imp name patterns with `*` for any run of characters; null reaches
|
|
88
|
+
// every imp on the host.
|
|
89
|
+
readonly imps: readonly string[] | null;
|
|
90
|
+
|
|
91
|
+
// The secrets the caller may grant to the imps it reaches and revoke
|
|
92
|
+
// from them.
|
|
93
|
+
readonly grantable: readonly string[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface ImpSecret {
|
|
97
|
+
readonly name: string;
|
|
98
|
+
readonly kind: 'anthropic' | 'custom' | 'github' | 'npm';
|
|
99
|
+
readonly rules: readonly ImpSecretRule[];
|
|
100
|
+
|
|
101
|
+
// The imps holding a grant of the secret.
|
|
102
|
+
readonly imps: readonly string[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// How impd adds a secret to requests for one host.
|
|
106
|
+
export interface ImpSecretRule {
|
|
107
|
+
readonly host: string;
|
|
108
|
+
readonly header: string;
|
|
109
|
+
readonly scheme: 'basic' | 'bearer' | 'raw';
|
|
110
|
+
readonly user?: string;
|
|
54
111
|
}
|
|
55
112
|
|
|
56
113
|
export type ImpState = 'creating' | 'running' | 'sleeping' | 'stopped' | 'error';
|
|
57
114
|
|
|
58
115
|
export interface ImpView {
|
|
116
|
+
// impd's id for this imp, which a new imp made under the same name never
|
|
117
|
+
// shares.
|
|
118
|
+
readonly id: string;
|
|
59
119
|
readonly name: string;
|
|
60
120
|
readonly state: ImpState;
|
|
61
121
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether an imp name matches one of a token's imp patterns, as impd
|
|
3
|
+
* matches them: each pattern is an imp name with `*` for any run of
|
|
4
|
+
* characters, including none, so `dev-*` matches `dev-` and `dev-a1`, and
|
|
5
|
+
* a pattern without `*` matches only its own name.
|
|
6
|
+
*/
|
|
7
|
+
export function isImpNameAllowed(patterns: readonly string[], name: string): boolean {
|
|
8
|
+
return patterns.some((pattern) => isPatternMatch(pattern, name));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function isPatternMatch(pattern: string, name: string): boolean {
|
|
12
|
+
const [first = '', ...rest] = pattern.split('*');
|
|
13
|
+
const last = rest.pop();
|
|
14
|
+
|
|
15
|
+
if (last === undefined) {
|
|
16
|
+
return name === first;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (name.length < first.length + last.length || !name.startsWith(first) || !name.endsWith(last)) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Each middle part in order, between the prefix and the suffix.
|
|
24
|
+
let at = first.length;
|
|
25
|
+
const end = name.length - last.length;
|
|
26
|
+
|
|
27
|
+
for (const part of rest) {
|
|
28
|
+
const found = name.indexOf(part, at);
|
|
29
|
+
|
|
30
|
+
if (found === -1 || found + part.length > end) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
at = found + part.length;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { AgentID } from '../shared/agent-id';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether a hook line, judged by the agent it carries, comes from the
|
|
5
|
+
* harness atc started for its session rather than from a harness nested
|
|
6
|
+
* inside it, which inherits the session's environment and so reports under
|
|
7
|
+
* the same session. A line carrying an agent is the session's own when that
|
|
8
|
+
* agent is the session's. A line without one comes from a hook command that
|
|
9
|
+
* carries no agent flag, and stays the session's own until a line carrying
|
|
10
|
+
* the session's agent arrives from the same terminal: from then on the
|
|
11
|
+
* session's own hooks are known to carry it.
|
|
12
|
+
*/
|
|
13
|
+
export function isOwnHookEvent(
|
|
14
|
+
lineAgent: AgentID | undefined,
|
|
15
|
+
sessionAgent: AgentID,
|
|
16
|
+
hasAgentHookLines: boolean,
|
|
17
|
+
): boolean {
|
|
18
|
+
return lineAgent === undefined ? !hasAgentHookLines : lineAgent === sessionAgent;
|
|
19
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { SessionID } from '../shared/session-id';
|
|
2
|
+
import type { TargetAccess, TargetGrant } from './target-access';
|
|
3
|
+
|
|
4
|
+
// A session as the tree check reads it: its id, its parent if it has one,
|
|
5
|
+
// and the target it is bound to.
|
|
6
|
+
interface TreeMember extends TargetGrant {
|
|
7
|
+
readonly id: SessionID;
|
|
8
|
+
readonly parent: SessionID | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Whether the access reaches every session in the tree the given session
|
|
13
|
+
* belongs to: its top-level session and each sub-session of that one. A
|
|
14
|
+
* session the list does not hold, or one whose parent it does not hold,
|
|
15
|
+
* is out of reach.
|
|
16
|
+
*/
|
|
17
|
+
export function isTreeInReach(
|
|
18
|
+
sessions: readonly TreeMember[],
|
|
19
|
+
id: SessionID,
|
|
20
|
+
access: TargetAccess,
|
|
21
|
+
): boolean {
|
|
22
|
+
const session = sessions.find((x) => x.id === id);
|
|
23
|
+
|
|
24
|
+
if (session === undefined) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const root = session.parent ?? session.id;
|
|
29
|
+
const tree = sessions.filter((x) => x.id === root || x.parent === root);
|
|
30
|
+
|
|
31
|
+
return tree.some((x) => x.id === root) && tree.every((x) => access.canUse(x));
|
|
32
|
+
}
|
|
@@ -23,8 +23,11 @@ export function parseHookLine(line: string): HookEvent | null {
|
|
|
23
23
|
return null;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
const agent = parsed['agent'];
|
|
27
|
+
|
|
26
28
|
return {
|
|
27
29
|
atcId: toSessionID(parsed['atcId']),
|
|
30
|
+
...(typeof agent === 'string' && agent !== '' ? { agent } : {}),
|
|
28
31
|
event: parsed['event'],
|
|
29
32
|
payload: parsed['payload'],
|
|
30
33
|
};
|
|
@@ -46,6 +46,11 @@ export class SessionRuntime {
|
|
|
46
46
|
// later dropped may be restarting, so it keeps the inbox open.
|
|
47
47
|
tapAttached = false;
|
|
48
48
|
|
|
49
|
+
// Whether a hook line carrying the session's agent arrived since the
|
|
50
|
+
// terminal last booted. Once one has, the session's own hooks are known
|
|
51
|
+
// to carry it, and a line without one comes from another harness.
|
|
52
|
+
hasAgentHookLines = false;
|
|
53
|
+
|
|
49
54
|
// Returns the boot-scoped state to how a fresh terminal starts, at the
|
|
50
55
|
// dims it boots with: no SessionStart yet and no tap since. Every path
|
|
51
56
|
// that boots a new terminal for an existing session runs it, so a revived
|
|
@@ -54,6 +59,7 @@ export class SessionRuntime {
|
|
|
54
59
|
this.dims = dims;
|
|
55
60
|
this.startedAt = null;
|
|
56
61
|
this.tapAttached = false;
|
|
62
|
+
this.hasAgentHookLines = false;
|
|
57
63
|
}
|
|
58
64
|
|
|
59
65
|
dispose(): void {
|
package/src/daemon/sessions.ts
CHANGED
|
@@ -479,8 +479,15 @@ export class SessionManager {
|
|
|
479
479
|
// Adopts a headless session back into a terminal: a fresh PTY resumes the
|
|
480
480
|
// same agent session id. On a remote host the host wakes first, and a
|
|
481
481
|
// harness still running inside it is attached rather than started again;
|
|
482
|
-
// every other session left asleep on that host comes back with it.
|
|
483
|
-
|
|
482
|
+
// every other session left asleep on that host comes back with it. The
|
|
483
|
+
// caller's check runs again after each await, and a failed check leaves
|
|
484
|
+
// the session as it was.
|
|
485
|
+
async adoptTerminal(
|
|
486
|
+
id: SessionID,
|
|
487
|
+
cols: number,
|
|
488
|
+
rows: number,
|
|
489
|
+
canProceed: () => boolean = () => true,
|
|
490
|
+
): Promise<Session | null> {
|
|
484
491
|
const s = this.sessions.find((x) => x.id === id);
|
|
485
492
|
|
|
486
493
|
if (!s || s.pty !== null || s.agentSessionID === undefined || this.adopting.has(id)) {
|
|
@@ -489,7 +496,7 @@ export class SessionManager {
|
|
|
489
496
|
|
|
490
497
|
const settled = await this.isRollbackSettled(id);
|
|
491
498
|
|
|
492
|
-
if (!settled) {
|
|
499
|
+
if (!settled || !canProceed()) {
|
|
493
500
|
return null;
|
|
494
501
|
}
|
|
495
502
|
|
|
@@ -516,9 +523,11 @@ export class SessionManager {
|
|
|
516
523
|
this.adopting.delete(id);
|
|
517
524
|
}
|
|
518
525
|
|
|
519
|
-
// A kill, a second adopt,
|
|
520
|
-
// the host
|
|
526
|
+
// A kill, a second adopt, a failed spawn's rollback, or a change that
|
|
527
|
+
// takes the session out of the caller's reach can land while the host
|
|
528
|
+
// wakes.
|
|
521
529
|
if (
|
|
530
|
+
!canProceed() ||
|
|
522
531
|
s.pty !== null ||
|
|
523
532
|
!this.sessions.includes(s) ||
|
|
524
533
|
this.rollingBack.has(id) ||
|
|
@@ -729,6 +738,7 @@ export class SessionManager {
|
|
|
729
738
|
id: SessionID = mintSessionID(),
|
|
730
739
|
target = 'local',
|
|
731
740
|
materialized: MaterializedSpawn | null = null,
|
|
741
|
+
requireInReach: () => void = () => {},
|
|
732
742
|
): Promise<Session> {
|
|
733
743
|
const adapter = this.findAdapter(agent);
|
|
734
744
|
|
|
@@ -751,6 +761,10 @@ export class SessionManager {
|
|
|
751
761
|
...overrides,
|
|
752
762
|
});
|
|
753
763
|
|
|
764
|
+
// The caller's check runs again once the host is ready, before the
|
|
765
|
+
// harness starts.
|
|
766
|
+
requireInReach();
|
|
767
|
+
|
|
754
768
|
const binding = this.mintBridgeBinding(id, target, execution.identity, hostKey);
|
|
755
769
|
|
|
756
770
|
const pty = provider.spawnHarness({
|
|
@@ -1248,7 +1262,8 @@ export class SessionManager {
|
|
|
1248
1262
|
// every harness on it kept inside, and fails whole when the host stays
|
|
1249
1263
|
// awake. A dead session on a target that can destroy its host is not
|
|
1250
1264
|
// forgotten by a kill: forgetting it destroys the host, which takes a
|
|
1251
|
-
// confirmed forget.
|
|
1265
|
+
// confirmed forget. The set is taken before the first await, so a
|
|
1266
|
+
// sub-session spawned while the kill waits on a host is not part of it.
|
|
1252
1267
|
async kill(id: SessionID): Promise<void> {
|
|
1253
1268
|
const s = this.sessions.find((x) => x.id === id);
|
|
1254
1269
|
|
|
@@ -1256,11 +1271,15 @@ export class SessionManager {
|
|
|
1256
1271
|
return;
|
|
1257
1272
|
}
|
|
1258
1273
|
|
|
1274
|
+
const children = this.collectChildren(id);
|
|
1275
|
+
|
|
1259
1276
|
if (s.pty) {
|
|
1260
1277
|
await this.stopHarness(s);
|
|
1261
1278
|
|
|
1262
|
-
for (const child of
|
|
1263
|
-
|
|
1279
|
+
for (const child of children) {
|
|
1280
|
+
if (this.sessions.includes(child) && child.parent === id) {
|
|
1281
|
+
await this.tryStopHarness(child);
|
|
1282
|
+
}
|
|
1264
1283
|
}
|
|
1265
1284
|
} else {
|
|
1266
1285
|
if (this.findProvider(s)?.capabilities.destroy === true) {
|
|
@@ -1271,8 +1290,7 @@ export class SessionManager {
|
|
|
1271
1290
|
);
|
|
1272
1291
|
}
|
|
1273
1292
|
|
|
1274
|
-
this.
|
|
1275
|
-
this.remove(s);
|
|
1293
|
+
this.removeWithChildren(s, children);
|
|
1276
1294
|
}
|
|
1277
1295
|
|
|
1278
1296
|
await this.writeFleet();
|
|
@@ -1288,7 +1306,9 @@ export class SessionManager {
|
|
|
1288
1306
|
* kept asleep in that host is refused until the host wakes. Its dead
|
|
1289
1307
|
* sub-sessions on other hosts go with it, unless their own target can
|
|
1290
1308
|
* destroy their host, and its live ones become top-level. A failed destroy
|
|
1291
|
-
* throws before anything is forgotten.
|
|
1309
|
+
* throws before anything is forgotten. The sub-sessions it may forget are
|
|
1310
|
+
* taken before the first await; one spawned while the forget waits on a
|
|
1311
|
+
* host becomes top-level.
|
|
1292
1312
|
*/
|
|
1293
1313
|
async forget(id: SessionID): Promise<boolean> {
|
|
1294
1314
|
const s = this.sessions.find((x) => x.id === id);
|
|
@@ -1297,6 +1317,7 @@ export class SessionManager {
|
|
|
1297
1317
|
return false;
|
|
1298
1318
|
}
|
|
1299
1319
|
|
|
1320
|
+
const children = this.collectChildren(id);
|
|
1300
1321
|
const provider = this.findProvider(s);
|
|
1301
1322
|
const destroys = provider !== null && provider.capabilities.destroy && s.hostKey === s.id;
|
|
1302
1323
|
|
|
@@ -1330,8 +1351,7 @@ export class SessionManager {
|
|
|
1330
1351
|
this.killTerminal(s);
|
|
1331
1352
|
}
|
|
1332
1353
|
|
|
1333
|
-
this.
|
|
1334
|
-
this.remove(s);
|
|
1354
|
+
this.removeWithChildren(s, children);
|
|
1335
1355
|
|
|
1336
1356
|
await this.writeFleet();
|
|
1337
1357
|
|
|
@@ -1340,22 +1360,42 @@ export class SessionManager {
|
|
|
1340
1360
|
return destroys;
|
|
1341
1361
|
}
|
|
1342
1362
|
|
|
1343
|
-
//
|
|
1344
|
-
// target can destroy its host: forgetting that one destroys the host,
|
|
1345
|
-
// takes its own confirmed forget.
|
|
1346
|
-
// top-level
|
|
1347
|
-
|
|
1348
|
-
|
|
1363
|
+
// Removes a forgotten session and its dead sub-sessions, except one whose
|
|
1364
|
+
// own target can destroy its host: forgetting that one destroys the host,
|
|
1365
|
+
// which takes its own confirmed forget. Only a sub-session among the given
|
|
1366
|
+
// ones may go. Every sub-session that stays becomes top-level before the
|
|
1367
|
+
// session's removal is announced, and is announced after it, so no
|
|
1368
|
+
// announcement ever finds a sub-session whose parent is gone or a parent
|
|
1369
|
+
// standing without the sub-sessions that leave with it.
|
|
1370
|
+
private removeWithChildren(s: Session, forgettable: readonly Session[]): void {
|
|
1371
|
+
const kept: Session[] = [];
|
|
1372
|
+
const dropped: Session[] = [];
|
|
1373
|
+
|
|
1374
|
+
for (const child of this.collectChildren(s.id)) {
|
|
1349
1375
|
const live = child.pty !== null || (child.kind === 'headless' && child.state !== 'exited');
|
|
1350
1376
|
|
|
1351
|
-
if (
|
|
1377
|
+
if (
|
|
1378
|
+
live ||
|
|
1379
|
+
!forgettable.includes(child) ||
|
|
1380
|
+
this.findProvider(child)?.capabilities.destroy === true
|
|
1381
|
+
) {
|
|
1352
1382
|
child.parent = null;
|
|
1353
1383
|
|
|
1354
|
-
|
|
1384
|
+
kept.push(child);
|
|
1355
1385
|
} else {
|
|
1356
|
-
|
|
1386
|
+
dropped.push(child);
|
|
1357
1387
|
}
|
|
1358
1388
|
}
|
|
1389
|
+
|
|
1390
|
+
this.remove(s);
|
|
1391
|
+
|
|
1392
|
+
for (const child of kept) {
|
|
1393
|
+
this.onEvent('state', child);
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
for (const child of dropped) {
|
|
1397
|
+
this.remove(child);
|
|
1398
|
+
}
|
|
1359
1399
|
}
|
|
1360
1400
|
|
|
1361
1401
|
/**
|
|
@@ -4,6 +4,23 @@ import type { SessionID } from '../shared/session-id';
|
|
|
4
4
|
interface TapEntry<TClient> {
|
|
5
5
|
readonly client: TClient;
|
|
6
6
|
readonly claimed: Set<MessageID>;
|
|
7
|
+
|
|
8
|
+
// Whether the tap also takes messages held under the session's agent
|
|
9
|
+
// session id, as opposed to its atc id alone.
|
|
10
|
+
readonly linked: boolean;
|
|
11
|
+
|
|
12
|
+
// Which attach made this tap; every attach makes a new one.
|
|
13
|
+
readonly generation: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A session's tap as a drain finds it before it waits: the attach that made
|
|
18
|
+
* it and whether it is linked. A delivery made for it lands only while the
|
|
19
|
+
* same attach holds the tap.
|
|
20
|
+
*/
|
|
21
|
+
export interface TapGeneration {
|
|
22
|
+
readonly generation: number;
|
|
23
|
+
readonly linked: boolean;
|
|
7
24
|
}
|
|
8
25
|
|
|
9
26
|
/**
|
|
@@ -13,14 +30,24 @@ interface TapEntry<TClient> {
|
|
|
13
30
|
export class TapRegistry<TClient> {
|
|
14
31
|
private readonly bySession = new Map<SessionID, TapEntry<TClient>>();
|
|
15
32
|
|
|
33
|
+
private nextGeneration = 0;
|
|
34
|
+
|
|
16
35
|
/**
|
|
17
36
|
* Makes the client the session's tap and returns the client it displaced,
|
|
18
|
-
* or null when there was none or it is the same connection.
|
|
37
|
+
* or null when there was none or it is the same connection. A linked tap
|
|
38
|
+
* also takes the messages held under the session's agent session id.
|
|
19
39
|
*/
|
|
20
|
-
attach(sessionID: SessionID, client: TClient): TClient | null {
|
|
40
|
+
attach(sessionID: SessionID, client: TClient, linked = true): TClient | null {
|
|
21
41
|
const previous = this.bySession.get(sessionID)?.client ?? null;
|
|
22
42
|
|
|
23
|
-
this.
|
|
43
|
+
this.nextGeneration += 1;
|
|
44
|
+
|
|
45
|
+
this.bySession.set(sessionID, {
|
|
46
|
+
client,
|
|
47
|
+
claimed: new Set(),
|
|
48
|
+
linked,
|
|
49
|
+
generation: this.nextGeneration,
|
|
50
|
+
});
|
|
24
51
|
|
|
25
52
|
return previous === client ? null : previous;
|
|
26
53
|
}
|
|
@@ -45,6 +72,12 @@ export class TapRegistry<TClient> {
|
|
|
45
72
|
return previous;
|
|
46
73
|
}
|
|
47
74
|
|
|
75
|
+
findTap(sessionID: SessionID): TapGeneration | null {
|
|
76
|
+
const entry = this.bySession.get(sessionID);
|
|
77
|
+
|
|
78
|
+
return entry === undefined ? null : { generation: entry.generation, linked: entry.linked };
|
|
79
|
+
}
|
|
80
|
+
|
|
48
81
|
hasTap(sessionID: SessionID): boolean {
|
|
49
82
|
return this.bySession.has(sessionID);
|
|
50
83
|
}
|
|
@@ -53,10 +86,15 @@ export class TapRegistry<TClient> {
|
|
|
53
86
|
return this.bySession.get(sessionID)?.client === client;
|
|
54
87
|
}
|
|
55
88
|
|
|
56
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Claims the message for the session's tap and returns its client, or
|
|
91
|
+
* null when the tap already took it or another attach replaced the tap
|
|
92
|
+
* the delivery was made for.
|
|
93
|
+
*/
|
|
94
|
+
claimDelivery(sessionID: SessionID, messageID: MessageID, generation: number): TClient | null {
|
|
57
95
|
const entry = this.bySession.get(sessionID);
|
|
58
96
|
|
|
59
|
-
if (entry === undefined || entry.claimed.has(messageID)) {
|
|
97
|
+
if (entry === undefined || entry.generation !== generation || entry.claimed.has(messageID)) {
|
|
60
98
|
return null;
|
|
61
99
|
}
|
|
62
100
|
|