@cotal-ai/cli 0.136.8 → 0.138.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chat/chat-view.d.ts +1 -1
- package/dist/chat/chat-view.d.ts.map +1 -1
- package/dist/chat/controller.d.ts +1 -1
- package/dist/chat/controller.d.ts.map +1 -1
- package/dist/chat/frontend.d.ts +1 -1
- package/dist/chat/frontend.d.ts.map +1 -1
- package/dist/chat/picker.d.ts +1 -1
- package/dist/chat/picker.d.ts.map +1 -1
- package/dist/chat/plain.d.ts +1 -1
- package/dist/chat/plain.d.ts.map +1 -1
- package/dist/chat/stdio-io.d.ts +1 -1
- package/dist/chat/stdio-io.d.ts.map +1 -1
- package/dist/chat/stdio.d.ts +1 -1
- package/dist/chat/stdio.d.ts.map +1 -1
- package/dist/chat/view.d.ts +1 -1
- package/dist/chat/view.d.ts.map +1 -1
- package/dist/commands/agents.d.ts +7 -149
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +15 -723
- package/dist/commands/agents.js.map +1 -1
- package/dist/commands/channels.js +1 -1
- package/dist/commands/channels.js.map +1 -1
- package/dist/commands/chat.d.ts +1 -24
- package/dist/commands/chat.d.ts.map +1 -1
- package/dist/commands/chat.js +3 -15
- package/dist/commands/chat.js.map +1 -1
- package/dist/commands/sync.d.ts +1 -0
- package/dist/commands/sync.d.ts.map +1 -1
- package/dist/commands/sync.js +1 -1
- package/dist/commands/sync.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +6 -1
- package/dist/commands/update.js.map +1 -1
- package/dist/console/app.js +1 -1
- package/dist/console/app.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +5 -6
- package/dist/chat/engine.d.ts +0 -111
- package/dist/chat/engine.d.ts.map +0 -1
- package/dist/chat/engine.js +0 -225
- package/dist/chat/engine.js.map +0 -1
- package/dist/chat/native-session.d.ts +0 -120
- package/dist/chat/native-session.d.ts.map +0 -1
- package/dist/chat/native-session.js +0 -487
- package/dist/chat/native-session.js.map +0 -1
- package/dist/chat/open-session.d.ts +0 -9
- package/dist/chat/open-session.d.ts.map +0 -1
- package/dist/chat/open-session.js +0 -120
- package/dist/chat/open-session.js.map +0 -1
- package/dist/chat/session.d.ts +0 -230
- package/dist/chat/session.d.ts.map +0 -1
- package/dist/chat/session.js +0 -2
- package/dist/chat/session.js.map +0 -1
- package/dist/chat/static-session.d.ts +0 -62
- package/dist/chat/static-session.d.ts.map +0 -1
- package/dist/chat/static-session.js +0 -236
- package/dist/chat/static-session.js.map +0 -1
- package/dist/chat/user-session.d.ts +0 -22
- package/dist/chat/user-session.d.ts.map +0 -1
- package/dist/chat/user-session.js +0 -180
- package/dist/chat/user-session.js.map +0 -1
- package/dist/lib/attach-client.d.ts +0 -112
- package/dist/lib/attach-client.d.ts.map +0 -1
- package/dist/lib/attach-client.js +0 -528
- package/dist/lib/attach-client.js.map +0 -1
package/dist/commands/agents.js
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
|
-
import { DEV_OWNER, assertLifecycleToken,
|
|
2
|
-
import { codeSpan, CommandFailure,
|
|
1
|
+
import { DEV_OWNER, assertLifecycleToken, peerLabel } from "@cotal-ai/core";
|
|
2
|
+
import { codeSpan, CommandFailure, findMesh, isCommandFailure, isConnectRefusal, jsonFlag, localStackRoot, oneLine, renderReport, plainFlag, presentationOf, resolveSeatControlTarget, targetFlags, writeResult, recordOperationSpace, resolveControlTarget, } from "@cotal-ai/workspace";
|
|
3
3
|
import * as prompts from "../lib/prompts.js";
|
|
4
4
|
import { canPrompt } from "./meshes-wizard.js";
|
|
5
|
-
import {} from "
|
|
6
|
-
import { c, presenceFacts } from "../ui.js";
|
|
5
|
+
import { presenceFacts } from "../ui.js";
|
|
7
6
|
import { cliControl, onInstance, onFlag } from "../lib/control.js";
|
|
8
7
|
import { AgentManagerUnsettled, managerPin, noLiveManager } from "../lib/manager-choice.js";
|
|
9
8
|
import { openTransient } from "../lib/transient.js";
|
|
10
|
-
import {
|
|
9
|
+
import { attachSeat as attachSeatWith, detachKey } from "@cotal-ai/workspace/attach";
|
|
11
10
|
import { completingFlagValue } from "../lib/completion.js";
|
|
12
11
|
import { displayCmd } from "../lib/self-exec.js";
|
|
13
12
|
import { shellWord } from "../lib/endpoint-errors.js";
|
|
14
13
|
import { runningStackManagerPid, stackManagerAbsent, stackManagerStarting } from "../lib/stack-journal.js";
|
|
15
14
|
import { connectorHarnesses } from "../lib/status.js";
|
|
16
|
-
import { askManager, scatterManager, managerFailure, readFailure, refuseUnregisteredManager, unansweredDescribeRefusal, managersByInstance, askYourManagers, locateSeat, managerReadFailure, refusedManagers, seatMissRefusal
|
|
15
|
+
import { askManager, scatterManager, managerFailure, readFailure, refuseUnregisteredManager, unansweredDescribeRefusal, managersByInstance, askYourManagers, locateSeat, managerReadFailure, refusedManagers, seatMissRefusal } from "@cotal-ai/workspace/control";
|
|
17
16
|
/**
|
|
18
17
|
* The manager's operator clients — thin control-plane request/reply commands (`stop`/`ps`/
|
|
19
18
|
* `attach`) that drive a RUNNING manager. Moved from `@cotal-ai/manager` in stage 2a of the CLI
|
|
@@ -765,727 +764,20 @@ async function readYourManagers(v, command, managers) {
|
|
|
765
764
|
throw new NoManagerAnswered(new CommandFailure("refused", "TARGET_UNAVAILABLE", `none of the ${managers.length} managers the exchange counts live answered`, { target: { space: space ?? null, scope: "your-managers" } }));
|
|
766
765
|
return seatCensus({ space, scope: "your-managers" }, instances, command, mode);
|
|
767
766
|
}
|
|
768
|
-
/**
|
|
769
|
-
*
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
/** How long any single wait on a LINK may take before the loop stops waiting on it. Neither
|
|
773
|
-
* `flush()` nor `drain()` carries a deadline of its own, and `connect`'s `timeout` covers only the
|
|
774
|
-
* dial: a link that half-opens AFTER connect (the exact slow death this command exists for) would
|
|
775
|
-
* otherwise pin the loop, and the detach key with it, until the connection's own heartbeat gave up
|
|
776
|
-
* around two minutes later. */
|
|
777
|
-
const LINK_DEADLINE_MS = 5_000;
|
|
778
|
-
/** Race a link round trip against a deadline, and answer `no` if the deadline wins. The timer is
|
|
779
|
-
* deliberately NOT unref'd: it is the only thing keeping the process alive while we wait on a
|
|
780
|
-
* socket that will never answer, and an unref'd one lets node empty its loop and abort the whole
|
|
781
|
-
* command on a pending await instead of finishing the wait and printing why. It is cleared as soon
|
|
782
|
-
* as either side settles, so a healthy link is never held open for the remainder of the deadline. */
|
|
783
|
-
export const withDeadline = async (work, ms) => {
|
|
784
|
-
let timer;
|
|
785
|
-
const deadline = new Promise((r) => { timer = setTimeout(() => r(false), ms); });
|
|
786
|
-
try {
|
|
787
|
-
return await Promise.race([work, deadline]);
|
|
788
|
-
}
|
|
789
|
-
finally {
|
|
790
|
-
clearTimeout(timer);
|
|
791
|
-
}
|
|
792
|
-
};
|
|
793
|
-
/** Did a flush RETURN inside the deadline? That, and not the publish, is what says a frame left:
|
|
794
|
-
* publishing is a local buffer write. A rejection and a timeout are the same answer here, `no`. */
|
|
795
|
-
const flushed = (nc) => withDeadline(nc.flush().then(() => true, () => false), LINK_DEADLINE_MS);
|
|
796
|
-
/** Give a connection back, bounded. Draining is another flush, so a link that has already missed
|
|
797
|
-
* its deadline is closed outright instead of being asked to drain through the same dead socket.
|
|
798
|
-
* A drain that REJECTS has closed nothing either: it gives up inside that same flush, before the
|
|
799
|
-
* close it would have done, so a rejection means close it here or leak the socket. */
|
|
800
|
-
const closeLink = async (nc) => {
|
|
801
|
-
const drained = await withDeadline(nc.drain().then(() => true, () => false), LINK_DEADLINE_MS);
|
|
802
|
-
// The close gets the same deadline as everything else on this path. Tearing a socket down is
|
|
803
|
-
// local work and should not be able to wait at all, but "should not" is what the drain above was
|
|
804
|
-
// supposed to be too, and a bound that covers every wait but one is a bound with a footnote.
|
|
805
|
-
if (!drained)
|
|
806
|
-
await withDeadline(nc.close().then(() => true, () => true), LINK_DEADLINE_MS);
|
|
807
|
-
};
|
|
808
|
-
/** A session that ran at least this long counts as one that WORKED, whether or not the seat spoke.
|
|
809
|
-
* Below it, a link that comes up for the control round trip and dies again is a flap, and resetting
|
|
810
|
-
* the backoff on those re-mints a grant, a credential and a hand-back connection every second. */
|
|
811
|
-
const SESSION_WORKED_MS = 5_000;
|
|
812
|
-
/** `inspect` found durable nonterminal state after the live seat disappeared. The manager owns the
|
|
813
|
-
* detail shape; the CLI keys only on its namespaced discriminator so the manager's rich diagnostic
|
|
814
|
-
* remains intact for operator commands while reconnect can act on the one fact it needs. */
|
|
815
|
-
const STATIC_SLOT_OBSERVATION_DETAIL = "ai.cotal.manager.static-slot-observation";
|
|
816
|
-
/** The manager's marker for an attach refused because the seat's runtime opens no terminal stream.
|
|
817
|
-
* Same arrangement as the detail above: the manager owns the shape and its guidance wording, and
|
|
818
|
-
* the CLI keys only on the namespaced discriminator. */
|
|
819
|
-
const RUNTIME_NO_TERMINAL_DETAIL = "ai.cotal.manager.runtime-no-terminal";
|
|
820
|
-
/**
|
|
821
|
-
* Classify a refusal on the manager's structured error, never on its wording. The wording is
|
|
822
|
-
* operator copy and changes; the code and namespaced detail are the contract. Getting this wrong in
|
|
823
|
-
* either direction is a real failure: reading `permission-denied` as transient turns a refusal into
|
|
824
|
-
* an infinite retry loop against a manager that has already said no, and reading `not-found` or an
|
|
825
|
-
* `inspect` static-slot observation as transient does the same to a seat that no longer exists.
|
|
826
|
-
* Other `failed-precondition` refusals remain transient, including the session ceiling.
|
|
827
|
-
*/
|
|
828
|
-
export function attachRefusal(code, details) {
|
|
829
|
-
if (code === "permission-denied")
|
|
830
|
-
return "denied";
|
|
831
|
-
if (code === "not-found")
|
|
832
|
-
return "gone";
|
|
833
|
-
// `unimplemented` is the manager saying this runtime does not do attach at all - a seat on an
|
|
834
|
-
// external terminal multiplexer or IDE, or a control instance that spawns nothing. Asking again
|
|
835
|
-
// cannot change it, so a reconnecting attach must stop rather than retry it forever.
|
|
836
|
-
if (code === "unimplemented")
|
|
837
|
-
return "denied";
|
|
838
|
-
if (code === "failed-precondition" && details?.some((detail) => detail.kind === STATIC_SLOT_OBSERVATION_DETAIL))
|
|
839
|
-
return "gone";
|
|
840
|
-
return "transient";
|
|
841
|
-
}
|
|
842
|
-
/**
|
|
843
|
-
* A re-establishment that threw, classified. A connect refusal that asking again cannot fix (a
|
|
844
|
-
* missing seed, refused credentials, a user-auth mesh offered static creds) is `fatal`, so the loop
|
|
845
|
-
* exits with the refusal's own sentence and hint instead of retrying it in silence. The control
|
|
846
|
-
* resolver's mode peek rethrows a broken mesh record as a raw target error before the connect
|
|
847
|
-
* helper can wrap it, and it only rethrows the codes that are not "no mesh found", so that is fatal
|
|
848
|
-
* too. Anything else, including a broker that is not reachable yet, is transient.
|
|
849
|
-
*/
|
|
850
|
-
class SeatAttachCallerUnavailable extends Error {
|
|
851
|
-
constructor() { super("the launching seat could not re-establish its own caller; no operator identity was substituted"); }
|
|
852
|
-
}
|
|
853
|
-
function thrownEstablishment(e) {
|
|
854
|
-
if (e instanceof SeatAttachCallerUnavailable)
|
|
855
|
-
return { ok: false, kind: "fatal", message: e.message };
|
|
856
|
-
if (e instanceof ConnectRefusal && e.kind === "permanent")
|
|
857
|
-
return { ok: false, kind: "fatal", message: [e.rendered.replace(/^✗ /, ""), e.hint].filter(Boolean).join("\n") };
|
|
858
|
-
if (isWorkspaceTargetError(e))
|
|
859
|
-
return { ok: false, kind: "fatal", message: renderWorkspaceError({ kind: "target", error: e }).replace(/^✗ /, "") };
|
|
860
|
-
return { ok: false, kind: "transient", message: e.message };
|
|
861
|
-
}
|
|
862
|
-
/**
|
|
863
|
-
* What a re-establishment TELLS the operator while it keeps trying, and what it keeps to itself.
|
|
864
|
-
* Returns the line to print, or undefined for silence.
|
|
865
|
-
*
|
|
866
|
-
* Only the MANAGER's own refusal is relayed, and only when it changes. An operator watching
|
|
867
|
-
* `reconnecting` for ten minutes cannot otherwise tell a sleeping laptop from a manager that is at
|
|
868
|
-
* its session ceiling, and the ceiling is the one a person can act on. Repeating a steady refusal
|
|
869
|
-
* on every attempt would bury it.
|
|
870
|
-
*
|
|
871
|
-
* A LOCAL failure to reach anything is deliberately silent: it is already what `connection lost,
|
|
872
|
-
* reconnecting` says, and the copy on this path is written for someone who just typed a command
|
|
873
|
-
* ("no mesh running at X - run `cotal up`"), which is the wrong thing to tell someone whose wifi
|
|
874
|
-
* dropped.
|
|
875
|
-
*/
|
|
876
|
-
export function reconnectNotice(est, alreadySaid) {
|
|
877
|
-
if (!est.fromManager || est.message === alreadySaid)
|
|
878
|
-
return undefined;
|
|
879
|
-
return `[cotal: ${est.message}]`;
|
|
880
|
-
}
|
|
881
|
-
/**
|
|
882
|
-
* The line an exit owes an operator about sessions this attach could not hand back, or nothing.
|
|
883
|
-
*
|
|
884
|
-
* Suppressed on `gone`, and that is not tidiness. That verdict exists only when the manager proves
|
|
885
|
-
* the live seat is absent: either `not-found`, or the namespaced durable static-slot observation
|
|
886
|
-
* after its process is gone. Freeing a seat ends every session bound to it (`freeSlot` into
|
|
887
|
-
* `endForTarget`, on despawn, self-stop, reap and natural exit alike). The sessions this would name
|
|
888
|
-
* have already been collected, so naming them would be inventing work for someone who is about to
|
|
889
|
-
* close their terminal.
|
|
890
|
-
*/
|
|
891
|
-
export function heldSessionNotice(pending, verdict) {
|
|
892
|
-
if (pending < 1 || verdict === "gone")
|
|
893
|
-
return undefined;
|
|
894
|
-
const what = pending === 1 ? "a session" : `${pending} sessions`;
|
|
895
|
-
return `[cotal: the manager still holds ${what} from the lost link; each frees when the seat or the manager ends it]`;
|
|
896
|
-
}
|
|
897
|
-
/**
|
|
898
|
-
* Ask the manager for a session and open it: a FRESH grant, a FRESH per-session caller credential,
|
|
899
|
-
* a FRESH connection, every time. Nothing is carried over from a previous attempt, so a reconnect
|
|
900
|
-
* re-runs the manager's whole authorization path (`serveGated` → `authorizeNamed` → a one-use
|
|
901
|
-
* holder-bound grant with its own TTL) exactly as the first attach did. A revoked or expired grant
|
|
902
|
-
* cannot be kept alive by reconnecting, because no grant is ever presented twice.
|
|
903
|
-
*/
|
|
904
|
-
async function establishAttachSession(v, on, reconnect, firstAttempt) {
|
|
905
|
-
// A reconnect must never cross a path that can END THE PROCESS. The mesh resolve and its
|
|
906
|
-
// preflight are written to do exactly that ("no mesh running at X - run `cotal up`"), which is
|
|
907
|
-
// the right answer for a person who just typed a command and the wrong one for a link that is
|
|
908
|
-
// coming back. So a RE-ESTABLISHMENT asks for the throwing form, and the loop retries a refusal
|
|
909
|
-
// whose kind is transient and stops on a permanent one.
|
|
910
|
-
//
|
|
911
|
-
// Keyed off `first`, NOT off the reconnect flag, and the difference is user-visible. A refusal
|
|
912
|
-
// that escapes as an exception is rendered by the dispatcher's generic handler, which prints
|
|
913
|
-
// `✗ ${message}` over a sentence that already opens with `✗` and drops the refusal's hint line.
|
|
914
|
-
// The reconnect flag is on by DEFAULT, so keying on it sent the very first attach down that path
|
|
915
|
-
// and turned today's "no mesh running at X - run `cotal up`" into a double mark with no remedy.
|
|
916
|
-
// The first attempt is not a re-establishment and has nothing to survive for.
|
|
917
|
-
//
|
|
918
|
-
// `pinForTarget` is deliberately not crossed here: seat locality is resolved ONCE, before the
|
|
919
|
-
// loop, and the pin is carried in. It is the other step on this path that exits.
|
|
920
|
-
// Same reach routing as stop: static mesh → any-mode on the `control-caller-admin` instrument;
|
|
921
|
-
// user mesh → the operator's own bearer with owner reach, the manager deciding (own owner-domain
|
|
922
|
-
// passes with "spawn", cross-owner needs ledger "admin").
|
|
923
|
-
// The console asks for the throwing form on the FIRST attempt as well (`onRefusal` on the
|
|
924
|
-
// target): inside a TUI a printed sentence and a `process.exit` would blank the operator's screen
|
|
925
|
-
// mid-session, and the refusal becomes a status-line notice instead. So "first" here means the
|
|
926
|
-
// first attempt of a command that wants the exit.
|
|
927
|
-
const first = firstAttempt && (v.onRefusal ?? "exit") === "exit";
|
|
928
|
-
const seat = v.asSeat ? await resolveSeatControlTarget(v, on) : undefined;
|
|
929
|
-
if (v.asSeat && !seat)
|
|
930
|
-
throw new SeatAttachCallerUnavailable();
|
|
931
|
-
const t = seat ?? await resolveControlTarget(v, "control-caller-admin", on, first ? {} : { onRefusal: "throw" });
|
|
932
|
-
recordOperationSpace(t.space);
|
|
933
|
-
const reach = t.auth.bearer ? "owner" : "any";
|
|
934
|
-
// A refusal's report names the mesh in its heading, so this block names it once, in a field.
|
|
935
|
-
if (first)
|
|
936
|
-
console.error(renderReport({ sections: [{ title: "Attach", blocks: [{ kind: "fields", rows: [["Space", t.space], ["Agent", v.name], ["Manager", on ?? t.auth.managerInstanceId ?? "native selection"]] }] }] }, "rich", process.stderr));
|
|
937
|
-
const reply = await askManager(cliControl, t.space, t.server, "attach", { name: v.name }, t.auth, reach, undefined, { instanceId: on, expectedTarget: v.expectedTarget });
|
|
938
|
-
if (!reply.ok) {
|
|
939
|
-
// The one refusal no attach can work around: this seat's runtime streams no terminal. It is
|
|
940
|
-
// said once, with the runtime's own guidance for watching the agent where it really runs, and
|
|
941
|
-
// the Next names what does reach the seat - never "inspect and reattach", which cannot succeed
|
|
942
|
-
// here and is the advice this refusal exists to replace.
|
|
943
|
-
const noTerminal = reply.details?.some((detail) => detail.kind === RUNTIME_NO_TERMINAL_DETAIL) === true;
|
|
944
|
-
if (first && noTerminal)
|
|
945
|
-
throw new CommandFailure("refused", "UNSUPPORTED", reply.error ? oneLine(reply.error) : "the seat's runtime streams no terminal, so there is none to attach", {
|
|
946
|
-
target: { space: t.space, agent: v.name, managerInstanceId: reply.managerInstanceId ?? on ?? null },
|
|
947
|
-
native: { code: reply.code, outcome: reply.nativeOutcome },
|
|
948
|
-
remedy: `nothing was attached and the agent was not touched; follow the runtime's own guidance above, or reach the seat over the mesh with ${codeSpan(`${displayCmd()} send -t ${shellWord(v.name)} "<message>"`)} and ${codeSpan(`${displayCmd()} watch --space ${shellWord(t.space)}`)}`,
|
|
949
|
-
});
|
|
950
|
-
if (first && (reply.preSubmitRefusal || reply.nativeOutcome === "not-executed"))
|
|
951
|
-
throw managerFailure(reply, "terminal attach", { space: t.space, agent: v.name, managerInstanceId: reply.managerInstanceId ?? on ?? null });
|
|
952
|
-
return { ok: false, kind: attachRefusal(reply.code, reply.details), message: reply.error ?? "error", fromManager: true, code: reply.code, ...(noTerminal ? { noTerminal: true } : {}) };
|
|
953
|
-
}
|
|
954
|
-
// P2 item 6: the reply is the holder-bound §13.6 session GRANT (no ws:// URL). Redeem it over the
|
|
955
|
-
// mesh. A static-auth mesh mints a per-session, rails-only caller cred from the resolved root's
|
|
956
|
-
// seed. A registered open mesh has no seed: the same bare connection `askManagerEp` already used
|
|
957
|
-
// for the control round trip opens the caller rail. USER mesh (bearer, no local seed): refuse LOUD
|
|
958
|
-
// — the 2-step user-mode redemption callout is the #29 follow-up, deliberately not wired here.
|
|
959
|
-
const { grant, resumedFrom } = reply.data;
|
|
960
|
-
// The trust material the TARGET resolved, not a second answer walked up from the cwd. This used
|
|
961
|
-
// to be `loadSpaceAuth(authDir(findCotalRoot()), t.space)`, which is the defect behind issue #722:
|
|
962
|
-
// resolution had already picked a root from the registry and connected with it, and then this line
|
|
963
|
-
// asked the current directory the same question and got a different answer. On any machine that is
|
|
964
|
-
// not a hypothetical: `~/.cotal` exists because the registry lives there, `findCotalRoot` accepts
|
|
965
|
-
// any directory merely NAMED `.cotal`, so every cwd under $HOME outside a project resolves here to
|
|
966
|
-
// the home directory - whose trust chain for the same space differs from the resolved root's in
|
|
967
|
-
// account, signing, sys and operator material. The result was a credential minted from a mesh the
|
|
968
|
-
// broker never trusted, surfacing as a bare `Authorization Violation` that named nothing.
|
|
969
|
-
// `control.ts` already stated the rule this restores: trust is carried forward rather than
|
|
970
|
-
// re-loaded, because a second load is a second answer to "which space's seed" for one command.
|
|
971
|
-
// A cwd anchor holding a DIFFERENT chain for this space is reported, never obeyed. It cannot
|
|
972
|
-
// change what this command does any more - the seed above comes from the resolution, not the walk
|
|
973
|
-
// - but staying silent is how issue #722 stayed a mystery: the operator had no way to learn that
|
|
974
|
-
// the directory they were standing in carried another mesh's trust. Reported on the way past, so
|
|
975
|
-
// it is said whether or not the attach then succeeds.
|
|
976
|
-
const shadow = t.root === undefined ? undefined : divergentCwdAnchor(t.root, t.space);
|
|
977
|
-
if (shadow)
|
|
978
|
-
console.error(`! this directory resolves to ${shadow.cwdRoot}, whose .cotal/auth holds a DIFFERENT trust chain for space "${t.space}".\n` +
|
|
979
|
-
` attach used ${t.root}, the root this mesh resolved to. The other one is not being used, and is worth a look.`);
|
|
980
|
-
const tls = t.auth.tls === true;
|
|
981
|
-
const id = newIdentity();
|
|
982
|
-
// WHICH CONTRACT redeems this grant is decided ONCE, by the recorded mesh mode, and the answer is
|
|
983
|
-
// a value this function then carries. It is never re-derived from the absence of a seed: that
|
|
984
|
-
// inference is issue #1205 itself, where an open mesh (which holds no seed BY DESIGN) was read as
|
|
985
|
-
// a static mesh whose seed had gone missing, and every open attach was refused.
|
|
986
|
-
const material = attachSessionMaterial(t);
|
|
987
|
-
if (material.kind === "fatal")
|
|
988
|
-
return { ok: false, kind: "fatal", message: material.message };
|
|
989
|
-
let link;
|
|
990
|
-
if (material.kind === "redeem") {
|
|
991
|
-
// #2312, USER mesh: present this login's identity together with the grant to the identity
|
|
992
|
-
// plane, which decides whether this principal holds the session and answers with a bearer the
|
|
993
|
-
// callout turns into the SAME session-caller rows and grant expiry the static arm mints. No seed
|
|
994
|
-
// is read or written, and nothing on this side decides who may hold the session.
|
|
995
|
-
let auth;
|
|
996
|
-
try {
|
|
997
|
-
auth = await userSessionAuth(t, grant);
|
|
998
|
-
}
|
|
999
|
-
catch (e) {
|
|
1000
|
-
return { ok: false, kind: "fatal", message: e instanceof Error ? e.message : String(e) };
|
|
1001
|
-
}
|
|
1002
|
-
link = { mode: "session-bearer", tls, ...auth };
|
|
1003
|
-
}
|
|
1004
|
-
else
|
|
1005
|
-
link =
|
|
1006
|
-
material.kind === "bare"
|
|
1007
|
-
// An open mesh has no credential system: the same bare connection the control round trip
|
|
1008
|
-
// already used opens the caller rail. Nothing is minted and nothing is invented.
|
|
1009
|
-
? { mode: "bare", tls }
|
|
1010
|
-
: {
|
|
1011
|
-
mode: "session-caller",
|
|
1012
|
-
tls,
|
|
1013
|
-
creds: await mintCreds(material.auth, id, "session-caller", {
|
|
1014
|
-
sessionCaller: { endpoint: grant.endpoint, sessionId: grant.sessionId, epoch: grant.serving.epoch },
|
|
1015
|
-
expiresAt: Math.floor(grant.exp / 1000), // grant.exp is ms (now+ttlMs); the JWT exp is seconds
|
|
1016
|
-
}),
|
|
1017
|
-
};
|
|
1018
|
-
// maxReconnectAttempts is the whole difference between the two modes, and it is deliberate.
|
|
1019
|
-
// ONE-SHOT keeps the old -1: the NATS layer redials forever under a single session.
|
|
1020
|
-
// RECONNECTING uses 0, because that redial is what breaks the attach rather than saving it — the
|
|
1021
|
-
// session is already dead by the time the link comes back (the serving rail either kept
|
|
1022
|
-
// advancing `seq` into a subject nobody was subscribed to, so the restored subscription faults
|
|
1023
|
-
// with `gap`, or it stalled out and closed while this side was away, so nothing ever arrives
|
|
1024
|
-
// again). Owning re-establishment here means the link coming back produces a real session with a
|
|
1025
|
-
// real repaint, instead of a restored socket over a session that ended without us.
|
|
1026
|
-
const nc = await dialerFor(t.server)({
|
|
1027
|
-
servers: t.server,
|
|
1028
|
-
inboxPrefix: `_INBOX_${id.id}`,
|
|
1029
|
-
// After the default: a bearer link's callout scopes the inbox on its own connect nonce.
|
|
1030
|
-
...redeemConnectOpts(link),
|
|
1031
|
-
maxReconnectAttempts: reconnect ? 0 : -1,
|
|
1032
|
-
// Detection latency is part of the defect, not a detail of it. A laptop waking from sleep does
|
|
1033
|
-
// not always get a socket error: the connection can sit half-open, and on the stock two-minute
|
|
1034
|
-
// ping with two misses the client would not learn its link was dead for about four minutes,
|
|
1035
|
-
// with the terminal frozen the whole time. Ten seconds puts that in tens of seconds. Only on
|
|
1036
|
-
// the reconnecting path, so `--no-reconnect` keeps today's timing along with today's exit.
|
|
1037
|
-
//
|
|
1038
|
-
// NOT MEASURED by this change's suite, and said so rather than implied: the smoke severs the
|
|
1039
|
-
// link by destroying sockets, so every number it prints is close detection. A fault model that
|
|
1040
|
-
// half-opens a link is what would grade this line, and it does not exist here yet.
|
|
1041
|
-
...(reconnect ? { pingInterval: 10_000 } : {}),
|
|
1042
|
-
});
|
|
1043
|
-
return { ok: true, nc, grant, link, inbox: id.id, server: t.server, ...(resumedFrom ? { resumedFrom } : {}) };
|
|
1044
|
-
}
|
|
1045
|
-
/** The ONE place a {@link RedeemLink} becomes NATS connect options. Both connections on the redeem
|
|
1046
|
-
* path (the session link and the abandoned-session hand-back) go through here, so neither can
|
|
1047
|
-
* invent a credential for an open mesh or drop one on a sealed mesh, and a third caller gets the
|
|
1048
|
-
* same two arms for free. */
|
|
1049
|
-
export function redeemConnectOpts(link) {
|
|
1050
|
-
if (link.mode === "session-bearer")
|
|
1051
|
-
return standaloneConnectOpts({ bearer: link.bearer, sentinelCreds: link.sentinelCreds, tls: link.tls });
|
|
1052
|
-
return link.mode === "bare"
|
|
1053
|
-
? standaloneConnectOpts({ tls: link.tls })
|
|
1054
|
-
: standaloneConnectOpts({ creds: link.creds, tls: link.tls });
|
|
1055
|
-
}
|
|
1056
|
-
/**
|
|
1057
|
-
* Which redeem contract this target gets: a bare open-mesh connection, a seed-backed mint, or a
|
|
1058
|
-
* loud refusal. Graded by `smoke:attach-open-mode`, whose mutations flip each arm in turn.
|
|
1059
|
-
*
|
|
1060
|
-
* EXHAUSTIVE over the three recorded modes on purpose, with `user` named rather than reached by
|
|
1061
|
-
* fallthrough. A `user` entry holds no local seed by design and its two-step redemption is not
|
|
1062
|
-
* wired, so it must refuse even on the day a user-mode root happens to carry static material on
|
|
1063
|
-
* disk; letting it fall through to the seed test would silently mint on the wrong identity plane.
|
|
1064
|
-
* An UNRECORDED mode (a raw off-registry connection) is the conservative arm: it is not known to be
|
|
1065
|
-
* open, so it takes the sealed test and refuses without a seed.
|
|
1066
|
-
*/
|
|
1067
|
-
export function attachSessionMaterial(t) {
|
|
1068
|
-
switch (t.mode) {
|
|
1069
|
-
// An OPEN mesh: no credential system at all, so there is no seed to be missing and nothing to
|
|
1070
|
-
// mint. This is the arm issue #1205 did not have.
|
|
1071
|
-
case "open":
|
|
1072
|
-
return { kind: "bare" };
|
|
1073
|
-
// A USER mesh: the identity plane redeems the grant for the bearer (#2312), EVEN IF a seed is
|
|
1074
|
-
// present, because the bearer plane is the control surface there and static material would be
|
|
1075
|
-
// the wrong identity. `spaceAuth` is never consulted on this arm.
|
|
1076
|
-
case "user":
|
|
1077
|
-
return { kind: "redeem" };
|
|
1078
|
-
// SEALED (`auth`) and unrecorded: the seed is required, and its absence is a refusal. A sealed
|
|
1079
|
-
// mesh must never be rescued by the open arm — that is the failure a fix for #1205 is most
|
|
1080
|
-
// likely to introduce, and `smoke:attach-open-mode` asserts the refusal by name against a live
|
|
1081
|
-
// sealed broker.
|
|
1082
|
-
default:
|
|
1083
|
-
return t.spaceAuth ? { kind: "mint", auth: t.spaceAuth } : { kind: "fatal", message: attachNoSeedMessage(t) };
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1086
|
-
/** Why attach cannot redeem a session grant, said in terms of what the command actually resolved.
|
|
1087
|
-
*
|
|
1088
|
-
* Three distinct states, kept distinct because the remedy differs and a single sentence covering
|
|
1089
|
-
* all three is the defect issue #722 opened on (its old text named an internal work item and no
|
|
1090
|
-
* root at all). A USER-AUTH mesh holds no local seed by design. A REGISTERED static mesh has a
|
|
1091
|
-
* root and it is named, so an operator can see which directory the command used rather than
|
|
1092
|
-
* guessing at their cwd.
|
|
1093
|
-
*
|
|
1094
|
-
* The third arm is an INVARIANT, not advice, and is written that way on purpose. A connection with
|
|
1095
|
-
* no resolved root is what the type allows, and no supported route reaches redemption in that
|
|
1096
|
-
* state: both off-registry routes are refused earlier, which `smoke:attach-auth-root` measures
|
|
1097
|
-
* rather than assumes. So that arm says what its own existence would mean instead of offering a
|
|
1098
|
-
* remedy for a situation that cannot currently arise. */
|
|
1099
|
-
function attachNoSeedMessage(t) {
|
|
1100
|
-
const shadow = t.root === undefined ? undefined : divergentCwdAnchor(t.root, t.space);
|
|
1101
|
-
const shadowLine = shadow
|
|
1102
|
-
? `\n NOTE this directory resolves to ${shadow.cwdRoot}, which holds a DIFFERENT trust chain for "${t.space}"; it was NOT used.`
|
|
1103
|
-
: "";
|
|
1104
|
-
const head = `mesh attach needs this space's local seed to redeem the session grant, and the mesh resolved for "${t.space}" does not provide one.`;
|
|
1105
|
-
if (t.root === undefined)
|
|
1106
|
-
return `${head}\n broker ${t.server}\n This connection resolved NO checkout root, and no supported route reaches redemption in that state: an off-registry \`--server\` on an unregistered space is refused for missing credentials, and \`--creds\` is refused at the control surface, both before a session grant is asked for. Reaching this sentence means a route now exists that skips both refusals.${shadowLine}`;
|
|
1107
|
-
return `${head}\n broker ${t.server}\n resolved root ${t.root}\n This is a static-auth mesh. Attach still needs this space's seed under ${t.root}/.cotal/auth. Restore the seed at that checkout. This mesh is not open, so attach will not connect without the seed.${shadowLine}`;
|
|
1108
|
-
}
|
|
1109
|
-
/**
|
|
1110
|
-
* Tell the manager a session is over, so it gets its slot back.
|
|
1111
|
-
*
|
|
1112
|
-
* NOTHING on the serving side reaps a session whose caller went away while the seat is quiet: the
|
|
1113
|
-
* rail's stall watchdog only arms once the send window FILLS (`endpoint-session-rail.ts`), an idle
|
|
1114
|
-
* seat never fills it, and the bridge has no expiry timer of its own. Measured against a manager
|
|
1115
|
-
* with an idle seat: 45s of dead link left the live-session count at 1, and the reconnect took it
|
|
1116
|
-
* to 2 — one slot per outage, held until the seat or the manager ends it, against a ceiling of 64.
|
|
1117
|
-
*
|
|
1118
|
-
* The mechanism is the advisory close frame the rail already defines; the manager's bridge ends on
|
|
1119
|
-
* it. On a static-auth mesh it has to be published with THIS session's caller credential, the only
|
|
1120
|
-
* one scoped to this session's subjects, so a re-establishment cannot send it on the abandoned
|
|
1121
|
-
* session's behalf. On a registered open mesh there is no session-caller seed: the same bare
|
|
1122
|
-
* connection the session used hands the slot back. Hence a short-lived connection minted from the
|
|
1123
|
-
* abandoned session's own material, never an invented seed.
|
|
1124
|
-
*/
|
|
1125
|
-
async function releaseAbandonedSession(s) {
|
|
1126
|
-
const nc = await dialerFor(s.server)({
|
|
1127
|
-
servers: s.server,
|
|
1128
|
-
inboxPrefix: `_INBOX_${s.inbox}`,
|
|
1129
|
-
...redeemConnectOpts(s.link),
|
|
1130
|
-
maxReconnectAttempts: 0,
|
|
1131
|
-
timeout: LINK_DEADLINE_MS,
|
|
1132
|
-
// The same short ping the reconnecting session uses. This connection exists BECAUSE a link died,
|
|
1133
|
-
// so it is the last one that should be left waiting on the stock two-minute heartbeat to find
|
|
1134
|
-
// out that the replacement died too.
|
|
1135
|
-
pingInterval: 10_000,
|
|
1136
|
-
});
|
|
1137
|
-
try {
|
|
1138
|
-
// The rail is opened only because `close()` is the public way to speak the framing; no timers,
|
|
1139
|
-
// and it tears itself down on the same call.
|
|
1140
|
-
openSessionRail({ nc, grant: s.grant, role: "caller", onData: () => { }, idleCreditMs: 0, stallTimeoutMs: 0 }).close();
|
|
1141
|
-
// Throwing is what keeps the record: the caller holds the session until a flush RETURNS.
|
|
1142
|
-
if (!(await flushed(nc)))
|
|
1143
|
-
throw new Error(`the close frame for session ${s.grant.sessionId} never left`);
|
|
1144
|
-
}
|
|
1145
|
-
finally {
|
|
1146
|
-
await closeLink(nc);
|
|
1147
|
-
}
|
|
1148
|
-
}
|
|
1149
|
-
/** Watch stdin for the detach key while there is NO session reading it. Without this the key is
|
|
1150
|
-
* dead for as long as a reconnect takes, which is exactly when an operator is most likely to give
|
|
1151
|
-
* up and press it. Non-detach keystrokes are dropped: there is no seat to send them to, and
|
|
1152
|
-
* buffering them would replay a burst into the agent on reconnect. */
|
|
1153
|
-
function watchDetachKey(byte) {
|
|
1154
|
-
const stdin = process.stdin;
|
|
1155
|
-
let fire;
|
|
1156
|
-
let pressedYet = false;
|
|
1157
|
-
const pressed = new Promise((r) => { fire = r; });
|
|
1158
|
-
const hit = () => { pressedYet = true; fire(); };
|
|
1159
|
-
// The whole chunk must be exactly one press of the detach key: the legacy control byte, or the
|
|
1160
|
-
// kitty keyboard protocol / xterm modifyOtherKeys encoding of the same press (see
|
|
1161
|
-
// `isDetachPress`, #598). A chunk carrying it alongside anything else is NOT a keypress:
|
|
1162
|
-
// measured on a pty, a real keypress arrives in a read of its own even at 3ms spacing, and the
|
|
1163
|
-
// only two ways the byte arrives with company are a paste and a reader that was not reading.
|
|
1164
|
-
// Treating a paste that happens to contain the byte (or the encoded sequence) as a detach would
|
|
1165
|
-
// turn data into a control action on input nobody typed; the reader that was not reading is the
|
|
1166
|
-
// defect this watcher's new lifetime fixes, not a matching problem.
|
|
1167
|
-
// Under the console, Ink has called `stdin.setEncoding("utf8")`, and an encoding persists on the
|
|
1168
|
-
// stream after Ink releases raw mode, so data arrives as a STRING there (the standalone command
|
|
1169
|
-
// gets Buffers). Normalize first: a one-character string is not the number 0x1d, and the compare
|
|
1170
|
-
// below would otherwise never match, making the key dead for as long as a reconnect takes.
|
|
1171
|
-
const onData = (d) => { if (isDetachPress(d, byte))
|
|
1172
|
-
hit(); };
|
|
1173
|
-
const onChunk = (data) => onData(Buffer.isBuffer(data) ? data : Buffer.from(data, "utf8"));
|
|
1174
|
-
stdin.on("data", onChunk);
|
|
1175
|
-
stdin.resume();
|
|
1176
|
-
// `pause: false` hands the stream to a session's own reader, which resumes it synchronously right
|
|
1177
|
-
// after; pausing there and resuming a line later would be a window where a byte has no owner,
|
|
1178
|
-
// which is the whole defect.
|
|
1179
|
-
// `hit` as well as `pressed`, because the press has TWO consumers and one of them cannot await:
|
|
1180
|
-
// the handoff to a session's reader is synchronous, and it has to know whether the byte it is
|
|
1181
|
-
// taking the stream over from has already arrived.
|
|
1182
|
-
return { pressed, hit: () => pressedYet, stop: ({ pause = true } = {}) => { stdin.off("data", onChunk); if (pause)
|
|
1183
|
-
stdin.pause(); } };
|
|
767
|
+
/** The remedy for a seat whose runtime streams no terminal, naming what does reach it in this
|
|
768
|
+
* CLI's own commands. */
|
|
769
|
+
function noTerminalRemedy({ space, name }) {
|
|
770
|
+
return `nothing was attached and the agent was not touched; follow the runtime's own guidance above, or reach the seat over the mesh with ${codeSpan(`${displayCmd()} send -t ${shellWord(name)} "<message>"`)} and ${codeSpan(`${displayCmd()} watch --space ${shellWord(space)}`)}`;
|
|
1184
771
|
}
|
|
1185
772
|
/**
|
|
1186
|
-
* The attach
|
|
1187
|
-
*
|
|
1188
|
-
*
|
|
1189
|
-
|
|
1190
|
-
async function runAttachLoop(vv, pin, reconnect, key, hold) {
|
|
1191
|
-
let first = true;
|
|
1192
|
-
let attempt = 0; // consecutive re-establishment attempts since the last live session
|
|
1193
|
-
// Sessions the manager still counts, waiting to be told. A LIST rather than one slot: a second
|
|
1194
|
-
// link death before the first hand-back lands would overwrite the first and leak it with nothing
|
|
1195
|
-
// said. It cannot grow without bound, because every entry needed a successful establishment, and
|
|
1196
|
-
// successful establishments are exactly what the manager's own session ceiling counts.
|
|
1197
|
-
const abandoned = [];
|
|
1198
|
-
let saidWhy = ""; // the last transient refusal printed, so a steady reason prints once, not per attempt
|
|
1199
|
-
// ONE reader owns stdin for the whole time there is no session: the backoff wait, the round trip
|
|
1200
|
-
// that hands the old session back and opens the new one, and the gap in between. It used to exist
|
|
1201
|
-
// only for the waits, which left the establishment unread: `stdin` was paused, the stream
|
|
1202
|
-
// buffered, and `attachClient`'s `stdin.resume()` flushed whatever had been typed at a terminal
|
|
1203
|
-
// with no session straight into the seat's pty. Measured on the code before this change, with the
|
|
1204
|
-
// bytes graded at the SEAT rather than on screen: typed during a wait, dropped; during an attempt
|
|
1205
|
-
// that failed, dropped; during an attempt that SUCCEEDED, delivered, and a 0x03 typed there
|
|
1206
|
-
// arrived as SIGINT at the agent and killed it (the seat recorded the signal and its pid was
|
|
1207
|
-
// gone). With the reader installed for the whole period those bytes are read and dropped, which
|
|
1208
|
-
// is what the loop always claimed to do, and the detach key works during an attempt as well as
|
|
1209
|
-
// during a wait.
|
|
1210
|
-
let idle;
|
|
1211
|
-
// A TERMINAL only, and the gate lives HERE rather than at the call sites, because there are three
|
|
1212
|
-
// of them and a gate written at one is a promise the other two break silently. `printf 'ls\n' |
|
|
1213
|
-
// cotal attach` writes into a PIPE, and those bytes are a script's input rather than an operator
|
|
1214
|
-
// giving up on a frozen screen: a reader would eat them with no fault anywhere in sight. That is
|
|
1215
|
-
// true in EVERY window, not only before the first session, so a pipe keeps the old contract
|
|
1216
|
-
// throughout, paused between sessions and flushed into the next one. Measured with the gate at the
|
|
1217
|
-
// first call site alone: a piped attach buffered correctly until its first reconnect and then had
|
|
1218
|
-
// the reader installed on it by the backoff, so `tail -f log | cotal attach` lost its feed across a
|
|
1219
|
-
// link blip. Cells J and I are the first-session halves, cell L is the reconnect one.
|
|
1220
|
-
const ownStdin = () => (process.stdin.isTTY ? (idle ??= watchDetachKey(key.byte)) : undefined);
|
|
1221
|
-
// Returns whether the detach key had ALREADY been pressed, which is the one thing the session's
|
|
1222
|
-
// own reader cannot find out for itself once this reader is gone.
|
|
1223
|
-
const releaseStdin = (opts) => {
|
|
1224
|
-
const pressed = idle?.hit() ?? false;
|
|
1225
|
-
idle?.stop(opts);
|
|
1226
|
-
idle = undefined;
|
|
1227
|
-
return pressed;
|
|
1228
|
-
};
|
|
1229
|
-
// Give the manager its slots back. Oldest first, and one failure ends the round rather than
|
|
1230
|
-
// skipping ahead, because the reason is the link and the link is the same for all of them.
|
|
1231
|
-
const releaseAbandoned = async () => {
|
|
1232
|
-
while (abandoned.length > 0) {
|
|
1233
|
-
try {
|
|
1234
|
-
await releaseAbandonedSession(abandoned[0]);
|
|
1235
|
-
abandoned.shift();
|
|
1236
|
-
}
|
|
1237
|
-
catch {
|
|
1238
|
-
break;
|
|
1239
|
-
}
|
|
1240
|
-
}
|
|
1241
|
-
};
|
|
1242
|
-
// Every exit runs through here, so the one thing an operator cannot see for themselves is never
|
|
1243
|
-
// swallowed. It TRIES first: an operator who detaches during the backoff may well be doing it
|
|
1244
|
-
// because the link came back, and returning straight out would leave a slot held that one dial
|
|
1245
|
-
// would have freed. The attempt is bounded exactly like every other, and what it could not
|
|
1246
|
-
// deliver is what gets said. What to say, and when to say nothing, is `heldSessionNotice`.
|
|
1247
|
-
const done = async (v) => {
|
|
1248
|
-
// Give the stream back on the way out, on EVERY exit, because this reader resumed it: a resumed
|
|
1249
|
-
// stdin is a ref'd handle, and one left behind holds the command open after it has said it
|
|
1250
|
-
// detached. That is the same shape as the abandoned backoff timer, and it is why this lives
|
|
1251
|
-
// here rather than at the call sites.
|
|
1252
|
-
releaseStdin();
|
|
1253
|
-
// And drop the process's own claim on the stream, which a PIPE keeps even after a pause. This is
|
|
1254
|
-
// the exit path by definition, so nothing here reads stdin again. MEASURED, on a piped attach
|
|
1255
|
-
// with the reader correctly not installed: the detach was honoured, the manager freed the slot,
|
|
1256
|
-
// `detached from` printed, and the process then sat past 30s with its three stdio pipes as the
|
|
1257
|
-
// only resources keeping the loop alive. A pause was not enough on its own.
|
|
1258
|
-
//
|
|
1259
|
-
// ONLY THE SOCKET KINDS HAVE `unref`, and this is a test of the stream rather than a fallback.
|
|
1260
|
-
// Measured: at a terminal stdin is a `tty.ReadStream` and at a pipe a `net.Socket`, both of which
|
|
1261
|
-
// have it; given a FILE (`cotal attach < seed.txt`) or a parent that spawns with stdio "ignore",
|
|
1262
|
-
// stdin is an `fs.ReadStream` and has no `unref` at all. Calling it there is a TypeError, which
|
|
1263
|
-
// is how `smoke:cli-on-instance` caught this: it spawns attach with stdin ignored, and the
|
|
1264
|
-
// unguarded call turned a pinned-rail deadline into `process.stdin.unref is not a function`. The
|
|
1265
|
-
// same measurement says the other branch needs nothing: a file-backed stdin ends at EOF instead
|
|
1266
|
-
// of staying open like a socket, and a process that takes one and releases it exits at once with
|
|
1267
|
-
// an empty resource list, where the pipe leaves a PipeWrap behind. So the release exists exactly
|
|
1268
|
-
// where there is something to release. Cell N of `smoke:attach-stdin` is the file-backed route.
|
|
1269
|
-
if (typeof process.stdin.unref === "function")
|
|
1270
|
-
process.stdin.unref();
|
|
1271
|
-
if (v.kind !== "gone")
|
|
1272
|
-
await releaseAbandoned();
|
|
1273
|
-
const notice = heldSessionNotice(abandoned.length, v.kind);
|
|
1274
|
-
if (notice)
|
|
1275
|
-
console.error(c.dim(notice));
|
|
1276
|
-
return v;
|
|
1277
|
-
};
|
|
1278
|
-
// The FIRST establishment is an attach with no session too, and it is the path every attach takes.
|
|
1279
|
-
// Own the keyboard from here rather than from the first reconnect, so a key struck while the
|
|
1280
|
-
// command is still resolving the mesh is read and dropped instead of buffered and flushed into the
|
|
1281
|
-
// seat when the session opens. Measured before this line existed: a nonce typed at that prompt
|
|
1282
|
-
// arrived at the agent.
|
|
1283
|
-
//
|
|
1284
|
-
// `ownStdin` is a no-op at a pipe; both halves are cells (I and J), so neither is an assumption.
|
|
1285
|
-
if (reconnect)
|
|
1286
|
-
ownStdin();
|
|
1287
|
-
for (;;) {
|
|
1288
|
-
if (!first) {
|
|
1289
|
-
// Back off BEFORE the attempt, and stay interruptible: the detach key must work while we
|
|
1290
|
-
// wait, not only while a session is up. The timer is CLEARED rather than left to the race,
|
|
1291
|
-
// because losing a `Promise.race` does not stop a `setTimeout`: the detach is honoured at
|
|
1292
|
-
// once, the terminal comes back and `detached from` prints, and then node holds the process
|
|
1293
|
-
// open until the abandoned timer fires. On the 30s rung that is half a minute of a shell that
|
|
1294
|
-
// has already said it detached and will not give the prompt back. Measured before this line
|
|
1295
|
-
// existed: press to first output 0.1s, press to EXIT 27.0s with the next attempt 26.9s away,
|
|
1296
|
-
// and 8.3s with it 8.1s away, tracking the rung rather than the work.
|
|
1297
|
-
const ms = RECONNECT_BACKOFF_MS[Math.min(attempt, RECONNECT_BACKOFF_MS.length - 1)];
|
|
1298
|
-
const watch = ownStdin(); // and it STAYS installed through the attempt this wait precedes
|
|
1299
|
-
let timer;
|
|
1300
|
-
const waited = new Promise((r) => { timer = setTimeout(() => r(false), ms); });
|
|
1301
|
-
// No watcher at a pipe, and nothing to replace it with: a detach byte a script writes here
|
|
1302
|
-
// buffers and reaches the next session's own reader, which is exactly what it did before this
|
|
1303
|
-
// change. Racing a promise that can never settle would be the same thing written longer.
|
|
1304
|
-
const detached = watch ? await Promise.race([waited, watch.pressed.then(() => true)]) : await waited;
|
|
1305
|
-
clearTimeout(timer);
|
|
1306
|
-
if (detached)
|
|
1307
|
-
return await done({ kind: "ended", reason: "detached" });
|
|
1308
|
-
attempt++;
|
|
1309
|
-
}
|
|
1310
|
-
// Give the manager its slots back BEFORE claiming another one. The order is load-bearing at the
|
|
1311
|
-
// ceiling: a client that claims first is refused by the very sessions it abandoned. A failure
|
|
1312
|
-
// is retried on the next attempt and accounted for by `done` if it never lands.
|
|
1313
|
-
await releaseAbandoned();
|
|
1314
|
-
let est;
|
|
1315
|
-
const attempting = establishAttachSession(vv, pin, reconnect, first);
|
|
1316
|
-
// A press DURING the round trip ends the attach now, rather than when the round trip returns.
|
|
1317
|
-
// The in-flight establishment is still awaited: it is bounded like every other one, and a
|
|
1318
|
-
// session that lands after we have stopped looking is a slot the manager still counts, so it
|
|
1319
|
-
// goes on the abandoned list and `done` hands it back over the link that is by then up.
|
|
1320
|
-
//
|
|
1321
|
-
// `idle` is also set on the FIRST establishment at a terminal, where what it buys is the same
|
|
1322
|
-
// thing it buys everywhere else: what gets typed at a prompt that has not come up yet is read
|
|
1323
|
-
// and dropped rather than delivered later, which is what cell I grades. It is not a second way
|
|
1324
|
-
// to abort. Raw mode is entered in `attachClient`'s `onReady` (attach-client.ts), so before the
|
|
1325
|
-
// first session this process has not touched the terminal's mode at all, and Ctrl-C at a cooked
|
|
1326
|
-
// tty is how an operator leaves a command that has not attached.
|
|
1327
|
-
if (idle) {
|
|
1328
|
-
const raced = await Promise.race([
|
|
1329
|
-
attempting.then((e) => ({ est: e }), () => ({ est: undefined })),
|
|
1330
|
-
idle.pressed.then(() => ({ detached: true })),
|
|
1331
|
-
]);
|
|
1332
|
-
if ("detached" in raced) {
|
|
1333
|
-
releaseStdin();
|
|
1334
|
-
const late = await attempting.catch(() => undefined);
|
|
1335
|
-
if (late?.ok) {
|
|
1336
|
-
abandoned.push({ grant: late.grant, link: late.link, inbox: late.inbox, server: late.server });
|
|
1337
|
-
// And CLOSE the link that session came up on. Nothing here will ever read it, and an open
|
|
1338
|
-
// NATS connection is a ref'd handle: measured, the attach printed `detached from` and then
|
|
1339
|
-
// sat there forever, because the hand-back mints its own short-lived connection from the
|
|
1340
|
-
// abandoned session's own credential and never touches this one. Same shape as the
|
|
1341
|
-
// abandoned backoff timer, one layer down.
|
|
1342
|
-
await closeLink(late.nc);
|
|
1343
|
-
}
|
|
1344
|
-
return await done({ kind: "ended", reason: "detached" });
|
|
1345
|
-
}
|
|
1346
|
-
}
|
|
1347
|
-
try {
|
|
1348
|
-
est = await attempting;
|
|
1349
|
-
}
|
|
1350
|
-
catch (e) {
|
|
1351
|
-
// The FIRST attempt throws exactly as it always has — the CLI's top-level handler renders
|
|
1352
|
-
// it. Only a reconnect turns a thrown establishment into another attempt. Give the stream
|
|
1353
|
-
// back on the way out: this is the one exit from this loop that does not run through `done`,
|
|
1354
|
-
// and a resumed stdin is a ref'd handle that would hold the command open after it has already
|
|
1355
|
-
// printed why it is giving up.
|
|
1356
|
-
if (first) {
|
|
1357
|
-
releaseStdin();
|
|
1358
|
-
throw e;
|
|
1359
|
-
}
|
|
1360
|
-
est = thrownEstablishment(e);
|
|
1361
|
-
}
|
|
1362
|
-
if (!est.ok) {
|
|
1363
|
-
// Nothing changes for the first attach: any refusal is the same loud exit as before.
|
|
1364
|
-
if (first || est.kind === "fatal" || est.kind === "denied")
|
|
1365
|
-
return await done({ kind: "failed", message: est.message, ...(est.fromManager ? { refusal: { code: est.code, noTerminal: est.noTerminal === true } } : {}) });
|
|
1366
|
-
if (est.kind === "gone")
|
|
1367
|
-
return await done({ kind: "gone" });
|
|
1368
|
-
const notice = reconnectNotice(est, saidWhy);
|
|
1369
|
-
if (notice) {
|
|
1370
|
-
saidWhy = est.message;
|
|
1371
|
-
console.error(c.dim(notice));
|
|
1372
|
-
}
|
|
1373
|
-
continue; // transient: the manager or the link is unreachable right now
|
|
1374
|
-
}
|
|
1375
|
-
saidWhy = "";
|
|
1376
|
-
const from = est.resumedFrom ? ` (resumed from ${est.resumedFrom.host}:${est.resumedFrom.source})` : "";
|
|
1377
|
-
if (first)
|
|
1378
|
-
console.error(c.dim(`attached to ${vv.name}${from} - ${key.label} to detach`));
|
|
1379
|
-
else
|
|
1380
|
-
console.error(c.dim("[cotal: reconnected]"));
|
|
1381
|
-
first = false;
|
|
1382
|
-
let outcome;
|
|
1383
|
-
const startedAt = Date.now();
|
|
1384
|
-
const transport = meshSessionTransport(est.nc, est.grant);
|
|
1385
|
-
try {
|
|
1386
|
-
// The manager replays its byte-exact backlog snapshot on every open (the `ready` handshake
|
|
1387
|
-
// in session/bridge.ts), so the reconnected screen repaints through the path that already
|
|
1388
|
-
// exists; there is no second backlog here.
|
|
1389
|
-
outcome = await attachClient(transport, hold, () => releaseStdin({ pause: false }));
|
|
1390
|
-
}
|
|
1391
|
-
finally {
|
|
1392
|
-
// Take the stream back FIRST, ahead of the hand-back's own round trips. A session that ends
|
|
1393
|
-
// while its SOCKET is still alive is the case this whole change is about, and everything
|
|
1394
|
-
// below it here is a publish, a flush and a close, each bounded by LINK_DEADLINE_MS: placed
|
|
1395
|
-
// after them, this line would leave the keyboard unowned for as long as a dying link takes
|
|
1396
|
-
// to answer, which is precisely when an operator reaches for the detach key. attachClient's
|
|
1397
|
-
// cleanup has already removed its own reader and paused the stream, so the ownerless gap is
|
|
1398
|
-
// the width of this statement rather than of two deadlines. `done` releases it again on
|
|
1399
|
-
// whichever exit follows, and the backoff wait re-installs the same reader.
|
|
1400
|
-
if (reconnect)
|
|
1401
|
-
ownStdin();
|
|
1402
|
-
// Hand the session back. With the link still up that is one advisory frame over the
|
|
1403
|
-
// connection already open (a rail that broke while the socket lived — a stall, a gap — is
|
|
1404
|
-
// exactly this case, and it is idempotent after a detach has already closed it). The FLUSH is
|
|
1405
|
-
// what decides whether it left, not the publish: a link can die between the closed-check and
|
|
1406
|
-
// the frame, which is the same slow death this whole change is about. Only a flush that
|
|
1407
|
-
// returned counts as handed back; anything else keeps the credential for the next attempt,
|
|
1408
|
-
// and see releaseAbandonedSession for why no other credential can carry it.
|
|
1409
|
-
let handedBack = false;
|
|
1410
|
-
if (!est.nc.isClosed()) {
|
|
1411
|
-
transport.close();
|
|
1412
|
-
handedBack = await flushed(est.nc);
|
|
1413
|
-
}
|
|
1414
|
-
if (!handedBack && reconnect) {
|
|
1415
|
-
abandoned.push({ grant: est.grant, link: est.link, inbox: est.inbox, server: est.server });
|
|
1416
|
-
}
|
|
1417
|
-
await closeLink(est.nc);
|
|
1418
|
-
}
|
|
1419
|
-
// The backoff resets on a session that WORKED, not on one that merely opened. `carried` is not
|
|
1420
|
-
// enough on its own: a seat that has produced nothing has an empty backlog snapshot, so a
|
|
1421
|
-
// perfectly healthy attach to a QUIET agent would never reset and would climb to the 30s cap
|
|
1422
|
-
// over a few blips, which is the seat this whole change cares most about. A session that simply
|
|
1423
|
-
// lasted is the other half of the same question.
|
|
1424
|
-
if (outcome.carried || Date.now() - startedAt >= SESSION_WORKED_MS)
|
|
1425
|
-
attempt = 0;
|
|
1426
|
-
if (!reconnect) {
|
|
1427
|
-
// One-shot: a faulted session still throws, so `--no-reconnect` exits the way it does today.
|
|
1428
|
-
if (outcome.error)
|
|
1429
|
-
throw outcome.error;
|
|
1430
|
-
return await done({ kind: "ended", reason: outcome.reason });
|
|
1431
|
-
}
|
|
1432
|
-
if (!isTransportEnd(outcome.reason)) {
|
|
1433
|
-
// A faulted end this classification does not recognise is NOT a detach. Printing
|
|
1434
|
-
// `detached from` over it would be the silent swallow this whole change exists to remove,
|
|
1435
|
-
// so it exits non-zero carrying the fault. Every `fireEnd` site passes a reason today, so
|
|
1436
|
-
// this is a guard against a future one that forgets, not a live path.
|
|
1437
|
-
if (outcome.error)
|
|
1438
|
-
return await done({ kind: "failed", message: outcome.error.message });
|
|
1439
|
-
return await done({ kind: "ended", reason: outcome.reason });
|
|
1440
|
-
}
|
|
1441
|
-
console.error(c.dim("[cotal: connection lost, reconnecting]"));
|
|
1442
|
-
// Raw from here on, whether or not the session that just ended reached ready. A first session
|
|
1443
|
-
// whose link died inside its opening flush never ran `onReady`, so the terminal is still cooked:
|
|
1444
|
-
// the line discipline would echo the detach key as `^]` and hold it until Enter, and the reader
|
|
1445
|
-
// below would never see it (#1471). The attach has already said it is attached, so Ctrl-C at a
|
|
1446
|
-
// cooked tty is no longer the way out; the detach key is.
|
|
1447
|
-
hold.enterRaw();
|
|
1448
|
-
}
|
|
1449
|
-
}
|
|
1450
|
-
/**
|
|
1451
|
-
* The whole attach, as one call: seat locality, the establish-redeem-drive loop with its reconnect,
|
|
1452
|
-
* end-reason classification and abandoned-session hand-back, and the terminal given back before
|
|
1453
|
-
* the verdict is returned. `cotal attach` and the console's `a` / `:attach` both run THIS, so the
|
|
1454
|
-
* console can never drift into a lesser attach. `onRefusal` chooses what an unresolvable mesh or
|
|
1455
|
-
* an absent seat does: the command prints and exits; the console needs a returned verdict, because
|
|
1456
|
-
* an exit inside a TUI blanks the screen. The pin, when the caller already knows it (`--on`), is
|
|
1457
|
-
* honoured; otherwise the seat is located the way `stop` locates it.
|
|
773
|
+
* The attach `cotal attach`, the console and spawn-then-attach run: the shared attach from
|
|
774
|
+
* `@cotal-ai/workspace/attach`, with this CLI's control client, verb and remedy. On the command path
|
|
775
|
+
* (`exit`) with no pin given, the seat is located here first, before the detach key is read and the
|
|
776
|
+
* terminal is held, because that step prints and exits in the CLI's words.
|
|
1458
777
|
*/
|
|
1459
778
|
export async function attachSeat(v, opts) {
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
if (opts.onRefusal === "exit")
|
|
1463
|
-
on = await pinForTarget(v, "cotal attach");
|
|
1464
|
-
else {
|
|
1465
|
-
const loc = await locateSeat(cliControl, v, v.name, { onRefusal: "throw" });
|
|
1466
|
-
if (loc.kind === "absent")
|
|
1467
|
-
return { kind: "failed", message: seatNotFoundMessage(loc, v.name) };
|
|
1468
|
-
if (loc.kind === "several")
|
|
1469
|
-
return { kind: "failed", message: seatMissRefusal(v.name, "cotal attach", loc) };
|
|
1470
|
-
if (loc.kind === "pin")
|
|
1471
|
-
on = loc.instanceId;
|
|
1472
|
-
}
|
|
1473
|
-
}
|
|
1474
|
-
const detach = detachKey();
|
|
1475
|
-
const hold = holdTerminal();
|
|
1476
|
-
try {
|
|
1477
|
-
return await runAttachLoop({ ...v, onRefusal: opts.onRefusal }, on, opts.reconnect, detach, hold);
|
|
1478
|
-
}
|
|
1479
|
-
catch (e) {
|
|
1480
|
-
if (opts.onRefusal === "exit")
|
|
1481
|
-
throw e;
|
|
1482
|
-
return { kind: "failed", message: e.message };
|
|
1483
|
-
}
|
|
1484
|
-
finally {
|
|
1485
|
-
// The terminal comes back before anything else happens, whatever ended the attach — including
|
|
1486
|
-
// a throw out of the one-shot path, which is how `--no-reconnect` keeps its old exit.
|
|
1487
|
-
hold.restore();
|
|
1488
|
-
}
|
|
779
|
+
const on = opts.on === undefined && opts.onRefusal === "exit" ? await pinForTarget(v, "cotal attach") : opts.on;
|
|
780
|
+
return attachSeatWith(v, { ...opts, on, control: cliControl, verb: "cotal attach", noTerminalRemedy });
|
|
1489
781
|
}
|
|
1490
782
|
async function pickAttach(values) {
|
|
1491
783
|
const census = await readSeats(values, "ps", await managerPin(values, "cotal attach"));
|