@kendoo.agentdesk/agentdesk 0.19.0 → 0.19.2

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.2] — 2026-04-18
17
+
18
+ ### Fixed
19
+ - `[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.
20
+
21
+ ### Added
22
+ - `[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.
23
+
24
+ ### Changed
25
+ - `[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.
26
+
27
+ ## [0.19.1] — 2026-04-18
28
+
29
+ ### Fixed
30
+ - `[CLI]` Creating a new task from a description now locks the task to the project configured in settings. The prompt previously told the agent to "create a Jira issue" without naming a project, so Claude was free to pick any project its token had access to. Jira and Linear create-from-description paths now explicitly pass the configured project key / team key and tell the agent not to choose a different one.
31
+
11
32
  ## [0.19.0] — 2026-04-18
12
33
 
13
34
  ### Changed
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
+ }
@@ -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
 
package/cli/prompt.mjs CHANGED
@@ -61,9 +61,23 @@ export function buildPrompt({ taskId, taskLink, description, createTask, tracker
61
61
  if (createTask && description) {
62
62
  let createInstr = "\n\n## CREATE TASK (MANDATORY — FIRST ACTION IN INTAKE)\n\nNo task ID was provided — only a description. Jane MUST create a new task in the tracker as the VERY FIRST action before anything else.\n\n";
63
63
  if (tracker === "linear") {
64
- createInstr += `Create a Linear issue using the GraphQL API:\n- Endpoint: https://api.linear.app/graphql\n- Auth: Authorization: $LINEAR_API_KEY\n- Set the title based on the description below\n- After creation, use the returned identifier (e.g., KEN-530) as the task ID for the rest of the session\n`;
64
+ const linearTeamKey = config.linear?.teamKey;
65
+ createInstr += `Create a Linear issue using the GraphQL API:\n- Endpoint: https://api.linear.app/graphql\n- Auth: Authorization: $LINEAR_API_KEY\n`;
66
+ if (linearTeamKey) {
67
+ createInstr += `- **Team: MUST be \`${linearTeamKey}\`.** First resolve the team id: \`{ teams(filter: { key: { eq: "${linearTeamKey}" } }) { nodes { id } } }\`, then pass that id as \`teamId\` in \`issueCreate\`. Do NOT pick any other team, even if your account has access to others.\n`;
68
+ } else {
69
+ createInstr += `- ⚠ No Linear team key is configured. Ask the user which team this issue should live in BEFORE creating it.\n`;
70
+ }
71
+ createInstr += `- Set the title based on the description below\n- After creation, use the returned identifier (e.g., ${linearTeamKey || "KEN"}-530) as the task ID for the rest of the session\n`;
65
72
  } else if (tracker === "jira") {
66
- createInstr += `Create a Jira issue:\n- Endpoint: ${config.jira?.baseUrl || ""}/rest/api/3/issue\n- Auth: Basic auth with $JIRA_EMAIL and $JIRA_API_TOKEN\n- Set the summary based on the description below\n- After creation, use the returned key (e.g., PROJ-42) as the task ID for the rest of the session\n`;
73
+ const jiraProject = config.jira?.project;
74
+ createInstr += `Create a Jira issue:\n- Endpoint: ${config.jira?.baseUrl || ""}/rest/api/3/issue\n- Auth: Basic auth with $JIRA_EMAIL and $JIRA_API_TOKEN\n`;
75
+ if (jiraProject) {
76
+ createInstr += `- **Project: MUST be \`${jiraProject}\`.** Set \`fields.project.key = "${jiraProject}"\` in the create payload. Do NOT pick any other project, even if your account has access to others.\n`;
77
+ } else {
78
+ createInstr += `- ⚠ No Jira project key is configured. Ask the user which project this task should live in BEFORE creating it.\n`;
79
+ }
80
+ createInstr += `- Set the summary based on the description below\n- After creation, use the returned key (e.g., ${jiraProject || "PROJ"}-42) as the task ID for the rest of the session\n`;
67
81
  } else if (tracker === "github") {
68
82
  createInstr += `Create a GitHub issue:\n- Run: gh issue create --title "..." --body "..."\n- After creation, use the returned issue number as the task ID for the rest of the session\n`;
69
83
  }
@@ -442,9 +456,17 @@ export function buildPhasedPrompt({ phase, taskId, taskLink, description, create
442
456
  if (phase === "INTAKE" && createTask && description) {
443
457
  let createInstr = "\n\n## CREATE TASK (MANDATORY — FIRST ACTION)\n\nNo task ID was provided. Jane MUST create a new task in the tracker as the VERY FIRST action.\n\n";
444
458
  if (tracker === "linear") {
459
+ const linearTeamKey = config.linear?.teamKey;
445
460
  createInstr += `Create a Linear issue using the GraphQL API.\n`;
461
+ if (linearTeamKey) {
462
+ createInstr += `**Team MUST be \`${linearTeamKey}\`.** Resolve its id via \`{ teams(filter: { key: { eq: "${linearTeamKey}" } }) { nodes { id } } }\` and pass as \`teamId\` in \`issueCreate\`. Do NOT pick any other team.\n`;
463
+ }
446
464
  } else if (tracker === "jira") {
465
+ const jiraProject = config.jira?.project;
447
466
  createInstr += `Create a Jira issue at ${config.jira?.baseUrl || ""}.\n`;
467
+ if (jiraProject) {
468
+ createInstr += `**Project MUST be \`${jiraProject}\`.** Set \`fields.project.key = "${jiraProject}"\` in the create payload. Do NOT pick any other project.\n`;
469
+ }
448
470
  } else if (tracker === "github") {
449
471
  createInstr += `Create a GitHub issue: gh issue create --title "..." --body "..."\n`;
450
472
  }
@@ -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.0",
3
+ "version": "0.19.2",
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
  },