sharednet 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +28 -0
- package/bin/sharednet.js +5 -0
- package/dist/api-client.d.ts +11 -0
- package/dist/api-client.js +82 -0
- package/dist/cli.d.ts +19 -0
- package/dist/cli.js +328 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.js +20 -0
- package/dist/guest.d.ts +29 -0
- package/dist/guest.js +676 -0
- package/dist/instance-computation.d.ts +10 -0
- package/dist/instance-computation.js +26 -0
- package/dist/login.d.ts +20 -0
- package/dist/login.js +137 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +11 -0
- package/dist/runtime-detection.d.ts +43 -0
- package/dist/runtime-detection.js +165 -0
- package/dist/session.d.ts +80 -0
- package/dist/session.js +182 -0
- package/dist/storage.d.ts +74 -0
- package/dist/storage.js +368 -0
- package/package.json +51 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import { localError } from "./errors.js";
|
|
3
|
+
import { detectRuntime } from "./runtime-detection.js";
|
|
4
|
+
/** The driver's session, when the driver exposes one. See runtime-detection.ts. */
|
|
5
|
+
export function detectRuntimeSession(env) {
|
|
6
|
+
const detected = detectRuntime(env, { parentProcess: () => null });
|
|
7
|
+
return detected.anchor === null ? null : { runtimeKind: detected.kind, anchor: detected.anchor };
|
|
8
|
+
}
|
|
9
|
+
export function computeLocalInstanceKey(installationSecret, runtimeKind, providerSessionAnchor) {
|
|
10
|
+
let key;
|
|
11
|
+
try {
|
|
12
|
+
key = Buffer.from(installationSecret, "base64url");
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
throw localError("invalid_local_state", "The installation secret is invalid.");
|
|
16
|
+
}
|
|
17
|
+
if (key.byteLength !== 32) {
|
|
18
|
+
throw localError("invalid_local_state", "The installation secret is invalid.");
|
|
19
|
+
}
|
|
20
|
+
if (!providerSessionAnchor) {
|
|
21
|
+
throw localError("runtime_session_not_detected", "The current runtime session could not be detected.");
|
|
22
|
+
}
|
|
23
|
+
return createHmac("sha256", key)
|
|
24
|
+
.update(`${runtimeKind}\0${providerSessionAnchor}`, "utf8")
|
|
25
|
+
.digest("hex");
|
|
26
|
+
}
|
package/dist/login.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sharednet login`: a device-style approval. The CLI starts a login, shows a
|
|
3
|
+
* code and a URL, the human approves it in the Web, and the CLI polls until
|
|
4
|
+
* it is handed an API key minted at that moment. Any anonymous seats this
|
|
5
|
+
* machine holds are sent as proof of possession so the approval binds their
|
|
6
|
+
* Principals to the account. Decision 2026-09-06 §3.
|
|
7
|
+
*/
|
|
8
|
+
type Environment = Record<string, string | undefined>;
|
|
9
|
+
export interface LoginDependencies {
|
|
10
|
+
env: Environment;
|
|
11
|
+
fetch: typeof globalThis.fetch;
|
|
12
|
+
stdout: (value: string) => void;
|
|
13
|
+
stderr: (value: string) => void;
|
|
14
|
+
now: () => Date;
|
|
15
|
+
sleep?: (ms: number) => Promise<void>;
|
|
16
|
+
/** Opens a URL in the human's browser; the default shells out. */
|
|
17
|
+
openBrowser?: (url: string) => Promise<boolean>;
|
|
18
|
+
}
|
|
19
|
+
export declare function login(args: string[], dependencies: LoginDependencies): Promise<unknown>;
|
|
20
|
+
export {};
|
package/dist/login.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { readdir } from "node:fs/promises";
|
|
3
|
+
import { hostname } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { ApiClient, resolveBaseUrl } from "./api-client.js";
|
|
6
|
+
import { CliError, localError } from "./errors.js";
|
|
7
|
+
import { getOrCreateInstallationSecret, getStoragePaths, readRoomCredential, writeStoredApiCredential, } from "./storage.js";
|
|
8
|
+
const VALUE_OPTIONS = new Set(["label"]);
|
|
9
|
+
const FLAG_OPTIONS = new Set(["no-browser"]);
|
|
10
|
+
function parseLoginArguments(args) {
|
|
11
|
+
let label;
|
|
12
|
+
let openBrowser = true;
|
|
13
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
14
|
+
const argument = args[index];
|
|
15
|
+
if (!argument.startsWith("--")) {
|
|
16
|
+
throw localError("invalid_arguments", "Usage: sharednet login [--label <where>] [--no-browser]");
|
|
17
|
+
}
|
|
18
|
+
const separator = argument.indexOf("=");
|
|
19
|
+
const name = argument.slice(2, separator === -1 ? undefined : separator);
|
|
20
|
+
if (FLAG_OPTIONS.has(name)) {
|
|
21
|
+
if (separator !== -1)
|
|
22
|
+
throw localError("invalid_option", `The --${name} option does not accept a value.`);
|
|
23
|
+
openBrowser = false;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (!VALUE_OPTIONS.has(name))
|
|
27
|
+
throw localError("unknown_option", "The command contains an unknown option.");
|
|
28
|
+
const value = separator === -1 ? args[++index] : argument.slice(separator + 1);
|
|
29
|
+
if (!value || value.startsWith("--"))
|
|
30
|
+
throw localError("missing_option_value", `The --${name} option requires a value.`);
|
|
31
|
+
label = value;
|
|
32
|
+
}
|
|
33
|
+
return { label, openBrowser };
|
|
34
|
+
}
|
|
35
|
+
async function defaultOpenBrowser(url) {
|
|
36
|
+
const command = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? ["cmd", "/c", "start", "", url] : ["xdg-open", url];
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
try {
|
|
39
|
+
const child = spawn(command[0], command.slice(1), { detached: true, stdio: "ignore" });
|
|
40
|
+
child.once("error", () => resolve(false));
|
|
41
|
+
child.once("spawn", () => {
|
|
42
|
+
child.unref();
|
|
43
|
+
resolve(true);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
resolve(false);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/** Every seat token this machine holds, so approval can bind their Principals. */
|
|
52
|
+
async function heldSeatTokens(paths) {
|
|
53
|
+
let roomDirs;
|
|
54
|
+
try {
|
|
55
|
+
roomDirs = await readdir(paths.roomsDir);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (error.code === "ENOENT")
|
|
59
|
+
return [];
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
const tokens = [];
|
|
63
|
+
for (const roomId of roomDirs) {
|
|
64
|
+
if (!/^rom_[A-Za-z0-9]+$/.test(roomId))
|
|
65
|
+
continue;
|
|
66
|
+
let files;
|
|
67
|
+
try {
|
|
68
|
+
files = await readdir(join(paths.roomsDir, roomId));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
for (const file of files) {
|
|
74
|
+
const memberId = file.replace(/\.json$/, "");
|
|
75
|
+
if (!/^(?:i|mem)_[A-Za-z0-9]+$/.test(memberId))
|
|
76
|
+
continue;
|
|
77
|
+
const credential = await readRoomCredential(paths, roomId, memberId).catch(() => null);
|
|
78
|
+
if (credential && /^(?:sni|rmt)_[A-Za-z0-9_-]{43}$/.test(credential.member_token)) {
|
|
79
|
+
tokens.push(credential.member_token);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return tokens.slice(0, 50);
|
|
84
|
+
}
|
|
85
|
+
export async function login(args, dependencies) {
|
|
86
|
+
const { label, openBrowser } = parseLoginArguments(args);
|
|
87
|
+
const baseUrl = resolveBaseUrl(dependencies.env.SHAREDNET_BASE_URL);
|
|
88
|
+
const paths = getStoragePaths(dependencies.env);
|
|
89
|
+
const client = new ApiClient(baseUrl, dependencies.fetch);
|
|
90
|
+
const seats = await heldSeatTokens(paths);
|
|
91
|
+
const started = await client.request("POST", "/cli/logins", "", {
|
|
92
|
+
label: label ?? hostname() ?? null,
|
|
93
|
+
...(seats.length > 0 ? { seats } : {}),
|
|
94
|
+
});
|
|
95
|
+
if (!started?.login?.id || !started.user_code || !started.poll_token || !started.verify_url) {
|
|
96
|
+
throw new CliError("invalid_server_response", "The SharedNet service returned an invalid response.", 5);
|
|
97
|
+
}
|
|
98
|
+
dependencies.stderr(`Approve this terminal in your browser.\n\n Code: ${started.user_code}\n Open: ${started.verify_url}\n\n` +
|
|
99
|
+
(started.login.bind_instance_ids.length > 0
|
|
100
|
+
? `Approval also binds ${started.login.bind_instance_ids.length} seat(s) this machine holds to your account.\n\n`
|
|
101
|
+
: ""));
|
|
102
|
+
if (openBrowser) {
|
|
103
|
+
const opened = await (dependencies.openBrowser ?? defaultOpenBrowser)(started.verify_url);
|
|
104
|
+
if (!opened)
|
|
105
|
+
dependencies.stderr("Could not open a browser; open the URL yourself.\n");
|
|
106
|
+
}
|
|
107
|
+
const sleep = dependencies.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
108
|
+
const intervalMs = Math.max(1, started.interval_seconds || 3) * 1000;
|
|
109
|
+
const deadline = Date.parse(started.login.expires_at);
|
|
110
|
+
for (;;) {
|
|
111
|
+
const result = await client.request("POST", `/cli/logins/${encodeURIComponent(started.login.id)}/poll`, started.poll_token);
|
|
112
|
+
if (result.state === "approved") {
|
|
113
|
+
const installationSecret = await getOrCreateInstallationSecret(paths);
|
|
114
|
+
await writeStoredApiCredential(paths, {
|
|
115
|
+
schema_version: 1,
|
|
116
|
+
base_url: baseUrl,
|
|
117
|
+
principal_id: result.principal.id,
|
|
118
|
+
api_key_id: result.api_key_id,
|
|
119
|
+
api_key: result.api_key,
|
|
120
|
+
installation_secret: installationSecret,
|
|
121
|
+
created_at: dependencies.now().toISOString(),
|
|
122
|
+
expires_at: null,
|
|
123
|
+
});
|
|
124
|
+
// The key stays in the credential file; the terminal sees only ids.
|
|
125
|
+
return {
|
|
126
|
+
principal_id: result.principal.id,
|
|
127
|
+
api_key_id: result.api_key_id,
|
|
128
|
+
bound_instance_ids: result.login.bind_instance_ids,
|
|
129
|
+
credentials_file: paths.credentialsFile,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
if (Number.isFinite(deadline) && dependencies.now().getTime() >= deadline) {
|
|
133
|
+
throw new CliError("login_expired", "The login was not approved in time. Run sharednet login again.", 4);
|
|
134
|
+
}
|
|
135
|
+
await sleep(intervalMs);
|
|
136
|
+
}
|
|
137
|
+
}
|
package/dist/main.d.ts
ADDED
package/dist/main.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { runCli } from "./cli.js";
|
|
5
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
6
|
+
return runCli(argv);
|
|
7
|
+
}
|
|
8
|
+
const invokedPath = process.argv[1] ? resolve(process.argv[1]) : "";
|
|
9
|
+
if (invokedPath && fileURLToPath(import.meta.url) === invokedPath) {
|
|
10
|
+
process.exitCode = await main();
|
|
11
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which coding-agent driver is running this CLI, read off the driver's own
|
|
3
|
+
* environment. Everything here is diagnostic: it names the seat on the Web and
|
|
4
|
+
* picks its icon, and the session anchor feeds the local Instance key. Nothing
|
|
5
|
+
* is trusted for authorization.
|
|
6
|
+
*
|
|
7
|
+
* Markers verified on 2026-09-06 on macOS:
|
|
8
|
+
* Claude Code CLAUDECODE=1, CLAUDE_CODE_SESSION_ID, CLAUDE_CODE_ENTRYPOINT
|
|
9
|
+
* (claude-desktop | cli), CLAUDE_AGENT_SDK_VERSION
|
|
10
|
+
* Codex CLI CODEX_SESSION_ID, CODEX_THREAD_ID (lineage, not a session),
|
|
11
|
+
* CODEX_CI=1 under `codex exec`; parent process is `codex`
|
|
12
|
+
* The OpenCode, OpenHands, Gemini CLI and Cursor markers below are taken from
|
|
13
|
+
* their documentation and not yet verified on a machine; the parent-process
|
|
14
|
+
* fallback covers a driver whose variables differ.
|
|
15
|
+
*/
|
|
16
|
+
export type RuntimeSource = "detected" | "declared";
|
|
17
|
+
export interface DetectedRuntime {
|
|
18
|
+
/** A handle such as `claude-code`; `custom` when nothing was recognised. */
|
|
19
|
+
kind: string;
|
|
20
|
+
/** The driver's exact session id, kept on this machine and only ever HMACed. */
|
|
21
|
+
anchor: string | null;
|
|
22
|
+
version: string | null;
|
|
23
|
+
entrypoint: string | null;
|
|
24
|
+
source: RuntimeSource;
|
|
25
|
+
}
|
|
26
|
+
type Environment = Record<string, string | undefined>;
|
|
27
|
+
/**
|
|
28
|
+
* The nearest ancestor process that is a known driver, or null. A CLI run
|
|
29
|
+
* from a Codex shell inside a Claude Code session has both drivers'
|
|
30
|
+
* variables in its environment; the process tree says which one is actually
|
|
31
|
+
* running it.
|
|
32
|
+
*/
|
|
33
|
+
export declare function nearestDriverAncestor(startPid?: number): string | null;
|
|
34
|
+
/** The name of the process that launched this one, or null when it cannot be read. */
|
|
35
|
+
export declare function parentProcessName(ppid?: number): string | null;
|
|
36
|
+
export declare function detectRuntime(env: Environment, options?: {
|
|
37
|
+
parentProcess?: () => string | null;
|
|
38
|
+
ancestorDriver?: () => string | null;
|
|
39
|
+
}): DetectedRuntime;
|
|
40
|
+
export declare function isRuntimeKind(value: string): boolean;
|
|
41
|
+
/** The diagnostic fields a detected driver contributes to an Instance's metadata. */
|
|
42
|
+
export declare function runtimeMetadataOf(runtime: DetectedRuntime): Record<string, string>;
|
|
43
|
+
export {};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
const RUNTIME_KIND_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
|
|
4
|
+
function nonEmpty(value) {
|
|
5
|
+
const normalized = value?.trim();
|
|
6
|
+
return normalized ? normalized : null;
|
|
7
|
+
}
|
|
8
|
+
function clean(value) {
|
|
9
|
+
if (value === null)
|
|
10
|
+
return null;
|
|
11
|
+
const printable = value.replace(/[^\x20-\x7e]/g, "").trim().slice(0, 64);
|
|
12
|
+
return printable.length > 0 ? printable : null;
|
|
13
|
+
}
|
|
14
|
+
function hasPrefix(env, prefix) {
|
|
15
|
+
return Object.keys(env).some((key) => key.startsWith(prefix) && nonEmpty(env[key]) !== null);
|
|
16
|
+
}
|
|
17
|
+
const PARENT_PROCESS_KINDS = {
|
|
18
|
+
claude: "claude-code",
|
|
19
|
+
codex: "codex",
|
|
20
|
+
opencode: "opencode",
|
|
21
|
+
openhands: "openhands",
|
|
22
|
+
gemini: "gemini-cli",
|
|
23
|
+
cursor: "cursor",
|
|
24
|
+
};
|
|
25
|
+
function processEntry(pid) {
|
|
26
|
+
if (process.platform === "win32" || !Number.isInteger(pid) || pid <= 1)
|
|
27
|
+
return null;
|
|
28
|
+
const result = spawnSync("ps", ["-o", "ppid=,comm=", "-p", String(pid)], {
|
|
29
|
+
encoding: "utf8",
|
|
30
|
+
timeout: 2000,
|
|
31
|
+
});
|
|
32
|
+
if (result.status !== 0)
|
|
33
|
+
return null;
|
|
34
|
+
const match = /^\s*(\d+)\s+(.+?)\s*$/.exec(result.stdout);
|
|
35
|
+
if (!match)
|
|
36
|
+
return null;
|
|
37
|
+
return { ppid: Number(match[1]), name: basename(match[2]) };
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The nearest ancestor process that is a known driver, or null. A CLI run
|
|
41
|
+
* from a Codex shell inside a Claude Code session has both drivers'
|
|
42
|
+
* variables in its environment; the process tree says which one is actually
|
|
43
|
+
* running it.
|
|
44
|
+
*/
|
|
45
|
+
export function nearestDriverAncestor(startPid = process.ppid) {
|
|
46
|
+
let pid = startPid;
|
|
47
|
+
for (let hop = 0; hop < 12; hop += 1) {
|
|
48
|
+
const entry = processEntry(pid);
|
|
49
|
+
if (!entry)
|
|
50
|
+
return null;
|
|
51
|
+
const kind = PARENT_PROCESS_KINDS[entry.name.toLowerCase()];
|
|
52
|
+
if (kind)
|
|
53
|
+
return kind;
|
|
54
|
+
if (entry.ppid <= 1 || entry.ppid === pid)
|
|
55
|
+
return null;
|
|
56
|
+
pid = entry.ppid;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
/** The name of the process that launched this one, or null when it cannot be read. */
|
|
61
|
+
export function parentProcessName(ppid = process.ppid) {
|
|
62
|
+
return processEntry(ppid)?.name ?? null;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Detect the driver. Every driver whose variables are present is a candidate;
|
|
66
|
+
* one that exposes its own session id outranks one that only leaves markers,
|
|
67
|
+
* because markers are inherited by everything a driver spawns while a session
|
|
68
|
+
* id is set per process. When two drivers both expose a session id, the
|
|
69
|
+
* process tree decides; when it cannot, the first in DRIVER_ORDER wins.
|
|
70
|
+
*/
|
|
71
|
+
const DRIVER_ORDER = ["codex", "claude-code", "opencode", "openhands", "gemini-cli", "cursor"];
|
|
72
|
+
function candidates(env) {
|
|
73
|
+
const found = [];
|
|
74
|
+
const claudeAnchor = nonEmpty(env.CLAUDE_CODE_SESSION_ID) ?? nonEmpty(env.CLAUDE_SESSION_ID);
|
|
75
|
+
if (claudeAnchor !== null || nonEmpty(env.CLAUDECODE) !== null) {
|
|
76
|
+
found.push({
|
|
77
|
+
kind: "claude-code",
|
|
78
|
+
anchor: claudeAnchor,
|
|
79
|
+
version: clean(nonEmpty(env.CLAUDE_AGENT_SDK_VERSION)),
|
|
80
|
+
entrypoint: clean(nonEmpty(env.CLAUDE_CODE_ENTRYPOINT)),
|
|
81
|
+
source: "detected",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
// CODEX_THREAD_ID is deliberately not an anchor: it is lineage, not the
|
|
85
|
+
// executing session, and would collapse concurrent child sessions.
|
|
86
|
+
const codexAnchor = nonEmpty(env.CODEX_SESSION_ID);
|
|
87
|
+
if (codexAnchor !== null) {
|
|
88
|
+
found.push({
|
|
89
|
+
kind: "codex",
|
|
90
|
+
anchor: codexAnchor,
|
|
91
|
+
version: clean(nonEmpty(env.CODEX_VERSION)),
|
|
92
|
+
entrypoint: nonEmpty(env.CODEX_CI) !== null ? "exec" : null,
|
|
93
|
+
source: "detected",
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
if (hasPrefix(env, "OPENCODE")) {
|
|
97
|
+
found.push({
|
|
98
|
+
kind: "opencode",
|
|
99
|
+
anchor: nonEmpty(env.OPENCODE_SESSION_ID),
|
|
100
|
+
version: clean(nonEmpty(env.OPENCODE_VERSION)),
|
|
101
|
+
entrypoint: null,
|
|
102
|
+
source: "detected",
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
if (hasPrefix(env, "OPENHANDS")) {
|
|
106
|
+
found.push({
|
|
107
|
+
kind: "openhands",
|
|
108
|
+
anchor: nonEmpty(env.OPENHANDS_SESSION_ID) ?? nonEmpty(env.OPENHANDS_CONVERSATION_ID),
|
|
109
|
+
version: clean(nonEmpty(env.OPENHANDS_VERSION)),
|
|
110
|
+
entrypoint: null,
|
|
111
|
+
source: "detected",
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (nonEmpty(env.GEMINI_CLI) !== null || hasPrefix(env, "GEMINI_CLI_")) {
|
|
115
|
+
found.push({
|
|
116
|
+
kind: "gemini-cli",
|
|
117
|
+
anchor: nonEmpty(env.GEMINI_CLI_SESSION_ID),
|
|
118
|
+
version: clean(nonEmpty(env.GEMINI_CLI_VERSION)),
|
|
119
|
+
entrypoint: null,
|
|
120
|
+
source: "detected",
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
if (nonEmpty(env.CURSOR_AGENT) !== null || nonEmpty(env.CURSOR_TRACE_ID) !== null) {
|
|
124
|
+
found.push({
|
|
125
|
+
kind: "cursor",
|
|
126
|
+
anchor: nonEmpty(env.CURSOR_TRACE_ID),
|
|
127
|
+
version: null,
|
|
128
|
+
entrypoint: null,
|
|
129
|
+
source: "detected",
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return found.sort((a, b) => DRIVER_ORDER.indexOf(a.kind) - DRIVER_ORDER.indexOf(b.kind));
|
|
133
|
+
}
|
|
134
|
+
export function detectRuntime(env, options = {}) {
|
|
135
|
+
const found = candidates(env);
|
|
136
|
+
const withSession = found.filter((candidate) => candidate.anchor !== null);
|
|
137
|
+
if (withSession.length === 1)
|
|
138
|
+
return withSession[0];
|
|
139
|
+
if (withSession.length > 1) {
|
|
140
|
+
const byTree = (options.ancestorDriver ?? nearestDriverAncestor)();
|
|
141
|
+
return withSession.find((candidate) => candidate.kind === byTree) ?? withSession[0];
|
|
142
|
+
}
|
|
143
|
+
if (found.length > 0) {
|
|
144
|
+
const byTree = (options.ancestorDriver ?? nearestDriverAncestor)();
|
|
145
|
+
return found.find((candidate) => candidate.kind === byTree) ?? found[0];
|
|
146
|
+
}
|
|
147
|
+
const parent = (options.parentProcess ?? parentProcessName)();
|
|
148
|
+
const byParent = parent ? PARENT_PROCESS_KINDS[parent.toLowerCase()] : undefined;
|
|
149
|
+
if (byParent) {
|
|
150
|
+
return { kind: byParent, anchor: null, version: null, entrypoint: null, source: "detected" };
|
|
151
|
+
}
|
|
152
|
+
return { kind: "custom", anchor: null, version: null, entrypoint: null, source: "declared" };
|
|
153
|
+
}
|
|
154
|
+
export function isRuntimeKind(value) {
|
|
155
|
+
return RUNTIME_KIND_PATTERN.test(value);
|
|
156
|
+
}
|
|
157
|
+
/** The diagnostic fields a detected driver contributes to an Instance's metadata. */
|
|
158
|
+
export function runtimeMetadataOf(runtime) {
|
|
159
|
+
const metadata = { runtime_source: runtime.source };
|
|
160
|
+
if (runtime.version)
|
|
161
|
+
metadata.driver_version = runtime.version;
|
|
162
|
+
if (runtime.entrypoint)
|
|
163
|
+
metadata.entrypoint = runtime.entrypoint;
|
|
164
|
+
return metadata;
|
|
165
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { ApiClient } from "./api-client.ts";
|
|
2
|
+
import { type StoragePaths, type StoredSession } from "./storage.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Acting as an account: the stored credential, registering this session as an
|
|
5
|
+
* Instance, keeping its lease alive. Shared by `session start`, the Room
|
|
6
|
+
* commands, and `join` when a credential is present.
|
|
7
|
+
*/
|
|
8
|
+
type Environment = Record<string, string | undefined>;
|
|
9
|
+
export declare const CLI_VERSION = "0.1.0";
|
|
10
|
+
export interface AgentShape {
|
|
11
|
+
id: string;
|
|
12
|
+
principal_id?: string;
|
|
13
|
+
handle?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface InstanceShape {
|
|
16
|
+
id: string;
|
|
17
|
+
principal_id: string;
|
|
18
|
+
agent_id: string | null;
|
|
19
|
+
started_at: string;
|
|
20
|
+
lease_expires_at: string;
|
|
21
|
+
token_expires_at?: string;
|
|
22
|
+
expires_at?: string;
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
export interface InstanceStartPayload {
|
|
26
|
+
instance: InstanceShape;
|
|
27
|
+
token: string;
|
|
28
|
+
heartbeat_after_seconds: number;
|
|
29
|
+
}
|
|
30
|
+
export interface CurrentInstancePayload {
|
|
31
|
+
principal: unknown;
|
|
32
|
+
agent: AgentShape | null;
|
|
33
|
+
instance: InstanceShape;
|
|
34
|
+
}
|
|
35
|
+
export declare function resolveApiKey(env: Environment, paths: StoragePaths, baseUrl: string): Promise<string>;
|
|
36
|
+
/**
|
|
37
|
+
* Resolves `--agent` to a tag. An `a_` id is fetched; anything else is a
|
|
38
|
+
* handle and is created on first use, the way `git tag` behaves — the server's
|
|
39
|
+
* POST is idempotent by handle, so one call covers both "exists" and "new".
|
|
40
|
+
* `default` names the absence of a tag and resolves to nothing.
|
|
41
|
+
*/
|
|
42
|
+
export declare function resolveTag(client: ApiClient, apiKey: string, requested: string): Promise<AgentShape | null>;
|
|
43
|
+
/**
|
|
44
|
+
* Where this session runs, for humans telling untagged sessions apart. It is
|
|
45
|
+
* shown, never trusted: the server records it as diagnostics and nothing reads
|
|
46
|
+
* it for authorization or grouping.
|
|
47
|
+
*
|
|
48
|
+
* Only the workspace's last path segment is sent. The full path is a map of
|
|
49
|
+
* this machine — home directory, user name, client folders — and none of that
|
|
50
|
+
* is needed to tell "the one in the sharednet folder" from the others.
|
|
51
|
+
*/
|
|
52
|
+
export declare function runtimeMetadata(env: Environment): Record<string, string>;
|
|
53
|
+
export declare function validateRuntime(value: string): string;
|
|
54
|
+
export declare function storedSessionFromStart(baseUrl: string, localInstanceKey: string | null, payload: InstanceStartPayload): StoredSession;
|
|
55
|
+
export declare function selectSession(paths: StoragePaths, baseUrl: string, explicitId: string | undefined, env: Environment): Promise<StoredSession>;
|
|
56
|
+
export declare function refreshIfNeeded(client: ApiClient, paths: StoragePaths, session: StoredSession, now: Date): Promise<StoredSession>;
|
|
57
|
+
export interface RegisterInstanceOptions {
|
|
58
|
+
/** `--runtime`: overrides the detected driver's name, never its session. */
|
|
59
|
+
runtimeOverride?: string;
|
|
60
|
+
/** `--new`: a fresh Instance even though the session was detected. */
|
|
61
|
+
forceNew: boolean;
|
|
62
|
+
/** `--agent`: a tag id or handle to group the Instance under. */
|
|
63
|
+
agent?: string;
|
|
64
|
+
/** Without a detected session and without `forceNew`, refuse (session start) or register fresh (join). */
|
|
65
|
+
freshWhenUndetected: boolean;
|
|
66
|
+
/** Sent as given; omitted, the server applies the Principal's default (public). */
|
|
67
|
+
reach?: "public" | "private";
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Registers this session as an Instance of the account behind the stored
|
|
71
|
+
* credential. A same-session re-registration comes back with the existing
|
|
72
|
+
* Instance and a fresh token; a new session comes back created.
|
|
73
|
+
*/
|
|
74
|
+
export declare function registerInstance(env: Environment, fetchImplementation: typeof globalThis.fetch, paths: StoragePaths, baseUrl: string, options: RegisterInstanceOptions): Promise<{
|
|
75
|
+
session: StoredSession;
|
|
76
|
+
payload: InstanceStartPayload;
|
|
77
|
+
}>;
|
|
78
|
+
/** True when this machine can act as an account: a key in the environment or a stored credential for this origin. */
|
|
79
|
+
export declare function hasAccountCredential(env: Environment, paths: StoragePaths, baseUrl: string): Promise<boolean>;
|
|
80
|
+
export {};
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { hostname, platform } from "node:os";
|
|
2
|
+
import { ApiClient } from "./api-client.js";
|
|
3
|
+
import { CliError, localError } from "./errors.js";
|
|
4
|
+
import { computeLocalInstanceKey } from "./instance-computation.js";
|
|
5
|
+
import { detectRuntime, isRuntimeKind, runtimeMetadataOf } from "./runtime-detection.js";
|
|
6
|
+
import { deleteSession, getOrCreateInstallationSecret, listSessions, readSessionById, readStoredApiCredential, writeSession, } from "./storage.js";
|
|
7
|
+
export const CLI_VERSION = "0.1.0";
|
|
8
|
+
function apiKeyAuthenticationError() {
|
|
9
|
+
return new CliError("authentication_required", "Set SHAREDNET_API_KEY or run sharednet login first.", 3);
|
|
10
|
+
}
|
|
11
|
+
export async function resolveApiKey(env, paths, baseUrl) {
|
|
12
|
+
const environmentKey = env.SHAREDNET_API_KEY?.trim();
|
|
13
|
+
if (environmentKey)
|
|
14
|
+
return environmentKey;
|
|
15
|
+
const credential = await readStoredApiCredential(paths);
|
|
16
|
+
if (!credential)
|
|
17
|
+
throw apiKeyAuthenticationError();
|
|
18
|
+
if (credential.base_url !== baseUrl) {
|
|
19
|
+
throw localError("credential_origin_mismatch", "The stored credential belongs to a different SharedNet origin.");
|
|
20
|
+
}
|
|
21
|
+
if (credential.expires_at && Date.parse(credential.expires_at) <= Date.now()) {
|
|
22
|
+
throw new CliError("invalid_credentials", "The stored API key has expired.", 3);
|
|
23
|
+
}
|
|
24
|
+
return credential.api_key;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Resolves `--agent` to a tag. An `a_` id is fetched; anything else is a
|
|
28
|
+
* handle and is created on first use, the way `git tag` behaves — the server's
|
|
29
|
+
* POST is idempotent by handle, so one call covers both "exists" and "new".
|
|
30
|
+
* `default` names the absence of a tag and resolves to nothing.
|
|
31
|
+
*/
|
|
32
|
+
export async function resolveTag(client, apiKey, requested) {
|
|
33
|
+
const handle = requested.normalize("NFKC").trim().toLowerCase();
|
|
34
|
+
if (handle === "default")
|
|
35
|
+
return null;
|
|
36
|
+
let agent;
|
|
37
|
+
if (/^a_[A-Za-z0-9]+$/.test(requested)) {
|
|
38
|
+
const payload = await client.request("GET", `/agents/${encodeURIComponent(requested)}`, apiKey);
|
|
39
|
+
agent = payload.agent;
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
if (!/^[a-z][a-z0-9-]{0,31}$/.test(handle)) {
|
|
43
|
+
throw localError("invalid_agent", "An Agent is an a_ id or a handle matching ^[a-z][a-z0-9-]{0,31}$.");
|
|
44
|
+
}
|
|
45
|
+
const payload = await client.request("POST", "/agents", apiKey, {
|
|
46
|
+
handle,
|
|
47
|
+
});
|
|
48
|
+
agent = payload.agent;
|
|
49
|
+
}
|
|
50
|
+
if (!agent?.id) {
|
|
51
|
+
throw new CliError("invalid_server_response", "The SharedNet service returned an invalid Agent response.", 5);
|
|
52
|
+
}
|
|
53
|
+
return agent;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Where this session runs, for humans telling untagged sessions apart. It is
|
|
57
|
+
* shown, never trusted: the server records it as diagnostics and nothing reads
|
|
58
|
+
* it for authorization or grouping.
|
|
59
|
+
*
|
|
60
|
+
* Only the workspace's last path segment is sent. The full path is a map of
|
|
61
|
+
* this machine — home directory, user name, client folders — and none of that
|
|
62
|
+
* is needed to tell "the one in the sharednet folder" from the others.
|
|
63
|
+
*/
|
|
64
|
+
export function runtimeMetadata(env) {
|
|
65
|
+
const clean = (value) => (value ?? "").replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, 256);
|
|
66
|
+
const metadata = {};
|
|
67
|
+
const host = clean(hostname());
|
|
68
|
+
const workspacePath = clean(env.PWD ?? process.cwd());
|
|
69
|
+
const workspace = workspacePath.split(/[\\/]+/).filter(Boolean).at(-1) ?? "";
|
|
70
|
+
if (host)
|
|
71
|
+
metadata.hostname = host;
|
|
72
|
+
if (workspace)
|
|
73
|
+
metadata.workspace = workspace;
|
|
74
|
+
metadata.os = platform();
|
|
75
|
+
return metadata;
|
|
76
|
+
}
|
|
77
|
+
export function validateRuntime(value) {
|
|
78
|
+
const kind = value.normalize("NFKC").trim().toLowerCase();
|
|
79
|
+
if (isRuntimeKind(kind))
|
|
80
|
+
return kind;
|
|
81
|
+
throw localError("invalid_runtime", "Runtime must be a handle such as claude-code, codex, or opencode.");
|
|
82
|
+
}
|
|
83
|
+
export function storedSessionFromStart(baseUrl, localInstanceKey, payload) {
|
|
84
|
+
const { instance, token } = payload;
|
|
85
|
+
if (!instance?.id || !instance.principal_id || !token) {
|
|
86
|
+
throw new CliError("invalid_server_response", "The SharedNet service returned an invalid Instance response.", 5);
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
schema_version: 1,
|
|
90
|
+
base_url: baseUrl,
|
|
91
|
+
principal_id: instance.principal_id,
|
|
92
|
+
agent_id: instance.agent_id ?? null,
|
|
93
|
+
instance_id: instance.id,
|
|
94
|
+
local_instance_key: localInstanceKey,
|
|
95
|
+
instance_token: token,
|
|
96
|
+
created_at: instance.started_at,
|
|
97
|
+
lease_expires_at: instance.lease_expires_at,
|
|
98
|
+
expires_at: null,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export async function selectSession(paths, baseUrl, explicitId, env) {
|
|
102
|
+
const selectedId = explicitId || env.SHAREDNET_SESSION?.trim();
|
|
103
|
+
if (selectedId) {
|
|
104
|
+
if (!/^i_[A-Za-z0-9_-]+$/.test(selectedId)) {
|
|
105
|
+
throw localError("invalid_session_id", "SHAREDNET_SESSION must be an Instance ID.");
|
|
106
|
+
}
|
|
107
|
+
const selected = await readSessionById(paths, selectedId);
|
|
108
|
+
if (!selected || selected.base_url !== baseUrl) {
|
|
109
|
+
throw localError("session_not_found", "The selected local SharedNet session was not found.");
|
|
110
|
+
}
|
|
111
|
+
return selected;
|
|
112
|
+
}
|
|
113
|
+
const usable = (await listSessions(paths)).filter((session) => session.base_url === baseUrl);
|
|
114
|
+
if (usable.length !== 1) {
|
|
115
|
+
throw localError("session_selection_required", "Select a local Instance with --session or SHAREDNET_SESSION.");
|
|
116
|
+
}
|
|
117
|
+
return usable[0];
|
|
118
|
+
}
|
|
119
|
+
export async function refreshIfNeeded(client, paths, session, now) {
|
|
120
|
+
if (Date.parse(session.lease_expires_at) - now.getTime() > 30_000)
|
|
121
|
+
return session;
|
|
122
|
+
try {
|
|
123
|
+
const payload = await client.request("POST", "/instances/current/heartbeat", session.instance_token, {});
|
|
124
|
+
const refreshed = {
|
|
125
|
+
...session,
|
|
126
|
+
lease_expires_at: payload.instance.lease_expires_at,
|
|
127
|
+
expires_at: null,
|
|
128
|
+
};
|
|
129
|
+
await writeSession(paths, refreshed);
|
|
130
|
+
return refreshed;
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
if (error instanceof CliError && error.exitCode === 3) {
|
|
134
|
+
await deleteSession(paths, session.instance_id);
|
|
135
|
+
}
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Registers this session as an Instance of the account behind the stored
|
|
141
|
+
* credential. A same-session re-registration comes back with the existing
|
|
142
|
+
* Instance and a fresh token; a new session comes back created.
|
|
143
|
+
*/
|
|
144
|
+
export async function registerInstance(env, fetchImplementation, paths, baseUrl, options) {
|
|
145
|
+
const installationSecret = await getOrCreateInstallationSecret(paths);
|
|
146
|
+
// The driver is read off its own environment; --runtime only overrides the name.
|
|
147
|
+
const detected = detectRuntime(env);
|
|
148
|
+
const runtimeKind = options.runtimeOverride ? validateRuntime(options.runtimeOverride) : detected.kind;
|
|
149
|
+
let localInstanceKey = null;
|
|
150
|
+
if (!options.forceNew) {
|
|
151
|
+
const undetected = detected.anchor === null || (options.runtimeOverride !== undefined && runtimeKind !== detected.kind);
|
|
152
|
+
if (undetected && !options.freshWhenUndetected) {
|
|
153
|
+
throw localError("runtime_session_not_detected", "The current runtime session could not be detected; use --new deliberately.");
|
|
154
|
+
}
|
|
155
|
+
if (!undetected) {
|
|
156
|
+
// The key goes to the server, which is the one place that can guarantee
|
|
157
|
+
// one live Instance per runtime session. The raw session id stays here.
|
|
158
|
+
localInstanceKey = computeLocalInstanceKey(installationSecret, runtimeKind, detected.anchor);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const apiKey = await resolveApiKey(env, paths, baseUrl);
|
|
162
|
+
const client = new ApiClient(baseUrl, fetchImplementation);
|
|
163
|
+
const tag = options.agent ? await resolveTag(client, apiKey, options.agent) : undefined;
|
|
164
|
+
const payload = await client.request("POST", "/instances", apiKey, {
|
|
165
|
+
runtime_kind: runtimeKind,
|
|
166
|
+
cli_version: CLI_VERSION,
|
|
167
|
+
...(localInstanceKey ? { local_instance_key: localInstanceKey } : {}),
|
|
168
|
+
...(tag === undefined ? {} : { agent_id: tag?.id ?? null }),
|
|
169
|
+
...(options.reach === undefined ? {} : { reach: options.reach }),
|
|
170
|
+
runtime_metadata: { ...runtimeMetadata(env), ...runtimeMetadataOf(detected) },
|
|
171
|
+
});
|
|
172
|
+
const session = storedSessionFromStart(baseUrl, localInstanceKey, payload);
|
|
173
|
+
await writeSession(paths, session);
|
|
174
|
+
return { session, payload };
|
|
175
|
+
}
|
|
176
|
+
/** True when this machine can act as an account: a key in the environment or a stored credential for this origin. */
|
|
177
|
+
export async function hasAccountCredential(env, paths, baseUrl) {
|
|
178
|
+
if (env.SHAREDNET_API_KEY?.trim())
|
|
179
|
+
return true;
|
|
180
|
+
const credential = await readStoredApiCredential(paths).catch(() => null);
|
|
181
|
+
return credential !== null && credential.base_url === baseUrl;
|
|
182
|
+
}
|