@dev-mainsequence/codex-plugin 0.0.0-stage → 0.4.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/.codex-plugin/plugin.json +22 -0
- package/.mcp.json +16 -0
- package/LICENSE +21 -0
- package/NOTICE +13 -0
- package/README.md +155 -2
- package/assets/mainsequence-icon.png +0 -0
- package/assets/mainsequence-icon.svg +454 -0
- package/build-info.json +10 -0
- package/dist/hosts/codex.js +62 -0
- package/dist/hosts/codexPresentation.js +165 -0
- package/dist/hosts/discovery.js +138 -0
- package/dist/hosts/panel/panel.html +165 -0
- package/dist/package.json +1 -0
- package/dist/panel-server.js +135 -0
- package/dist/runner.js +71 -0
- package/dist/vendor/devtools/LICENSE +21 -0
- package/dist/vendor/devtools/NOTICE +12 -0
- package/dist/vendor/devtools/index.js +72 -0
- package/dist/vendor/devtools/package.json +5 -0
- package/dist/vendor/devtools/shared/accountScope.js +19 -0
- package/dist/vendor/devtools/shared/actions.js +164 -0
- package/dist/vendor/devtools/shared/agentReadiness.js +71 -0
- package/dist/vendor/devtools/shared/agents.js +240 -0
- package/dist/vendor/devtools/shared/arguments.js +142 -0
- package/dist/vendor/devtools/shared/cli.js +144 -0
- package/dist/vendor/devtools/shared/config.js +64 -0
- package/dist/vendor/devtools/shared/devcontainerJsonc.js +153 -0
- package/dist/vendor/devtools/shared/envfile.js +128 -0
- package/dist/vendor/devtools/shared/environments.js +38 -0
- package/dist/vendor/devtools/shared/hostSession.js +324 -0
- package/dist/vendor/devtools/shared/machineSession.js +338 -0
- package/dist/vendor/devtools/shared/mcp.js +421 -0
- package/dist/vendor/devtools/shared/mcpTransport.js +177 -0
- package/dist/vendor/devtools/shared/models.js +12 -0
- package/dist/vendor/devtools/shared/organizationProjectWorkspace.js +300 -0
- package/dist/vendor/devtools/shared/organizationProjects.js +134 -0
- package/dist/vendor/devtools/shared/process.js +61 -0
- package/dist/vendor/devtools/shared/projectOperations.js +279 -0
- package/dist/vendor/devtools/shared/projects.js +390 -0
- package/dist/vendor/devtools/shared/pythonProjectStatus.js +86 -0
- package/dist/vendor/devtools/shared/pythonSdkStatus.js +26 -0
- package/dist/vendor/devtools/shared/repositories.js +215 -0
- package/dist/vendor/devtools/shared/repositoryClient.js +108 -0
- package/dist/vendor/devtools/shared/repositoryIdentity.js +116 -0
- package/dist/vendor/devtools/shared/repositoryModels.js +72 -0
- package/dist/vendor/devtools/shared/session.js +532 -0
- package/docs/adr/0002-conversational-project-mcp-client.md +59 -0
- package/docs/agents-tools-panel.md +63 -0
- package/docs/codex-verification.md +161 -0
- package/docs/conversational-mcp.md +54 -0
- package/hooks/hooks.json +17 -0
- package/package.json +50 -4
- package/skills/mainsequence-mcp/SKILL.md +40 -0
- package/skills/ms/SKILL.md +8 -0
- package/skills/ms/agents/openai.yaml +7 -0
- package/skills/ms/assets/mainsequence-icon.png +0 -0
- package/skills/ms/assets/mainsequence-icon.svg +454 -0
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.MachineSessionService = void 0;
|
|
37
|
+
exports.requireBackend = requireBackend;
|
|
38
|
+
exports.createSessionService = createSessionService;
|
|
39
|
+
// Extracted from ms-vscode-extension/src/api.ts and authSession.ts (0.1.45).
|
|
40
|
+
// Host-independent machine mode; the VS Code legacy fallback stays with that host.
|
|
41
|
+
const crypto = __importStar(require("crypto"));
|
|
42
|
+
const mcpTransport_1 = require("./mcpTransport.js");
|
|
43
|
+
const http = __importStar(require("http"));
|
|
44
|
+
const config_1 = require("./config.js");
|
|
45
|
+
const machineSession_1 = require("./machineSession.js");
|
|
46
|
+
const models_1 = require("./models.js");
|
|
47
|
+
const accountScope_1 = require("./accountScope.js");
|
|
48
|
+
class AccountChangedError extends models_1.ToolingError {
|
|
49
|
+
constructor() { super('AUTH_REJECTED', 'The MainSequence account or session changed. Refresh the current view before continuing.'); }
|
|
50
|
+
}
|
|
51
|
+
function requireBackend(input) {
|
|
52
|
+
const backend = (0, config_1.normalizeBackendUrl)(input);
|
|
53
|
+
let url;
|
|
54
|
+
try {
|
|
55
|
+
url = new URL(backend);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'An HTTP(S) backend URL is required.');
|
|
59
|
+
}
|
|
60
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || /[\r\n]/.test(backend)) {
|
|
61
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'The backend must be an HTTP(S) URL without credentials, query, or fragment.');
|
|
62
|
+
}
|
|
63
|
+
// Keep the original normalized text: URL serialization would change the session account hash.
|
|
64
|
+
return backend;
|
|
65
|
+
}
|
|
66
|
+
function expiry(access) {
|
|
67
|
+
try {
|
|
68
|
+
const exp = JSON.parse(Buffer.from(access.split('.')[1], 'base64url').toString()).exp;
|
|
69
|
+
return typeof exp === 'number' && Number.isFinite(exp) ? new Date(exp * 1000).toISOString() : undefined;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** " on 2026-09-30 18:45 UTC" when the token carries an expiry that has passed. */
|
|
76
|
+
function expiredOn(token) {
|
|
77
|
+
const at = expiry(token);
|
|
78
|
+
return at && Date.parse(at) <= Date.now() ? ` on ${at.slice(0, 16).replace('T', ' ')} UTC` : '';
|
|
79
|
+
}
|
|
80
|
+
/** Say which credentials the backend refused and how to repair them; no token value is included. */
|
|
81
|
+
function refusedCredentials(source, backend, token) {
|
|
82
|
+
const when = expiredOn(token);
|
|
83
|
+
if (source === 'environment') {
|
|
84
|
+
return new models_1.ToolingError('AUTH_REJECTED', `The backend refused the credentials set in this process's environment (MAINSEQUENCE_ACCESS_TOKEN / MAINSEQUENCE_REFRESH_TOKEN) for ${backend}` +
|
|
85
|
+
(when ? `: they expired${when}.` : '.') +
|
|
86
|
+
' They win over the saved session. Start the process without them to use the saved session, or sign in again and set a current pair.');
|
|
87
|
+
}
|
|
88
|
+
return new models_1.ToolingError('AUTH_REJECTED', `The backend refused the saved session for ${backend}` + (when ? `: it expired${when}.` : ': it was revoked or belongs to another backend.') +
|
|
89
|
+
' Sign in again.');
|
|
90
|
+
}
|
|
91
|
+
function tokenPair(data, previous) {
|
|
92
|
+
const access = data?.access || data?.access_token || data?.token || data?.jwt;
|
|
93
|
+
const refresh = data?.refresh || data?.refresh_token || previous?.refresh;
|
|
94
|
+
const user = data?.user || data?.profile || data?.metadata?.user || data?.user_metadata || {};
|
|
95
|
+
const username = data?.username || data?.user_name || user?.username || user?.user_name || previous?.username || '';
|
|
96
|
+
if (typeof access !== 'string' || !access || typeof refresh !== 'string' || !refresh) {
|
|
97
|
+
throw new models_1.ToolingError('API_ERROR', 'The backend did not return a valid session pair.');
|
|
98
|
+
}
|
|
99
|
+
return { access, refresh, username: typeof username === 'string' ? username : '' };
|
|
100
|
+
}
|
|
101
|
+
class MachineSessionService {
|
|
102
|
+
host;
|
|
103
|
+
options;
|
|
104
|
+
store;
|
|
105
|
+
env;
|
|
106
|
+
renewals = new Map();
|
|
107
|
+
secrets = new Set();
|
|
108
|
+
verifiedAccounts = new Map();
|
|
109
|
+
constructor(host, options = {}) {
|
|
110
|
+
this.host = host;
|
|
111
|
+
this.options = options;
|
|
112
|
+
this.env = { ...(options.env ?? process.env) };
|
|
113
|
+
this.store = options.store === undefined ? (0, machineSession_1.openCredentialStore)({ env: this.env }) : options.store;
|
|
114
|
+
for (const [key, value] of Object.entries(this.env)) {
|
|
115
|
+
if (value && /TOKEN|CREDENTIAL_SECRET/i.test(key))
|
|
116
|
+
this.secrets.add(value);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** Only for internal error/output sanitization; never a token transport. */
|
|
120
|
+
sanitize(text) {
|
|
121
|
+
let output = text;
|
|
122
|
+
for (const secret of this.secrets) {
|
|
123
|
+
if (output === secret)
|
|
124
|
+
return '[redacted]';
|
|
125
|
+
if (secret.length >= 8)
|
|
126
|
+
output = output.split(secret).join('[redacted]');
|
|
127
|
+
}
|
|
128
|
+
return output
|
|
129
|
+
.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[redacted]')
|
|
130
|
+
.replace(/(https?:\/\/)[^/\s@]+@/gi, '$1[redacted]@')
|
|
131
|
+
.replace(/([?&](?:access_token|refresh_token|token|credential_secret|client_secret)=)[^&#\s]+/gi, '$1[redacted]');
|
|
132
|
+
}
|
|
133
|
+
remember(session) {
|
|
134
|
+
if (session.access)
|
|
135
|
+
this.secrets.add(session.access);
|
|
136
|
+
if (session.refresh)
|
|
137
|
+
this.secrets.add(session.refresh);
|
|
138
|
+
return session;
|
|
139
|
+
}
|
|
140
|
+
requireStore() {
|
|
141
|
+
if (!this.store)
|
|
142
|
+
throw new models_1.ToolingError('STORE_UNAVAILABLE', 'A machine credential store is required: macOS Keychain or Linux Secret Service with secret-tool.');
|
|
143
|
+
return this.store;
|
|
144
|
+
}
|
|
145
|
+
async saved(backend) {
|
|
146
|
+
try {
|
|
147
|
+
const value = this.options.persistence ? await this.options.persistence.read(backend) : (0, machineSession_1.readSession)(this.requireStore(), backend);
|
|
148
|
+
return value ? this.remember(value) : null;
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
if (error instanceof models_1.ToolingError)
|
|
152
|
+
throw error;
|
|
153
|
+
throw new models_1.ToolingError('STORE_ERROR', error instanceof machineSession_1.ForeignEntryError
|
|
154
|
+
? 'The saved entry belongs to an older tool. Sign in to replace it.'
|
|
155
|
+
: 'The machine session could not be read. Check that the credential store is unlocked and available.');
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
environment(backend) {
|
|
159
|
+
if (this.env.MAINSEQUENCE_RUNTIME_CREDENTIAL_ID || this.env.MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET) {
|
|
160
|
+
throw new models_1.ToolingError('UNSUPPORTED_ACTION', 'Runtime credentials are not a developer session. Use a separate developer process.');
|
|
161
|
+
}
|
|
162
|
+
if (this.env.MAINSEQUENCE_TOKEN)
|
|
163
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'MAINSEQUENCE_TOKEN is unsupported; use explicit MAINSEQUENCE_ACCESS_TOKEN and MAINSEQUENCE_ENDPOINT.');
|
|
164
|
+
if (!this.env.MAINSEQUENCE_ACCESS_TOKEN && !this.env.MAINSEQUENCE_REFRESH_TOKEN)
|
|
165
|
+
return null;
|
|
166
|
+
if (!this.env.MAINSEQUENCE_ENDPOINT || requireBackend(this.env.MAINSEQUENCE_ENDPOINT) !== backend) {
|
|
167
|
+
throw new models_1.ToolingError('BACKEND_MISMATCH', 'Process credentials must name the same backend with MAINSEQUENCE_ENDPOINT.');
|
|
168
|
+
}
|
|
169
|
+
return this.remember({ access: this.env.MAINSEQUENCE_ACCESS_TOKEN || '', refresh: this.env.MAINSEQUENCE_REFRESH_TOKEN || '', username: '' });
|
|
170
|
+
}
|
|
171
|
+
async selected(backend) {
|
|
172
|
+
const explicit = this.environment(backend);
|
|
173
|
+
if (explicit)
|
|
174
|
+
return { session: explicit, source: 'environment' };
|
|
175
|
+
const session = await this.saved(backend);
|
|
176
|
+
if (!session)
|
|
177
|
+
throw new models_1.ToolingError('NOT_AUTHENTICATED', 'No session is saved for this backend. Run login.');
|
|
178
|
+
return { session, source: 'machine' };
|
|
179
|
+
}
|
|
180
|
+
async http(backend, method, apiPath, body, access, timeoutMs) {
|
|
181
|
+
this.options.signal?.throwIfAborted();
|
|
182
|
+
if ((apiPath !== '/mcp' && !/^\/(api|auth)\//.test(apiPath)) || apiPath.includes('..'))
|
|
183
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'Only existing /api/, /auth/, or central /mcp endpoints can be requested.');
|
|
184
|
+
const controller = new AbortController();
|
|
185
|
+
const abort = () => controller.abort();
|
|
186
|
+
this.options.signal?.addEventListener('abort', abort, { once: true });
|
|
187
|
+
const timer = setTimeout(abort, this.options.requestTimeoutMs ?? timeoutMs ?? 30_000);
|
|
188
|
+
try {
|
|
189
|
+
const response = await (this.options.fetch ?? fetch)(backend + apiPath, {
|
|
190
|
+
method, redirect: 'error', signal: controller.signal,
|
|
191
|
+
headers: { 'Content-Type': 'application/json', ...(apiPath === '/mcp' ? { Accept: 'application/json, text/event-stream', 'MCP-Protocol-Version': '2025-11-25' } : {}), ...(access ? { Authorization: `Bearer ${access}` } : {}) },
|
|
192
|
+
body: body === undefined ? undefined : JSON.stringify(body)
|
|
193
|
+
});
|
|
194
|
+
if (response.status === 401 || response.status === 403)
|
|
195
|
+
throw new models_1.ToolingError('AUTH_REJECTED', 'The backend rejected the selected credentials.');
|
|
196
|
+
if (!response.ok)
|
|
197
|
+
throw new models_1.ToolingError('API_ERROR', `The backend returned HTTP ${response.status}.`);
|
|
198
|
+
if (apiPath === '/mcp')
|
|
199
|
+
return await (0, mcpTransport_1.readMcpResponse)(response, body);
|
|
200
|
+
if (response.status === 204)
|
|
201
|
+
return undefined;
|
|
202
|
+
try {
|
|
203
|
+
return await response.json();
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
throw new models_1.ToolingError('API_ERROR', 'The backend returned an invalid JSON response.');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
if (this.options.signal?.aborted)
|
|
211
|
+
throw new models_1.ToolingError('CANCELLED', 'The action was cancelled.');
|
|
212
|
+
if (error instanceof models_1.ToolingError)
|
|
213
|
+
throw error;
|
|
214
|
+
throw new models_1.ToolingError('BACKEND_UNREACHABLE', 'The backend could not be reached. The saved session has been kept.');
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
clearTimeout(timer);
|
|
218
|
+
this.options.signal?.removeEventListener('abort', abort);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
renew(backend, expectedAccount) {
|
|
222
|
+
const renewalKey = JSON.stringify([backend, expectedAccount]);
|
|
223
|
+
const running = this.renewals.get(renewalKey);
|
|
224
|
+
if (running)
|
|
225
|
+
return running;
|
|
226
|
+
const task = (async () => {
|
|
227
|
+
// Bypass cached state: another SDK process may have rotated the refresh token.
|
|
228
|
+
const previous = await this.saved(backend);
|
|
229
|
+
if (!previous?.refresh)
|
|
230
|
+
throw new models_1.ToolingError('NOT_AUTHENTICATED', 'No refreshable machine session is saved for this backend.');
|
|
231
|
+
if (expectedAccount !== undefined && previous.username && previous.username !== expectedAccount)
|
|
232
|
+
throw this.accountChanged();
|
|
233
|
+
let data;
|
|
234
|
+
try {
|
|
235
|
+
data = await this.http(backend, 'POST', '/auth/jwt-token/token/refresh/', { refresh: previous.refresh });
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
if (error instanceof models_1.ToolingError && error.code === 'AUTH_REJECTED')
|
|
239
|
+
throw refusedCredentials('machine', backend, previous.refresh);
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
const session = this.remember(tokenPair(data, previous));
|
|
243
|
+
if (expectedAccount !== undefined) {
|
|
244
|
+
const current = await this.saved(backend);
|
|
245
|
+
if (!current || current.access !== previous.access || current.refresh !== previous.refresh)
|
|
246
|
+
throw this.accountChanged();
|
|
247
|
+
}
|
|
248
|
+
// Refresh may rotate the old token. Keep the new pair even if the subsequent
|
|
249
|
+
// profile check is unavailable; no domain request runs before that check passes.
|
|
250
|
+
await this.persist(backend, session);
|
|
251
|
+
if (expectedAccount !== undefined)
|
|
252
|
+
await this.verifyAccount(backend, session, expectedAccount);
|
|
253
|
+
return session;
|
|
254
|
+
})();
|
|
255
|
+
this.renewals.set(renewalKey, task);
|
|
256
|
+
void task.finally(() => this.renewals.delete(renewalKey)).catch(() => { });
|
|
257
|
+
return task;
|
|
258
|
+
}
|
|
259
|
+
async persist(backend, session) {
|
|
260
|
+
try {
|
|
261
|
+
if (this.options.persistence)
|
|
262
|
+
await this.options.persistence.write(backend, session);
|
|
263
|
+
else
|
|
264
|
+
(0, machineSession_1.saveSession)(this.requireStore(), backend, session);
|
|
265
|
+
const saved = await this.saved(backend);
|
|
266
|
+
if (!saved || saved.access !== session.access || saved.refresh !== session.refresh)
|
|
267
|
+
throw new Error('verification failed');
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
throw new models_1.ToolingError('PERSISTENCE_FAILED', 'Authorization completed but the machine session could not be saved and verified.');
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
async status(input, check = false) {
|
|
274
|
+
const backend = requireBackend(input);
|
|
275
|
+
const explicit = this.environment(backend);
|
|
276
|
+
let session = explicit || ((this.store || this.options.persistence) ? await this.saved(backend) : null);
|
|
277
|
+
const source = explicit ? 'environment' : session ? (this.options.persistence?.mode ?? 'machine') : 'none';
|
|
278
|
+
let expiresAt = expiry(session?.access || '');
|
|
279
|
+
let authenticated = Boolean(session?.access && (!expiresAt || Date.parse(expiresAt) > Date.now()) || (!explicit && session?.refresh));
|
|
280
|
+
if (check) {
|
|
281
|
+
if (!authenticated)
|
|
282
|
+
throw new models_1.ToolingError('NOT_AUTHENTICATED', 'No usable session is selected for this backend.');
|
|
283
|
+
if (authenticated) {
|
|
284
|
+
const profile = await this.request(backend, 'GET', '/api/v1/users/me/');
|
|
285
|
+
const username = profile?.user?.username || profile?.username;
|
|
286
|
+
if (typeof username === 'string')
|
|
287
|
+
session = { ...session, username };
|
|
288
|
+
authenticated = true;
|
|
289
|
+
expiresAt = expiry((explicit || await this.saved(backend))?.access || '');
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
backend, authenticated, source,
|
|
294
|
+
persistence: explicit ? 'environment' : this.options.persistence?.mode ?? (this.store ? 'machine' : 'unavailable'),
|
|
295
|
+
store: this.options.persistence?.name ?? this.store?.name ?? null,
|
|
296
|
+
...(session?.username ? { username: session.username } : {}),
|
|
297
|
+
...(expiresAt ? { expiresAt } : {}), ...(check ? { checked: true } : {})
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
async request(input, method, apiPath, body, retrySafe = method === 'GET', options) {
|
|
301
|
+
return this.accountRequest(input, method, apiPath, body, retrySafe, options);
|
|
302
|
+
}
|
|
303
|
+
accountChanged() {
|
|
304
|
+
return new AccountChangedError();
|
|
305
|
+
}
|
|
306
|
+
recordAccount(backend, session, profile, expectedAccount) {
|
|
307
|
+
const username = profile?.user?.username || profile?.username;
|
|
308
|
+
if (username !== expectedAccount) {
|
|
309
|
+
this.verifiedAccounts.delete(JSON.stringify([backend, session.access]));
|
|
310
|
+
throw this.accountChanged();
|
|
311
|
+
}
|
|
312
|
+
if (this.verifiedAccounts.size >= 32)
|
|
313
|
+
this.verifiedAccounts.delete(this.verifiedAccounts.keys().next().value);
|
|
314
|
+
this.verifiedAccounts.set(JSON.stringify([backend, session.access]), username);
|
|
315
|
+
}
|
|
316
|
+
async verifyAccount(backend, session, expectedAccount) {
|
|
317
|
+
if (this.verifiedAccounts.get(JSON.stringify([backend, session.access])) === expectedAccount)
|
|
318
|
+
return;
|
|
319
|
+
this.recordAccount(backend, session, await this.http(backend, 'GET', '/api/v1/users/me/', undefined, session.access), expectedAccount);
|
|
320
|
+
}
|
|
321
|
+
/** The same selected credential is verified and used. A 401 retry must verify its new credential too. */
|
|
322
|
+
async withAccount(input, expectedAccount) {
|
|
323
|
+
const backend = requireBackend(input);
|
|
324
|
+
(0, accountScope_1.validateExpectedAccount)(expectedAccount);
|
|
325
|
+
const target = (value) => { if (requireBackend(value) !== backend)
|
|
326
|
+
throw new models_1.ToolingError('BACKEND_MISMATCH', 'The request belongs to another backend.'); };
|
|
327
|
+
const unsupported = async () => { throw new models_1.ToolingError('UNSUPPORTED_ACTION', 'Account-bound operations cannot change authentication.'); };
|
|
328
|
+
const scoped = {
|
|
329
|
+
request: (value, method, apiPath, body, retrySafe = method === 'GET', options) => {
|
|
330
|
+
target(value);
|
|
331
|
+
return this.accountRequest(backend, method, apiPath, body, retrySafe, options, expectedAccount);
|
|
332
|
+
},
|
|
333
|
+
status: async (value) => {
|
|
334
|
+
target(value);
|
|
335
|
+
await this.accountRequest(backend, 'GET', '/api/v1/users/me/', undefined, true, undefined, expectedAccount);
|
|
336
|
+
const current = await this.selected(backend);
|
|
337
|
+
await this.verifyAccount(backend, current.session, expectedAccount);
|
|
338
|
+
const status = await this.status(backend);
|
|
339
|
+
if (!status.authenticated)
|
|
340
|
+
throw this.accountChanged();
|
|
341
|
+
return { ...status, username: expectedAccount, checked: true };
|
|
342
|
+
},
|
|
343
|
+
login: unsupported, logout: unsupported, refresh: unsupported,
|
|
344
|
+
sanitize: text => this.sanitize(text),
|
|
345
|
+
};
|
|
346
|
+
await scoped.status(backend, true);
|
|
347
|
+
return scoped;
|
|
348
|
+
}
|
|
349
|
+
async accountRequest(input, method, apiPath, body, retrySafe, options, expectedAccount) {
|
|
350
|
+
if (options?.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0 || options.timeoutMs > 300000))
|
|
351
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'Request timeout must be between 1 and 300000 milliseconds.');
|
|
352
|
+
const backend = requireBackend(input);
|
|
353
|
+
let { session, source } = await this.selected(backend);
|
|
354
|
+
if (!session.access || (expiry(session.access) && Date.parse(expiry(session.access)) <= Date.now() + 30_000)) {
|
|
355
|
+
if (source === 'machine')
|
|
356
|
+
session = await this.renew(backend, expectedAccount);
|
|
357
|
+
else if (!session.access)
|
|
358
|
+
throw new models_1.ToolingError('NOT_AUTHENTICATED', 'The explicit environment has no access token.');
|
|
359
|
+
}
|
|
360
|
+
const send = async (selected) => {
|
|
361
|
+
const checkSelected = async () => {
|
|
362
|
+
if (expectedAccount === undefined)
|
|
363
|
+
return;
|
|
364
|
+
const current = await this.selected(backend);
|
|
365
|
+
if (current.source !== source || current.session.access !== selected.access || current.session.refresh !== selected.refresh)
|
|
366
|
+
throw this.accountChanged();
|
|
367
|
+
};
|
|
368
|
+
if (expectedAccount !== undefined && apiPath !== '/api/v1/users/me/')
|
|
369
|
+
await this.verifyAccount(backend, selected, expectedAccount);
|
|
370
|
+
await checkSelected();
|
|
371
|
+
const value = await this.http(backend, method, apiPath, body, selected.access, options?.timeoutMs);
|
|
372
|
+
if (expectedAccount !== undefined && apiPath === '/api/v1/users/me/')
|
|
373
|
+
this.recordAccount(backend, selected, value, expectedAccount);
|
|
374
|
+
await checkSelected();
|
|
375
|
+
return value;
|
|
376
|
+
};
|
|
377
|
+
try {
|
|
378
|
+
return await send(session);
|
|
379
|
+
}
|
|
380
|
+
catch (error) {
|
|
381
|
+
if (!(error instanceof models_1.ToolingError) || error.code !== 'AUTH_REJECTED')
|
|
382
|
+
throw error;
|
|
383
|
+
// Scope failures are not expired-token failures and must never trigger a replay.
|
|
384
|
+
if (error instanceof AccountChangedError)
|
|
385
|
+
throw error;
|
|
386
|
+
if (source === 'environment')
|
|
387
|
+
throw refusedCredentials('environment', backend, session.access);
|
|
388
|
+
if (!retrySafe)
|
|
389
|
+
throw error;
|
|
390
|
+
const latest = await this.saved(backend);
|
|
391
|
+
const retry = latest?.access && latest.access !== session.access ? latest : await this.renew(backend, expectedAccount);
|
|
392
|
+
try {
|
|
393
|
+
return await send(retry);
|
|
394
|
+
}
|
|
395
|
+
catch (retryError) {
|
|
396
|
+
if (retryError instanceof AccountChangedError)
|
|
397
|
+
throw retryError;
|
|
398
|
+
if (retryError instanceof models_1.ToolingError && retryError.code === 'AUTH_REJECTED')
|
|
399
|
+
throw refusedCredentials('machine', backend, retry.refresh || retry.access);
|
|
400
|
+
throw retryError;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
async refresh(input) {
|
|
405
|
+
const backend = requireBackend(input);
|
|
406
|
+
await this.renew(backend); // Explicitly renew the saved machine pair, not a user process override.
|
|
407
|
+
return this.machineStatus(backend);
|
|
408
|
+
}
|
|
409
|
+
async machineStatus(backend) {
|
|
410
|
+
const session = await this.saved(backend);
|
|
411
|
+
return { backend, authenticated: Boolean(session?.access || session?.refresh), source: session ? (this.options.persistence?.mode ?? 'machine') : 'none', persistence: this.options.persistence?.mode ?? 'machine', store: this.options.persistence?.name ?? this.store?.name ?? null, username: session?.username, expiresAt: expiry(session?.access || '') };
|
|
412
|
+
}
|
|
413
|
+
async logout(input) {
|
|
414
|
+
const backend = requireBackend(input);
|
|
415
|
+
const session = await this.saved(backend);
|
|
416
|
+
let revoked = false;
|
|
417
|
+
try {
|
|
418
|
+
if (session?.refresh) {
|
|
419
|
+
await this.http(backend, 'POST', '/auth/cli/revoke/', { refresh: session.refresh });
|
|
420
|
+
revoked = true;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
catch { /* local logout is still useful offline; report revocation separately */ }
|
|
424
|
+
if (this.options.signal?.aborted)
|
|
425
|
+
throw new models_1.ToolingError('CANCELLED', 'The action was cancelled.');
|
|
426
|
+
try {
|
|
427
|
+
if (this.options.persistence)
|
|
428
|
+
await this.options.persistence.remove(backend);
|
|
429
|
+
else
|
|
430
|
+
(0, machineSession_1.clearSession)(this.requireStore(), backend);
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
throw new models_1.ToolingError('STORE_ERROR', 'The machine session could not be removed.');
|
|
434
|
+
}
|
|
435
|
+
return { revoked };
|
|
436
|
+
}
|
|
437
|
+
async login(input) {
|
|
438
|
+
const backend = requireBackend(input);
|
|
439
|
+
if (!this.options.persistence)
|
|
440
|
+
this.requireStore();
|
|
441
|
+
// Probe before opening the browser. An old marked entry can be replaced by explicit login.
|
|
442
|
+
try {
|
|
443
|
+
await this.saved(backend);
|
|
444
|
+
}
|
|
445
|
+
catch (error) {
|
|
446
|
+
if (!(error instanceof machineSession_1.ForeignEntryError) && !(error instanceof models_1.ToolingError && error.code === 'STORE_ERROR' && error.message.includes('older tool')))
|
|
447
|
+
throw new models_1.ToolingError('STORE_ERROR', 'The credential store is unavailable. Unlock it before signing in.');
|
|
448
|
+
}
|
|
449
|
+
this.options.signal?.throwIfAborted();
|
|
450
|
+
const state = crypto.randomBytes(32).toString('base64url');
|
|
451
|
+
const verifier = crypto.randomBytes(64).toString('base64url');
|
|
452
|
+
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
453
|
+
let resolveCallback;
|
|
454
|
+
let rejectCallback;
|
|
455
|
+
const callback = new Promise((resolve, reject) => { resolveCallback = resolve; rejectCallback = reject; });
|
|
456
|
+
void callback.catch(() => { });
|
|
457
|
+
let completed = false;
|
|
458
|
+
const server = http.createServer((req, res) => {
|
|
459
|
+
const parsed = new URL(req.url || '/', 'http://127.0.0.1');
|
|
460
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
461
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
462
|
+
if (parsed.pathname !== '/callback') {
|
|
463
|
+
res.writeHead(404).end('Not found');
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (parsed.searchParams.get('state') !== state) {
|
|
467
|
+
res.writeHead(400).end('Invalid authorization state');
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (completed) {
|
|
471
|
+
res.writeHead(409).end('Callback already handled');
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
completed = true;
|
|
475
|
+
const code = parsed.searchParams.get('code');
|
|
476
|
+
if (!code || parsed.searchParams.has('error')) {
|
|
477
|
+
rejectCallback(new models_1.ToolingError('INVALID_CALLBACK', 'Browser authorization did not return an authorization code.'));
|
|
478
|
+
res.writeHead(400).end('MainSequence authorization failed. Return to your developer tool.');
|
|
479
|
+
}
|
|
480
|
+
else {
|
|
481
|
+
resolveCallback(code);
|
|
482
|
+
res.end('MainSequence authorization received. Return to your developer tool to check that it was saved.');
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
const abort = () => rejectCallback(new models_1.ToolingError('CANCELLED', 'Login was cancelled.'));
|
|
486
|
+
this.options.signal?.addEventListener('abort', abort, { once: true });
|
|
487
|
+
const timer = setTimeout(() => rejectCallback(new models_1.ToolingError('LOGIN_TIMEOUT', 'Timed out waiting for browser authorization.')), this.options.loginTimeoutMs ?? 300_000);
|
|
488
|
+
try {
|
|
489
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
|
|
490
|
+
const address = server.address();
|
|
491
|
+
if (!address || typeof address === 'string')
|
|
492
|
+
throw new models_1.ToolingError('BROWSER_FAILED', 'Could not start the loopback callback.');
|
|
493
|
+
const redirectUri = `http://127.0.0.1:${address.port}/callback`;
|
|
494
|
+
const params = new URLSearchParams({ client_id: 'mainsequence-cli', response_type: 'code', state, redirect_uri: redirectUri, code_challenge: challenge, code_challenge_method: 'S256', prompt: 'select_account' });
|
|
495
|
+
let opened = false;
|
|
496
|
+
try {
|
|
497
|
+
opened = await this.host.openBrowser(`${backend}/auth/cli/authorize/?${params}`);
|
|
498
|
+
}
|
|
499
|
+
catch { /* safe error below */ }
|
|
500
|
+
if (!opened)
|
|
501
|
+
throw new models_1.ToolingError('BROWSER_FAILED', 'Could not open the browser. Check your desktop browser configuration.');
|
|
502
|
+
const code = await callback;
|
|
503
|
+
const session = this.remember(tokenPair(await this.http(backend, 'POST', '/auth/cli/token/', { client_id: 'mainsequence-cli', code, code_verifier: verifier, redirect_uri: redirectUri })));
|
|
504
|
+
await this.persist(backend, session);
|
|
505
|
+
if (!session.username) {
|
|
506
|
+
try {
|
|
507
|
+
const profile = await this.request(backend, 'GET', '/api/v1/users/me/');
|
|
508
|
+
const username = profile?.user?.username || profile?.username;
|
|
509
|
+
const latest = await this.saved(backend);
|
|
510
|
+
if (typeof username === 'string' && username.trim() && latest)
|
|
511
|
+
await this.persist(backend, { ...latest, username: username.trim() });
|
|
512
|
+
}
|
|
513
|
+
catch { /* Profile is best-effort; the authorization pair is already durable. */ }
|
|
514
|
+
}
|
|
515
|
+
this.options.signal?.throwIfAborted();
|
|
516
|
+
return this.machineStatus(backend);
|
|
517
|
+
}
|
|
518
|
+
finally {
|
|
519
|
+
clearTimeout(timer);
|
|
520
|
+
this.options.signal?.removeEventListener('abort', abort);
|
|
521
|
+
server.closeAllConnections();
|
|
522
|
+
await new Promise(resolve => { if (server.listening)
|
|
523
|
+
server.close(() => resolve());
|
|
524
|
+
else
|
|
525
|
+
resolve(); });
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
exports.MachineSessionService = MachineSessionService;
|
|
530
|
+
function createSessionService(host, options = {}) {
|
|
531
|
+
return new MachineSessionService(host, options);
|
|
532
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# ADR 0002: Conversational project MCP connections in the Codex plugin
|
|
2
|
+
|
|
3
|
+
Date: 2026-10-09. Moved from mainsequence-devtools on 2026-10-21 with the Codex plugin; "this repository" below refers to devtools, where the MCP client (the `mcp-*` actions and the connection store) still lives. The skill, hook and bundled runner that expose it in Codex are released from this repository.
|
|
4
|
+
|
|
5
|
+
Status: Accepted for implementation by the owner's instruction, "OK then extend the codex plugin", following the explicit proposal to use a bundled client instead of native Codex MCP registration.
|
|
6
|
+
|
|
7
|
+
## Decision
|
|
8
|
+
|
|
9
|
+
Extend this repository's shared dispatcher and bundled runner with discovery, add, list, remove, tool-catalog and tool-call actions. Package a normally discoverable mainsequence-mcp skill alongside the explicit-only ms developer-command skill. A person can ask which project MCPs exist, select one, use its tools in the same conversation, and use the saved connection in later conversations. They do not invoke /ms or edit Codex configuration for these actions.
|
|
10
|
+
|
|
11
|
+
The plugin is an MCP client. Codex runs its bundled helper through its existing execution capability. Each project remains a direct remote MCP endpoint; the platform directory is not a proxy for its tools. No new service, background daemon, application deployment, backend endpoint, credential namespace, or native host modification is introduced.
|
|
12
|
+
|
|
13
|
+
The distinction from native registration is part of the contract: a saved project does not appear as its own native Codex MCP server or model tool namespace. The helper discovers the live schemas and invokes the selected tool through MCP. The plugin must already be installed and its conversational skill loaded by the host. Distributing an updated plugin is separate from adding a project connection.
|
|
14
|
+
|
|
15
|
+
## Authentication decision and evidence
|
|
16
|
+
|
|
17
|
+
Reuse the existing developer machine session and authenticated GET /api/v1/resource-releases/{uid}/exchange-launch/. For a FastAPI release this returns mode=token, release_kind=fastapi, an opaque short-lived release credential, and rpc_url. The current project gateway accepts these release credentials on POST /mcp under current User, release and Environment permissions. Resource-bound browser OAuth remains the independent direct-client login mechanism; this client neither modifies it nor copies a central OAuth token to a project.
|
|
18
|
+
|
|
19
|
+
This resolves the implementation question using an existing mechanism: an already signed-in developer needs no new browser consent per project and no new backend authorization handoff. A central MCP connection in Codex alone is not a machine session. When that session is missing or expired beyond renewal, the existing hosted developer login remains necessary; the plugin cannot promise first-time sign-in entirely within chat.
|
|
20
|
+
|
|
21
|
+
The inspected platform contract is owned by platform ADR-0050 sections 4 and 6. Its current text explicitly permits existing release bearer credentials at project MCP endpoints, and its gateway tests cover that path. This ADR records the decision to consume that existing contract so it can be evaluated independently of implementation. No platform ADR amendment or backend behavior change is required by this implementation. No agent-specific approval/exclusion list is introduced.
|
|
22
|
+
|
|
23
|
+
The platform credential is sent only to the selected backend. The release credential is sent only to the freshly resolved canonical project endpoint, after matching the exchange's RPC origin. It is held in memory for that invocation, redacted from results, and never added to connection files, tool arguments or command lines. Existing machine-session rotation, OS-store behavior and explicit process-identity precedence remain authoritative.
|
|
24
|
+
|
|
25
|
+
## Discovery and connection lifecycle
|
|
26
|
+
|
|
27
|
+
Discovery initializes central POST /mcp and calls resource_release.mcp_endpoints with search, limit and offset. It returns the existing directory projection; there is no copied application tool catalog.
|
|
28
|
+
|
|
29
|
+
Add selects a release UID, rereads the authenticated release detail, validates its current connection, exchanges a release credential, initializes the project and enumerates its paginated tools before saving. Repeated add replaces only the same backend/UID record. Duplicate display names require selecting a UID. Arbitrary caller-provided project URLs are not accepted.
|
|
30
|
+
|
|
31
|
+
Saved records contain only backend, release UID, display name, and the canonical MCP connection metadata (URL, transport, resource metadata URL, Environment UID, active revision UID). Store them in mcp-connections.json next to the existing CLI configuration:
|
|
32
|
+
|
|
33
|
+
- macOS: ~/Library/Application Support/MainSequenceCLI/
|
|
34
|
+
- Linux: ~/.config/mainsequence/
|
|
35
|
+
- Windows: %APPDATA%/MainSequenceCLI/ (connection metadata only; native saved authentication remains unsupported by the existing Node store).
|
|
36
|
+
|
|
37
|
+
Writes use a private file, a lock, and atomic replacement. Corrupt records are preserved and reported. Library/test hosts may supply mcpConnectionsPath without changing the platform credential store.
|
|
38
|
+
|
|
39
|
+
List is local and does not promise availability. Tools/call require a saved record, reread current release access and MCP capability, and reject an endpoint change until the person adds that release again. A new active revision on the same endpoint uses its current catalog. Remove forgets only the selected record; it does not log out or revoke credentials.
|
|
40
|
+
|
|
41
|
+
## Transport and operation semantics
|
|
42
|
+
|
|
43
|
+
Use only the platform's stateless Streamable HTTP profile, MCP 2025-11-25, including initialize, notifications/initialized, tools/list and tools/call. Support JSON and POST SSE results, bounded bodies, pagination, request timeouts and cancellation. Do not add deprecated HTTP+SSE endpoint discovery, protocol downgrade, stateful sessions, subscriptions, tasks, sampling, elicitation, native Codex registration or reload fallbacks.
|
|
44
|
+
|
|
45
|
+
All server-advertised ordinary tools can be selected, including writes. Tool annotations inform the harness; they do not constitute another permission system. The server validates tool arguments and operation permissions.
|
|
46
|
+
|
|
47
|
+
Project requests do not follow redirects and are never automatically replayed. Once a tools/call request starts, failure or cancellation reports potentially partial/uncertain effects. MCP isError results remain available as error data with known authentication credentials redacted.
|
|
48
|
+
|
|
49
|
+
## Classification and verification
|
|
50
|
+
|
|
51
|
+
A: Amend ADR 0001 to distinguish its original deterministic developer path from the newly approved conversational MCP client.
|
|
52
|
+
|
|
53
|
+
B: This ADR owns the new local connection lifecycle and host limitation.
|
|
54
|
+
|
|
55
|
+
C: This is an owner-requested feature, not a defect or code-only fix; no defect issue is opened.
|
|
56
|
+
|
|
57
|
+
D: Update README and the conversational MCP guide in the same change.
|
|
58
|
+
|
|
59
|
+
Verification uses separate loopback platform and project servers, synthetic credentials and temporary connection files. Cover discovery through the actual tool name, same-session use, persistence across client instances/processes, duplicate names, backend isolation, private renewal, revocation, endpoint changes, pagination, JSON/SSE, bounded transport failures, no write replay, cancellation, redaction and packaged runner execution. No live project operation or OpenAI inference is required. Real endpoint interoperability and automatic model selection remain deployment validation; fixture tests must not be described as live desktop end-to-end proof.
|