@dev-mainsequence/codex-plugin 0.0.0-stage → 0.3.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.
Files changed (50) hide show
  1. package/.codex-plugin/plugin.json +21 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +13 -0
  4. package/README.md +143 -2
  5. package/assets/mainsequence-icon.png +0 -0
  6. package/assets/mainsequence-icon.svg +454 -0
  7. package/build-info.json +10 -0
  8. package/dist/hosts/codex.js +62 -0
  9. package/dist/hosts/codexPresentation.js +164 -0
  10. package/dist/package.json +1 -0
  11. package/dist/runner.js +71 -0
  12. package/dist/vendor/devtools/LICENSE +21 -0
  13. package/dist/vendor/devtools/NOTICE +12 -0
  14. package/dist/vendor/devtools/index.js +68 -0
  15. package/dist/vendor/devtools/package.json +5 -0
  16. package/dist/vendor/devtools/shared/actions.js +156 -0
  17. package/dist/vendor/devtools/shared/agentReadiness.js +71 -0
  18. package/dist/vendor/devtools/shared/agents.js +236 -0
  19. package/dist/vendor/devtools/shared/arguments.js +142 -0
  20. package/dist/vendor/devtools/shared/cli.js +144 -0
  21. package/dist/vendor/devtools/shared/config.js +64 -0
  22. package/dist/vendor/devtools/shared/devcontainerJsonc.js +153 -0
  23. package/dist/vendor/devtools/shared/envfile.js +128 -0
  24. package/dist/vendor/devtools/shared/hostSession.js +324 -0
  25. package/dist/vendor/devtools/shared/machineSession.js +338 -0
  26. package/dist/vendor/devtools/shared/mcp.js +329 -0
  27. package/dist/vendor/devtools/shared/mcpTransport.js +164 -0
  28. package/dist/vendor/devtools/shared/models.js +12 -0
  29. package/dist/vendor/devtools/shared/organizationProjectWorkspace.js +300 -0
  30. package/dist/vendor/devtools/shared/organizationProjects.js +145 -0
  31. package/dist/vendor/devtools/shared/process.js +61 -0
  32. package/dist/vendor/devtools/shared/projectOperations.js +279 -0
  33. package/dist/vendor/devtools/shared/projects.js +390 -0
  34. package/dist/vendor/devtools/shared/pythonProjectStatus.js +86 -0
  35. package/dist/vendor/devtools/shared/pythonSdkStatus.js +26 -0
  36. package/dist/vendor/devtools/shared/repositories.js +215 -0
  37. package/dist/vendor/devtools/shared/repositoryClient.js +108 -0
  38. package/dist/vendor/devtools/shared/repositoryIdentity.js +116 -0
  39. package/dist/vendor/devtools/shared/repositoryModels.js +72 -0
  40. package/dist/vendor/devtools/shared/session.js +444 -0
  41. package/docs/adr/0002-conversational-project-mcp-client.md +59 -0
  42. package/docs/codex-verification.md +124 -0
  43. package/docs/conversational-mcp.md +54 -0
  44. package/hooks/hooks.json +17 -0
  45. package/package.json +46 -4
  46. package/skills/mainsequence-mcp/SKILL.md +40 -0
  47. package/skills/ms/SKILL.md +8 -0
  48. package/skills/ms/agents/openai.yaml +7 -0
  49. package/skills/ms/assets/mainsequence-icon.png +0 -0
  50. package/skills/ms/assets/mainsequence-icon.svg +454 -0
@@ -0,0 +1,444 @@
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
+ function requireBackend(input) {
48
+ const backend = (0, config_1.normalizeBackendUrl)(input);
49
+ let url;
50
+ try {
51
+ url = new URL(backend);
52
+ }
53
+ catch {
54
+ throw new models_1.ToolingError('INVALID_REQUEST', 'An HTTP(S) backend URL is required.');
55
+ }
56
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || /[\r\n]/.test(backend)) {
57
+ throw new models_1.ToolingError('INVALID_REQUEST', 'The backend must be an HTTP(S) URL without credentials, query, or fragment.');
58
+ }
59
+ // Keep the original normalized text: URL serialization would change the session account hash.
60
+ return backend;
61
+ }
62
+ function expiry(access) {
63
+ try {
64
+ const exp = JSON.parse(Buffer.from(access.split('.')[1], 'base64url').toString()).exp;
65
+ return typeof exp === 'number' && Number.isFinite(exp) ? new Date(exp * 1000).toISOString() : undefined;
66
+ }
67
+ catch {
68
+ return undefined;
69
+ }
70
+ }
71
+ /** " on 2026-09-30 18:45 UTC" when the token carries an expiry that has passed. */
72
+ function expiredOn(token) {
73
+ const at = expiry(token);
74
+ return at && Date.parse(at) <= Date.now() ? ` on ${at.slice(0, 16).replace('T', ' ')} UTC` : '';
75
+ }
76
+ /** Say which credentials the backend refused and how to repair them; no token value is included. */
77
+ function refusedCredentials(source, backend, token) {
78
+ const when = expiredOn(token);
79
+ if (source === 'environment') {
80
+ 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}` +
81
+ (when ? `: they expired${when}.` : '.') +
82
+ ' 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.');
83
+ }
84
+ 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.') +
85
+ ' Sign in again.');
86
+ }
87
+ function tokenPair(data, previous) {
88
+ const access = data?.access || data?.access_token || data?.token || data?.jwt;
89
+ const refresh = data?.refresh || data?.refresh_token || previous?.refresh;
90
+ const user = data?.user || data?.profile || data?.metadata?.user || data?.user_metadata || {};
91
+ const username = data?.username || data?.user_name || user?.username || user?.user_name || previous?.username || '';
92
+ if (typeof access !== 'string' || !access || typeof refresh !== 'string' || !refresh) {
93
+ throw new models_1.ToolingError('API_ERROR', 'The backend did not return a valid session pair.');
94
+ }
95
+ return { access, refresh, username: typeof username === 'string' ? username : '' };
96
+ }
97
+ class MachineSessionService {
98
+ host;
99
+ options;
100
+ store;
101
+ env;
102
+ renewals = new Map();
103
+ secrets = new Set();
104
+ constructor(host, options = {}) {
105
+ this.host = host;
106
+ this.options = options;
107
+ this.env = { ...(options.env ?? process.env) };
108
+ this.store = options.store === undefined ? (0, machineSession_1.openCredentialStore)({ env: this.env }) : options.store;
109
+ for (const [key, value] of Object.entries(this.env)) {
110
+ if (value && /TOKEN|CREDENTIAL_SECRET/i.test(key))
111
+ this.secrets.add(value);
112
+ }
113
+ }
114
+ /** Only for internal error/output sanitization; never a token transport. */
115
+ sanitize(text) {
116
+ let output = text;
117
+ for (const secret of this.secrets) {
118
+ if (output === secret)
119
+ return '[redacted]';
120
+ if (secret.length >= 8)
121
+ output = output.split(secret).join('[redacted]');
122
+ }
123
+ return output
124
+ .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[redacted]')
125
+ .replace(/(https?:\/\/)[^/\s@]+@/gi, '$1[redacted]@')
126
+ .replace(/([?&](?:access_token|refresh_token|token|credential_secret|client_secret)=)[^&#\s]+/gi, '$1[redacted]');
127
+ }
128
+ remember(session) {
129
+ if (session.access)
130
+ this.secrets.add(session.access);
131
+ if (session.refresh)
132
+ this.secrets.add(session.refresh);
133
+ return session;
134
+ }
135
+ requireStore() {
136
+ if (!this.store)
137
+ throw new models_1.ToolingError('STORE_UNAVAILABLE', 'A machine credential store is required: macOS Keychain or Linux Secret Service with secret-tool.');
138
+ return this.store;
139
+ }
140
+ async saved(backend) {
141
+ try {
142
+ const value = this.options.persistence ? await this.options.persistence.read(backend) : (0, machineSession_1.readSession)(this.requireStore(), backend);
143
+ return value ? this.remember(value) : null;
144
+ }
145
+ catch (error) {
146
+ if (error instanceof models_1.ToolingError)
147
+ throw error;
148
+ throw new models_1.ToolingError('STORE_ERROR', error instanceof machineSession_1.ForeignEntryError
149
+ ? 'The saved entry belongs to an older tool. Sign in to replace it.'
150
+ : 'The machine session could not be read. Check that the credential store is unlocked and available.');
151
+ }
152
+ }
153
+ environment(backend) {
154
+ if (this.env.MAINSEQUENCE_RUNTIME_CREDENTIAL_ID || this.env.MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET) {
155
+ throw new models_1.ToolingError('UNSUPPORTED_ACTION', 'Runtime credentials are not a developer session. Use a separate developer process.');
156
+ }
157
+ if (this.env.MAINSEQUENCE_TOKEN)
158
+ throw new models_1.ToolingError('INVALID_REQUEST', 'MAINSEQUENCE_TOKEN is unsupported; use explicit MAINSEQUENCE_ACCESS_TOKEN and MAINSEQUENCE_ENDPOINT.');
159
+ if (!this.env.MAINSEQUENCE_ACCESS_TOKEN && !this.env.MAINSEQUENCE_REFRESH_TOKEN)
160
+ return null;
161
+ if (!this.env.MAINSEQUENCE_ENDPOINT || requireBackend(this.env.MAINSEQUENCE_ENDPOINT) !== backend) {
162
+ throw new models_1.ToolingError('BACKEND_MISMATCH', 'Process credentials must name the same backend with MAINSEQUENCE_ENDPOINT.');
163
+ }
164
+ return this.remember({ access: this.env.MAINSEQUENCE_ACCESS_TOKEN || '', refresh: this.env.MAINSEQUENCE_REFRESH_TOKEN || '', username: '' });
165
+ }
166
+ async selected(backend) {
167
+ const explicit = this.environment(backend);
168
+ if (explicit)
169
+ return { session: explicit, source: 'environment' };
170
+ const session = await this.saved(backend);
171
+ if (!session)
172
+ throw new models_1.ToolingError('NOT_AUTHENTICATED', 'No session is saved for this backend. Run login.');
173
+ return { session, source: 'machine' };
174
+ }
175
+ async http(backend, method, apiPath, body, access, timeoutMs) {
176
+ this.options.signal?.throwIfAborted();
177
+ if ((apiPath !== '/mcp' && !/^\/(api|auth)\//.test(apiPath)) || apiPath.includes('..'))
178
+ throw new models_1.ToolingError('INVALID_REQUEST', 'Only existing /api/, /auth/, or central /mcp endpoints can be requested.');
179
+ const controller = new AbortController();
180
+ const abort = () => controller.abort();
181
+ this.options.signal?.addEventListener('abort', abort, { once: true });
182
+ const timer = setTimeout(abort, this.options.requestTimeoutMs ?? timeoutMs ?? 30_000);
183
+ try {
184
+ const response = await (this.options.fetch ?? fetch)(backend + apiPath, {
185
+ method, redirect: 'error', signal: controller.signal,
186
+ headers: { 'Content-Type': 'application/json', ...(apiPath === '/mcp' ? { Accept: 'application/json, text/event-stream', 'MCP-Protocol-Version': '2025-11-25' } : {}), ...(access ? { Authorization: `Bearer ${access}` } : {}) },
187
+ body: body === undefined ? undefined : JSON.stringify(body)
188
+ });
189
+ if (response.status === 401 || response.status === 403)
190
+ throw new models_1.ToolingError('AUTH_REJECTED', 'The backend rejected the selected credentials.');
191
+ if (!response.ok)
192
+ throw new models_1.ToolingError('API_ERROR', `The backend returned HTTP ${response.status}.`);
193
+ if (apiPath === '/mcp')
194
+ return await (0, mcpTransport_1.readMcpResponse)(response, body);
195
+ if (response.status === 204)
196
+ return undefined;
197
+ try {
198
+ return await response.json();
199
+ }
200
+ catch {
201
+ throw new models_1.ToolingError('API_ERROR', 'The backend returned an invalid JSON response.');
202
+ }
203
+ }
204
+ catch (error) {
205
+ if (this.options.signal?.aborted)
206
+ throw new models_1.ToolingError('CANCELLED', 'The action was cancelled.');
207
+ if (error instanceof models_1.ToolingError)
208
+ throw error;
209
+ throw new models_1.ToolingError('BACKEND_UNREACHABLE', 'The backend could not be reached. The saved session has been kept.');
210
+ }
211
+ finally {
212
+ clearTimeout(timer);
213
+ this.options.signal?.removeEventListener('abort', abort);
214
+ }
215
+ }
216
+ renew(backend) {
217
+ const running = this.renewals.get(backend);
218
+ if (running)
219
+ return running;
220
+ const task = (async () => {
221
+ // Bypass cached state: another SDK process may have rotated the refresh token.
222
+ const previous = await this.saved(backend);
223
+ if (!previous?.refresh)
224
+ throw new models_1.ToolingError('NOT_AUTHENTICATED', 'No refreshable machine session is saved for this backend.');
225
+ let data;
226
+ try {
227
+ data = await this.http(backend, 'POST', '/auth/jwt-token/token/refresh/', { refresh: previous.refresh });
228
+ }
229
+ catch (error) {
230
+ if (error instanceof models_1.ToolingError && error.code === 'AUTH_REJECTED')
231
+ throw refusedCredentials('machine', backend, previous.refresh);
232
+ throw error;
233
+ }
234
+ const session = this.remember(tokenPair(data, previous));
235
+ await this.persist(backend, session);
236
+ return session;
237
+ })();
238
+ this.renewals.set(backend, task);
239
+ void task.finally(() => this.renewals.delete(backend)).catch(() => { });
240
+ return task;
241
+ }
242
+ async persist(backend, session) {
243
+ try {
244
+ if (this.options.persistence)
245
+ await this.options.persistence.write(backend, session);
246
+ else
247
+ (0, machineSession_1.saveSession)(this.requireStore(), backend, session);
248
+ const saved = await this.saved(backend);
249
+ if (!saved || saved.access !== session.access || saved.refresh !== session.refresh)
250
+ throw new Error('verification failed');
251
+ }
252
+ catch {
253
+ throw new models_1.ToolingError('PERSISTENCE_FAILED', 'Authorization completed but the machine session could not be saved and verified.');
254
+ }
255
+ }
256
+ async status(input, check = false) {
257
+ const backend = requireBackend(input);
258
+ const explicit = this.environment(backend);
259
+ let session = explicit || ((this.store || this.options.persistence) ? await this.saved(backend) : null);
260
+ const source = explicit ? 'environment' : session ? (this.options.persistence?.mode ?? 'machine') : 'none';
261
+ let expiresAt = expiry(session?.access || '');
262
+ let authenticated = Boolean(session?.access && (!expiresAt || Date.parse(expiresAt) > Date.now()) || (!explicit && session?.refresh));
263
+ if (check) {
264
+ if (!authenticated)
265
+ throw new models_1.ToolingError('NOT_AUTHENTICATED', 'No usable session is selected for this backend.');
266
+ if (authenticated) {
267
+ const profile = await this.request(backend, 'GET', '/api/v1/users/me/');
268
+ const username = profile?.user?.username || profile?.username;
269
+ if (typeof username === 'string')
270
+ session = { ...session, username };
271
+ authenticated = true;
272
+ expiresAt = expiry((explicit || await this.saved(backend))?.access || '');
273
+ }
274
+ }
275
+ return {
276
+ backend, authenticated, source,
277
+ persistence: explicit ? 'environment' : this.options.persistence?.mode ?? (this.store ? 'machine' : 'unavailable'),
278
+ store: this.options.persistence?.name ?? this.store?.name ?? null,
279
+ ...(session?.username ? { username: session.username } : {}),
280
+ ...(expiresAt ? { expiresAt } : {}), ...(check ? { checked: true } : {})
281
+ };
282
+ }
283
+ async request(input, method, apiPath, body, retrySafe = method === 'GET', options) {
284
+ if (options?.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0 || options.timeoutMs > 300000))
285
+ throw new models_1.ToolingError('INVALID_REQUEST', 'Request timeout must be between 1 and 300000 milliseconds.');
286
+ const backend = requireBackend(input);
287
+ let { session, source } = await this.selected(backend);
288
+ if (!session.access || (expiry(session.access) && Date.parse(expiry(session.access)) <= Date.now() + 30_000)) {
289
+ if (source === 'machine')
290
+ session = await this.renew(backend);
291
+ else if (!session.access)
292
+ throw new models_1.ToolingError('NOT_AUTHENTICATED', 'The explicit environment has no access token.');
293
+ }
294
+ try {
295
+ return await this.http(backend, method, apiPath, body, session.access, options?.timeoutMs);
296
+ }
297
+ catch (error) {
298
+ if (!(error instanceof models_1.ToolingError) || error.code !== 'AUTH_REJECTED')
299
+ throw error;
300
+ if (source === 'environment')
301
+ throw refusedCredentials('environment', backend, session.access);
302
+ if (!retrySafe)
303
+ throw error;
304
+ const latest = await this.saved(backend);
305
+ const retry = latest?.access && latest.access !== session.access ? latest : await this.renew(backend);
306
+ try {
307
+ return await this.http(backend, method, apiPath, body, retry.access, options?.timeoutMs);
308
+ }
309
+ catch (retryError) {
310
+ if (retryError instanceof models_1.ToolingError && retryError.code === 'AUTH_REJECTED')
311
+ throw refusedCredentials('machine', backend, retry.refresh || retry.access);
312
+ throw retryError;
313
+ }
314
+ }
315
+ }
316
+ async refresh(input) {
317
+ const backend = requireBackend(input);
318
+ await this.renew(backend); // Explicitly renew the saved machine pair, not a user process override.
319
+ return this.machineStatus(backend);
320
+ }
321
+ async machineStatus(backend) {
322
+ const session = await this.saved(backend);
323
+ 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 || '') };
324
+ }
325
+ async logout(input) {
326
+ const backend = requireBackend(input);
327
+ const session = await this.saved(backend);
328
+ let revoked = false;
329
+ try {
330
+ if (session?.refresh) {
331
+ await this.http(backend, 'POST', '/auth/cli/revoke/', { refresh: session.refresh });
332
+ revoked = true;
333
+ }
334
+ }
335
+ catch { /* local logout is still useful offline; report revocation separately */ }
336
+ if (this.options.signal?.aborted)
337
+ throw new models_1.ToolingError('CANCELLED', 'The action was cancelled.');
338
+ try {
339
+ if (this.options.persistence)
340
+ await this.options.persistence.remove(backend);
341
+ else
342
+ (0, machineSession_1.clearSession)(this.requireStore(), backend);
343
+ }
344
+ catch {
345
+ throw new models_1.ToolingError('STORE_ERROR', 'The machine session could not be removed.');
346
+ }
347
+ return { revoked };
348
+ }
349
+ async login(input) {
350
+ const backend = requireBackend(input);
351
+ if (!this.options.persistence)
352
+ this.requireStore();
353
+ // Probe before opening the browser. An old marked entry can be replaced by explicit login.
354
+ try {
355
+ await this.saved(backend);
356
+ }
357
+ catch (error) {
358
+ if (!(error instanceof machineSession_1.ForeignEntryError) && !(error instanceof models_1.ToolingError && error.code === 'STORE_ERROR' && error.message.includes('older tool')))
359
+ throw new models_1.ToolingError('STORE_ERROR', 'The credential store is unavailable. Unlock it before signing in.');
360
+ }
361
+ this.options.signal?.throwIfAborted();
362
+ const state = crypto.randomBytes(32).toString('base64url');
363
+ const verifier = crypto.randomBytes(64).toString('base64url');
364
+ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
365
+ let resolveCallback;
366
+ let rejectCallback;
367
+ const callback = new Promise((resolve, reject) => { resolveCallback = resolve; rejectCallback = reject; });
368
+ void callback.catch(() => { });
369
+ let completed = false;
370
+ const server = http.createServer((req, res) => {
371
+ const parsed = new URL(req.url || '/', 'http://127.0.0.1');
372
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
373
+ res.setHeader('Cache-Control', 'no-store');
374
+ if (parsed.pathname !== '/callback') {
375
+ res.writeHead(404).end('Not found');
376
+ return;
377
+ }
378
+ if (parsed.searchParams.get('state') !== state) {
379
+ res.writeHead(400).end('Invalid authorization state');
380
+ return;
381
+ }
382
+ if (completed) {
383
+ res.writeHead(409).end('Callback already handled');
384
+ return;
385
+ }
386
+ completed = true;
387
+ const code = parsed.searchParams.get('code');
388
+ if (!code || parsed.searchParams.has('error')) {
389
+ rejectCallback(new models_1.ToolingError('INVALID_CALLBACK', 'Browser authorization did not return an authorization code.'));
390
+ res.writeHead(400).end('MainSequence authorization failed. Return to your developer tool.');
391
+ }
392
+ else {
393
+ resolveCallback(code);
394
+ res.end('MainSequence authorization received. Return to your developer tool to check that it was saved.');
395
+ }
396
+ });
397
+ const abort = () => rejectCallback(new models_1.ToolingError('CANCELLED', 'Login was cancelled.'));
398
+ this.options.signal?.addEventListener('abort', abort, { once: true });
399
+ const timer = setTimeout(() => rejectCallback(new models_1.ToolingError('LOGIN_TIMEOUT', 'Timed out waiting for browser authorization.')), this.options.loginTimeoutMs ?? 300_000);
400
+ try {
401
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
402
+ const address = server.address();
403
+ if (!address || typeof address === 'string')
404
+ throw new models_1.ToolingError('BROWSER_FAILED', 'Could not start the loopback callback.');
405
+ const redirectUri = `http://127.0.0.1:${address.port}/callback`;
406
+ 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' });
407
+ let opened = false;
408
+ try {
409
+ opened = await this.host.openBrowser(`${backend}/auth/cli/authorize/?${params}`);
410
+ }
411
+ catch { /* safe error below */ }
412
+ if (!opened)
413
+ throw new models_1.ToolingError('BROWSER_FAILED', 'Could not open the browser. Check your desktop browser configuration.');
414
+ const code = await callback;
415
+ const session = this.remember(tokenPair(await this.http(backend, 'POST', '/auth/cli/token/', { client_id: 'mainsequence-cli', code, code_verifier: verifier, redirect_uri: redirectUri })));
416
+ await this.persist(backend, session);
417
+ if (!session.username) {
418
+ try {
419
+ const profile = await this.request(backend, 'GET', '/api/v1/users/me/');
420
+ const username = profile?.user?.username || profile?.username;
421
+ const latest = await this.saved(backend);
422
+ if (typeof username === 'string' && username.trim() && latest)
423
+ await this.persist(backend, { ...latest, username: username.trim() });
424
+ }
425
+ catch { /* Profile is best-effort; the authorization pair is already durable. */ }
426
+ }
427
+ this.options.signal?.throwIfAborted();
428
+ return this.machineStatus(backend);
429
+ }
430
+ finally {
431
+ clearTimeout(timer);
432
+ this.options.signal?.removeEventListener('abort', abort);
433
+ server.closeAllConnections();
434
+ await new Promise(resolve => { if (server.listening)
435
+ server.close(() => resolve());
436
+ else
437
+ resolve(); });
438
+ }
439
+ }
440
+ }
441
+ exports.MachineSessionService = MachineSessionService;
442
+ function createSessionService(host, options = {}) {
443
+ return new MachineSessionService(host, options);
444
+ }
@@ -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.
@@ -0,0 +1,124 @@
1
+ # Codex plugin verification
2
+
3
+ ## 0.3.0 — extraction from devtools and rename to `mainsequence` (2026-10-21)
4
+
5
+ The plugin moved out of [mainsequence-devtools](https://github.com/mainsequence-sdk/mainsequence-devtools)
6
+ (its [ADR 0003](https://github.com/mainsequence-sdk/mainsequence-devtools/blob/main/docs/adr/0003-codex-plugin-extraction.md))
7
+ and was renamed from `mainsequence-devtools` to `mainsequence` by the owner's decision. The
8
+ one open question was how an installed Codex host treats the marketplace change: an update
9
+ of the installed plugin, or a new plugin. Everything below ran against the Codex CLI bundled
10
+ with the desktop app, version 0.163.0-alpha.10.2 on macOS, in isolated `CODEX_HOME`
11
+ directories; the user's own Codex home was not touched, and the only model endpoint was a
12
+ counted loopback fixture that rejects inference. The npm package was not published yet, so
13
+ the renamed plugin was installed from a local marketplace copy of the built `codex/`
14
+ artifact; `npm run release:check` verifies that the npm archive contains the same files.
15
+
16
+ ### Marketplace change in an installed host
17
+
18
+ A marketplace shaped like the devtools one (name `mainsequence`) first listed the former
19
+ entry, `mainsequence-devtools` from `@dev-mainsequence/devtools@0.2.8`, which Codex installed
20
+ from the npm registry. Its hook was trusted by copying the already reviewed hook hash into
21
+ `config.toml`. The marketplace file was then replaced by the new single entry `mainsequence`.
22
+
23
+ | Step | `codex plugin list` | `hooks/list` | `skills/list` | `/ms --help` |
24
+ | --- | --- | --- | --- | --- |
25
+ | 1. Old plugin installed and trusted | `mainsequence-devtools@mainsequence` installed, enabled, 0.2.8 | old hook trusted, enabled | `mainsequence-devtools:ms`, `mainsequence-devtools:mainsequence-mcp` | blocked by the old hook, 0 model requests |
26
+ | 2. Marketplace entry replaced, nothing else changed | no installed plugin listed; `mainsequence@mainsequence` available, not installed | old hook still trusted and enabled | old skills still registered | blocked by the old hook, 0 model requests |
27
+ | 3. New plugin installed, hook not yet trusted | `mainsequence@mainsequence` installed, enabled, 0.3.0 | old hook trusted; new hook untrusted | both plugins' skills, two entries named `ms` | blocked by the old hook only, 0 model requests |
28
+ | 4. New hook trusted as well | same | both trusted | same | blocked twice, once per hook, 0 model requests |
29
+ | 5. `codex plugin remove mainsequence-devtools@mainsequence` | `mainsequence@mainsequence` only | new hook only | `mainsequence:ms`, `mainsequence:mainsequence-mcp` | blocked once, 0 model requests; an ordinary prompt passes through with 1 fixture request |
30
+
31
+ Findings:
32
+
33
+ - Codex identifies a plugin by `<marketplace entry name>@<marketplace name>`. Replacing the
34
+ entry is **a new plugin, not an update**: `mainsequence@mainsequence` installs beside
35
+ `mainsequence-devtools@mainsequence` into `plugins/cache/mainsequence/mainsequence/0.3.0`.
36
+ - The old install keeps running from Codex's cache after its marketplace entry disappears.
37
+ `config.toml` still has `[plugins."mainsequence-devtools@mainsequence"] enabled = true`,
38
+ the app server still loads its hook and skills, and `/ms` is still intercepted, although
39
+ `codex plugin list` no longer shows it. `codex plugin remove` still accepts its id.
40
+ - Hook trust is recorded per plugin id. The trusted hash is identical for both ids
41
+ (`sha256:8935862c…`) because the hook definition did not change, so what a user reviews
42
+ for the new plugin is exactly what they reviewed before, but the new id must be trusted
43
+ once; until then the new hook does not run.
44
+ - With both plugins installed and trusted, both hooks run on every `/ms` and both block the
45
+ turn, and the slash menu carries two `ms` entries. Removing the old plugin is therefore part
46
+ of the upgrade, not optional cleanup. Removal leaves the old `hooks.state` trust entry in
47
+ `config.toml`; it is inert.
48
+
49
+ Migration note for users of `mainsequence-devtools` (also in the README):
50
+
51
+ ```sh
52
+ codex plugin marketplace upgrade mainsequence
53
+ codex plugin add mainsequence@mainsequence
54
+ codex plugin remove mainsequence-devtools@mainsequence
55
+ ```
56
+
57
+ then review and trust the new plugin's hook and start a new chat (or toggle the plugin in
58
+ the desktop Plugins settings). The MainSequence login and saved MCP connections live
59
+ outside the plugin and need no migration.
60
+
61
+ ### Installed host check (`npm run verify:codex`)
62
+
63
+ Run with `CODEX_HOME` pointing at the isolated home from step 5 above and
64
+ `MAINSEQUENCE_CODEX_PLUGIN_ID=mainsequence@mainsequence`. `skills/list` reported the enabled
65
+ `mainsequence:ms` entry with display name `ms`; `hooks/list` reported the hook trusted and
66
+ enabled. Every handled prompt blocked with zero model requests; ordinary prompts reached the
67
+ offline fixture once.
68
+
69
+ | Prompt | Action result | Host hook status | Model requests |
70
+ | --- | --- | --- | --- |
71
+ | selected `ms` + `--help` | success | blocked | 0 |
72
+ | selected `ms` + `--help --json` | success, structured output | blocked | 0 |
73
+ | selected `ms` + `--help` with the desktop's trailing newline | success | blocked | 0 |
74
+ | selected `ms` without arguments | success, help | blocked | 0 |
75
+ | selected `ms` + `invalid-action` | failure | blocked | 0 |
76
+ | selected `ms` + `sync --message "retired option"` | failure, retired option explained | blocked | 0 |
77
+ | literal `/ms --help` ending in LF or CRLF | success | blocked | 0 |
78
+ | literal `/ms --help --all` | success, complete command menu | blocked | 0 |
79
+ | literal `/ms sync --help` | success, command-specific options | blocked | 0 |
80
+ | literal `/ms --help` followed by `status` on another line | failure, invalid request | blocked | 0 |
81
+ | literal `/ms sync` in a Python fixture project | success, dependency files refreshed, Git unchanged, 0 backend requests | blocked | 0, including in flight |
82
+ | selected `ms` + `sync` in a Node fixture project | success, dependency files refreshed, Git unchanged, 0 backend requests | blocked | 0, including in flight |
83
+ | ordinary fixture prompt | passes through | completed | 1, to the offline fixture |
84
+ | `:ms --help` | passes through | completed | 1, to the offline fixture |
85
+
86
+ The sync cases ran the vendored devtools 0.3.0 inside the installed plugin, from Codex's
87
+ cache, without `node_modules`. The script now passes the `mcp_servers.main_sequence`
88
+ override only where that server is configured, since the override is invalid in a home
89
+ without it, and it reports Codex's stderr when the app server exits early.
90
+
91
+ ### Existing-chat reload regression (`npm run verify:codex:reload`)
92
+
93
+ Rerun from this repository's artifact in a temporary Codex home with the renamed plugin id.
94
+ Same behaviour as recorded for 0.2.x:
95
+
96
+ | Case | Hook runs | Model requests |
97
+ | --- | --- | --- |
98
+ | Existing thread after external enable | none | 1, rejected by the offline fixture |
99
+ | New thread in the same backend | blocked | 0 |
100
+ | Existing thread after `config/batchWrite` with `reloadUserConfig: true` | blocked | 0 |
101
+
102
+ ### Package checks
103
+
104
+ `npm run release:check` under Node 22.22.0 and npm 11.6.2: type-check, build from the
105
+ locally packed devtools 0.3.0 (its archive integrity is recorded in `package-lock.json` and
106
+ `build-info.json`), 15 Node tests (hook dispatch, skill-mention ownership including the
107
+ rejection of the former `mainsequence-devtools:ms` mention, presentation, tokenizer safety,
108
+ Python and Node sync fixtures, the self-contained artifact outside the checkout, and the
109
+ dependency boundary), release metadata, the standalone archive and the npm archive, which
110
+ was unpacked and exercised as Codex installs it (manifest, hooks, skills, bundled runner,
111
+ pass-through, mention handling and the terminal CLI).
112
+
113
+ ### Remaining
114
+
115
+ - `@dev-mainsequence/devtools@0.3.0` is not on the registry yet. The lockfile integrity
116
+ comes from a local `npm pack` of the devtools release branch; confirm it against
117
+ `npm view @dev-mainsequence/devtools@0.3.0 dist.integrity` after publication, and update
118
+ the lockfile and rebuild if it differs, before tagging this package.
119
+ - The renamed plugin was installed from a local marketplace source, not from npm. The npm
120
+ install path itself is the one Codex used for 0.2.8 in step 1 and is unchanged.
121
+ - The desktop composer and renderer were not visually verified; the host API, event stream
122
+ and CLI presentation were.
123
+ - The owner's own Codex home still has `mainsequence-devtools@mainsequence` 0.2.7 installed
124
+ and the former local marketplace registered; migrating it is a user step after publication.