@davidvornholt/standards 0.13.0 → 0.14.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 +11 -2
- package/package.json +27 -1
- package/src/cli.ts +11 -0
- package/src/creds-add-cloudflare-commit.ts +46 -0
- package/src/creds-add-github.ts +66 -0
- package/src/creds-add.ts +183 -0
- package/src/creds-browser.ts +28 -0
- package/src/creds-cloudflare-account.ts +4 -0
- package/src/creds-cloudflare-api.ts +200 -0
- package/src/creds-cloudflare-condition.ts +63 -0
- package/src/creds-cloudflare-pagination.ts +49 -0
- package/src/creds-cloudflare.ts +197 -0
- package/src/creds-commands.ts +200 -0
- package/src/creds-dest.ts +183 -0
- package/src/creds-login-cloudflare.ts +155 -0
- package/src/creds-login-github-manifest.ts +104 -0
- package/src/creds-login-github.ts +133 -0
- package/src/creds-naming.ts +82 -0
- package/src/creds-plan-revoke.ts +15 -0
- package/src/creds-plan-run.ts +198 -0
- package/src/creds-plan.ts +185 -0
- package/src/creds-prompt.ts +62 -0
- package/src/creds-sops-editor.ts +170 -0
- package/src/creds-sops-structure.ts +199 -0
- package/src/creds-sops.ts +198 -0
- package/src/creds-store-lock.ts +102 -0
- package/src/creds-store.ts +199 -0
- package/src/dev-env.ts +4 -22
- package/src/poller-codex.ts +34 -6
- package/src/poller-config.ts +11 -1
- package/src/poller-fix-run.ts +5 -4
- package/src/poller-job-shared.ts +3 -1
- package/src/poller-review-execution.ts +6 -5
- package/src/poller-units.ts +7 -2
- package/src/sops-exec.ts +47 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// SOPS reads inspect only plaintext key structure. Writes use a non-
|
|
2
|
+
// interactive editor so values never touch argv, stdout, or an unencrypted
|
|
3
|
+
// file outside SOPS's own temporary-file handling.
|
|
4
|
+
|
|
5
|
+
import { readFile } from 'node:fs/promises';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import process from 'node:process';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import {
|
|
10
|
+
type SopsValueChange as EditorValueChange,
|
|
11
|
+
inspectSopsScalarStructure,
|
|
12
|
+
SET_CHANGES_ENV,
|
|
13
|
+
type SopsScalarStructureResult,
|
|
14
|
+
} from './creds-sops-editor';
|
|
15
|
+
import {
|
|
16
|
+
inspectSopsStructure,
|
|
17
|
+
isContainedSopsPath,
|
|
18
|
+
parseSopsKeyPath,
|
|
19
|
+
type SopsShapeFailure,
|
|
20
|
+
} from './creds-sops-structure';
|
|
21
|
+
import { runSops } from './sops-exec';
|
|
22
|
+
|
|
23
|
+
export type { SopsValueChange } from './creds-sops-editor';
|
|
24
|
+
|
|
25
|
+
const SOPS_UNCHANGED_STATUS = 200;
|
|
26
|
+
type ReadError = {
|
|
27
|
+
readonly ok: false;
|
|
28
|
+
readonly kind: 'read-error';
|
|
29
|
+
readonly problem: string;
|
|
30
|
+
};
|
|
31
|
+
type ReadFailure = SopsShapeFailure | ReadError;
|
|
32
|
+
export type EncryptedKeysReadResult =
|
|
33
|
+
| { readonly ok: true; readonly keys: ReadonlyArray<string> }
|
|
34
|
+
| ReadFailure;
|
|
35
|
+
export type SopsScalarDestinationResult = SopsScalarStructureResult | ReadError;
|
|
36
|
+
export type SopsWriteResult =
|
|
37
|
+
| { readonly ok: true }
|
|
38
|
+
| { readonly ok: false; readonly problem: string };
|
|
39
|
+
export type SopsStoredValueVerification =
|
|
40
|
+
| { readonly ok: true; readonly matches: boolean }
|
|
41
|
+
| { readonly ok: false; readonly problem: string };
|
|
42
|
+
type SopsStoredValueInput = {
|
|
43
|
+
readonly consumer: string;
|
|
44
|
+
readonly rel: string;
|
|
45
|
+
readonly dottedPath: string;
|
|
46
|
+
readonly expectedValue: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const listEncryptedKeys = (text: string): EncryptedKeysReadResult => {
|
|
50
|
+
const result = inspectSopsStructure(text, true);
|
|
51
|
+
return result.ok ? { ok: true, keys: result.keys } : result;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const readEncryptedKeys = async (
|
|
55
|
+
consumer: string,
|
|
56
|
+
rel: string,
|
|
57
|
+
): Promise<EncryptedKeysReadResult> => {
|
|
58
|
+
if (!isContainedSopsPath(consumer, rel, 'file')) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
kind: 'read-error',
|
|
62
|
+
problem: `unsafe encrypted secrets target ${rel}`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
return listEncryptedKeys(await readFile(join(consumer, rel), 'utf8'));
|
|
67
|
+
} catch {
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
kind: 'read-error',
|
|
71
|
+
problem: `could not read encrypted secrets target ${rel}`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const inspectSopsScalarDestination = async (
|
|
77
|
+
consumer: string,
|
|
78
|
+
rel: string,
|
|
79
|
+
dottedPath: string,
|
|
80
|
+
): Promise<SopsScalarDestinationResult> => {
|
|
81
|
+
if (!isContainedSopsPath(consumer, rel, 'file')) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
kind: 'read-error',
|
|
85
|
+
problem: `unsafe encrypted secrets target ${rel}`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const text = await readFile(join(consumer, rel), 'utf8');
|
|
90
|
+
return inspectSopsScalarStructure(
|
|
91
|
+
inspectSopsStructure(text, true),
|
|
92
|
+
dottedPath,
|
|
93
|
+
);
|
|
94
|
+
} catch {
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
kind: 'read-error',
|
|
98
|
+
problem: `could not read encrypted secrets target ${rel}`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const verifySopsScalarLeaf = async (
|
|
104
|
+
consumer: string,
|
|
105
|
+
rel: string,
|
|
106
|
+
dottedPath: string,
|
|
107
|
+
): Promise<SopsWriteResult> => {
|
|
108
|
+
const result = await inspectSopsScalarDestination(consumer, rel, dottedPath);
|
|
109
|
+
return result.ok && result.state === 'scalar'
|
|
110
|
+
? { ok: true }
|
|
111
|
+
: {
|
|
112
|
+
ok: false,
|
|
113
|
+
problem: result.ok
|
|
114
|
+
? `SOPS write did not create ${dottedPath} in ${rel}`
|
|
115
|
+
: result.problem,
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const editorCommand = (): string => {
|
|
120
|
+
const editor = fileURLToPath(
|
|
121
|
+
new URL('./creds-sops-editor.ts', import.meta.url),
|
|
122
|
+
);
|
|
123
|
+
return `"${process.execPath}" "${editor}"`;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export const verifySopsStoredValueWith = (
|
|
127
|
+
runner: typeof runSops,
|
|
128
|
+
input: SopsStoredValueInput,
|
|
129
|
+
): SopsStoredValueVerification => {
|
|
130
|
+
const { consumer, rel, dottedPath, expectedValue } = input;
|
|
131
|
+
const path = parseSopsKeyPath(dottedPath);
|
|
132
|
+
const problem = `could not verify stored SOPS value at ${dottedPath} in ${rel}`;
|
|
133
|
+
if (path === null || !isContainedSopsPath(consumer, rel, 'file')) {
|
|
134
|
+
return { ok: false, problem };
|
|
135
|
+
}
|
|
136
|
+
const extract = path
|
|
137
|
+
.map((segment) => `[${JSON.stringify(segment)}]`)
|
|
138
|
+
.join('');
|
|
139
|
+
const result = runner(
|
|
140
|
+
['decrypt', '--extract', extract, '--output-type', 'json', rel],
|
|
141
|
+
consumer,
|
|
142
|
+
);
|
|
143
|
+
if (result.status !== 0) {
|
|
144
|
+
return { ok: false, problem };
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
const stored: unknown = JSON.parse(result.stdout);
|
|
148
|
+
return typeof stored === 'string'
|
|
149
|
+
? { ok: true, matches: stored === expectedValue }
|
|
150
|
+
: { ok: false, problem };
|
|
151
|
+
} catch {
|
|
152
|
+
return { ok: false, problem };
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export const verifySopsStoredValue = (
|
|
157
|
+
consumer: string,
|
|
158
|
+
rel: string,
|
|
159
|
+
dottedPath: string,
|
|
160
|
+
expectedValue: string,
|
|
161
|
+
): SopsStoredValueVerification =>
|
|
162
|
+
verifySopsStoredValueWith(runSops, {
|
|
163
|
+
consumer,
|
|
164
|
+
rel,
|
|
165
|
+
dottedPath,
|
|
166
|
+
expectedValue,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
export const setSopsValues = (
|
|
170
|
+
consumer: string,
|
|
171
|
+
rel: string,
|
|
172
|
+
changes: ReadonlyArray<EditorValueChange>,
|
|
173
|
+
): SopsWriteResult => {
|
|
174
|
+
if (!isContainedSopsPath(consumer, rel, 'file')) {
|
|
175
|
+
return { ok: false, problem: `unsafe encrypted secrets target ${rel}` };
|
|
176
|
+
}
|
|
177
|
+
const result = runSops(['edit', rel], consumer, {
|
|
178
|
+
// biome-ignore lint/style/useNamingConvention: sops defines this environment variable.
|
|
179
|
+
SOPS_EDITOR: editorCommand(),
|
|
180
|
+
[SET_CHANGES_ENV]: JSON.stringify(changes),
|
|
181
|
+
});
|
|
182
|
+
if (result.status === 0 || result.status === SOPS_UNCHANGED_STATUS) {
|
|
183
|
+
return { ok: true };
|
|
184
|
+
}
|
|
185
|
+
const paths = changes.map(({ path }) => path).join(', ');
|
|
186
|
+
const detail = result.errorMessage ?? result.stderr.trim();
|
|
187
|
+
return {
|
|
188
|
+
ok: false,
|
|
189
|
+
problem: `could not write ${paths} into ${rel}${detail ? `: ${detail}` : ''}`,
|
|
190
|
+
};
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
export const setSopsValue = (
|
|
194
|
+
consumer: string,
|
|
195
|
+
rel: string,
|
|
196
|
+
path: string,
|
|
197
|
+
value: string,
|
|
198
|
+
): SopsWriteResult => setSopsValues(consumer, rel, [{ path, value }]);
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Broker store lock: a mkdir-based mutex serializing read-modify-write
|
|
2
|
+
// cycles on broker.yaml. Store writes are sub-second, so a lock directory
|
|
3
|
+
// older than the staleness window can only belong to a dead process and is
|
|
4
|
+
// broken automatically; the timeout error names the directory and the manual
|
|
5
|
+
// remediation for the pathological rest.
|
|
6
|
+
|
|
7
|
+
import { mkdir, rmdir, stat } from 'node:fs/promises';
|
|
8
|
+
import { dirname } from 'node:path';
|
|
9
|
+
import { isRecord } from './github-settings-parse';
|
|
10
|
+
|
|
11
|
+
const OWNER_ONLY_DIR_MODE = 0o700;
|
|
12
|
+
|
|
13
|
+
export type BrokerLockOptions = {
|
|
14
|
+
readonly retryMs?: number;
|
|
15
|
+
readonly timeoutMs?: number;
|
|
16
|
+
readonly staleMs?: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const DEFAULT_RETRY_MS = 25;
|
|
20
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
21
|
+
const DEFAULT_STALE_MS = 30_000;
|
|
22
|
+
|
|
23
|
+
const sleep = (ms: number): Promise<void> =>
|
|
24
|
+
new Promise((resolve) => setTimeout(resolve, ms));
|
|
25
|
+
|
|
26
|
+
const tryAcquire = async (lockPath: string): Promise<boolean> => {
|
|
27
|
+
try {
|
|
28
|
+
await mkdir(lockPath, { mode: OWNER_ONLY_DIR_MODE });
|
|
29
|
+
return true;
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (isRecord(error) && error.code === 'EEXIST') {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// True when the contender may immediately retry: the lock vanished, or it
|
|
39
|
+
// was stale and this process removed it (losing the removal race to another
|
|
40
|
+
// contender also lands here — the retry's mkdir arbitrates).
|
|
41
|
+
const breakIfStale = async (
|
|
42
|
+
lockPath: string,
|
|
43
|
+
staleMs: number,
|
|
44
|
+
): Promise<boolean> => {
|
|
45
|
+
try {
|
|
46
|
+
const info = await stat(lockPath);
|
|
47
|
+
if (Date.now() - info.mtimeMs < staleMs) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
await rmdir(lockPath);
|
|
51
|
+
return true;
|
|
52
|
+
} catch {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// One acquisition attempt: acquired, or cleared a stale/vanished lock (retry
|
|
58
|
+
// immediately), or waited one retry slot; a live lock past the deadline
|
|
59
|
+
// throws with the remediation hint.
|
|
60
|
+
const attemptAcquire = async (
|
|
61
|
+
lockPath: string,
|
|
62
|
+
staleMs: number,
|
|
63
|
+
retryMs: number,
|
|
64
|
+
deadline: number,
|
|
65
|
+
): Promise<boolean> => {
|
|
66
|
+
if (await tryAcquire(lockPath)) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
if (await breakIfStale(lockPath, staleMs)) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
if (Date.now() >= deadline) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`lock timeout: another creds process holds ${lockPath}; if none is running, remove that directory and retry`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
await sleep(retryMs);
|
|
78
|
+
return false;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const withBrokerLock = async <T>(
|
|
82
|
+
path: string,
|
|
83
|
+
operation: () => Promise<T>,
|
|
84
|
+
options: BrokerLockOptions = {},
|
|
85
|
+
): Promise<T> => {
|
|
86
|
+
const retryMs = options.retryMs ?? DEFAULT_RETRY_MS;
|
|
87
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
88
|
+
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
89
|
+
const lockPath = `${path}.lock`;
|
|
90
|
+
await mkdir(dirname(path), { recursive: true, mode: OWNER_ONLY_DIR_MODE });
|
|
91
|
+
const deadline = Date.now() + timeoutMs;
|
|
92
|
+
let acquired = false;
|
|
93
|
+
while (!acquired) {
|
|
94
|
+
// biome-ignore lint/performance/noAwaitInLoops: lock acquisition retries are inherently sequential.
|
|
95
|
+
acquired = await attemptAcquire(lockPath, staleMs, retryMs, deadline);
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
return await operation();
|
|
99
|
+
} finally {
|
|
100
|
+
await rmdir(lockPath);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { existsSync, statSync } from 'node:fs';
|
|
3
|
+
import { mkdir, open, readFile, rename, unlink } from 'node:fs/promises';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { basename, dirname, join } from 'node:path';
|
|
6
|
+
import process from 'node:process';
|
|
7
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
8
|
+
import { isCloudflareAccountId } from './creds-cloudflare-account';
|
|
9
|
+
import { withBrokerLock } from './creds-store-lock';
|
|
10
|
+
import { isNonEmptyString, isRecord } from './github-settings-parse';
|
|
11
|
+
export type GithubBrokerApp = {
|
|
12
|
+
readonly appId: number;
|
|
13
|
+
readonly slug: string;
|
|
14
|
+
readonly htmlUrl: string;
|
|
15
|
+
readonly clientId: string;
|
|
16
|
+
readonly privateKey: string;
|
|
17
|
+
};
|
|
18
|
+
export type CloudflareBrokerAccount = Readonly<
|
|
19
|
+
Record<'accountId' | 'token', string>
|
|
20
|
+
>;
|
|
21
|
+
export type BrokerStore = {
|
|
22
|
+
readonly github: GithubBrokerApp | null;
|
|
23
|
+
readonly cloudflare: ReadonlyArray<CloudflareBrokerAccount>;
|
|
24
|
+
};
|
|
25
|
+
export const EMPTY_BROKER_STORE: BrokerStore = { github: null, cloudflare: [] };
|
|
26
|
+
const OWNER_ONLY_FILE_MODE = 0o600;
|
|
27
|
+
const OWNER_ONLY_DIR_MODE = 0o700;
|
|
28
|
+
const FILE_MODE_MODULUS = 0o1000;
|
|
29
|
+
const GROUP_OTHER_MODULUS = 0o100;
|
|
30
|
+
const OCTAL_RADIX = 8;
|
|
31
|
+
export const inspectBrokerFileMode = (path: string) => {
|
|
32
|
+
try {
|
|
33
|
+
const mode = statSync(path).mode % FILE_MODE_MODULUS;
|
|
34
|
+
return {
|
|
35
|
+
exists: true,
|
|
36
|
+
problem:
|
|
37
|
+
mode % GROUP_OTHER_MODULUS === 0
|
|
38
|
+
? null
|
|
39
|
+
: `mode ${mode.toString(OCTAL_RADIX)} is broader than 0600; tighten with chmod 600`,
|
|
40
|
+
};
|
|
41
|
+
} catch {
|
|
42
|
+
return { exists: false, problem: null };
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
export const resolveBrokerPath = (): string =>
|
|
46
|
+
process.env.STANDARDS_BROKER_FILE ??
|
|
47
|
+
join(
|
|
48
|
+
process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'),
|
|
49
|
+
'standards/broker.yaml',
|
|
50
|
+
);
|
|
51
|
+
const parseGithub = (raw: unknown): GithubBrokerApp | null => {
|
|
52
|
+
if (raw === undefined || raw === null) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
if (
|
|
56
|
+
!isRecord(raw) ||
|
|
57
|
+
typeof raw.app_id !== 'number' ||
|
|
58
|
+
!Number.isInteger(raw.app_id) ||
|
|
59
|
+
!isNonEmptyString(raw.slug) ||
|
|
60
|
+
!isNonEmptyString(raw.html_url) ||
|
|
61
|
+
!isNonEmptyString(raw.client_id) ||
|
|
62
|
+
!isNonEmptyString(raw.private_key)
|
|
63
|
+
) {
|
|
64
|
+
throw new Error('invalid github: run `standards creds login github`');
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
appId: raw.app_id,
|
|
68
|
+
slug: raw.slug,
|
|
69
|
+
htmlUrl: raw.html_url,
|
|
70
|
+
clientId: raw.client_id,
|
|
71
|
+
privateKey: raw.private_key,
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
const validateCloudflareAccounts = (
|
|
75
|
+
accounts: ReadonlyArray<CloudflareBrokerAccount>,
|
|
76
|
+
path: string,
|
|
77
|
+
): ReadonlyArray<CloudflareBrokerAccount> => {
|
|
78
|
+
const accountIds = new Set<string>();
|
|
79
|
+
for (const account of accounts) {
|
|
80
|
+
if (!isCloudflareAccountId(account.accountId)) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`${path}: Cloudflare account_id must be exactly 32 lowercase hexadecimal characters`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (!isNonEmptyString(account.token)) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
'invalid cloudflare: run `standards creds login cloudflare`',
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
if (accountIds.has(account.accountId)) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`${path}: duplicate Cloudflare account ${account.accountId}; keep one bootstrap credential per account`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
accountIds.add(account.accountId);
|
|
96
|
+
}
|
|
97
|
+
return accounts;
|
|
98
|
+
};
|
|
99
|
+
const parseCloudflare = (
|
|
100
|
+
raw: unknown,
|
|
101
|
+
path: string,
|
|
102
|
+
): ReadonlyArray<CloudflareBrokerAccount> => {
|
|
103
|
+
if (raw === undefined || raw === null) {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
if (!Array.isArray(raw)) {
|
|
107
|
+
throw new Error(`${path}: "cloudflare" must be a list of accounts`);
|
|
108
|
+
}
|
|
109
|
+
const accounts = raw.map((entry) => {
|
|
110
|
+
const valid =
|
|
111
|
+
isRecord(entry) &&
|
|
112
|
+
isNonEmptyString(entry.account_id) &&
|
|
113
|
+
isNonEmptyString(entry.token);
|
|
114
|
+
if (!valid) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
'invalid cloudflare: run `standards creds login cloudflare`',
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return { accountId: String(entry.account_id), token: String(entry.token) };
|
|
120
|
+
});
|
|
121
|
+
return validateCloudflareAccounts(accounts, path);
|
|
122
|
+
};
|
|
123
|
+
export const readBrokerStore = async (path: string): Promise<BrokerStore> => {
|
|
124
|
+
const raw = existsSync(path)
|
|
125
|
+
? (parseYaml(await readFile(path, 'utf8')) as unknown)
|
|
126
|
+
: null;
|
|
127
|
+
if (raw === null || raw === undefined) {
|
|
128
|
+
return EMPTY_BROKER_STORE;
|
|
129
|
+
}
|
|
130
|
+
if (!isRecord(raw)) {
|
|
131
|
+
throw new Error(`${path} must contain a YAML mapping`);
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
github: parseGithub(raw.github),
|
|
135
|
+
cloudflare: parseCloudflare(raw.cloudflare, path),
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
export const updateBrokerStore = async (
|
|
139
|
+
path: string,
|
|
140
|
+
update: (store: BrokerStore) => BrokerStore | Promise<BrokerStore>,
|
|
141
|
+
sync: typeof syncDirectory = syncDirectory,
|
|
142
|
+
): Promise<void> =>
|
|
143
|
+
withBrokerLock(path, async () => {
|
|
144
|
+
const store = await update(await readBrokerStore(path));
|
|
145
|
+
validateCloudflareAccounts(store.cloudflare, path);
|
|
146
|
+
await writeBrokerStoreUnlocked(path, store, sync);
|
|
147
|
+
});
|
|
148
|
+
const storeDocument = (store: BrokerStore): unknown => ({
|
|
149
|
+
...(store.github === null
|
|
150
|
+
? {}
|
|
151
|
+
: {
|
|
152
|
+
github: {
|
|
153
|
+
app_id: store.github.appId,
|
|
154
|
+
slug: store.github.slug,
|
|
155
|
+
html_url: store.github.htmlUrl,
|
|
156
|
+
client_id: store.github.clientId,
|
|
157
|
+
private_key: store.github.privateKey,
|
|
158
|
+
},
|
|
159
|
+
}),
|
|
160
|
+
...(store.cloudflare.length === 0
|
|
161
|
+
? {}
|
|
162
|
+
: {
|
|
163
|
+
cloudflare: store.cloudflare.map((account) => ({
|
|
164
|
+
account_id: account.accountId,
|
|
165
|
+
token: account.token,
|
|
166
|
+
})),
|
|
167
|
+
}),
|
|
168
|
+
});
|
|
169
|
+
const syncDirectory = async (path: string): Promise<void> => {
|
|
170
|
+
const handle = await open(path, 'r');
|
|
171
|
+
try {
|
|
172
|
+
await handle.sync();
|
|
173
|
+
} finally {
|
|
174
|
+
await handle.close();
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
const writeBrokerStoreUnlocked = async (
|
|
178
|
+
path: string,
|
|
179
|
+
store: BrokerStore,
|
|
180
|
+
sync: typeof syncDirectory,
|
|
181
|
+
): Promise<void> => {
|
|
182
|
+
const parent = dirname(path);
|
|
183
|
+
await mkdir(parent, { recursive: true, mode: OWNER_ONLY_DIR_MODE });
|
|
184
|
+
const temporary = join(parent, `.${basename(path)}.${randomUUID()}.tmp`);
|
|
185
|
+
const handle = await open(temporary, 'wx', OWNER_ONLY_FILE_MODE);
|
|
186
|
+
try {
|
|
187
|
+
try {
|
|
188
|
+
await handle.writeFile(stringifyYaml(storeDocument(store)), 'utf8');
|
|
189
|
+
await handle.chmod(OWNER_ONLY_FILE_MODE);
|
|
190
|
+
await handle.sync();
|
|
191
|
+
} finally {
|
|
192
|
+
await handle.close();
|
|
193
|
+
}
|
|
194
|
+
await rename(temporary, path);
|
|
195
|
+
await sync(parent);
|
|
196
|
+
} finally {
|
|
197
|
+
await unlink(temporary).catch(() => undefined);
|
|
198
|
+
}
|
|
199
|
+
};
|
package/src/dev-env.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import { parseDevEnvDocument } from './dev-env-document';
|
|
9
9
|
import { renderDotenv } from './dev-env-dotenv';
|
|
10
10
|
import { writeDevEnvFiles } from './dev-env-transaction';
|
|
11
|
+
import { runSops } from './sops-exec';
|
|
11
12
|
|
|
12
13
|
export const DEV_SECRETS_FILE = 'secrets/dev.yaml';
|
|
13
14
|
|
|
@@ -17,30 +18,11 @@ type DecryptResult =
|
|
|
17
18
|
| { readonly ok: true; readonly value: unknown }
|
|
18
19
|
| { readonly ok: false; readonly problem: string };
|
|
19
20
|
|
|
20
|
-
// sops emits JSON here so the CLI never parses the encrypted YAML itself
|
|
21
|
-
// nix fallback mirrors the canonical secrets.just tool resolution.
|
|
21
|
+
// sops emits JSON here so the CLI never parses the encrypted YAML itself.
|
|
22
22
|
const decryptDevSecrets = (consumer: string): DecryptResult => {
|
|
23
|
-
const
|
|
24
|
-
cwd: consumer,
|
|
25
|
-
encoding: 'utf8',
|
|
26
|
-
});
|
|
27
|
-
const result =
|
|
28
|
-
localSops.error === undefined && localSops.status !== null
|
|
29
|
-
? localSops
|
|
30
|
-
: spawnSync(
|
|
31
|
-
'nix',
|
|
32
|
-
[
|
|
33
|
-
'--extra-experimental-features',
|
|
34
|
-
'nix-command flakes',
|
|
35
|
-
'run',
|
|
36
|
-
'nixpkgs#sops',
|
|
37
|
-
'--',
|
|
38
|
-
...SOPS_ARGS,
|
|
39
|
-
],
|
|
40
|
-
{ cwd: consumer, encoding: 'utf8' },
|
|
41
|
-
);
|
|
23
|
+
const result = runSops(SOPS_ARGS, consumer);
|
|
42
24
|
if (result.status !== 0) {
|
|
43
|
-
const detail = result.
|
|
25
|
+
const detail = result.errorMessage ?? result.stderr.trim();
|
|
44
26
|
return {
|
|
45
27
|
ok: false,
|
|
46
28
|
problem: detail
|
package/src/poller-codex.ts
CHANGED
|
@@ -34,6 +34,23 @@ type CodexExecutor = (
|
|
|
34
34
|
|
|
35
35
|
const defaultExecutor: CodexExecutor = execFileSync;
|
|
36
36
|
|
|
37
|
+
type CodexRunOptions = {
|
|
38
|
+
readonly workDir: string;
|
|
39
|
+
readonly gitCommonDir: string;
|
|
40
|
+
readonly prompt: string;
|
|
41
|
+
readonly config: PollerConfig;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const exitCause = (details: Record<string, unknown>): string => {
|
|
45
|
+
if (typeof details.status === 'number') {
|
|
46
|
+
return `exit status ${details.status}`;
|
|
47
|
+
}
|
|
48
|
+
if (typeof details.signal === 'string') {
|
|
49
|
+
return `signal ${details.signal}`;
|
|
50
|
+
}
|
|
51
|
+
return 'unknown exit cause';
|
|
52
|
+
};
|
|
53
|
+
|
|
37
54
|
// Remove the poller's direct GitHub token variables so an approved Codex run
|
|
38
55
|
// cannot trivially bypass the protected-path and trust checks with API writes.
|
|
39
56
|
// The shared service identity is not credential-isolated: auth state and other
|
|
@@ -47,11 +64,10 @@ const agentEnv = (): Record<string, string | undefined> => {
|
|
|
47
64
|
};
|
|
48
65
|
|
|
49
66
|
export const runCodex = (
|
|
50
|
-
|
|
51
|
-
prompt: string,
|
|
52
|
-
config: PollerConfig,
|
|
67
|
+
options: CodexRunOptions,
|
|
53
68
|
execute: CodexExecutor = defaultExecutor,
|
|
54
69
|
): CodexRunResult => {
|
|
70
|
+
const { workDir, gitCommonDir, prompt, config } = options;
|
|
55
71
|
rmSync(join(workDir, OUTCOME_DIR), { recursive: true, force: true });
|
|
56
72
|
try {
|
|
57
73
|
execute(
|
|
@@ -62,6 +78,8 @@ export const runCodex = (
|
|
|
62
78
|
workDir,
|
|
63
79
|
'--sandbox',
|
|
64
80
|
'workspace-write',
|
|
81
|
+
'--add-dir',
|
|
82
|
+
gitCommonDir,
|
|
65
83
|
'-c',
|
|
66
84
|
'sandbox_workspace_write.network_access=true',
|
|
67
85
|
'-m',
|
|
@@ -80,14 +98,24 @@ export const runCodex = (
|
|
|
80
98
|
);
|
|
81
99
|
return { succeeded: true, failure: null };
|
|
82
100
|
} catch (error) {
|
|
101
|
+
const details: Record<string, unknown> = isRecord(error) ? error : {};
|
|
83
102
|
const stderr =
|
|
84
|
-
|
|
85
|
-
?
|
|
103
|
+
typeof details.stderr === 'string'
|
|
104
|
+
? details.stderr.trim().slice(-STDERR_SNIPPET_LIMIT)
|
|
86
105
|
: '';
|
|
87
106
|
const message = error instanceof Error ? error.message : String(error);
|
|
107
|
+
// Node's "Command failed" message echoes the full command line, and the
|
|
108
|
+
// prompt embedded in it would push stderr past the failure-comment
|
|
109
|
+
// snippet limit — report the exit cause instead of the echoed command.
|
|
110
|
+
const cause = message.startsWith('Command failed')
|
|
111
|
+
? exitCause(details)
|
|
112
|
+
: message;
|
|
88
113
|
return {
|
|
89
114
|
succeeded: false,
|
|
90
|
-
failure:
|
|
115
|
+
failure:
|
|
116
|
+
stderr === ''
|
|
117
|
+
? `codex exec failed: ${cause}`
|
|
118
|
+
: `codex exec failed (${cause}):\n${stderr}`,
|
|
91
119
|
};
|
|
92
120
|
}
|
|
93
121
|
};
|
package/src/poller-config.ts
CHANGED
|
@@ -28,6 +28,7 @@ const DEFAULT_STALE_CLAIM_HOURS = 6;
|
|
|
28
28
|
// wedged agents, not to bound honest work.
|
|
29
29
|
const DEFAULT_RUN_TIMEOUT_MINUTES = 240;
|
|
30
30
|
const MINUTES_PER_HOUR = 60;
|
|
31
|
+
const MAX_WATCHED_REPOSITORIES = 12;
|
|
31
32
|
|
|
32
33
|
export type PollerConfig = {
|
|
33
34
|
readonly repos: ReadonlyArray<string>;
|
|
@@ -52,7 +53,16 @@ const parseRepos = (
|
|
|
52
53
|
raw: unknown,
|
|
53
54
|
problems: Array<string>,
|
|
54
55
|
): ReadonlyArray<string> => {
|
|
55
|
-
if (!
|
|
56
|
+
if (!Array.isArray(raw)) {
|
|
57
|
+
problems.push('poller config "repos" must be a string array');
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
if (raw.length > MAX_WATCHED_REPOSITORIES) {
|
|
61
|
+
problems.push(
|
|
62
|
+
`poller config "repos" supports at most ${MAX_WATCHED_REPOSITORIES} repositories at the one-minute polling cadence; reduce the list or split it across pollers with independent GitHub API budgets`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
if (!raw.every((repo): repo is string => typeof repo === 'string')) {
|
|
56
66
|
problems.push('poller config "repos" must be a string array');
|
|
57
67
|
return [];
|
|
58
68
|
}
|
package/src/poller-fix-run.ts
CHANGED
|
@@ -139,9 +139,10 @@ export const runFixJob = async (
|
|
|
139
139
|
workspace,
|
|
140
140
|
};
|
|
141
141
|
try {
|
|
142
|
-
const run = runCodex(
|
|
143
|
-
workspace.dir,
|
|
144
|
-
|
|
142
|
+
const run = runCodex({
|
|
143
|
+
workDir: workspace.dir,
|
|
144
|
+
gitCommonDir: cacheClone,
|
|
145
|
+
prompt: fixPrompt({
|
|
145
146
|
repo,
|
|
146
147
|
issueNumber: issue.number,
|
|
147
148
|
title: currentIssue.title,
|
|
@@ -149,7 +150,7 @@ export const runFixJob = async (
|
|
|
149
150
|
answers: preamble.answers,
|
|
150
151
|
}),
|
|
151
152
|
config,
|
|
152
|
-
);
|
|
153
|
+
});
|
|
153
154
|
await validateFixClaim(job);
|
|
154
155
|
const outcome = run.succeeded ? await readFixOutcome(workspace.dir) : null;
|
|
155
156
|
if (outcome === null) {
|
package/src/poller-job-shared.ts
CHANGED
|
@@ -31,7 +31,9 @@ export type JobLabels = {
|
|
|
31
31
|
readonly failed: string;
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
// Must exceed runCodex's failure string at its longest (its stderr snippet
|
|
35
|
+
// plus the exit-cause prefix), so the diagnostic tail is never cut off.
|
|
36
|
+
const FAILURE_SNIPPET_LIMIT = 4000;
|
|
35
37
|
|
|
36
38
|
export type JobPreamble =
|
|
37
39
|
| { readonly kind: 'rejected' }
|
|
@@ -36,17 +36,18 @@ export const executeReviewJob = async (options: {
|
|
|
36
36
|
),
|
|
37
37
|
);
|
|
38
38
|
try {
|
|
39
|
-
const run = runCodex(
|
|
40
|
-
workspace.dir,
|
|
41
|
-
|
|
39
|
+
const run = runCodex({
|
|
40
|
+
workDir: workspace.dir,
|
|
41
|
+
gitCommonDir: cacheClone,
|
|
42
|
+
prompt: reviewPrompt({
|
|
42
43
|
repo: deps.repo,
|
|
43
44
|
prNumber: pr.number,
|
|
44
45
|
title: pr.title,
|
|
45
46
|
baseSha: reviewBase,
|
|
46
47
|
answers,
|
|
47
48
|
}),
|
|
48
|
-
deps.config,
|
|
49
|
-
);
|
|
49
|
+
config: deps.config,
|
|
50
|
+
});
|
|
50
51
|
await validateReviewClaim({
|
|
51
52
|
deps,
|
|
52
53
|
pr,
|