@join-pantheon/cli 0.27.0 → 0.29.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/client.js CHANGED
@@ -2,6 +2,7 @@ import WebSocket from "ws";
2
2
  import { signChallenge } from "./identity.js";
3
3
  import { newId } from "./protocol.js";
4
4
  import { VERSION } from "./version.js";
5
+ import { describeNetError, explainNetError, noteRelayAnswered, settleSystemFallback, systemFallbackPending, trySystemFallback, wsOptions } from "./network.js";
5
6
  export class PantheonClient {
6
7
  cfg;
7
8
  onEvent;
@@ -33,6 +34,9 @@ export class PantheonClient {
33
34
  connected = false;
34
35
  pending = null;
35
36
  lastError = null;
37
+ everReadMarked = false;
38
+ lastInbound = 0;
39
+ watchdog = null;
36
40
  session;
37
41
  role;
38
42
  enrolledToken = null;
@@ -74,7 +78,46 @@ export class PantheonClient {
74
78
  connect() {
75
79
  if (!this.everConnected)
76
80
  this.closed = false;
77
- return this.attempt(this.localOverride ?? this.cfg.relay).catch(async (e) => {
81
+ const first = this.localOverride ?? this.cfg.relay;
82
+ const trial = !this.localOverride && systemFallbackPending(first);
83
+ const answered = (x) => x?.relayAnswered === true;
84
+ return this.attempt(first).then(() => { noteRelayAnswered(first); }, async (e) => {
85
+ if (trial) {
86
+ settleSystemFallback(first, answered(e));
87
+ }
88
+ else if (answered(e)) {
89
+ noteRelayAnswered(first);
90
+ }
91
+ else if (!this.localOverride && trySystemFallback(first, e)) {
92
+ if (this.everConnected)
93
+ throw e;
94
+ this.closed = false;
95
+ const kept = this.lastError;
96
+ try {
97
+ await this.attempt(first);
98
+ settleSystemFallback(first, true);
99
+ return;
100
+ }
101
+ catch (e2) {
102
+ const reached = answered(e2);
103
+ settleSystemFallback(first, reached);
104
+ if (reached)
105
+ e = e2;
106
+ else
107
+ this.lastError = kept;
108
+ }
109
+ }
110
+ return this.afterFailure(e);
111
+ }).catch((e) => {
112
+ if (!explainNetError(e, first))
113
+ throw e;
114
+ const out = new Error(describeNetError(e, first));
115
+ out.code = e.code;
116
+ throw out;
117
+ });
118
+ }
119
+ async afterFailure(e) {
120
+ {
78
121
  if (this.localOverride)
79
122
  throw e;
80
123
  const transport = /ETIMEDOUT|ECONNREFUSED|ENOTFOUND|EHOSTUNREACH|ENETUNREACH|handshake has timed out|socket hang up/i
@@ -87,17 +130,39 @@ export class PantheonClient {
87
130
  this.closed = false;
88
131
  this.localOverride = local;
89
132
  this.usingLoopback = true;
133
+ if (this.everConnected)
134
+ throw e;
90
135
  return this.attempt(local);
91
- });
136
+ }
92
137
  }
93
138
  attempt(address) {
94
139
  return new Promise((resolve, reject) => {
95
140
  let settled = false;
141
+ let heard = false;
142
+ const fail = reject;
143
+ reject = (err) => fail(heard ? Object.assign(err, { relayAnswered: true }) : err);
96
144
  this.ws?.terminate();
97
- const ws = new WebSocket(address, { handshakeTimeout: 6000 });
145
+ if (this.watchdog) {
146
+ clearInterval(this.watchdog);
147
+ this.watchdog = null;
148
+ }
149
+ const ws = new WebSocket(address, { handshakeTimeout: 6000, ...wsOptions(address) });
98
150
  this.ws = ws;
151
+ this.lastInbound = Date.now();
152
+ ws.on("ping", () => { this.lastInbound = Date.now(); });
153
+ ws.on("pong", () => { this.lastInbound = Date.now(); });
154
+ this.watchdog = setInterval(() => {
155
+ if (this.ws === ws && this.connected && Date.now() - this.lastInbound > 45_000) {
156
+ try {
157
+ ws.terminate();
158
+ }
159
+ catch { }
160
+ }
161
+ }, 5_000);
162
+ this.watchdog.unref?.();
99
163
  ws.on("open", () => { });
100
164
  ws.on("message", (raw) => {
165
+ this.lastInbound = Date.now();
101
166
  let msg;
102
167
  try {
103
168
  msg = JSON.parse(String(raw));
@@ -105,6 +170,8 @@ export class PantheonClient {
105
170
  catch {
106
171
  return;
107
172
  }
173
+ if (typeof msg?.t === "string")
174
+ heard = true;
108
175
  this.onEvent?.(msg);
109
176
  switch (msg.t) {
110
177
  case "challenge":
@@ -120,7 +187,7 @@ export class PantheonClient {
120
187
  display: this.cfg.display ?? this.cfg.member,
121
188
  agent: this.cfg.agent ?? "unknown",
122
189
  pkg: VERSION,
123
- from_seq: this.seq,
190
+ ...(this.seq > 0 ? { from_seq: this.seq } : {}),
124
191
  ...(this.cfg.repo ? { repo: this.cfg.repo, ...(this.cfg.repoOrigin ? { repo_origin: this.cfg.repoOrigin } : {}) } : {}),
125
192
  }));
126
193
  break;
@@ -153,8 +220,13 @@ export class PantheonClient {
153
220
  this.session = msg.session;
154
221
  this.everConnected = true;
155
222
  this.head = msg.seq;
156
- if (this.readSeq < msg.seq)
223
+ if (msg.seq < this.seq) {
224
+ this.seq = msg.seq;
225
+ this.readSeq = Math.min(this.readSeq, msg.seq);
226
+ }
227
+ if (!this.everReadMarked && this.readSeq < msg.seq)
157
228
  this.readSeq = msg.seq;
229
+ this.everReadMarked = true;
158
230
  if (msg.token) {
159
231
  this.enrolledToken = msg.token;
160
232
  this.cfg.token = msg.token;
@@ -213,6 +285,8 @@ export class PantheonClient {
213
285
  }
214
286
  case "error": {
215
287
  this.lastError = `${msg.code}: ${msg.message}`;
288
+ if (msg.code === "SPACE_DELETED")
289
+ this.closed = true;
216
290
  const fn = msg.cid ? this.acks.get(msg.cid) : undefined;
217
291
  if (fn && msg.cid) {
218
292
  this.acks.delete(msg.cid);
@@ -252,13 +326,22 @@ export class PantheonClient {
252
326
  reject(new Error(this.lastError ?? "relay connection failed"));
253
327
  }
254
328
  setTimeout(() => { if (!this.closed)
255
- this.connect().catch(() => { }); }, this.backoff);
329
+ this.connect().catch(() => { }); }, Math.round(this.backoff * (0.5 + Math.random())));
256
330
  this.backoff = Math.min(this.backoff * 2, 15_000);
257
331
  };
258
332
  ws.on("close", down);
259
333
  ws.on("error", (e) => { this.lastError = String(e.message ?? e); down(); });
260
334
  });
261
335
  }
336
+ handOff() {
337
+ this.closed = true;
338
+ for (const w of this.waiters) {
339
+ clearTimeout(w.timer);
340
+ w.resolve([]);
341
+ }
342
+ this.waiters = [];
343
+ this.ws?.close(4000, "handover");
344
+ }
262
345
  close() {
263
346
  this.closed = true;
264
347
  for (const w of this.waiters) {
@@ -266,7 +349,7 @@ export class PantheonClient {
266
349
  w.resolve([]);
267
350
  }
268
351
  this.waiters = [];
269
- this.ws?.close();
352
+ this.ws?.close(1000);
270
353
  }
271
354
  push(rec) {
272
355
  for (let i = 0; i < this.waiters.length; i++) {
@@ -312,7 +395,7 @@ export class PantheonClient {
312
395
  });
313
396
  }
314
397
  rpc(payload) {
315
- if (!this.ws || this.ws.readyState !== 1) {
398
+ if (!this.ws || this.ws.readyState !== 1 || !this.connected) {
316
399
  return Promise.reject(new Error(`not connected to relay (${this.lastError ?? "offline"})`));
317
400
  }
318
401
  const cid = `c${++this.cid}`;
package/dist/config.js CHANGED
@@ -192,6 +192,21 @@ export function resolveForHook(cwd = process.cwd()) {
192
192
  const here = r.repo;
193
193
  return bound.some((b) => b.path === here.root || (!!b.origin && b.origin === here.origin)) ? r.cfg : null;
194
194
  }
195
+ export function identity(file = loadAll()) {
196
+ const env = envView(file);
197
+ if (env)
198
+ return applyAgentEnv({
199
+ ...env,
200
+ ...(file?.account ? { account: file.account } : {}), ...(file?.pendingAuth ? { pendingAuth: file.pendingAuth } : {}),
201
+ });
202
+ if (!file)
203
+ return null;
204
+ return applyAgentEnv({
205
+ relay: file.relay, space: "", token: "", member: file.member, publicKey: file.publicKey, privateKey: file.privateKey,
206
+ ...(file.display ? { display: file.display } : {}), ...(file.agent ? { agent: file.agent } : {}),
207
+ ...(file.account ? { account: file.account } : {}), ...(file.pendingAuth ? { pendingAuth: file.pendingAuth } : {}),
208
+ });
209
+ }
195
210
  export function load() {
196
211
  return resolveFor(process.cwd()).cfg;
197
212
  }
package/dist/doctor.js CHANGED
@@ -15,11 +15,13 @@ import { approvalSummary, chosenApproval, onPath } from "./install.js";
15
15
  import { hookState, readIntent, agentHookStates } from "./notify.js";
16
16
  import { loadPersonas } from "./persona.js";
17
17
  import { workerProc, menubarProc, notifierProc } from "./machine.js";
18
- import { chooseAgent, describeChoice, unattendedRefusal } from "./agents.js";
18
+ import { chooseAgent, describeChoice, unattendedRefusal, skippedSummary } from "./agents.js";
19
19
  import { onPathNamed } from "./which.js";
20
20
  import { VERSION } from "./version.js";
21
21
  import { serviceInstalled, servicesUnavailable } from "./service.js";
22
22
  import { hostEnv, platformName } from "./host-env.js";
23
+ import { currentPlan, explainNetError, routeFor, redactProxy } from "./network.js";
24
+ import { refusalOf } from "./refusal.js";
23
25
  const HOSTED_RELAY = "relay.joinpantheon.network";
24
26
  const git = (args, cwd) => {
25
27
  try {
@@ -167,14 +169,16 @@ export async function doctor(projectDir) {
167
169
  const line = describeChoice(choice);
168
170
  const bad = choice.signIn && !choice.signIn.ok && !choice.signIn.unknown;
169
171
  const fellBack = !!prof.agent.primary_surface && choice.backend.id !== prof.agent.primary_surface && agents.length > 0;
170
- const shareLine = `${choice.backend.label} — ${choice.signIn?.detail ?? "not checked"}${choice.skipped.length ? `; ${choice.skipped.length} skipped (not signed in)` : ""}`;
172
+ const shareLine = `${choice.backend.label} — ${choice.signIn?.detail ?? "not checked"}${choice.skipped.length ? `; ${skippedSummary(choice)}` : ""}`;
171
173
  const refused = unattendedRefusal(choice);
172
174
  if (refused)
173
175
  add("warn", "worker agent", line, `woken runs are refused here, not tried: ${refused}`, shareLine);
174
176
  else if (bad)
175
177
  add("warn", "worker agent", line, `nothing is signed in, so every woken run here fails until you sign in: ${choice.skipped.map((x) => x.why).join("; ") || "sign in to the agent CLI"}`, shareLine);
176
178
  else if (fellBack)
177
- add("warn", "worker agent", line, `you chose ${prof.agent.primary_surface} at setup; runs use ${choice.backend.label} until it is signed in again`, shareLine);
179
+ add("warn", "worker agent", line, prof.agent.primary_surface === "kiro"
180
+ ? `you chose Kiro at setup; Kiro cannot yet be fenced to its worktree for unattended runs, so runs use ${choice.backend.label}`
181
+ : `you chose ${prof.agent.primary_surface} at setup; runs use ${choice.backend.label} until it is signed in again`, shareLine);
178
182
  else
179
183
  add("pass", "worker agent", line, undefined, shareLine);
180
184
  }
@@ -222,6 +226,20 @@ export async function doctor(projectDir) {
222
226
  else {
223
227
  add("pass", "transport", url.protocol === "wss:" ? "wss:// (TLS)" : `ws:// on a private address (${url.host})`, undefined, url.protocol === "wss:" ? "wss:// (TLS)" : "ws:// on a private address");
224
228
  }
229
+ const plan = currentPlan();
230
+ const via = routeFor(cfg.relay, plan);
231
+ if (via) {
232
+ add("pass", "network", `through the proxy ${redactProxy(via)} (from ${plan.source})`, undefined, `through a proxy (${plan.source})`);
233
+ }
234
+ else if (plan.fallbackOnly && (plan.https || plan.http)) {
235
+ add("pass", "network", `direct; the ${plan.source === "macos" ? "macOS" : "Windows"} proxy ${redactProxy(plan.https ?? plan.http)} is used only if a direct connection fails`, undefined, `direct, system proxy as fallback`);
236
+ }
237
+ else if (plan.pac) {
238
+ add("warn", "network", `direct, but this machine uses a proxy auto-config script, which Pantheon cannot read`, "if the relay turns out unreachable below, save the proxy that script points to: pantheon network --proxy http://host:port", "direct; PAC script present");
239
+ }
240
+ else {
241
+ add("pass", "network", "direct — no proxy set", undefined, "direct");
242
+ }
225
243
  const health = `${cfg.relay.replace(/^ws/, "http")}/health`;
226
244
  const where = hosted ? "hosted" : "self-hosted";
227
245
  try {
@@ -245,6 +263,11 @@ export async function doctor(projectDir) {
245
263
  }
246
264
  catch (e) {
247
265
  const msg = e.message;
266
+ const nx = explainNetError(e, health);
267
+ if (nx) {
268
+ add("fail", "relay", `cannot reach ${health} — ${nx.why}`, nx.fix, `unreachable (${where}): ${nx.share}`);
269
+ return out;
270
+ }
248
271
  add("fail", "relay", `cannot reach ${health} — ${msg}`, "start it with `pantheon relay`, or check the tunnel/tailnet. Your teammate must be able to reach the SAME address.", `unreachable (${where}): ${/abort|timeout/i.test(msg) ? "timed out" : /ECONNREFUSED/.test(msg) ? "connection refused" : /ENOTFOUND|EAI_AGAIN/.test(msg) ? "name did not resolve" : "fetch failed"}`);
249
272
  return out;
250
273
  }
@@ -252,9 +275,15 @@ export async function doctor(projectDir) {
252
275
  let joined = false;
253
276
  await client.connect().then(() => { joined = true; }).catch(() => { });
254
277
  if (!joined) {
278
+ const nx = client.pending ? null : explainNetError(new Error(client.lastError ?? ""), cfg.relay);
279
+ if (nx) {
280
+ add("fail", "live link", `the health check got through but the live connection did not — ${nx.why}`, nx.fix, `live connection failed: ${nx.share}`);
281
+ client.close();
282
+ return out;
283
+ }
255
284
  client.pending
256
- ? add("fail", "membership", "registered but not admitted", `ask an admitted member to run: pantheon admit ${cfg.member} --may-wake (confirm fingerprint ${fingerprint(cfg.publicKey)} with them by voice first)`, "registered, not admitted")
257
- : add("fail", "membership", `refused: ${client.lastError ?? "unknown"}`, "if this is KEY_MISMATCH, someone else already registered your member name — pick another with --me, or have them revoke it", `refused: ${String(client.lastError ?? "unknown").split(/[\s:]/)[0]}`);
285
+ ? add("fail", "membership", "registered but not admitted", `ask an admitted member to run: pantheon admit ${cfg.member} (confirm fingerprint ${fingerprint(cfg.publicKey)} with them by voice first)`, "registered, not admitted")
286
+ : ((r) => add("fail", "membership", `refused: ${r?.message ?? client.lastError ?? "unknown"}`, (r?.fix ?? "run pantheon status for the relay's reason").replace("<you>", cfg.member), `refused: ${r?.code ?? String(client.lastError ?? "unknown").split(/[\s:]/)[0]}`))(refusalOf(client.lastError ?? ""));
258
287
  client.close();
259
288
  return out;
260
289
  }
@@ -269,7 +298,7 @@ export async function doctor(projectDir) {
269
298
  .map((p) => `${p.member}[${online.has(p.member) ? "online" : "no session open"}]`).join(" "), undefined, `${admittedPeers.length} admitted, ${peersOnline} with a session open`)
270
299
  : add("warn", "peers", "nobody else is admitted to this space yet", "send your teammate an invite from `pantheon invite` (or the one `pantheon start` printed)", "nobody else admitted yet");
271
300
  if (pending.length) {
272
- add("warn", "pending", pending.map((p) => `${p.member} (${p.fingerprint})`).join(" "), `confirm the fingerprint by voice, then: pantheon admit ${pending[0].member} --may-wake`, `${pending.length} waiting to be admitted`);
301
+ add("warn", "pending", pending.map((p) => `${p.member} (${p.fingerprint})`).join(" "), `confirm the fingerprint by voice, then: pantheon admit ${pending[0].member} (add --may-wake only if they should be able to wake this machine)`, `${pending.length} waiting to be admitted`);
273
302
  }
274
303
  const wakers = client.members.filter((m) => m.admitted && m.may_wake && m.member !== cfg.member);
275
304
  const hookStates = agentHookStates(projectDir);
package/dist/grants.js CHANGED
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import { PANTHEON_TOOLS, PANTHEON_TOOL_NAMES, toolsAllowed } from "./tools.js";
5
+ import { kiroDir } from "./kiro-files.js";
5
6
  export const CLAUDE_ALLOW = PANTHEON_TOOLS;
6
7
  export const CURSOR_ALLOW = PANTHEON_TOOL_NAMES.map((t) => `Mcp(pantheon:${t})`);
7
8
  export const claudeAllow = (approval) => toolsAllowed(approval).map((t) => `mcp__pantheon__${t}`);
@@ -12,6 +13,7 @@ const isOursClaude = (t) => t === CLAUDE_SERVER_RULE || t.startsWith("mcp__panth
12
13
  const isOursCursor = (t) => /^Mcp\(pantheon(?::[^)]*)?\)$/.test(t);
13
14
  export const claudeSettingsFile = () => join(homedir(), ".claude", "settings.json");
14
15
  export const cursorCliConfigFile = () => join(homedir(), ".cursor", "cli-config.json");
16
+ export const kiroMcpFile = () => join(kiroDir(), "settings", "mcp.json");
15
17
  function readJson(file) {
16
18
  if (!existsSync(file))
17
19
  return null;
@@ -125,8 +127,21 @@ export function grantToolPermissions(approval = "approve") {
125
127
  { tool: "cursor perms", status: grantCursorToolPermissions(undefined, approval) },
126
128
  ];
127
129
  }
128
- export function revokeToolPermissions(claudeFile = claudeSettingsFile(), cursorFile = cursorCliConfigFile()) {
130
+ export function revokeToolPermissions(claudeFile = claudeSettingsFile(), cursorFile = cursorCliConfigFile(), kiroFile = kiroMcpFile()) {
129
131
  const out = [];
132
+ {
133
+ const doc = readJson(kiroFile);
134
+ const servers = doc && doc !== "invalid" ? doc.mcpServers : undefined;
135
+ const entry = servers && typeof servers === "object" ? servers.pantheon : undefined;
136
+ if (entry && typeof entry === "object" && "autoApprove" in entry) {
137
+ delete entry.autoApprove;
138
+ try {
139
+ writeJson(kiroFile, doc);
140
+ out.push(` removed pantheon tool approvals (autoApprove) from ${kiroFile}`);
141
+ }
142
+ catch { }
143
+ }
144
+ }
130
145
  for (const [file, isOurs] of [
131
146
  [claudeFile, isOursClaude],
132
147
  [cursorFile, isOursCursor],
@@ -3,7 +3,7 @@ import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import { MAX_CONSECUTIVE_BLOCKS, isOurHookCommand } from "./notify.js";
5
5
  import { copilotHome, kiroHome } from "./agents.js";
6
- import { KIRO_AGENT_NAME, kiroAgentFile, kiroAgentDoc, readKiroAgent, writeKiroAgent, kiroDefaultIsPantheon } from "./kiro-files.js";
6
+ import { KIRO_AGENT_NAME, kiroAgentFile, kiroAgentDoc, readKiroAgent, writeKiroAgent, kiroDefaultIsPantheon, gitTracks } from "./kiro-files.js";
7
7
  function ours(cmd) {
8
8
  return isOurHookCommand(cmd);
9
9
  }
@@ -262,6 +262,8 @@ export function kiroHookState(scope, projectDir = process.cwd()) {
262
262
  export function installKiroHooks(bin, scope, projectDir = process.cwd(), allowed = []) {
263
263
  const file = kiroAgentPath(scope, projectDir);
264
264
  const a = readKiroAgent(file);
265
+ if (a?.tracked)
266
+ return [` skipped ${file} is tracked by git — it belongs to the repo, so pantheon neither writes nor removes it`];
265
267
  if (a && !a.ours)
266
268
  return [` skipped ${file} is an agent called "${KIRO_AGENT_NAME}" that pantheon did not write — left as it is`];
267
269
  writeKiroAgent(file, kiroAgentDoc(bin, allowed));
@@ -279,7 +281,7 @@ export function removeKiroHooks(projectDir = process.cwd()) {
279
281
  const out = [];
280
282
  for (const scope of ["project", "user"]) {
281
283
  const file = kiroAgentPath(scope, projectDir);
282
- if (!kiroHookState(scope, projectDir).installed)
284
+ if (!kiroHookState(scope, projectDir).installed || gitTracks(file))
283
285
  continue;
284
286
  try {
285
287
  rmSync(file, { force: true });
package/dist/host-env.js CHANGED
@@ -1,12 +1,35 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  const CLOUD_VARS = [
3
3
  ["CODESPACES", "GitHub Codespaces ($CODESPACES)"],
4
+ ["CLOUDENV_ENVIRONMENT_ID", "GitHub Codespaces ($CLOUDENV_ENVIRONMENT_ID)"],
4
5
  ["REMOTE_CONTAINERS", "a VS Code dev container ($REMOTE_CONTAINERS)"],
5
6
  ["REMOTE_CONTAINERS_IPC", "a VS Code dev container ($REMOTE_CONTAINERS_IPC)"],
6
7
  ["DEVCONTAINER", "a dev container ($DEVCONTAINER)"],
7
8
  ["GITPOD_WORKSPACE_ID", "a Gitpod workspace ($GITPOD_WORKSPACE_ID)"],
8
9
  ["CLOUD_SHELL", "a cloud shell ($CLOUD_SHELL)"],
10
+ ["REPL_ID", "a Replit workspace ($REPL_ID)"],
9
11
  ];
12
+ const CONTAINER_VARS = [
13
+ ["KUBERNETES_SERVICE_HOST", "a Kubernetes pod ($KUBERNETES_SERVICE_HOST)"],
14
+ ];
15
+ const HOST_INITS = new Set(["systemd", "init", "openrc-init", "runit", "runit-init", "launchd", "dinit", "s6-svscan", "s6-linux-init", "shepherd"]);
16
+ const SANDBOX_ROOT_FS = new Set(["overlay", "overlayfs", "9p", "virtiofs", "aufs"]);
17
+ export function rootFsType(mountinfo) {
18
+ return rootMount(mountinfo)?.type ?? null;
19
+ }
20
+ export function rootMount(mountinfo) {
21
+ for (const line of mountinfo.split("\n")) {
22
+ const [pre, post] = line.split(" - ");
23
+ if (!pre || !post)
24
+ continue;
25
+ const f = pre.trim().split(/\s+/);
26
+ if (f[4] === "/") {
27
+ const [type = "", source = ""] = post.trim().split(/\s+/);
28
+ return { type, source };
29
+ }
30
+ }
31
+ return null;
32
+ }
10
33
  export function hostEnv(env = process.env, plat = process.platform, exists = existsSync, read = (p) => readFileSync(p, "utf8")) {
11
34
  if (plat === "darwin") {
12
35
  return { platform: plat, kind: "local", evidence: [], ephemeral: false, desktop: true };
@@ -19,17 +42,39 @@ export function hostEnv(env = process.env, plat = process.platform, exists = exi
19
42
  kind = "cloud-workspace";
20
43
  }
21
44
  }
45
+ for (const [name, label] of CONTAINER_VARS)
46
+ if (env[name])
47
+ evidence.push(label);
48
+ if (env.container)
49
+ evidence.push(`a container runtime set $container=${env.container}`);
50
+ const readIf = (p) => { try {
51
+ return exists(p) ? read(p) : "";
52
+ }
53
+ catch {
54
+ return "";
55
+ } };
22
56
  if (exists("/.dockerenv"))
23
57
  evidence.push("/.dockerenv exists");
24
58
  if (exists("/run/.containerenv"))
25
59
  evidence.push("/run/.containerenv exists (podman)");
26
- let cgroup = "";
27
- try {
28
- cgroup = exists("/proc/1/cgroup") ? read("/proc/1/cgroup") : "";
29
- }
30
- catch { }
31
- if (/docker|containerd|kubepods|lxc/.test(cgroup))
60
+ if (exists("/var/run/secrets/kubernetes.io"))
61
+ evidence.push("/var/run/secrets/kubernetes.io exists (a Kubernetes pod)");
62
+ const version = readIf("/proc/version");
63
+ const wsl = !!env.WSL_DISTRO_NAME || /microsoft/i.test(version);
64
+ const cgroup = readIf("/proc/1/cgroup");
65
+ if (/docker|containerd|kubepods|lxc|libpod|crio/.test(cgroup))
32
66
  evidence.push("pid 1 is in a container cgroup (/proc/1/cgroup)");
67
+ else if (cgroup.trim() === "0::/" && !wsl)
68
+ evidence.push("pid 1 is at the root of its own cgroup namespace (/proc/1/cgroup: 0::/)");
69
+ const comm = readIf("/proc/1/comm").trim();
70
+ if (comm && !HOST_INITS.has(comm))
71
+ evidence.push(`pid 1 is "${comm}", not an init system (/proc/1/comm)`);
72
+ if (/gvisor/i.test(version) || /#1 SMP Sun Jan 10 15:06:54 PST 2016/.test(version))
73
+ evidence.push("the kernel is gVisor's (/proc/version)");
74
+ const root = rootMount(readIf("/proc/1/mountinfo"));
75
+ const ostree = exists("/run/ostree-booted") || root?.source === "composefs" || root?.type === "composefs";
76
+ if (root && SANDBOX_ROOT_FS.has(root.type) && !ostree)
77
+ evidence.push(`the root filesystem is ${root.type} (/proc/1/mountinfo)`);
33
78
  if (evidence.length && kind === "local")
34
79
  kind = "container";
35
80
  return {