Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
],
"scripts": {
"build": "tsc && chmod +x build/index.js",
"test": "tsc && node --test test/*.test.mjs",
"start": "node build/index.js",
"prepare": "npm run build"
},
Expand Down
174 changes: 174 additions & 0 deletions src/device-login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
export type ToolResult = {
content: { type: "text"; text: string }[];
isError?: boolean;
};

export type DeviceStart = {
device_code: string;
user_code: string;
verification_uri: string;
verification_uri_complete: string;
expires_in: number;
interval: number;
};

type DeviceTokenSuccess = {
access_token: string;
token_type?: string;
taskbounty_user_id?: string;
};

type DeviceLoginDeps = {
siteOrigin: string;
credPath: string;
fetchFn?: typeof fetch;
persistToken: (accessToken: string, userId?: string) => void;
sleep?: (ms: number) => Promise<void>;
now?: () => number;
};

const defaultSleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));

function approvalInstruction(start: DeviceStart): string {
return (
`Open this URL in your browser and approve:\n ${start.verification_uri_complete}\n` +
`Your code: ${start.user_code}\n` +
`(If the link does not prefill, go to ${start.verification_uri} and enter the code.)\n\n` +
`Waiting for approval...`
);
}

export async function deviceLogin(
clientName: string,
deps: DeviceLoginDeps,
): Promise<ToolResult> {
const fetchFn = deps.fetchFn ?? fetch;
const sleep = deps.sleep ?? defaultSleep;
const now = deps.now ?? Date.now;

let start: DeviceStart;
try {
const res = await fetchFn(`${deps.siteOrigin}/api/mcp/device/start`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ client_name: clientName }),
});
if (!res.ok) {
const t = await res.text();
return {
content: [
{
type: "text",
text: `Could not start login (HTTP ${res.status}) from ${deps.siteOrigin}/api/mcp/device/start\n\n${t}`,
},
],
isError: true,
};
}
start = (await res.json()) as DeviceStart;
} catch (err) {
return {
content: [
{
type: "text",
text: `Network error starting login: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}

const instruction = approvalInstruction(start);
const deadline = now() + start.expires_in * 1000;
let intervalMs = Math.max(1, start.interval) * 1000;

// First poll happens after one interval, giving the user time to approve.
while (now() < deadline) {
await sleep(intervalMs);
let res: Response;
try {
res = await fetchFn(`${deps.siteOrigin}/api/mcp/device/token`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ device_code: start.device_code }),
});
} catch {
// Transient network issue; keep polling until the deadline.
continue;
}

if (res.ok) {
const data = (await res.json()) as DeviceTokenSuccess;
try {
deps.persistToken(data.access_token, data.taskbounty_user_id);
} catch (err) {
return {
content: [
{
type: "text",
text: `${instruction}\n\nLogin succeeded but could not write ${deps.credPath}: ${err instanceof Error ? err.message : String(err)}. Set TASKBOUNTY_API_KEY=${data.access_token} instead.`,
},
],
isError: true,
};
}
return {
content: [
{
type: "text",
text:
`${instruction}\n\nLogged in. Credentials saved to ${deps.credPath} (mode 0600).\n` +
`For CI or headless use you can also set TASKBOUNTY_API_KEY=${data.access_token}.\n\n` +
`You can now use autopilot_enable and post_from_issue.`,
},
],
};
}

let errCode = "";
try {
errCode = ((await res.json()) as { error?: string }).error ?? "";
} catch {
errCode = "";
}
if (errCode === "authorization_pending") continue;
if (errCode === "slow_down") {
intervalMs += 5000;
continue;
}
if (errCode === "expired_token" || errCode === "access_denied") {
return {
content: [
{
type: "text",
text:
`${instruction}\n\n` +
(errCode === "access_denied"
? "Login was denied in the browser. Run taskbounty_login again to retry."
: "Login code expired before approval. Run taskbounty_login again to retry."),
},
],
isError: true,
};
}
return {
content: [
{
type: "text",
text: `${instruction}\n\nLogin failed (HTTP ${res.status}, error="${errCode}"). Run taskbounty_login again to retry.`,
},
],
isError: true,
};
}

return {
content: [
{
type: "text",
text: `${instruction}\n\nLogin timed out waiting for browser approval. Run taskbounty_login again to retry.`,
},
],
isError: true,
};
}
Loading