@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 +21 -0
- package/README.md +4 -2
- package/cli/daemon.mjs +3 -12
- package/cli/dotenv.mjs +22 -0
- package/cli/init.mjs +44 -6
- package/cli/orchestrator.mjs +86 -56
- package/cli/session-preflight.mjs +91 -0
- package/cli/session-sandbox.mjs +31 -14
- package/cli/team.mjs +8 -13
- package/cli/tracker-url.mjs +27 -0
- package/package.json +2 -2
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
|
-
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
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.
|
package/cli/orchestrator.mjs
CHANGED
|
@@ -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
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
158
|
-
if (
|
|
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)
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
+
}
|
package/cli/session-sandbox.mjs
CHANGED
|
@@ -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.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
50
|
+
// reads the token from a sibling file (mode 0600) instead of
|
|
51
|
+
// interpolating it into a shell string — that 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
|
-
`
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
},
|