copillm 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -0
- package/dist/agentconfig/apply.js +53 -0
- package/dist/agentconfig/load.js +163 -0
- package/dist/agentconfig/markerBlock.js +76 -0
- package/dist/agentconfig/render.js +317 -0
- package/dist/agentconfig/schema.js +65 -0
- package/dist/auth/copilotToken.js +122 -0
- package/dist/auth/credentials.js +221 -0
- package/dist/auth/deviceFlow.js +89 -0
- package/dist/auth/ensureAuthenticated.js +55 -0
- package/dist/auth/githubIdentity.js +42 -0
- package/dist/auth/interactivePrompt.js +135 -0
- package/dist/claude/cache.js +20 -0
- package/dist/claude/settingsConflict.js +85 -0
- package/dist/cli/agentEnv.js +56 -0
- package/dist/cli/configCommands.js +149 -0
- package/dist/cli/envBlock.js +43 -0
- package/dist/cli/launchAgent.js +59 -0
- package/dist/cli/resolveAgent.js +361 -0
- package/dist/cli.js +1178 -0
- package/dist/codex/init.js +93 -0
- package/dist/config/config.js +51 -0
- package/dist/config/fsSecurity.js +39 -0
- package/dist/config/home.js +62 -0
- package/dist/config/logging.js +33 -0
- package/dist/config/upstream.js +38 -0
- package/dist/models/anthropicDefaults.js +138 -0
- package/dist/models/discovery.js +208 -0
- package/dist/pi/init.js +174 -0
- package/dist/server/anthropicModelsResponse.js +151 -0
- package/dist/server/codexSchema.js +100 -0
- package/dist/server/debugInfo.js +48 -0
- package/dist/server/lock.js +150 -0
- package/dist/server/proxy.js +715 -0
- package/dist/translation/openaiAnthropic.js +391 -0
- package/dist/translation/streamingOpenAIToAnthropic.js +290 -0
- package/dist/types/index.js +1 -0
- package/package.json +50 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Schema for `~/.copillm/agent.toml` (global) and `<cwd>/.copillm/agent.toml`
|
|
4
|
+
* (project overlay). See plans/unified-booping-mango.md for design rationale.
|
|
5
|
+
*
|
|
6
|
+
* Sections under `[defaults.*]` apply to every profile; profiles override by
|
|
7
|
+
* deep-merge. v1 only wires `instructions` and `mcp` into fan-out — the other
|
|
8
|
+
* sections (`skills`, `agents`, `hooks`, `permissions`) are reserved-but-
|
|
9
|
+
* permissive so users can start populating them without future TOML breaking.
|
|
10
|
+
*/
|
|
11
|
+
export const UNSET_SENTINEL = "@unset";
|
|
12
|
+
const StringRecord = z.record(z.string());
|
|
13
|
+
const McpStdioSchema = z
|
|
14
|
+
.object({
|
|
15
|
+
transport: z.literal("stdio"),
|
|
16
|
+
command: z.string().min(1),
|
|
17
|
+
args: z.array(z.string()).optional(),
|
|
18
|
+
env: StringRecord.optional(),
|
|
19
|
+
cwd: z.string().optional(),
|
|
20
|
+
scope: z.enum(["project", "user"]).optional()
|
|
21
|
+
})
|
|
22
|
+
.strict();
|
|
23
|
+
const McpHttpSchema = z
|
|
24
|
+
.object({
|
|
25
|
+
transport: z.enum(["http", "sse"]),
|
|
26
|
+
url: z.string().url(),
|
|
27
|
+
headers: StringRecord.optional(),
|
|
28
|
+
scope: z.enum(["project", "user"]).optional()
|
|
29
|
+
})
|
|
30
|
+
.strict();
|
|
31
|
+
const McpInheritUnset = z
|
|
32
|
+
.object({
|
|
33
|
+
inherit: z.literal(UNSET_SENTINEL)
|
|
34
|
+
})
|
|
35
|
+
.strict();
|
|
36
|
+
export const McpServerSchema = z.union([McpStdioSchema, McpHttpSchema, McpInheritUnset]);
|
|
37
|
+
const InstructionsSchema = z
|
|
38
|
+
.object({
|
|
39
|
+
body: z.string()
|
|
40
|
+
})
|
|
41
|
+
.strict();
|
|
42
|
+
const McpSchema = z
|
|
43
|
+
.object({
|
|
44
|
+
servers: z.record(McpServerSchema).optional()
|
|
45
|
+
})
|
|
46
|
+
.strict();
|
|
47
|
+
const PassthroughRecord = z.record(z.unknown());
|
|
48
|
+
const SectionSchema = z
|
|
49
|
+
.object({
|
|
50
|
+
instructions: InstructionsSchema.optional(),
|
|
51
|
+
mcp: McpSchema.optional(),
|
|
52
|
+
// v1 reserved sections: validated as objects but not interpreted.
|
|
53
|
+
skills: PassthroughRecord.optional(),
|
|
54
|
+
agents: PassthroughRecord.optional(),
|
|
55
|
+
hooks: PassthroughRecord.optional(),
|
|
56
|
+
permissions: PassthroughRecord.optional()
|
|
57
|
+
})
|
|
58
|
+
.strict();
|
|
59
|
+
export const AgentTomlSchema = z
|
|
60
|
+
.object({
|
|
61
|
+
active_profile: z.string().min(1).optional(),
|
|
62
|
+
defaults: SectionSchema.optional(),
|
|
63
|
+
profiles: z.record(SectionSchema).optional()
|
|
64
|
+
})
|
|
65
|
+
.strict();
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { tokenExchangeUrl } from "../config/upstream.js";
|
|
2
|
+
const DEFAULT_REFRESH_THRESHOLD_SECONDS = 300;
|
|
3
|
+
const MIN_ACCEPTABLE_TTL_SECONDS = 30;
|
|
4
|
+
export class CopilotTokenManagerError extends Error {
|
|
5
|
+
constructor(message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "CopilotTokenManagerError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export class CopilotTokenExchangeError extends CopilotTokenManagerError {
|
|
11
|
+
statusCode;
|
|
12
|
+
responseBodySnippet;
|
|
13
|
+
constructor(message, statusCode, responseBodySnippet) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.statusCode = statusCode;
|
|
16
|
+
this.responseBodySnippet = responseBodySnippet;
|
|
17
|
+
this.name = "CopilotTokenExchangeError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export class CopilotTokenPayloadError extends CopilotTokenManagerError {
|
|
21
|
+
constructor(message) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = "CopilotTokenPayloadError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export class CopilotTokenExpiredError extends CopilotTokenManagerError {
|
|
27
|
+
constructor(message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "CopilotTokenExpiredError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export class CopilotTokenManager {
|
|
33
|
+
githubToken;
|
|
34
|
+
state = null;
|
|
35
|
+
refreshInFlight = null;
|
|
36
|
+
constructor(githubToken) {
|
|
37
|
+
this.githubToken = githubToken;
|
|
38
|
+
}
|
|
39
|
+
get current() {
|
|
40
|
+
return this.state;
|
|
41
|
+
}
|
|
42
|
+
expiresInSeconds(nowUnix = this.nowUnix()) {
|
|
43
|
+
if (!this.state) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
return Math.max(0, this.state.expiresAtUnix - nowUnix);
|
|
47
|
+
}
|
|
48
|
+
shouldRefresh(options) {
|
|
49
|
+
const threshold = options?.refreshThresholdSeconds ?? DEFAULT_REFRESH_THRESHOLD_SECONDS;
|
|
50
|
+
const expiresIn = this.expiresInSeconds(options?.nowUnix ?? this.nowUnix());
|
|
51
|
+
return expiresIn === null || expiresIn <= threshold;
|
|
52
|
+
}
|
|
53
|
+
async ensureToken(options) {
|
|
54
|
+
const normalized = this.normalizeEnsureTokenOptions(options);
|
|
55
|
+
const needsRefresh = normalized.forceRefresh || this.shouldRefresh({ refreshThresholdSeconds: normalized.refreshThresholdSeconds });
|
|
56
|
+
if (!needsRefresh) {
|
|
57
|
+
return this.state.token;
|
|
58
|
+
}
|
|
59
|
+
const next = await this.refreshToken();
|
|
60
|
+
return next.token;
|
|
61
|
+
}
|
|
62
|
+
clear() {
|
|
63
|
+
this.state = null;
|
|
64
|
+
this.refreshInFlight = null;
|
|
65
|
+
}
|
|
66
|
+
async exchange() {
|
|
67
|
+
const response = await fetch(tokenExchangeUrl(), {
|
|
68
|
+
method: "GET",
|
|
69
|
+
headers: {
|
|
70
|
+
Authorization: `token ${this.githubToken}`,
|
|
71
|
+
"User-Agent": "copillm/0.1.0",
|
|
72
|
+
Accept: "application/json"
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
const responseBody = await response.text();
|
|
77
|
+
const snippet = responseBody.slice(0, 256);
|
|
78
|
+
throw new CopilotTokenExchangeError(`Copilot token exchange failed (${response.status}).`, response.status, snippet);
|
|
79
|
+
}
|
|
80
|
+
const payload = (await response.json());
|
|
81
|
+
if (!payload.token || !payload.expires_at || !Number.isFinite(payload.expires_at)) {
|
|
82
|
+
throw new CopilotTokenPayloadError("Token exchange response was missing required fields.");
|
|
83
|
+
}
|
|
84
|
+
const now = this.nowUnix();
|
|
85
|
+
const ttl = payload.expires_at - now;
|
|
86
|
+
if (ttl <= MIN_ACCEPTABLE_TTL_SECONDS) {
|
|
87
|
+
throw new CopilotTokenExpiredError(`Received near-expired Copilot token (ttl_seconds=${Math.max(0, ttl)}).`);
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
token: payload.token,
|
|
91
|
+
expiresAtUnix: payload.expires_at
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
normalizeEnsureTokenOptions(input) {
|
|
95
|
+
if (typeof input === "boolean") {
|
|
96
|
+
return {
|
|
97
|
+
forceRefresh: input,
|
|
98
|
+
refreshThresholdSeconds: DEFAULT_REFRESH_THRESHOLD_SECONDS
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
forceRefresh: input?.forceRefresh ?? false,
|
|
103
|
+
refreshThresholdSeconds: input?.refreshThresholdSeconds ?? DEFAULT_REFRESH_THRESHOLD_SECONDS
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
async refreshToken() {
|
|
107
|
+
if (!this.refreshInFlight) {
|
|
108
|
+
this.refreshInFlight = this.exchange()
|
|
109
|
+
.then((next) => {
|
|
110
|
+
this.state = next;
|
|
111
|
+
return next;
|
|
112
|
+
})
|
|
113
|
+
.finally(() => {
|
|
114
|
+
this.refreshInFlight = null;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return this.refreshInFlight;
|
|
118
|
+
}
|
|
119
|
+
nowUnix() {
|
|
120
|
+
return Math.floor(Date.now() / 1000);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { ensureAppHome } from "../config/config.js";
|
|
5
|
+
import { credentialsPath, credentialsReadPath } from "../config/home.js";
|
|
6
|
+
import { writeFileSecureAtomic } from "../config/fsSecurity.js";
|
|
7
|
+
const SERVICE = "copillm";
|
|
8
|
+
const ACCOUNT = "github-oauth-token";
|
|
9
|
+
const FileCredentialSchema = z.object({
|
|
10
|
+
version: z.literal(1),
|
|
11
|
+
github_token: z.string().min(1),
|
|
12
|
+
account_type: z.enum(["individual", "business", "enterprise"]),
|
|
13
|
+
saved_at: z.string().min(1)
|
|
14
|
+
});
|
|
15
|
+
// Module-level in-memory credential. Only populated when SaveMode === "session".
|
|
16
|
+
// Never persisted; cleared on clearStoredCredential() and on process exit.
|
|
17
|
+
let sessionCredential = null;
|
|
18
|
+
function forceSessionBackend() {
|
|
19
|
+
return process.env.COPILLM_FORCE_SESSION_BACKEND === "1";
|
|
20
|
+
}
|
|
21
|
+
async function tryImportKeytar() {
|
|
22
|
+
if (forceSessionBackend()) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const mod = await import("keytar");
|
|
27
|
+
return mod.default;
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (isMissingKeytarError(error)) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
if (error instanceof Error) {
|
|
34
|
+
throw new Error(`Failed to initialize OS keychain backend: ${error.message}`);
|
|
35
|
+
}
|
|
36
|
+
throw new Error("Failed to initialize OS keychain backend: unknown error");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function resolveKeytar() {
|
|
40
|
+
if (forceSessionBackend()) {
|
|
41
|
+
return { keytar: null, reason: "forced_session_backend" };
|
|
42
|
+
}
|
|
43
|
+
const keytar = await tryImportKeytar();
|
|
44
|
+
if (keytar) {
|
|
45
|
+
return { keytar, reason: null };
|
|
46
|
+
}
|
|
47
|
+
return { keytar: null, reason: "keytar module is unavailable on this machine" };
|
|
48
|
+
}
|
|
49
|
+
function parseCredentialFile() {
|
|
50
|
+
const path = credentialsReadPath();
|
|
51
|
+
const raw = readFileSync(path, "utf8");
|
|
52
|
+
let parsedJson;
|
|
53
|
+
try {
|
|
54
|
+
parsedJson = JSON.parse(raw);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (error instanceof Error) {
|
|
58
|
+
throw new Error(`Credential file exists but contains invalid JSON at ${path}: ${error.message}`);
|
|
59
|
+
}
|
|
60
|
+
throw new Error(`Credential file exists but contains invalid JSON at ${path}.`);
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const parsed = FileCredentialSchema.parse(parsedJson);
|
|
64
|
+
return { token: parsed.github_token, accountType: parsed.account_type };
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error instanceof Error) {
|
|
68
|
+
throw new Error(`Credential file exists but is invalid at ${path}: ${error.message}`);
|
|
69
|
+
}
|
|
70
|
+
throw new Error(`Credential file exists but is invalid at ${path}.`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function writeCredentialFile(token, accountType) {
|
|
74
|
+
ensureAppHome();
|
|
75
|
+
const payload = {
|
|
76
|
+
version: 1,
|
|
77
|
+
github_token: token,
|
|
78
|
+
account_type: accountType,
|
|
79
|
+
saved_at: new Date().toISOString()
|
|
80
|
+
};
|
|
81
|
+
writeFileSecureAtomic(credentialsPath(), JSON.stringify(payload, null, 2), 0o600);
|
|
82
|
+
}
|
|
83
|
+
function canUsePlaintextFallback() {
|
|
84
|
+
if (forceSessionBackend()) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
return process.stdin.isTTY || process.env.COPILLM_ALLOW_PLAINTEXT_CREDENTIALS === "1";
|
|
88
|
+
}
|
|
89
|
+
function isMissingKeytarError(error) {
|
|
90
|
+
if (!(error instanceof Error)) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
const message = error.message.toLowerCase();
|
|
94
|
+
return (message.includes("cannot find package 'keytar'") ||
|
|
95
|
+
message.includes("cannot find module 'keytar'") ||
|
|
96
|
+
message.includes("module not found"));
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Returns whether a credential is present and which backend holds it.
|
|
100
|
+
* Never returns the token itself — callers that need to introspect should use
|
|
101
|
+
* this helper to avoid accidentally pulling the secret into a code path that
|
|
102
|
+
* might log or print it.
|
|
103
|
+
*/
|
|
104
|
+
export async function inspectStoredCredential() {
|
|
105
|
+
if (sessionCredential) {
|
|
106
|
+
return { stored: true, backend: "session" };
|
|
107
|
+
}
|
|
108
|
+
if (fs.existsSync(credentialsReadPath())) {
|
|
109
|
+
return { stored: true, backend: "file" };
|
|
110
|
+
}
|
|
111
|
+
const { keytar } = await resolveKeytar();
|
|
112
|
+
if (!keytar) {
|
|
113
|
+
return { stored: false, backend: null };
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
const token = await keytar.getPassword(SERVICE, ACCOUNT);
|
|
117
|
+
if (token) {
|
|
118
|
+
return { stored: true, backend: "keytar" };
|
|
119
|
+
}
|
|
120
|
+
return { stored: false, backend: null };
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (error instanceof Error) {
|
|
124
|
+
throw new Error(`Failed to read token from OS keychain: ${error.message}`);
|
|
125
|
+
}
|
|
126
|
+
throw new Error("Failed to read token from OS keychain.");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
export async function loadStoredCredential() {
|
|
130
|
+
if (sessionCredential) {
|
|
131
|
+
return { token: sessionCredential.token, accountType: sessionCredential.accountType, source: "session" };
|
|
132
|
+
}
|
|
133
|
+
if (fs.existsSync(credentialsReadPath())) {
|
|
134
|
+
const parsed = parseCredentialFile();
|
|
135
|
+
return { token: parsed.token, accountType: parsed.accountType, source: "file" };
|
|
136
|
+
}
|
|
137
|
+
const { keytar } = await resolveKeytar();
|
|
138
|
+
if (!keytar) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
let token;
|
|
142
|
+
try {
|
|
143
|
+
token = await keytar.getPassword(SERVICE, ACCOUNT);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
if (error instanceof Error) {
|
|
147
|
+
throw new Error(`Failed to read token from OS keychain: ${error.message}`);
|
|
148
|
+
}
|
|
149
|
+
throw new Error("Failed to read token from OS keychain.");
|
|
150
|
+
}
|
|
151
|
+
if (!token) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
return { token, accountType: "individual", source: "keytar" };
|
|
155
|
+
}
|
|
156
|
+
export async function saveStoredCredential(token, accountType, options = {}) {
|
|
157
|
+
const mode = options.mode ?? "auto";
|
|
158
|
+
if (mode === "session") {
|
|
159
|
+
sessionCredential = { token, accountType };
|
|
160
|
+
return "session";
|
|
161
|
+
}
|
|
162
|
+
if (fs.existsSync(credentialsReadPath())) {
|
|
163
|
+
parseCredentialFile();
|
|
164
|
+
writeCredentialFile(token, accountType);
|
|
165
|
+
return "file";
|
|
166
|
+
}
|
|
167
|
+
const { keytar, reason } = await resolveKeytar();
|
|
168
|
+
if (keytar) {
|
|
169
|
+
try {
|
|
170
|
+
await keytar.setPassword(SERVICE, ACCOUNT, token);
|
|
171
|
+
return "keytar";
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
if (error instanceof Error) {
|
|
175
|
+
throw new Error(`Failed to write token to OS keychain: ${error.message}`);
|
|
176
|
+
}
|
|
177
|
+
throw new Error("Failed to write token to OS keychain.");
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (!canUsePlaintextFallback()) {
|
|
181
|
+
throw new Error(`OS keychain backend unavailable (${reason ?? "unknown reason"}). Plaintext fallback is disabled in non-interactive mode; set COPILLM_ALLOW_PLAINTEXT_CREDENTIALS=1 to allow it.`);
|
|
182
|
+
}
|
|
183
|
+
writeCredentialFile(token, accountType);
|
|
184
|
+
return "file";
|
|
185
|
+
}
|
|
186
|
+
export async function clearStoredCredential() {
|
|
187
|
+
// Always clear in-memory session token first; it shadows other backends.
|
|
188
|
+
const hadSession = sessionCredential !== null;
|
|
189
|
+
sessionCredential = null;
|
|
190
|
+
const readablePath = credentialsReadPath();
|
|
191
|
+
const canonicalPath = credentialsPath();
|
|
192
|
+
if (fs.existsSync(readablePath)) {
|
|
193
|
+
fs.unlinkSync(readablePath);
|
|
194
|
+
if (readablePath !== canonicalPath && fs.existsSync(canonicalPath)) {
|
|
195
|
+
fs.unlinkSync(canonicalPath);
|
|
196
|
+
}
|
|
197
|
+
return { backend: "file", removed: true };
|
|
198
|
+
}
|
|
199
|
+
const { keytar, reason } = await resolveKeytar();
|
|
200
|
+
if (keytar) {
|
|
201
|
+
try {
|
|
202
|
+
const removed = await keytar.deletePassword(SERVICE, ACCOUNT);
|
|
203
|
+
return { backend: "keytar", removed: removed || hadSession };
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
if (error instanceof Error) {
|
|
207
|
+
throw new Error(`Failed to clear token from OS keychain: ${error.message}`);
|
|
208
|
+
}
|
|
209
|
+
throw new Error("Failed to clear token from OS keychain.");
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (hadSession) {
|
|
213
|
+
return { backend: "session", removed: true };
|
|
214
|
+
}
|
|
215
|
+
throw new Error(`No credential backend available to clear credentials (${reason ?? "unknown reason"}).`);
|
|
216
|
+
}
|
|
217
|
+
// Test seam: forcibly clear the in-memory session credential. Not exported via
|
|
218
|
+
// the package surface for end users — only used by unit tests.
|
|
219
|
+
export function __resetSessionCredentialForTests() {
|
|
220
|
+
sessionCredential = null;
|
|
221
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const GITHUB_CLIENT_ID = "Iv1.b507a08c87ecfe98";
|
|
2
|
+
const DEVICE_CODE_URL = "https://github.com/login/device/code";
|
|
3
|
+
const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
|
|
4
|
+
export async function loginViaDeviceFlow() {
|
|
5
|
+
const start = await fetch(DEVICE_CODE_URL, {
|
|
6
|
+
method: "POST",
|
|
7
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
8
|
+
body: new URLSearchParams({ client_id: GITHUB_CLIENT_ID, scope: "read:user" })
|
|
9
|
+
});
|
|
10
|
+
if (!start.ok) {
|
|
11
|
+
throw new Error(`Device flow init failed (${start.status}).`);
|
|
12
|
+
}
|
|
13
|
+
const payload = parseDeviceCodeResponse((await start.json()));
|
|
14
|
+
const verificationUrl = payload.verification_uri_complete ?? payload.verification_uri;
|
|
15
|
+
process.stdout.write(`Open ${verificationUrl} and enter code ${payload.user_code}\n`);
|
|
16
|
+
const deadline = Date.now() + payload.expires_in * 1000;
|
|
17
|
+
let intervalMs = Math.max(1, payload.interval) * 1000;
|
|
18
|
+
while (Date.now() < deadline) {
|
|
19
|
+
await sleep(intervalMs);
|
|
20
|
+
const poll = await fetch(ACCESS_TOKEN_URL, {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
23
|
+
body: new URLSearchParams({
|
|
24
|
+
client_id: GITHUB_CLIENT_ID,
|
|
25
|
+
device_code: payload.device_code,
|
|
26
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code"
|
|
27
|
+
})
|
|
28
|
+
});
|
|
29
|
+
if (!poll.ok) {
|
|
30
|
+
throw new Error(`Access token poll failed (${poll.status}).`);
|
|
31
|
+
}
|
|
32
|
+
const tokenPayload = parseAccessTokenResponse((await poll.json()));
|
|
33
|
+
if (tokenPayload.access_token) {
|
|
34
|
+
return tokenPayload.access_token;
|
|
35
|
+
}
|
|
36
|
+
if (tokenPayload.error === "authorization_pending") {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (tokenPayload.error === "slow_down") {
|
|
40
|
+
intervalMs += 1000;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (tokenPayload.error === "expired_token") {
|
|
44
|
+
throw new Error("Device code expired before authorization completed.");
|
|
45
|
+
}
|
|
46
|
+
if (tokenPayload.error === "access_denied") {
|
|
47
|
+
throw new Error("Authorization was denied.");
|
|
48
|
+
}
|
|
49
|
+
const description = tokenPayload.error_description ? ` (${tokenPayload.error_description})` : "";
|
|
50
|
+
throw new Error(`Unexpected OAuth polling error: ${tokenPayload.error ?? "unknown"}${description}`);
|
|
51
|
+
}
|
|
52
|
+
throw new Error("Device authorization timed out.");
|
|
53
|
+
}
|
|
54
|
+
function sleep(ms) {
|
|
55
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
56
|
+
}
|
|
57
|
+
function parseDeviceCodeResponse(value) {
|
|
58
|
+
if (!value || typeof value !== "object") {
|
|
59
|
+
throw new Error("Device flow init returned an invalid payload.");
|
|
60
|
+
}
|
|
61
|
+
const payload = value;
|
|
62
|
+
if (typeof payload.device_code !== "string" ||
|
|
63
|
+
typeof payload.user_code !== "string" ||
|
|
64
|
+
typeof payload.verification_uri !== "string" ||
|
|
65
|
+
typeof payload.expires_in !== "number" ||
|
|
66
|
+
typeof payload.interval !== "number") {
|
|
67
|
+
throw new Error("Device flow init response is missing required fields.");
|
|
68
|
+
}
|
|
69
|
+
if (payload.verification_uri_complete !== undefined && typeof payload.verification_uri_complete !== "string") {
|
|
70
|
+
throw new Error("Device flow init response contains an invalid verification_uri_complete field.");
|
|
71
|
+
}
|
|
72
|
+
return payload;
|
|
73
|
+
}
|
|
74
|
+
function parseAccessTokenResponse(value) {
|
|
75
|
+
if (!value || typeof value !== "object") {
|
|
76
|
+
throw new Error("Device flow poll returned an invalid payload.");
|
|
77
|
+
}
|
|
78
|
+
const payload = value;
|
|
79
|
+
if (payload.access_token !== undefined && typeof payload.access_token !== "string") {
|
|
80
|
+
throw new Error("Device flow poll response contains an invalid access_token field.");
|
|
81
|
+
}
|
|
82
|
+
if (payload.error !== undefined && typeof payload.error !== "string") {
|
|
83
|
+
throw new Error("Device flow poll response contains an invalid error field.");
|
|
84
|
+
}
|
|
85
|
+
if (payload.error_description !== undefined && typeof payload.error_description !== "string") {
|
|
86
|
+
throw new Error("Device flow poll response contains an invalid error_description field.");
|
|
87
|
+
}
|
|
88
|
+
return payload;
|
|
89
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* If no credential is stored, prompts the user to log in interactively. After
|
|
3
|
+
* a successful device flow, decides where to put the token: keychain (if
|
|
4
|
+
* available), or prompts plaintext / session / cancel.
|
|
5
|
+
*
|
|
6
|
+
* Caller is responsible for checking !opts.detach — this function assumes a
|
|
7
|
+
* TTY-attached foreground process and will throw if stdin is not a TTY when
|
|
8
|
+
* a prompt is needed.
|
|
9
|
+
*/
|
|
10
|
+
export async function ensureAuthenticatedInteractive(deps) {
|
|
11
|
+
const existing = await deps.inspectStoredCredential();
|
|
12
|
+
if (existing.stored) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
if (!deps.isTty()) {
|
|
16
|
+
throw new Error("Not authenticated and stdin is not a TTY. Run `copillm auth login` first.");
|
|
17
|
+
}
|
|
18
|
+
deps.print("You are not logged in. copillm needs a GitHub OAuth token to talk to Copilot.\n");
|
|
19
|
+
const wantsLogin = await deps.confirm("Log in now?");
|
|
20
|
+
if (!wantsLogin) {
|
|
21
|
+
throw new Error("Aborted. Run `copillm auth login` when you're ready.");
|
|
22
|
+
}
|
|
23
|
+
const token = await deps.loginViaDeviceFlow();
|
|
24
|
+
const accountType = deps.loadAccountType();
|
|
25
|
+
// Try the normal save path first (keychain or pre-existing plaintext file).
|
|
26
|
+
// If that's not viable, fall back to an explicit user choice.
|
|
27
|
+
try {
|
|
28
|
+
const backend = await deps.saveStoredCredential(token, accountType);
|
|
29
|
+
deps.print(`Credentials stored via ${deps.describeBackend(backend)}.\n`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Falls through to the explicit-choice prompt below. We swallow the
|
|
34
|
+
// specific error because the user is about to choose a path anyway.
|
|
35
|
+
}
|
|
36
|
+
deps.print("OS keychain is unavailable on this machine. Choose where to store the token:\n");
|
|
37
|
+
const choice = await deps.choose(" (p) plaintext file at ~/.copillm/credentials.json (s) in-memory for this session only (c) cancel", [
|
|
38
|
+
{ key: "p", label: "plaintext", value: "plaintext" },
|
|
39
|
+
{ key: "s", label: "session", value: "session" },
|
|
40
|
+
{ key: "c", label: "cancel", value: "cancel" }
|
|
41
|
+
]);
|
|
42
|
+
if (choice === "cancel") {
|
|
43
|
+
throw new Error("Login aborted.");
|
|
44
|
+
}
|
|
45
|
+
if (choice === "session") {
|
|
46
|
+
await deps.saveStoredCredential(token, accountType, { mode: "session" });
|
|
47
|
+
deps.print("Token kept in memory only — you'll need to log in again when this process exits.\n");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
// choice === "plaintext" — allow the plaintext fallback explicitly for this
|
|
51
|
+
// process. The credentials module checks this env var at save time.
|
|
52
|
+
deps.setEnv("COPILLM_ALLOW_PLAINTEXT_CREDENTIALS", "1");
|
|
53
|
+
await deps.saveStoredCredential(token, accountType);
|
|
54
|
+
deps.print("Credentials stored via credentials file.\n");
|
|
55
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { loadStoredCredential } from "./credentials.js";
|
|
2
|
+
import { getGithubUserSummary, GithubUserFetchError } from "../server/debugInfo.js";
|
|
3
|
+
/**
|
|
4
|
+
* Inspection-style wrapper around loadStoredCredential + getGithubUserSummary
|
|
5
|
+
* that resolves the GitHub identity without ever exposing the token to the
|
|
6
|
+
* caller.
|
|
7
|
+
*
|
|
8
|
+
* Designed for the `auth status` code path: see the repo guideline
|
|
9
|
+
* "Don't wire status surfaces through loadStoredCredential". This helper is
|
|
10
|
+
* the only place the bearer touches the network from that code path, and the
|
|
11
|
+
* return value is shaped so the secret can't escape it.
|
|
12
|
+
*
|
|
13
|
+
* On any failure (no credential, network error, timeout, HTTP error) returns
|
|
14
|
+
* null so callers can gracefully fall back to existing offline output.
|
|
15
|
+
*/
|
|
16
|
+
export async function inspectGithubIdentity(options = {}) {
|
|
17
|
+
let credential;
|
|
18
|
+
try {
|
|
19
|
+
credential = await loadStoredCredential();
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
if (!credential) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const summary = await getGithubUserSummary(credential.token, {
|
|
29
|
+
timeoutMs: options.timeoutMs ?? 4_000
|
|
30
|
+
});
|
|
31
|
+
if (!summary.login) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return { login: summary.login, name: summary.name };
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error instanceof GithubUserFetchError) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|