@kendoo.agentdesk/agentdesk 0.19.1 → 0.19.3

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/CHANGELOG.md CHANGED
@@ -8,6 +8,27 @@ All user-facing changes to AgentDesk. Each entry is tagged:
8
8
 
9
9
  Internal refactors, infrastructure changes, and architectural notes are not listed here.
10
10
 
11
+ ## [Unreleased]
12
+
13
+ ### Added
14
+ - `[UI]` Private-session sharing flow. When someone visits a session URL they don't own, they now see a friendly "Shh… this one's private" page with a one-click "Request access" button instead of a blank error. The session owner sees pending requests in a new header inbox and can grant viewer access for just that session or the whole project. Viewer-granted sessions show up in the teammate's sidebar tagged as a viewer.
15
+
16
+ ## [0.19.3] — 2026-04-19
17
+
18
+ ### Fixed
19
+ - `[CLI]` `agentdesk init` no longer silently saves tracker credentials that fail verification. Previously a typo in the email, a revoked API token, or a wrong tenant URL would print a one-line warning and proceed — leaving the user with a "valid-looking" `.env` that then failed at `agentdesk team` time with a generic "authentication failed" message. Init now prompts to re-enter credentials, save anyway, or cancel, so broken config is caught at the moment it's typed instead of the next time a session is started.
20
+
21
+ ## [0.19.2] — 2026-04-18
22
+
23
+ ### Fixed
24
+ - `[CLI]` Scoped sessions can now `git push` and `gh pr create` end-to-end. Previously the sandbox initialized its scoped GitHub credential helper from server-fetched creds only, ignoring tokens stored in the project's `.env` — so any project that ran `agentdesk init` with the GitHub token saved locally (the documented path) silently spawned a session whose credential helper returned an empty password, and the agent had to ask the operator to push manually.
25
+
26
+ ### Added
27
+ - `[CLI]` Session preflight: a missing GitHub token or an SSH-only `origin` remote now fail at session start with a clean, actionable message instead of degrading silently mid-run.
28
+
29
+ ### Changed
30
+ - `[CLI]` The scoped credential helper reads the GitHub token from a 0600 file instead of interpolating it into a shell script — defends against tokens containing shell metacharacters.
31
+
11
32
  ## [0.19.1] — 2026-04-18
12
33
 
13
34
  ### Fixed
package/README.md CHANGED
@@ -64,7 +64,7 @@ agentdesk team -d "Fix the checkout total calculation"
64
64
  agentdesk team -d "Add Google OAuth to the login page"
65
65
  ```
66
66
 
67
- Watch the session live at [agentdesk.live](https://agentdesk.live). Each session gets a deep link you can share.
67
+ Watch the session live at [agentdesk.live](https://agentdesk.live). Each session gets a deep link you can share — the session ID is visible in the header and sidebar with a one-click copy, alongside the tracker task ID. Teammates who don't have access yet see a friendly "private session" page with a one-click request-access button, and you can grant them viewer access for that session or the whole project from the dashboard.
68
68
 
69
69
  ## Commands
70
70
 
@@ -226,6 +226,7 @@ Once running, a "Run Team" button appears on [agentdesk.live](https://agentdesk.
226
226
 
227
227
  - **Per-session identity isolation** — every session runs with `HOME` / `GH_CONFIG_DIR` / `XDG_CONFIG_HOME` pointed at a private scratch dir containing only this project's tracker credentials and commit identity. `gh`, `git`, and `ssh` inside the session cannot see your global accounts, other projects' tokens, or keys elsewhere on disk.
228
228
  - **Kernel-enforced sandbox when available** — on macOS (`sandbox-exec`) and Linux (`bwrap` / bubblewrap), sessions run with writes restricted to the project dir + scratch home + `/tmp`, and reads denied on known credential locations (`~/.ssh`, `~/.aws`, `~/.config/gh`, `~/.gitconfig`, `~/.netrc`, etc.). If the tool isn't available, sessions fall back to scoped-env isolation with a one-line notice. Opt out for debugging with `AGENTDESK_NO_SANDBOX=1`.
229
+ - **Credentials prerequisite** — scoped sessions push code over HTTPS using a per-project GitHub token. `agentdesk init` saves it to the project's `.env` as `GITHUB_TOKEN`; if you skip that step, the session refuses to start with an actionable message. SSH-only remotes (`git@github.com:...`) are also rejected — the sandbox intentionally isolates `~/.ssh`. Switch the origin to `https://github.com/<owner>/<repo>.git` or run without scoped isolation.
229
230
  - **Outbound only** — no ports opened on your machine
230
231
  - **Project allowlist** — only runs on projects registered via `agentdesk init`
231
232
  - **No arbitrary commands** — only spawns Claude with a fixed set of allowed tools
@@ -244,7 +245,8 @@ Once running, a "Run Team" button appears on [agentdesk.live](https://agentdesk.
244
245
  ## Dashboard Features
245
246
 
246
247
  - **Live sessions** — watch agents collaborate in real-time
247
- - **Session deep links** — share a direct URL to any session
248
+ - **Session deep links** — share a direct URL to any session; teammates without access can request it in one click and be granted viewer access per-session or project-wide
249
+ - **Access requests inbox** — pending requests from teammates appear in the dashboard header for quick approval
248
250
  - **Handoff status** — see when a session hit a limit and is waiting to resume
249
251
  - **Project settings** — configure tracker, team, custom agents, and instructions per project
250
252
  - **Account settings** — manage your API key and profile
package/cli/daemon.mjs CHANGED
@@ -14,6 +14,7 @@ import { buildPrompt } from "./prompt.mjs";
14
14
  import { createStreamParser } from "./stream-parser.mjs";
15
15
  import { runOrchestrator, runPhasedOrchestrator } from "./orchestrator.mjs";
16
16
  import { getRegisteredProjects, registerLocalProject } from "./projects.mjs";
17
+ import { buildTrackerUrl } from "./tracker-url.mjs";
17
18
  import { fileURLToPath } from "url";
18
19
  import { dirname } from "path";
19
20
 
@@ -357,18 +358,8 @@ export async function runDaemon() {
357
358
  ? prompt.toLowerCase().replace(/[^a-z0-9\s-]/g, "").trim().replace(/\s+/g, "-").slice(0, 40) || `task-${Date.now().toString(36)}`
358
359
  : `daemon-${Date.now().toString(36)}`);
359
360
 
360
- // Build task link
361
- let taskLink = null;
362
- if (remoteTaskId) {
363
- if (tracker === "linear" && config.linear?.workspace) {
364
- taskLink = `https://linear.app/${config.linear.workspace}/issue/${remoteTaskId}`;
365
- } else if (tracker === "jira" && config.jira?.baseUrl) {
366
- taskLink = `${config.jira.baseUrl.replace(/\/+$/, "")}/browse/${remoteTaskId}`;
367
- } else if (tracker === "github") {
368
- const repo = config.github?.repo || "";
369
- if (repo) taskLink = `https://github.com/${repo}/issues/${remoteTaskId}`;
370
- }
371
- }
361
+ // Build task link (only when a real tracker task ID was provided)
362
+ const taskLink = remoteTaskId ? buildTrackerUrl({ tracker, config, taskId: remoteTaskId }) : null;
372
363
 
373
364
  // Resolve team
374
365
  const team = resolveTeam(config);
package/cli/dotenv.mjs ADDED
@@ -0,0 +1,22 @@
1
+ // Minimal .env reader used by orchestrator + preflight. Does not interpret
2
+ // quotes or escapes — matches the pre-existing behavior of the duplicated
3
+ // loaders in cli/team.mjs, cli/init.mjs, and cli/daemon.mjs (which we are
4
+ // NOT consolidating in this PR — out of scope).
5
+
6
+ import { existsSync, readFileSync } from "fs";
7
+ import { join } from "path";
8
+
9
+ export function loadDotEnv(dir) {
10
+ if (!dir) return {};
11
+ const envPath = join(dir, ".env");
12
+ if (!existsSync(envPath)) return {};
13
+ const vars = {};
14
+ for (const line of readFileSync(envPath, "utf-8").split("\n")) {
15
+ const trimmed = line.trim();
16
+ if (!trimmed || trimmed.startsWith("#")) continue;
17
+ const eq = trimmed.indexOf("=");
18
+ if (eq === -1) continue;
19
+ vars[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
20
+ }
21
+ return vars;
22
+ }
package/cli/init.mjs CHANGED
@@ -406,16 +406,54 @@ export async function runInit(cwd, opts = {}) {
406
406
  config.github = { repo, login };
407
407
  }
408
408
 
409
- // Verify tracker + fetch available projects to pick from.
409
+ // Verify tracker + fetch available projects to pick from. If verification
410
+ // fails (bad email, revoked token, typo in base URL), loop back and let the
411
+ // user re-enter credentials rather than silently saving broken config.
410
412
  let trackerLocation = null;
411
413
  if (tracker === "jira") trackerLocation = config.jira?.baseUrl;
414
+ let verified = { ok: true };
412
415
  if (tracker) {
413
- const verified = await verifyAndPickTrackerProject({ rl, cwd, finalProjectKey, tracker, config, location: trackerLocation });
414
- if (!verified.ok) {
415
- console.log(" Continuing without tracker verification.");
416
- console.log(" You can re-run `agentdesk init` after fixing credentials.");
416
+ while (true) {
417
+ verified = await verifyAndPickTrackerProject({ rl, cwd, finalProjectKey, tracker, config, location: trackerLocation });
418
+ if (verified.ok) break;
417
419
  console.log("");
418
- } else if (verified.trackerProjectId) {
420
+ const next = await selectOption(rl, "What now?", [
421
+ { label: "Re-enter credentials and try again", value: "retry" },
422
+ { label: "Save config anyway (will need to fix before `agentdesk team`)", value: "skip" },
423
+ { label: "Cancel init", value: "cancel" },
424
+ ]);
425
+ console.log("");
426
+ if (next.value === "cancel") {
427
+ rl.close();
428
+ return;
429
+ }
430
+ if (next.value === "skip") break;
431
+ // Retry: re-prompt only the tracker auth fields, not the whole wizard.
432
+ if (tracker === "linear") {
433
+ const key = await promptRequired(rl, "Linear API key");
434
+ saveEnvVar(cwd, "LINEAR_API_KEY", key);
435
+ console.log(" ✓ Updated LINEAR_API_KEY in .env");
436
+ console.log("");
437
+ } else if (tracker === "jira") {
438
+ const baseUrl = (await ask(rl, ` Jira tenant URL [${config.jira?.baseUrl || ""}]: `)).trim() || config.jira?.baseUrl;
439
+ const email = await promptRequired(rl, "Your Atlassian login email");
440
+ const token = await promptRequired(rl, "Jira API token");
441
+ saveEnvVar(cwd, "JIRA_EMAIL", email);
442
+ saveEnvVar(cwd, "JIRA_API_TOKEN", token);
443
+ if (baseUrl) {
444
+ config.jira = { ...(config.jira || {}), baseUrl: baseUrl.replace(/\/+$/, "") };
445
+ trackerLocation = config.jira.baseUrl;
446
+ }
447
+ console.log(" ✓ Updated JIRA_EMAIL and JIRA_API_TOKEN in .env");
448
+ console.log("");
449
+ } else if (tracker === "github") {
450
+ const token = await promptRequired(rl, "GitHub token");
451
+ saveEnvVar(cwd, "GITHUB_TOKEN", token);
452
+ console.log(" ✓ Updated GITHUB_TOKEN in .env");
453
+ console.log("");
454
+ }
455
+ }
456
+ if (verified.ok && verified.trackerProjectId) {
419
457
  if (tracker === "linear") config.linear = { ...(config.linear || {}), teamKey: verified.trackerProjectId };
420
458
  if (tracker === "jira") config.jira = { ...(config.jira || {}), project: verified.trackerProjectId };
421
459
  // Use the tracker's project key as the default agentdesk project key.
@@ -9,6 +9,9 @@ import { buildPrompt, buildSoloPrompt, buildPhasedPrompt } from "./prompt.mjs";
9
9
  import { createStreamParser } from "./stream-parser.mjs";
10
10
  import { createScratchHome } from "./session-sandbox.mjs";
11
11
  import { wrapIsolatedSpawn, probeIsolation } from "./session-isolation.mjs";
12
+ import { buildTrackerUrl } from "./tracker-url.mjs";
13
+ import { resolveGitHubCreds, assertPushable, PreflightError } from "./session-preflight.mjs";
14
+ import { loadDotEnv } from "./dotenv.mjs";
12
15
 
13
16
  const __dirname = dirname(fileURLToPath(import.meta.url));
14
17
  const CLI_VERSION = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8")).version;
@@ -26,20 +29,6 @@ async function fetchTrackerCredentials(projectName, apiKey, serverUrl) {
26
29
  return {};
27
30
  }
28
31
 
29
- function loadDotEnv(dir) {
30
- const envPath = join(dir, ".env");
31
- if (!existsSync(envPath)) return {};
32
- const vars = {};
33
- for (const line of readFileSync(envPath, "utf-8").split("\n")) {
34
- const trimmed = line.trim();
35
- if (!trimmed || trimmed.startsWith("#")) continue;
36
- const eq = trimmed.indexOf("=");
37
- if (eq === -1) continue;
38
- vars[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
39
- }
40
- return vars;
41
- }
42
-
43
32
  function timestamp() {
44
33
  const d = new Date();
45
34
  return [d.getHours(), d.getMinutes(), d.getSeconds()]
@@ -78,19 +67,12 @@ export async function runOrchestrator({
78
67
  }) {
79
68
  const trackerCreds = await fetchTrackerCredentials(project?.name, apiKey, serverUrl);
80
69
 
81
- // Build a per-session scratch HOME that isolates THIS project's identity
82
- // from the user's global git/gh/ssh state and from other projects'
83
- // credentials. Cleanup runs at session end regardless of outcome.
84
- const sandbox = createScratchHome({
85
- projectId: project?.name,
86
- sessionId,
87
- creds: trackerCreds,
88
- commitIdentity: {
89
- name: config?.identityBadge || "AgentDesk",
90
- email: trackerCreds.JIRA_EMAIL || `agentdesk@local`,
91
- },
92
- });
93
- const env = { ...process.env, ...loadDotEnv(cwd), ...sandbox.env };
70
+ // Resolve creds from server + project .env BEFORE building the scratch
71
+ // HOME, so the sandbox helper writes the right token (or we fail fast
72
+ // when none is available see assertPushable below).
73
+ const resolvedToken = resolveGitHubCreds({ cwd, trackerCreds }).GITHUB_TOKEN;
74
+ const resolvedCreds = { ...trackerCreds, GITHUB_TOKEN: resolvedToken || trackerCreds.GITHUB_TOKEN };
75
+
94
76
  const startTime = Date.now();
95
77
  let totalInputTokens = 0;
96
78
  let totalOutputTokens = 0;
@@ -117,6 +99,35 @@ export async function runOrchestrator({
117
99
  cliVersion: CLI_VERSION,
118
100
  });
119
101
 
102
+ // Preflight: must run AFTER session:start (so the UI has a session to
103
+ // bind the error to) but BEFORE we touch the scratch sandbox or spawn
104
+ // the child. Surfaces missing creds / SSH-only remotes as a clean
105
+ // session error instead of letting the agent silently fail to push.
106
+ try {
107
+ assertPushable({ cwd, creds: resolvedCreds, projectName: project?.name });
108
+ } catch (err) {
109
+ if (err instanceof PreflightError) {
110
+ emit({ type: "session:error", code: err.code, message: err.message });
111
+ emit({ type: "session:end", duration: `${((Date.now() - startTime) / 1000).toFixed(1)}s`, steps: 0, inputTokens: 0, outputTokens: 0, status: "error" });
112
+ return { duration: "0s", steps: 0, inputTokens: 0, outputTokens: 0, handoff: false, error: err.message };
113
+ }
114
+ throw err;
115
+ }
116
+
117
+ // Build a per-session scratch HOME that isolates THIS project's identity
118
+ // from the user's global git/gh/ssh state and from other projects'
119
+ // credentials. Cleanup runs at session end regardless of outcome.
120
+ const sandbox = createScratchHome({
121
+ projectId: project?.name,
122
+ sessionId,
123
+ creds: resolvedCreds,
124
+ commitIdentity: {
125
+ name: config?.identityBadge || "AgentDesk",
126
+ email: resolvedCreds.JIRA_EMAIL || `agentdesk@local`,
127
+ },
128
+ });
129
+ const env = { ...process.env, ...loadDotEnv(cwd), ...sandbox.env };
130
+
120
131
  const initialPhase = soloAgent ? "EXECUTION" : "INTAKE";
121
132
  // Non-phased single run spans all phases in one process — use EXECUTION's model
122
133
  // as the representative choice (matches where the heavy lifting happens).
@@ -154,15 +165,8 @@ export async function runOrchestrator({
154
165
  onSessionUpdate({ taskId: newTaskId, title }) {
155
166
  if (newTaskId) {
156
167
  const update = { type: "session:update", taskId: newTaskId };
157
- // Build a clickable task link from tracker config
158
- if (tracker === "linear" && config.linear?.workspace) {
159
- update.taskLink = `https://linear.app/${config.linear.workspace}/issue/${newTaskId}`;
160
- } else if (tracker === "jira" && config.jira?.baseUrl) {
161
- update.taskLink = `${config.jira.baseUrl.replace(/\/+$/, "")}/browse/${newTaskId}`;
162
- } else if (tracker === "github") {
163
- const repo = config.github?.repo || "";
164
- if (repo) update.taskLink = `https://github.com/${repo}/issues/${newTaskId}`;
165
- }
168
+ const taskLink = buildTrackerUrl({ tracker, config, taskId: newTaskId });
169
+ if (taskLink) update.taskLink = taskLink;
166
170
  emit(update);
167
171
  }
168
172
  if (title) emit({ type: "session:update", title });
@@ -234,7 +238,7 @@ export async function runOrchestrator({
234
238
 
235
239
  // --- Phased orchestrator: runs 3 sequential Claude processes ---
236
240
 
237
- async function runSinglePhase({ prompt, cwd, env, teamNames, emit, modelArgs = [], scratchHome, sessionId }) {
241
+ async function runSinglePhase({ prompt, cwd, env, teamNames, emit, modelArgs = [], scratchHome, sessionId, tracker, config }) {
238
242
  const wrapped = wrapIsolatedSpawn({
239
243
  cmd: "claude",
240
244
  args: ["-p", prompt, ...modelArgs, "--allowedTools", "Bash,Read,Edit,Write,Glob,Grep", "--verbose", "--output-format", "stream-json"],
@@ -260,7 +264,12 @@ async function runSinglePhase({ prompt, cwd, env, teamNames, emit, modelArgs = [
260
264
  onToolUse({ agent, tool, description }) { steps++; emit({ type: "tool:use", agent, tool, description }); },
261
265
  onToolResult({ success, summary }) { emit({ type: "tool:result", success, summary }); },
262
266
  onSessionUpdate({ taskId: newTaskId, title }) {
263
- if (newTaskId) emit({ type: "session:update", taskId: newTaskId });
267
+ if (newTaskId) {
268
+ const update = { type: "session:update", taskId: newTaskId };
269
+ const taskLink = buildTrackerUrl({ tracker, config, taskId: newTaskId });
270
+ if (taskLink) update.taskLink = taskLink;
271
+ emit(update);
272
+ }
264
273
  if (title) emit({ type: "session:update", title });
265
274
  },
266
275
  onSessionEnd({ inputTokens: iT, outputTokens: oT, steps: s }) {
@@ -285,26 +294,15 @@ export async function runPhasedOrchestrator({
285
294
  }) {
286
295
  const trackerCreds = await fetchTrackerCredentials(project?.name, apiKey, serverUrl);
287
296
 
288
- const sandbox = createScratchHome({
289
- projectId: project?.name,
290
- sessionId,
291
- creds: trackerCreds,
292
- commitIdentity: {
293
- name: config?.identityBadge || "AgentDesk",
294
- email: trackerCreds.JIRA_EMAIL || `agentdesk@local`,
295
- },
296
- });
297
- const env = { ...process.env, ...loadDotEnv(cwd), ...sandbox.env };
297
+ // Resolve creds (server + project .env) before any scratch HOME setup
298
+ // so preflight can fail fast and the sandbox helper bakes the right
299
+ // token. Server creds win over .env when both are present.
300
+ const resolvedToken = resolveGitHubCreds({ cwd, trackerCreds }).GITHUB_TOKEN;
301
+ const resolvedCreds = { ...trackerCreds, GITHUB_TOKEN: resolvedToken || trackerCreds.GITHUB_TOKEN };
302
+
298
303
  const startTime = Date.now();
299
304
  const teamNames = teamSections.names;
300
305
 
301
- const probe = probeIsolation();
302
- if (probe.kind !== "none") {
303
- console.error(`[agentdesk] session isolation: ${probe.kind}`);
304
- } else {
305
- console.error(`[agentdesk] hard isolation unavailable (${probe.reason}) — scoped-env only`);
306
- }
307
-
308
306
  function timestamp() {
309
307
  const d = new Date();
310
308
  return [d.getHours(), d.getMinutes(), d.getSeconds()].map(n => String(n).padStart(2, "0")).join(":");
@@ -325,6 +323,38 @@ export async function runPhasedOrchestrator({
325
323
  cliVersion: CLI_VERSION,
326
324
  });
327
325
 
326
+ // Preflight: AFTER session:start so the UI can bind the error, BEFORE
327
+ // touching the scratch sandbox or spawning a child. Same contract as
328
+ // runOrchestrator above.
329
+ try {
330
+ assertPushable({ cwd, creds: resolvedCreds, projectName: project?.name });
331
+ } catch (err) {
332
+ if (err instanceof PreflightError) {
333
+ emit({ type: "session:error", code: err.code, message: err.message });
334
+ emit({ type: "session:end", duration: `${((Date.now() - startTime) / 1000).toFixed(1)}s`, steps: 0, inputTokens: 0, outputTokens: 0, status: "error" });
335
+ return { duration: "0s", steps: 0, inputTokens: 0, outputTokens: 0, handoff: false, error: err.message };
336
+ }
337
+ throw err;
338
+ }
339
+
340
+ const sandbox = createScratchHome({
341
+ projectId: project?.name,
342
+ sessionId,
343
+ creds: resolvedCreds,
344
+ commitIdentity: {
345
+ name: config?.identityBadge || "AgentDesk",
346
+ email: resolvedCreds.JIRA_EMAIL || `agentdesk@local`,
347
+ },
348
+ });
349
+ const env = { ...process.env, ...loadDotEnv(cwd), ...sandbox.env };
350
+
351
+ const probe = probeIsolation();
352
+ if (probe.kind !== "none") {
353
+ console.error(`[agentdesk] session isolation: ${probe.kind}`);
354
+ } else {
355
+ console.error(`[agentdesk] hard isolation unavailable (${probe.reason}) — scoped-env only`);
356
+ }
357
+
328
358
  const sessionMemoryPath = join(cwd, ".agentdesk", "session-memory.md");
329
359
  const reviewVerdictPath = join(cwd, ".agentdesk", "review-verdict.md");
330
360
  const MAX_REVIEW_RETRIES = 1; // One execution redo after a failed review, then force SUMMARY.
@@ -357,7 +387,7 @@ export async function runPhasedOrchestrator({
357
387
  });
358
388
 
359
389
  const modelArgs = modelArgsForPhase(phase, config?.phaseModels);
360
- const result = await runSinglePhase({ prompt, cwd, env, teamNames, emit, modelArgs, scratchHome: sandbox.home, sessionId });
390
+ const result = await runSinglePhase({ prompt, cwd, env, teamNames, emit, modelArgs, scratchHome: sandbox.home, sessionId, tracker, config });
361
391
 
362
392
  if (onChild) onChild(result.child);
363
393
 
@@ -0,0 +1,91 @@
1
+ // Session preflight: verify that a scoped session has the credentials
2
+ // and remote configuration it needs to push code, BEFORE we spawn the
3
+ // child Claude process. Failure here surfaces as a clean session error
4
+ // instead of an agent silently asking the operator to push by hand.
5
+ //
6
+ // Two checks today:
7
+ // 1. resolveGitHubCreds: server creds + project .env merged into one
8
+ // record. Server wins when both are present (consistency across
9
+ // machines).
10
+ // 2. assertPushable: GITHUB_TOKEN must exist AND the origin remote
11
+ // (if configured) must be HTTPS. SSH remotes are blocked because
12
+ // the kernel sandbox denies ~/.ssh — there is no SSH key to read.
13
+ //
14
+ // A repo with no `origin` remote is allowed: agents may add one
15
+ // themselves, and pushing isn't always required for a session.
16
+
17
+ import { execFileSync } from "child_process";
18
+ import { loadDotEnv } from "./dotenv.mjs";
19
+
20
+ export class PreflightError extends Error {
21
+ constructor(message, { code } = {}) {
22
+ super(message);
23
+ this.name = "PreflightError";
24
+ this.code = code || "PREFLIGHT_FAILED";
25
+ }
26
+ }
27
+
28
+ // Merge server-fetched tracker creds with the project's .env. Server
29
+ // values win when both are present so a token rotated centrally takes
30
+ // effect without a local edit. Returns { GITHUB_TOKEN, source } where
31
+ // source is "server" | "dotenv" | null.
32
+ export function resolveGitHubCreds({ cwd, trackerCreds = {} } = {}) {
33
+ const fromServer = trackerCreds.GITHUB_TOKEN || null;
34
+ const fromDotEnv = cwd ? (loadDotEnv(cwd).GITHUB_TOKEN || null) : null;
35
+ if (fromServer) return { GITHUB_TOKEN: fromServer, source: "server" };
36
+ if (fromDotEnv) return { GITHUB_TOKEN: fromDotEnv, source: "dotenv" };
37
+ return { GITHUB_TOKEN: null, source: null };
38
+ }
39
+
40
+ // Raw URL of the `origin` remote, or null if no origin is configured
41
+ // (or if git itself isn't installed). We use execFileSync (no shell)
42
+ // so a hostile cwd can't trigger command injection.
43
+ export function getGitRemoteUrl(cwd) {
44
+ if (!cwd) return null;
45
+ try {
46
+ return execFileSync("git", ["-C", cwd, "remote", "get-url", "origin"], {
47
+ stdio: ["ignore", "pipe", "ignore"],
48
+ encoding: "utf-8",
49
+ }).trim() || null;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ // HTTPS remotes are pushable through the credential helper we install
56
+ // in the scratch HOME. SSH remotes (git@host:owner/repo, ssh://...) are
57
+ // not — the sandbox intentionally denies ~/.ssh.
58
+ function isSshRemote(url) {
59
+ if (!url) return false;
60
+ if (url.startsWith("ssh://")) return true;
61
+ // scp-style: user@host:path. Distinguish from https:// by checking for
62
+ // the colon-before-slash pattern with no scheme.
63
+ return /^[^@/]+@[^:]+:/.test(url);
64
+ }
65
+
66
+ // Throws PreflightError with a user-facing `message` when:
67
+ // - no GITHUB_TOKEN is resolved (and a remote exists OR no remote is
68
+ // configured — we still warn so the agent can't silently fail later)
69
+ // - the configured origin is SSH
70
+ // A repo with NO origin remote is allowed when the token is present.
71
+ export function assertPushable({ cwd, creds = {}, projectName = "(unknown)" } = {}) {
72
+ const remote = getGitRemoteUrl(cwd);
73
+
74
+ if (!creds.GITHUB_TOKEN) {
75
+ throw new PreflightError(
76
+ `GitHub credentials not found for project "${projectName}". ` +
77
+ `Scoped sessions need a token to push code and open PRs. ` +
78
+ `Run 'agentdesk init' to reconfigure, or add GITHUB_TOKEN=<token> to .env in the project root.`,
79
+ { code: "MISSING_GITHUB_TOKEN" }
80
+ );
81
+ }
82
+
83
+ if (remote && isSshRemote(remote)) {
84
+ throw new PreflightError(
85
+ `Project "${projectName}" uses an SSH git remote (${remote}). ` +
86
+ `Scoped sessions push over HTTPS — SSH keys are intentionally isolated. ` +
87
+ `Switch the origin remote to https://<host>/<owner>/<repo>.git, or run this session without scoped isolation.`,
88
+ { code: "SSH_REMOTE" }
89
+ );
90
+ }
91
+ }
@@ -25,33 +25,43 @@ import { tmpdir } from "os";
25
25
  import { randomUUID } from "crypto";
26
26
 
27
27
  export function createScratchHome({ projectId, sessionId, creds = {}, commitIdentity = {} }) {
28
+ if (!creds.GITHUB_TOKEN) {
29
+ // Caller is responsible for running session-preflight before reaching
30
+ // here. An empty token would mean a silently-broken push later, so
31
+ // fail loudly instead.
32
+ throw new Error("createScratchHome called without GITHUB_TOKEN — preflight should have caught this");
33
+ }
34
+
28
35
  const base = join(tmpdir(), "agentdesk-sessions", `${safe(projectId)}-${sessionId || randomUUID().slice(0, 8)}`);
29
36
  const ghConfigDir = join(base, "gh");
30
37
  mkdirSync(ghConfigDir, { recursive: true, mode: 0o700 });
31
38
 
32
39
  // --- gh: a scoped hosts.yml overrides the user's ~/.config/gh/ when we
33
40
  // point GH_CONFIG_DIR at this dir. Only this project's token lives
34
- // here. Falls back to empty (no stored auth) if no token configured.
35
- if (creds.GITHUB_TOKEN) {
36
- writeFileSync(
37
- join(ghConfigDir, "hosts.yml"),
38
- `github.com:\n oauth_token: ${creds.GITHUB_TOKEN}\n git_protocol: https\n`,
39
- { mode: 0o600 }
40
- );
41
- }
41
+ // here.
42
+ writeFileSync(
43
+ join(ghConfigDir, "hosts.yml"),
44
+ `github.com:\n oauth_token: ${creds.GITHUB_TOKEN}\n git_protocol: https\n`,
45
+ { mode: 0o600 }
46
+ );
42
47
 
43
48
  // --- git: a scoped gitconfig overrides the user's ~/.gitconfig when we
44
49
  // point GIT_CONFIG_GLOBAL at this file. The credential helper
45
- // script echoes the project's GITHUB_TOKEN for HTTPS pushes so
46
- // git push authenticates as this project's identity not the
47
- // user's global SSH key or credential manager.
50
+ // reads the token from a sibling file (mode 0600) instead of
51
+ // interpolating it into a shell stringthat way a token with $,
52
+ // ", `, or newlines can't break the script or leak via /proc.
53
+ const tokenPath = join(base, "gh-token.txt");
54
+ writeFileSync(tokenPath, creds.GITHUB_TOKEN, { mode: 0o600 });
55
+
48
56
  const helperPath = join(base, "gh-credential-helper.sh");
49
57
  const helperScript = [
50
58
  `#!/usr/bin/env bash`,
51
59
  `if [ "$1" = "get" ]; then`,
52
60
  ` while IFS= read -r line; do [ -z "$line" ] && break; done`,
53
61
  ` echo "username=x-access-token"`,
54
- ` echo "password=${creds.GITHUB_TOKEN || ""}"`,
62
+ ` printf 'password='`,
63
+ ` cat ${shQuote(tokenPath)}`,
64
+ ` echo`,
55
65
  `fi`,
56
66
  ``,
57
67
  ].join("\n");
@@ -91,8 +101,8 @@ export function createScratchHome({ projectId, sessionId, creds = {}, commitIden
91
101
  GIT_COMMITTER_EMAIL: commitIdentity.email || "agentdesk@local",
92
102
  // Explicit GITHUB_TOKEN/GH_TOKEN overrides gh's stored auth (which
93
103
  // we've already replaced anyway, but belt + suspenders).
94
- GITHUB_TOKEN: creds.GITHUB_TOKEN || "",
95
- GH_TOKEN: creds.GITHUB_TOKEN || "",
104
+ GITHUB_TOKEN: creds.GITHUB_TOKEN,
105
+ GH_TOKEN: creds.GITHUB_TOKEN,
96
106
  // Tracker-specific env — only this project's credentials reach the agent.
97
107
  LINEAR_API_KEY: creds.LINEAR_API_KEY || "",
98
108
  JIRA_EMAIL: creds.JIRA_EMAIL || "",
@@ -119,3 +129,10 @@ function gitQuote(s) {
119
129
  if (/[\s"\\#;]/.test(s)) return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
120
130
  return s;
121
131
  }
132
+
133
+ // Shell-quote for embedding a file path into our credential helper script.
134
+ // Single-quote-wrap and escape any embedded single quotes — defends against
135
+ // scratch dirs with spaces or shell metacharacters.
136
+ function shQuote(s) {
137
+ return `'${String(s).replace(/'/g, `'\\''`)}'`;
138
+ }
package/cli/team.mjs CHANGED
@@ -11,6 +11,7 @@ import { getStoredApiKey } from "./login.mjs";
11
11
  import { resolveTeam, generateTeamPrompt } from "./agents.mjs";
12
12
  import { runOrchestrator, runPhasedOrchestrator } from "./orchestrator.mjs";
13
13
  import { checkTrackerPermissions, resolveCredentialsFromEnv } from "./tracker-check.mjs";
14
+ import { buildTrackerUrl } from "./tracker-url.mjs";
14
15
 
15
16
  const __dirname = dirname(fileURLToPath(import.meta.url));
16
17
  const CLI_VERSION = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8")).version;
@@ -69,17 +70,7 @@ export async function runTeam(taskId, opts = {}) {
69
70
  }
70
71
 
71
72
  // Build task link (only for existing tasks with a tracker)
72
- let taskLink = null;
73
- if (!createTask) {
74
- if (tracker === "linear" && config.linear?.workspace) {
75
- taskLink = `https://linear.app/${config.linear.workspace}/issue/${taskId}`;
76
- } else if (tracker === "jira" && config.jira?.baseUrl) {
77
- taskLink = `${config.jira.baseUrl.replace(/\/+$/, "")}/browse/${taskId}`;
78
- } else if (tracker === "github") {
79
- const repo = config.github?.repo || "";
80
- if (repo) taskLink = `https://github.com/${repo}/issues/${taskId}`;
81
- }
82
- }
73
+ const taskLink = !createTask ? buildTrackerUrl({ tracker, config, taskId }) : null;
83
74
  if (tracker) console.log(`Tracker: ${tracker}`);
84
75
  if (taskLink) {
85
76
  console.log(`Task: ${cyan}${taskLink}${reset}`);
@@ -222,7 +213,11 @@ export async function runTeam(taskId, opts = {}) {
222
213
 
223
214
  clearInterval(heartbeatInterval);
224
215
 
225
- if (result.handoff) {
216
+ if (result.error) {
217
+ const red = "\x1b[31m";
218
+ console.log(`\n━━━ ${red}SESSION COULD NOT START${reset} ━━━`);
219
+ console.log(` ${result.error}\n`);
220
+ } else if (result.handoff) {
226
221
  const yellow = "\x1b[33m";
227
222
  console.log(`\n━━━ ${yellow}HANDOFF${reset} ━━━`);
228
223
  console.log(` Session paused — likely hit Claude rate/context limit.`);
@@ -235,5 +230,5 @@ export async function runTeam(taskId, opts = {}) {
235
230
  console.log(` ${result.duration} | ${result.steps} steps${totalTokens ? ` | ${totalTokens.toLocaleString()} tokens` : ""}\n`);
236
231
 
237
232
  setTimeout(() => { try { vizWs?.close(); } catch {} }, 500);
238
- return 0;
233
+ return result.error ? 1 : 0;
239
234
  }
@@ -0,0 +1,27 @@
1
+ // Build a clickable URL for a tracker task. Single source of truth for
2
+ // Linear/Jira/GitHub URL construction — consumed by daemon.mjs,
3
+ // orchestrator.mjs, and team.mjs.
4
+
5
+ export function buildTrackerUrl({ tracker, config, taskId }) {
6
+ if (!tracker || !taskId || !config) return null;
7
+
8
+ if (tracker === "linear") {
9
+ const workspace = config.linear?.workspace;
10
+ if (!workspace) return null;
11
+ return `https://linear.app/${workspace}/issue/${taskId}`;
12
+ }
13
+
14
+ if (tracker === "jira") {
15
+ const baseUrl = config.jira?.baseUrl;
16
+ if (!baseUrl) return null;
17
+ return `${baseUrl.replace(/\/+$/, "")}/browse/${taskId}`;
18
+ }
19
+
20
+ if (tracker === "github") {
21
+ const repo = config.github?.repo;
22
+ if (!repo) return null;
23
+ return `https://github.com/${repo}/issues/${taskId}`;
24
+ }
25
+
26
+ return null;
27
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kendoo.agentdesk/agentdesk",
3
- "version": "0.19.1",
3
+ "version": "0.19.3",
4
4
  "description": "AI team orchestrator for Claude Code — run collaborative agent sessions from your terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,7 +22,7 @@
22
22
  "server": "node server/index.mjs",
23
23
  "build": "vite build",
24
24
  "preview": "vite preview",
25
- "test": "node --test tests/server.test.mjs tests/agents.test.mjs tests/homepage.test.mjs",
25
+ "test": "node --test tests/server.test.mjs tests/agents.test.mjs tests/homepage.test.mjs tests/sessionUtils.test.mjs tests/tracker-url.test.mjs tests/session-preflight.test.mjs tests/access.test.mjs",
26
26
  "lint:changelog": "node scripts/lint-changelog.mjs",
27
27
  "prepublishOnly": "node scripts/lint-changelog.mjs"
28
28
  },