keystone-cli 0.2.0 → 0.3.1
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 +30 -12
- package/package.json +20 -4
- package/src/cli.ts +171 -27
- package/src/expression/evaluator.test.ts +4 -0
- package/src/expression/evaluator.ts +9 -1
- package/src/parser/agent-parser.ts +11 -4
- package/src/parser/config-schema.ts +11 -0
- package/src/parser/schema.ts +20 -10
- package/src/parser/workflow-parser.ts +5 -4
- package/src/runner/llm-executor.test.ts +174 -81
- package/src/runner/llm-executor.ts +8 -3
- package/src/runner/mcp-client.test.ts +85 -47
- package/src/runner/mcp-client.ts +235 -42
- package/src/runner/mcp-manager.ts +42 -2
- package/src/runner/mcp-server.test.ts +22 -15
- package/src/runner/mcp-server.ts +21 -4
- package/src/runner/step-executor.test.ts +51 -8
- package/src/runner/step-executor.ts +69 -7
- package/src/runner/workflow-runner.ts +65 -24
- package/src/utils/auth-manager.test.ts +86 -0
- package/src/utils/auth-manager.ts +89 -0
- package/src/utils/config-loader.test.ts +30 -0
- package/src/utils/config-loader.ts +11 -1
- package/src/utils/mermaid.test.ts +18 -18
- package/src/utils/mermaid.ts +154 -20
- package/src/utils/redactor.test.ts +6 -0
- package/src/utils/redactor.ts +10 -1
- package/src/utils/sandbox.test.ts +29 -0
- package/src/utils/sandbox.ts +61 -0
|
@@ -34,7 +34,7 @@ interface RequestOutput {
|
|
|
34
34
|
// Mock node:readline/promises
|
|
35
35
|
const mockRl = {
|
|
36
36
|
question: mock(() => Promise.resolve('')),
|
|
37
|
-
close: mock(() => {}),
|
|
37
|
+
close: mock(() => { }),
|
|
38
38
|
};
|
|
39
39
|
|
|
40
40
|
mock.module('node:readline/promises', () => ({
|
|
@@ -49,13 +49,13 @@ describe('step-executor', () => {
|
|
|
49
49
|
beforeAll(() => {
|
|
50
50
|
try {
|
|
51
51
|
mkdirSync(tempDir, { recursive: true });
|
|
52
|
-
} catch (e) {}
|
|
52
|
+
} catch (e) { }
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
afterAll(() => {
|
|
56
56
|
try {
|
|
57
57
|
rmSync(tempDir, { recursive: true, force: true });
|
|
58
|
-
} catch (e) {}
|
|
58
|
+
} catch (e) { }
|
|
59
59
|
});
|
|
60
60
|
|
|
61
61
|
beforeEach(() => {
|
|
@@ -330,7 +330,7 @@ describe('step-executor', () => {
|
|
|
330
330
|
};
|
|
331
331
|
|
|
332
332
|
// @ts-ignore
|
|
333
|
-
const result = await executeStep(step, context, { log: () => {} });
|
|
333
|
+
const result = await executeStep(step, context, { log: () => { } });
|
|
334
334
|
expect(result.status).toBe('success');
|
|
335
335
|
expect(result.output).toBe(true);
|
|
336
336
|
expect(mockRl.question).toHaveBeenCalled();
|
|
@@ -347,11 +347,54 @@ describe('step-executor', () => {
|
|
|
347
347
|
};
|
|
348
348
|
|
|
349
349
|
// @ts-ignore
|
|
350
|
-
const result = await executeStep(step, context, { log: () => {} });
|
|
350
|
+
const result = await executeStep(step, context, { log: () => { } });
|
|
351
351
|
expect(result.status).toBe('success');
|
|
352
352
|
expect(result.output).toBe('user response');
|
|
353
353
|
});
|
|
354
354
|
|
|
355
|
+
it('should handle human confirmation (yes/no/empty)', async () => {
|
|
356
|
+
const step: HumanStep = {
|
|
357
|
+
id: 'h1',
|
|
358
|
+
type: 'human',
|
|
359
|
+
message: 'Proceed?',
|
|
360
|
+
inputType: 'confirm',
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
// Test 'yes'
|
|
364
|
+
mockRl.question.mockResolvedValue('yes');
|
|
365
|
+
// @ts-ignore
|
|
366
|
+
let result = await executeStep(step, context, { log: () => { } });
|
|
367
|
+
expect(result.output).toBe(true);
|
|
368
|
+
|
|
369
|
+
// Test 'no'
|
|
370
|
+
mockRl.question.mockResolvedValue('no');
|
|
371
|
+
// @ts-ignore
|
|
372
|
+
result = await executeStep(step, context, { log: () => { } });
|
|
373
|
+
expect(result.output).toBe(false);
|
|
374
|
+
|
|
375
|
+
// Test empty string (default to true)
|
|
376
|
+
mockRl.question.mockResolvedValue('');
|
|
377
|
+
// @ts-ignore
|
|
378
|
+
result = await executeStep(step, context, { log: () => { } });
|
|
379
|
+
expect(result.output).toBe(true);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('should fallback to text in confirm mode', async () => {
|
|
383
|
+
mockRl.question.mockResolvedValue('some custom response');
|
|
384
|
+
|
|
385
|
+
const step: HumanStep = {
|
|
386
|
+
id: 'h1',
|
|
387
|
+
type: 'human',
|
|
388
|
+
message: 'Proceed?',
|
|
389
|
+
inputType: 'confirm',
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
// @ts-ignore
|
|
393
|
+
const result = await executeStep(step, context, { log: () => { } });
|
|
394
|
+
expect(result.status).toBe('success');
|
|
395
|
+
expect(result.output).toBe('some custom response');
|
|
396
|
+
});
|
|
397
|
+
|
|
355
398
|
it('should suspend if not a TTY', async () => {
|
|
356
399
|
process.stdin.isTTY = false;
|
|
357
400
|
|
|
@@ -363,7 +406,7 @@ describe('step-executor', () => {
|
|
|
363
406
|
};
|
|
364
407
|
|
|
365
408
|
// @ts-ignore
|
|
366
|
-
const result = await executeStep(step, context, { log: () => {} });
|
|
409
|
+
const result = await executeStep(step, context, { log: () => { } });
|
|
367
410
|
expect(result.status).toBe('suspended');
|
|
368
411
|
expect(result.error).toBe('Proceed?');
|
|
369
412
|
});
|
|
@@ -374,7 +417,7 @@ describe('step-executor', () => {
|
|
|
374
417
|
const step: WorkflowStep = {
|
|
375
418
|
id: 'w1',
|
|
376
419
|
type: 'workflow',
|
|
377
|
-
|
|
420
|
+
path: 'child.yaml',
|
|
378
421
|
};
|
|
379
422
|
// @ts-ignore
|
|
380
423
|
const executeWorkflowFn = mock(() =>
|
|
@@ -392,7 +435,7 @@ describe('step-executor', () => {
|
|
|
392
435
|
const step: WorkflowStep = {
|
|
393
436
|
id: 'w1',
|
|
394
437
|
type: 'workflow',
|
|
395
|
-
|
|
438
|
+
path: 'child.yaml',
|
|
396
439
|
};
|
|
397
440
|
const result = await executeStep(step, context);
|
|
398
441
|
expect(result.status).toBe('failed');
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
FileStep,
|
|
6
6
|
HumanStep,
|
|
7
7
|
RequestStep,
|
|
8
|
+
ScriptStep,
|
|
8
9
|
ShellStep,
|
|
9
10
|
SleepStep,
|
|
10
11
|
Step,
|
|
@@ -14,6 +15,7 @@ import { executeShell } from './shell-executor.ts';
|
|
|
14
15
|
import type { Logger } from './workflow-runner.ts';
|
|
15
16
|
|
|
16
17
|
import * as readline from 'node:readline/promises';
|
|
18
|
+
import { SafeSandbox } from '../utils/sandbox.ts';
|
|
17
19
|
import { executeLlmStep } from './llm-executor.ts';
|
|
18
20
|
import type { MCPManager } from './mcp-manager.ts';
|
|
19
21
|
|
|
@@ -42,7 +44,8 @@ export async function executeStep(
|
|
|
42
44
|
context: ExpressionContext,
|
|
43
45
|
logger: Logger = console,
|
|
44
46
|
executeWorkflowFn?: (step: WorkflowStep, context: ExpressionContext) => Promise<StepResult>,
|
|
45
|
-
mcpManager?: MCPManager
|
|
47
|
+
mcpManager?: MCPManager,
|
|
48
|
+
workflowDir?: string
|
|
46
49
|
): Promise<StepResult> {
|
|
47
50
|
try {
|
|
48
51
|
let result: StepResult;
|
|
@@ -66,9 +69,10 @@ export async function executeStep(
|
|
|
66
69
|
result = await executeLlmStep(
|
|
67
70
|
step,
|
|
68
71
|
context,
|
|
69
|
-
(s, c) => executeStep(s, c, logger, executeWorkflowFn, mcpManager),
|
|
72
|
+
(s, c) => executeStep(s, c, logger, executeWorkflowFn, mcpManager, workflowDir),
|
|
70
73
|
logger,
|
|
71
|
-
mcpManager
|
|
74
|
+
mcpManager,
|
|
75
|
+
workflowDir
|
|
72
76
|
);
|
|
73
77
|
break;
|
|
74
78
|
case 'workflow':
|
|
@@ -77,6 +81,9 @@ export async function executeStep(
|
|
|
77
81
|
}
|
|
78
82
|
result = await executeWorkflowFn(step, context);
|
|
79
83
|
break;
|
|
84
|
+
case 'script':
|
|
85
|
+
result = await executeScriptStep(step, context, logger);
|
|
86
|
+
break;
|
|
80
87
|
default:
|
|
81
88
|
throw new Error(`Unknown step type: ${(step as Step).type}`);
|
|
82
89
|
}
|
|
@@ -180,6 +187,13 @@ async function executeFileStep(
|
|
|
180
187
|
throw new Error('Content is required for write operation');
|
|
181
188
|
}
|
|
182
189
|
const content = ExpressionEvaluator.evaluateString(step.content, context);
|
|
190
|
+
|
|
191
|
+
// Ensure parent directory exists
|
|
192
|
+
const fs = await import('node:fs/promises');
|
|
193
|
+
const pathModule = await import('node:path');
|
|
194
|
+
const dir = pathModule.dirname(path);
|
|
195
|
+
await fs.mkdir(dir, { recursive: true });
|
|
196
|
+
|
|
183
197
|
const bytes = await Bun.write(path, content);
|
|
184
198
|
return {
|
|
185
199
|
output: { path, bytes },
|
|
@@ -193,8 +207,13 @@ async function executeFileStep(
|
|
|
193
207
|
}
|
|
194
208
|
const content = ExpressionEvaluator.evaluateString(step.content, context);
|
|
195
209
|
|
|
196
|
-
//
|
|
210
|
+
// Ensure parent directory exists
|
|
197
211
|
const fs = await import('node:fs/promises');
|
|
212
|
+
const pathModule = await import('node:path');
|
|
213
|
+
const dir = pathModule.dirname(path);
|
|
214
|
+
await fs.mkdir(dir, { recursive: true });
|
|
215
|
+
|
|
216
|
+
// Use Node.js fs for efficient append operation
|
|
198
217
|
await fs.appendFile(path, content, 'utf-8');
|
|
199
218
|
|
|
200
219
|
return {
|
|
@@ -310,10 +329,25 @@ async function executeHumanStep(
|
|
|
310
329
|
try {
|
|
311
330
|
if (step.inputType === 'confirm') {
|
|
312
331
|
logger.log(`\n❓ ${message}`);
|
|
313
|
-
|
|
314
|
-
|
|
332
|
+
const answer = (await rl.question('Response (Y/n/text): ')).trim();
|
|
333
|
+
|
|
334
|
+
const lowerAnswer = answer.toLowerCase();
|
|
335
|
+
if (lowerAnswer === '' || lowerAnswer === 'y' || lowerAnswer === 'yes') {
|
|
336
|
+
return {
|
|
337
|
+
output: true,
|
|
338
|
+
status: 'success',
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
if (lowerAnswer === 'n' || lowerAnswer === 'no') {
|
|
342
|
+
return {
|
|
343
|
+
output: false,
|
|
344
|
+
status: 'success',
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Fallback to text if it's not a clear yes/no
|
|
315
349
|
return {
|
|
316
|
-
output:
|
|
350
|
+
output: answer,
|
|
317
351
|
status: 'success',
|
|
318
352
|
};
|
|
319
353
|
}
|
|
@@ -353,3 +387,31 @@ async function executeSleepStep(
|
|
|
353
387
|
status: 'success',
|
|
354
388
|
};
|
|
355
389
|
}
|
|
390
|
+
/**
|
|
391
|
+
* Execute a script step in a safe sandbox
|
|
392
|
+
*/
|
|
393
|
+
async function executeScriptStep(
|
|
394
|
+
step: ScriptStep,
|
|
395
|
+
context: ExpressionContext,
|
|
396
|
+
_logger: Logger
|
|
397
|
+
): Promise<StepResult> {
|
|
398
|
+
try {
|
|
399
|
+
const result = await SafeSandbox.execute(step.run, {
|
|
400
|
+
inputs: context.inputs,
|
|
401
|
+
secrets: context.secrets,
|
|
402
|
+
steps: context.steps,
|
|
403
|
+
env: context.env,
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
return {
|
|
407
|
+
output: result,
|
|
408
|
+
status: 'success',
|
|
409
|
+
};
|
|
410
|
+
} catch (error) {
|
|
411
|
+
return {
|
|
412
|
+
output: null,
|
|
413
|
+
status: 'failed',
|
|
414
|
+
error: error instanceof Error ? error.message : String(error),
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
2
3
|
import { WorkflowDb } from '../db/workflow-db.ts';
|
|
3
4
|
import type { ExpressionContext } from '../expression/evaluator.ts';
|
|
4
5
|
import { ExpressionEvaluator } from '../expression/evaluator.ts';
|
|
@@ -24,7 +25,7 @@ class RedactingLogger implements Logger {
|
|
|
24
25
|
constructor(
|
|
25
26
|
private inner: Logger,
|
|
26
27
|
private redactor: Redactor
|
|
27
|
-
) {}
|
|
28
|
+
) { }
|
|
28
29
|
|
|
29
30
|
log(msg: string): void {
|
|
30
31
|
this.inner.log(this.redactor.redact(msg));
|
|
@@ -46,12 +47,13 @@ export interface RunOptions {
|
|
|
46
47
|
logger?: Logger;
|
|
47
48
|
mcpManager?: MCPManager;
|
|
48
49
|
preventExit?: boolean; // Defaults to false
|
|
50
|
+
workflowDir?: string;
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
export interface StepContext {
|
|
52
54
|
output?: unknown;
|
|
53
55
|
outputs?: Record<string, unknown>;
|
|
54
|
-
status: 'success' | 'failed' | 'skipped';
|
|
56
|
+
status: 'success' | 'failed' | 'skipped' | 'pending' | 'suspended';
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
// Type for foreach results - wraps array to ensure JSON serialization preserves all properties
|
|
@@ -194,7 +196,7 @@ export class WorkflowRunner {
|
|
|
194
196
|
items[exec.iteration_index] = {
|
|
195
197
|
output: null,
|
|
196
198
|
outputs: {},
|
|
197
|
-
status: exec.status as 'failed' | '
|
|
199
|
+
status: exec.status as 'failed' | 'pending' | 'success' | 'skipped' | 'suspended',
|
|
198
200
|
};
|
|
199
201
|
}
|
|
200
202
|
}
|
|
@@ -303,9 +305,37 @@ export class WorkflowRunner {
|
|
|
303
305
|
private loadSecrets(): Record<string, string> {
|
|
304
306
|
const secrets: Record<string, string> = {};
|
|
305
307
|
|
|
308
|
+
// Common non-secret environment variables to exclude from redaction
|
|
309
|
+
const blocklist = new Set([
|
|
310
|
+
'USER',
|
|
311
|
+
'PATH',
|
|
312
|
+
'SHELL',
|
|
313
|
+
'HOME',
|
|
314
|
+
'PWD',
|
|
315
|
+
'LOGNAME',
|
|
316
|
+
'LANG',
|
|
317
|
+
'TERM',
|
|
318
|
+
'EDITOR',
|
|
319
|
+
'VISUAL',
|
|
320
|
+
'_',
|
|
321
|
+
'SHLVL',
|
|
322
|
+
'LC_ALL',
|
|
323
|
+
'OLDPWD',
|
|
324
|
+
'DISPLAY',
|
|
325
|
+
'TMPDIR',
|
|
326
|
+
'SSH_AUTH_SOCK',
|
|
327
|
+
'XPC_FLAGS',
|
|
328
|
+
'XPC_SERVICE_NAME',
|
|
329
|
+
'ITERM_SESSION_ID',
|
|
330
|
+
'ITERM_PROFILE',
|
|
331
|
+
'TERM_PROGRAM',
|
|
332
|
+
'TERM_PROGRAM_VERSION',
|
|
333
|
+
'COLORTERM',
|
|
334
|
+
]);
|
|
335
|
+
|
|
306
336
|
// Bun automatically loads .env file
|
|
307
337
|
for (const [key, value] of Object.entries(Bun.env)) {
|
|
308
|
-
if (value) {
|
|
338
|
+
if (value && !blocklist.has(key)) {
|
|
309
339
|
secrets[key] = value;
|
|
310
340
|
}
|
|
311
341
|
}
|
|
@@ -456,7 +486,8 @@ export class WorkflowRunner {
|
|
|
456
486
|
context,
|
|
457
487
|
this.logger,
|
|
458
488
|
this.executeSubWorkflow.bind(this),
|
|
459
|
-
this.mcpManager
|
|
489
|
+
this.mcpManager,
|
|
490
|
+
this.options.workflowDir
|
|
460
491
|
);
|
|
461
492
|
if (result.status === 'failed') {
|
|
462
493
|
throw new Error(result.error || 'Step failed');
|
|
@@ -482,11 +513,7 @@ export class WorkflowRunner {
|
|
|
482
513
|
return result;
|
|
483
514
|
}
|
|
484
515
|
|
|
485
|
-
|
|
486
|
-
const redactedOutput = this.redactor.redactValue(result.output);
|
|
487
|
-
const redactedError = result.error ? this.redactor.redact(result.error) : undefined;
|
|
488
|
-
|
|
489
|
-
await this.db.completeStep(stepExecId, result.status, redactedOutput, redactedError);
|
|
516
|
+
await this.db.completeStep(stepExecId, result.status, result.output, result.error);
|
|
490
517
|
|
|
491
518
|
// Ensure outputs is always an object for consistent access
|
|
492
519
|
let outputs: Record<string, unknown>;
|
|
@@ -618,6 +645,7 @@ export class WorkflowRunner {
|
|
|
618
645
|
|
|
619
646
|
// Execute and store result at correct index
|
|
620
647
|
try {
|
|
648
|
+
this.logger.log(` ⤷ [${i + 1}/${items.length}] Executing iteration...`);
|
|
621
649
|
itemResults[i] = await this.executeStepInternal(step, itemContext, stepExecId);
|
|
622
650
|
if (itemResults[i].status === 'failed') {
|
|
623
651
|
aborted = true;
|
|
@@ -700,6 +728,7 @@ export class WorkflowRunner {
|
|
|
700
728
|
): Promise<StepResult> {
|
|
701
729
|
const workflowPath = WorkflowRegistry.resolvePath(step.path);
|
|
702
730
|
const workflow = WorkflowParser.loadWorkflow(workflowPath);
|
|
731
|
+
const subWorkflowDir = dirname(workflowPath);
|
|
703
732
|
|
|
704
733
|
// Evaluate inputs for the sub-workflow
|
|
705
734
|
const inputs: Record<string, unknown> = {};
|
|
@@ -716,6 +745,7 @@ export class WorkflowRunner {
|
|
|
716
745
|
dbPath: this.db.dbPath,
|
|
717
746
|
logger: this.logger,
|
|
718
747
|
mcpManager: this.mcpManager,
|
|
748
|
+
workflowDir: subWorkflowDir,
|
|
719
749
|
});
|
|
720
750
|
|
|
721
751
|
try {
|
|
@@ -755,7 +785,7 @@ export class WorkflowRunner {
|
|
|
755
785
|
this.logger.log(`Run ID: ${this.runId}`);
|
|
756
786
|
this.logger.log(
|
|
757
787
|
'\n⚠️ Security Warning: Only run workflows from trusted sources.\n' +
|
|
758
|
-
|
|
788
|
+
' Workflows can execute arbitrary shell commands and access your environment.\n'
|
|
759
789
|
);
|
|
760
790
|
|
|
761
791
|
// Apply defaults and validate inputs
|
|
@@ -782,8 +812,7 @@ export class WorkflowRunner {
|
|
|
782
812
|
this.logger.log('All steps already completed. Nothing to resume.\n');
|
|
783
813
|
// Evaluate outputs from completed state
|
|
784
814
|
const outputs = this.evaluateOutputs();
|
|
785
|
-
|
|
786
|
-
await this.db.updateRunStatus(this.runId, 'completed', redactedOutputs);
|
|
815
|
+
await this.db.updateRunStatus(this.runId, 'completed', outputs);
|
|
787
816
|
this.logger.log('✨ Workflow already completed!\n');
|
|
788
817
|
return outputs;
|
|
789
818
|
}
|
|
@@ -794,6 +823,9 @@ export class WorkflowRunner {
|
|
|
794
823
|
|
|
795
824
|
this.logger.log(`Execution order: ${executionOrder.join(' → ')}\n`);
|
|
796
825
|
|
|
826
|
+
const totalSteps = executionOrder.length;
|
|
827
|
+
const stepIndices = new Map(executionOrder.map((id, index) => [id, index + 1]));
|
|
828
|
+
|
|
797
829
|
// Execute steps in parallel where possible (respecting dependencies)
|
|
798
830
|
const pendingSteps = new Set(remainingSteps);
|
|
799
831
|
const runningPromises = new Map<string, Promise<void>>();
|
|
@@ -806,18 +838,21 @@ export class WorkflowRunner {
|
|
|
806
838
|
if (!step) {
|
|
807
839
|
throw new Error(`Step ${stepId} not found in workflow`);
|
|
808
840
|
}
|
|
809
|
-
const dependenciesMet = step.needs.every((dep) => completedSteps.has(dep));
|
|
841
|
+
const dependenciesMet = step.needs.every((dep: string) => completedSteps.has(dep));
|
|
810
842
|
|
|
811
843
|
if (dependenciesMet) {
|
|
812
844
|
pendingSteps.delete(stepId);
|
|
813
845
|
|
|
814
846
|
// Start execution
|
|
815
|
-
|
|
847
|
+
const stepIndex = stepIndices.get(stepId);
|
|
848
|
+
this.logger.log(
|
|
849
|
+
`[${stepIndex}/${totalSteps}] ▶ Executing step: ${step.id} (${step.type})`
|
|
850
|
+
);
|
|
816
851
|
const promise = this.executeStepWithForeach(step)
|
|
817
852
|
.then(() => {
|
|
818
853
|
completedSteps.add(stepId);
|
|
819
854
|
runningPromises.delete(stepId);
|
|
820
|
-
this.logger.log(`
|
|
855
|
+
this.logger.log(`[${stepIndex}/${totalSteps}] ✓ Step ${step.id} completed\n`);
|
|
821
856
|
})
|
|
822
857
|
.catch((err) => {
|
|
823
858
|
runningPromises.delete(stepId);
|
|
@@ -852,11 +887,8 @@ export class WorkflowRunner {
|
|
|
852
887
|
// Evaluate outputs
|
|
853
888
|
const outputs = this.evaluateOutputs();
|
|
854
889
|
|
|
855
|
-
// Redact secrets from outputs before storing
|
|
856
|
-
const redactedOutputs = this.redactor.redactValue(outputs) as Record<string, unknown>;
|
|
857
|
-
|
|
858
890
|
// Mark run as complete
|
|
859
|
-
await this.db.updateRunStatus(this.runId, 'completed',
|
|
891
|
+
await this.db.updateRunStatus(this.runId, 'completed', outputs);
|
|
860
892
|
|
|
861
893
|
this.logger.log('✨ Workflow completed successfully!\n');
|
|
862
894
|
|
|
@@ -874,7 +906,9 @@ export class WorkflowRunner {
|
|
|
874
906
|
} finally {
|
|
875
907
|
this.removeSignalHandlers();
|
|
876
908
|
await this.runFinally();
|
|
877
|
-
|
|
909
|
+
if (!this.options.mcpManager) {
|
|
910
|
+
await this.mcpManager.stopAll();
|
|
911
|
+
}
|
|
878
912
|
this.db.close();
|
|
879
913
|
}
|
|
880
914
|
}
|
|
@@ -893,6 +927,8 @@ export class WorkflowRunner {
|
|
|
893
927
|
const completedFinallySteps = new Set<string>();
|
|
894
928
|
const pendingFinallySteps = new Set(this.workflow.finally.map((s) => s.id));
|
|
895
929
|
const runningPromises = new Map<string, Promise<void>>();
|
|
930
|
+
const totalFinallySteps = this.workflow.finally.length;
|
|
931
|
+
const finallyStepIndices = new Map(this.workflow.finally.map((s, index) => [s.id, index + 1]));
|
|
896
932
|
|
|
897
933
|
try {
|
|
898
934
|
while (pendingFinallySteps.size > 0 || runningPromises.size > 0) {
|
|
@@ -902,18 +938,23 @@ export class WorkflowRunner {
|
|
|
902
938
|
|
|
903
939
|
// Dependencies can be from main steps (already in this.stepContexts) or previous finally steps
|
|
904
940
|
const dependenciesMet = step.needs.every(
|
|
905
|
-
(dep) => this.stepContexts.has(dep) || completedFinallySteps.has(dep)
|
|
941
|
+
(dep: string) => this.stepContexts.has(dep) || completedFinallySteps.has(dep)
|
|
906
942
|
);
|
|
907
943
|
|
|
908
944
|
if (dependenciesMet) {
|
|
909
945
|
pendingFinallySteps.delete(stepId);
|
|
910
946
|
|
|
911
|
-
|
|
947
|
+
const finallyStepIndex = finallyStepIndices.get(stepId);
|
|
948
|
+
this.logger.log(
|
|
949
|
+
`[${finallyStepIndex}/${totalFinallySteps}] ▶ Executing finally step: ${step.id} (${step.type})`
|
|
950
|
+
);
|
|
912
951
|
const promise = this.executeStepWithForeach(step)
|
|
913
952
|
.then(() => {
|
|
914
953
|
completedFinallySteps.add(stepId);
|
|
915
954
|
runningPromises.delete(stepId);
|
|
916
|
-
this.logger.log(
|
|
955
|
+
this.logger.log(
|
|
956
|
+
`[${finallyStepIndex}/${totalFinallySteps}] ✓ Finally step ${step.id} completed\n`
|
|
957
|
+
);
|
|
917
958
|
})
|
|
918
959
|
.catch((err) => {
|
|
919
960
|
runningPromises.delete(stepId);
|
|
@@ -149,4 +149,90 @@ describe('AuthManager', () => {
|
|
|
149
149
|
consoleSpy.mockRestore();
|
|
150
150
|
});
|
|
151
151
|
});
|
|
152
|
+
|
|
153
|
+
describe('Device Login', () => {
|
|
154
|
+
it('initGitHubDeviceLogin should return device code data', async () => {
|
|
155
|
+
const mockFetch = mock(() =>
|
|
156
|
+
Promise.resolve(
|
|
157
|
+
new Response(
|
|
158
|
+
JSON.stringify({
|
|
159
|
+
device_code: 'dev_code',
|
|
160
|
+
user_code: 'USER-CODE',
|
|
161
|
+
verification_uri: 'https://github.com/login/device',
|
|
162
|
+
expires_in: 900,
|
|
163
|
+
interval: 5,
|
|
164
|
+
}),
|
|
165
|
+
{ status: 200 }
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
);
|
|
169
|
+
// @ts-ignore
|
|
170
|
+
global.fetch = mockFetch;
|
|
171
|
+
|
|
172
|
+
const result = await AuthManager.initGitHubDeviceLogin();
|
|
173
|
+
expect(result.device_code).toBe('dev_code');
|
|
174
|
+
expect(result.user_code).toBe('USER-CODE');
|
|
175
|
+
expect(mockFetch).toHaveBeenCalled();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('pollGitHubDeviceLogin should return token when successful', async () => {
|
|
179
|
+
let callCount = 0;
|
|
180
|
+
const mockFetch = mock(() => {
|
|
181
|
+
callCount++;
|
|
182
|
+
if (callCount === 1) {
|
|
183
|
+
return Promise.resolve(
|
|
184
|
+
new Response(
|
|
185
|
+
JSON.stringify({
|
|
186
|
+
error: 'authorization_pending',
|
|
187
|
+
}),
|
|
188
|
+
{ status: 200 }
|
|
189
|
+
)
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return Promise.resolve(
|
|
193
|
+
new Response(
|
|
194
|
+
JSON.stringify({
|
|
195
|
+
access_token: 'gh_access_token',
|
|
196
|
+
}),
|
|
197
|
+
{ status: 200 }
|
|
198
|
+
)
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
// @ts-ignore
|
|
202
|
+
global.fetch = mockFetch;
|
|
203
|
+
|
|
204
|
+
// Mock setTimeout to resolve immediately
|
|
205
|
+
const originalTimeout = global.setTimeout;
|
|
206
|
+
// @ts-ignore
|
|
207
|
+
global.setTimeout = (fn) => fn();
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const token = await AuthManager.pollGitHubDeviceLogin('dev_code');
|
|
211
|
+
expect(token).toBe('gh_access_token');
|
|
212
|
+
expect(callCount).toBe(2);
|
|
213
|
+
} finally {
|
|
214
|
+
global.setTimeout = originalTimeout;
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('pollGitHubDeviceLogin should throw on other errors', async () => {
|
|
219
|
+
const mockFetch = mock(() =>
|
|
220
|
+
Promise.resolve(
|
|
221
|
+
new Response(
|
|
222
|
+
JSON.stringify({
|
|
223
|
+
error: 'expired_token',
|
|
224
|
+
error_description: 'The device code has expired',
|
|
225
|
+
}),
|
|
226
|
+
{ status: 200 }
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
);
|
|
230
|
+
// @ts-ignore
|
|
231
|
+
global.fetch = mockFetch;
|
|
232
|
+
|
|
233
|
+
await expect(AuthManager.pollGitHubDeviceLogin('dev_code')).rejects.toThrow(
|
|
234
|
+
'The device code has expired'
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
152
238
|
});
|
|
@@ -6,6 +6,16 @@ export interface AuthData {
|
|
|
6
6
|
github_token?: string;
|
|
7
7
|
copilot_token?: string;
|
|
8
8
|
copilot_expires_at?: number;
|
|
9
|
+
openai_api_key?: string;
|
|
10
|
+
anthropic_api_key?: string;
|
|
11
|
+
mcp_tokens?: Record<
|
|
12
|
+
string,
|
|
13
|
+
{
|
|
14
|
+
access_token: string;
|
|
15
|
+
expires_at?: number;
|
|
16
|
+
refresh_token?: string;
|
|
17
|
+
}
|
|
18
|
+
>;
|
|
9
19
|
}
|
|
10
20
|
|
|
11
21
|
export const COPILOT_HEADERS = {
|
|
@@ -14,6 +24,8 @@ export const COPILOT_HEADERS = {
|
|
|
14
24
|
'User-Agent': 'GithubCopilot/1.255.0',
|
|
15
25
|
};
|
|
16
26
|
|
|
27
|
+
const GITHUB_CLIENT_ID = '013444988716b5155f4c'; // GitHub CLI Client ID
|
|
28
|
+
|
|
17
29
|
export class AuthManager {
|
|
18
30
|
private static getAuthPath(): string {
|
|
19
31
|
if (process.env.KEYSTONE_AUTH_PATH) {
|
|
@@ -44,6 +56,83 @@ export class AuthManager {
|
|
|
44
56
|
writeFileSync(path, JSON.stringify({ ...current, ...data }, null, 2));
|
|
45
57
|
}
|
|
46
58
|
|
|
59
|
+
static async initGitHubDeviceLogin(): Promise<{
|
|
60
|
+
device_code: string;
|
|
61
|
+
user_code: string;
|
|
62
|
+
verification_uri: string;
|
|
63
|
+
expires_in: number;
|
|
64
|
+
interval: number;
|
|
65
|
+
}> {
|
|
66
|
+
const response = await fetch('https://github.com/login/device/code', {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: {
|
|
69
|
+
'Content-Type': 'application/json',
|
|
70
|
+
Accept: 'application/json',
|
|
71
|
+
},
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
client_id: GITHUB_CLIENT_ID,
|
|
74
|
+
scope: 'read:user workflow repo',
|
|
75
|
+
}),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
throw new Error(`Failed to initialize device login: ${response.statusText}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return response.json() as Promise<{
|
|
83
|
+
device_code: string;
|
|
84
|
+
user_code: string;
|
|
85
|
+
verification_uri: string;
|
|
86
|
+
expires_in: number;
|
|
87
|
+
interval: number;
|
|
88
|
+
}>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
static async pollGitHubDeviceLogin(deviceCode: string): Promise<string> {
|
|
92
|
+
const poll = async (): Promise<string> => {
|
|
93
|
+
const response = await fetch('https://github.com/login/oauth/access_token', {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
headers: {
|
|
96
|
+
'Content-Type': 'application/json',
|
|
97
|
+
Accept: 'application/json',
|
|
98
|
+
},
|
|
99
|
+
body: JSON.stringify({
|
|
100
|
+
client_id: GITHUB_CLIENT_ID,
|
|
101
|
+
device_code: deviceCode,
|
|
102
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
103
|
+
}),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
throw new Error(`Failed to poll device login: ${response.statusText}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const data = (await response.json()) as {
|
|
111
|
+
access_token?: string;
|
|
112
|
+
error?: string;
|
|
113
|
+
error_description?: string;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
if (data.access_token) {
|
|
117
|
+
return data.access_token;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (data.error === 'authorization_pending') {
|
|
121
|
+
return ''; // Continue polling
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
throw new Error(data.error_description || data.error || 'Failed to get access token');
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// Poll every 5 seconds (GitHub's default interval is usually 5)
|
|
128
|
+
// In a real implementation, we should use the interval from initGitHubDeviceLogin
|
|
129
|
+
while (true) {
|
|
130
|
+
const token = await poll();
|
|
131
|
+
if (token) return token;
|
|
132
|
+
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
47
136
|
static async getCopilotToken(): Promise<string | undefined> {
|
|
48
137
|
const auth = AuthManager.load();
|
|
49
138
|
|