instabox 0.6.3 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/bin/instabox.mjs +23 -102
- package/lib/cli.mjs +1 -1
- package/lib/login.mjs +37 -36
- package/package.json +1 -1
- package/skills/instabox/SKILL.md +33 -27
- package/skills/instabox-basic-setup/SKILL.md +16 -23
- package/skills/instabox-basic-setup/scripts/content.mjs +6 -22
- package/skills/instabox-basic-setup/scripts/lib.mjs +49 -68
- package/skills/instabox-basic-setup/scripts/plan.mjs +17 -48
- package/skills/instabox-basic-setup/scripts/push.mjs +29 -32
- package/skills/instabox-basic-setup/scripts/scan.mjs +41 -32
- package/skills/instabox-move-repo/SKILL.md +7 -30
- package/skills/instabox-move-repo/scripts/deps.sh +1 -1
- package/skills/instabox-move-repo/scripts/repo-files.mjs +2 -2
- package/skills/instabox-snapshots/SKILL.md +4 -4
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// What a directly copied configuration file becomes: the local file with this
|
|
2
2
|
// machine's HOME rewritten to the box's, lines that look like credentials
|
|
3
3
|
// removed and macOS-only lines commented out. ~/.gitconfig also gets a few
|
|
4
|
-
// changes so git works on a box that has
|
|
4
|
+
// changes so git works on a box that has no signing or SSH key.
|
|
5
5
|
//
|
|
6
6
|
// Used by plan.mjs (for sizes) and push.mjs (for the upload). Nothing here
|
|
7
7
|
// prints a file's content.
|
|
@@ -40,25 +40,11 @@ export function scrubText(text, { path, home }) {
|
|
|
40
40
|
return { text: out.join('\n'), secrets, macos };
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
//
|
|
44
|
-
export const GH_HELPER = [
|
|
45
|
-
'',
|
|
46
|
-
`# ${TAG}: git over HTTPS to GitHub uses an explicitly selected env GH_TOKEN through gh`,
|
|
47
|
-
'[credential "https://github.com"]',
|
|
48
|
-
'\thelper =',
|
|
49
|
-
'\thelper = !gh auth git-credential',
|
|
50
|
-
'[credential "https://gist.github.com"]',
|
|
51
|
-
'\thelper =',
|
|
52
|
-
'\thelper = !gh auth git-credential',
|
|
53
|
-
'',
|
|
54
|
-
].join('\n');
|
|
55
|
-
|
|
56
|
-
// ~/.gitconfig for the box. ghToken: GH_TOKEN is explicitly selected for env delivery, so the gh helper is added.
|
|
57
|
-
// Changes, besides scrubText's:
|
|
43
|
+
// ~/.gitconfig for the box. Changes, besides scrubText's:
|
|
58
44
|
// - commit/tag signing is turned off: the signing key (GPG, SSH, 1Password) is not on the box;
|
|
59
45
|
// - url.<ssh>.insteadOf rewrites are commented out: the box has no SSH key, and they would
|
|
60
46
|
// send HTTPS remotes over SSH.
|
|
61
|
-
export function gitconfigText(text, { home
|
|
47
|
+
export function gitconfigText(text, { home }) {
|
|
62
48
|
const r = scrubText(text, { path: '.gitconfig', home });
|
|
63
49
|
let section = '';
|
|
64
50
|
let signing = 0;
|
|
@@ -77,13 +63,11 @@ export function gitconfigText(text, { home, ghToken }) {
|
|
|
77
63
|
}
|
|
78
64
|
return line;
|
|
79
65
|
});
|
|
80
|
-
|
|
81
|
-
if (ghToken) out = out.replace(/\n*$/, '\n') + GH_HELPER;
|
|
82
|
-
return { text: out, secrets: r.secrets, macos: r.macos, signing, insteadOf };
|
|
66
|
+
return { text: lines.join('\n'), secrets: r.secrets, macos: r.macos, signing, insteadOf };
|
|
83
67
|
}
|
|
84
68
|
|
|
85
69
|
// The content of one file of a file item, or {error}. f: {path, src, transform}.
|
|
86
|
-
export function fileContent(f, { home
|
|
70
|
+
export function fileContent(f, { home } = {}) {
|
|
87
71
|
let buf;
|
|
88
72
|
try {
|
|
89
73
|
buf = readFileSync(f.src);
|
|
@@ -93,7 +77,7 @@ export function fileContent(f, { home, ghToken = false } = {}) {
|
|
|
93
77
|
if (buf.includes(0)) return { error: `${f.src}: a binary file (config copying supports UTF-8 text only)` };
|
|
94
78
|
const text = buf.toString('utf8');
|
|
95
79
|
if (!Buffer.from(text).equals(buf)) return { error: `${f.src}: invalid UTF-8 text` };
|
|
96
|
-
if (f.transform === 'gitconfig') return gitconfigText(text, { home
|
|
80
|
+
if (f.transform === 'gitconfig') return gitconfigText(text, { home });
|
|
97
81
|
if (f.transform === 'raw') {
|
|
98
82
|
if (SECRET_VALUE.test(text) || text.split('\n').some((line) => SECRET_KEY_LINE.test(line)))
|
|
99
83
|
return { error: `${f.src}: possible credential in structured config; review it separately` };
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
// The one rule every function here keeps: a credential's value is never
|
|
4
4
|
// returned to the agent, stored in a file or printed. Code that looks at a
|
|
5
5
|
// secret returns names, places and sizes only.
|
|
6
|
-
import { isIP } from 'node:net';
|
|
7
6
|
import { spawnSync } from 'node:child_process';
|
|
8
7
|
import { lstatSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
9
8
|
import { fileURLToPath } from 'node:url';
|
|
@@ -38,50 +37,55 @@ export const SECRET_VALUE = new RegExp(
|
|
|
38
37
|
export const SECRET_KEY_LINE =
|
|
39
38
|
/(?:^|[\s{,;:])["']?[\w.-]*(?:token|password|passwd|secret|api[_-]?key|apikey|_auth|authorization|extraheader|private[_-]?key)[\w.-]*["']?\s*[=:]\s*["']?(?![0-9.]+\b|true\b|false\b)[^\s"',}]/i;
|
|
40
39
|
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
]
|
|
40
|
+
// The tokens this skill saves, each with the HTTPS API hosts its usual client sends it to in a
|
|
41
|
+
// request header. The box gets a placeholder and the outbound proxy fills in the token only for
|
|
42
|
+
// these hosts. A token whose client signs with it, sends it in a body or URL, or talks to hosts
|
|
43
|
+
// that cannot be named exactly is left out: the scan lists it as a manual step.
|
|
44
|
+
// FORMAT is the scan and approved-selection format. 4: proxy-only Secrets, hosts from
|
|
45
|
+
// TOKEN_HOSTS. Older files approved env delivery or other hosts; they are refused, never
|
|
46
|
+
// reinterpreted, so an approval cannot widen a credential's reach.
|
|
47
|
+
export const FORMAT = 4;
|
|
48
|
+
|
|
49
|
+
export const TOKEN_HOSTS = new Map(
|
|
50
|
+
Object.entries({
|
|
51
|
+
GH_TOKEN: ['api.github.com'],
|
|
52
|
+
GITHUB_TOKEN: ['api.github.com'],
|
|
53
|
+
ANTHROPIC_API_KEY: ['api.anthropic.com'],
|
|
54
|
+
OPENAI_API_KEY: ['api.openai.com'],
|
|
55
|
+
GEMINI_API_KEY: ['generativelanguage.googleapis.com'],
|
|
56
|
+
GOOGLE_API_KEY: ['generativelanguage.googleapis.com'],
|
|
57
|
+
GOOGLE_GENERATIVE_AI_API_KEY: ['generativelanguage.googleapis.com'],
|
|
58
|
+
OPENROUTER_API_KEY: ['openrouter.ai'],
|
|
59
|
+
GROQ_API_KEY: ['api.groq.com'],
|
|
60
|
+
MISTRAL_API_KEY: ['api.mistral.ai'],
|
|
61
|
+
DEEPSEEK_API_KEY: ['api.deepseek.com'],
|
|
62
|
+
XAI_API_KEY: ['api.x.ai'],
|
|
63
|
+
TOGETHER_API_KEY: ['api.together.ai', 'api.together.xyz'],
|
|
64
|
+
FIREWORKS_API_KEY: ['api.fireworks.ai'],
|
|
65
|
+
PERPLEXITY_API_KEY: ['api.perplexity.ai'],
|
|
66
|
+
COHERE_API_KEY: ['api.cohere.ai', 'api.cohere.com'],
|
|
67
|
+
VOYAGE_API_KEY: ['api.voyageai.com'],
|
|
68
|
+
REPLICATE_API_TOKEN: ['api.replicate.com'],
|
|
69
|
+
ELEVENLABS_API_KEY: ['api.elevenlabs.io'],
|
|
70
|
+
TAVILY_API_KEY: ['api.tavily.com'],
|
|
71
|
+
EXA_API_KEY: ['api.exa.ai'],
|
|
72
|
+
FIRECRAWL_API_KEY: ['api.firecrawl.dev'],
|
|
73
|
+
BRAVE_API_KEY: ['api.search.brave.com'],
|
|
74
|
+
HF_TOKEN: ['huggingface.co', 'router.huggingface.co'],
|
|
75
|
+
HUGGING_FACE_HUB_TOKEN: ['huggingface.co', 'router.huggingface.co'],
|
|
76
|
+
NPM_TOKEN: ['registry.npmjs.org'],
|
|
77
|
+
VERCEL_TOKEN: ['api.vercel.com'],
|
|
78
|
+
NETLIFY_AUTH_TOKEN: ['api.netlify.com'],
|
|
79
|
+
CLOUDFLARE_API_TOKEN: ['api.cloudflare.com'],
|
|
80
|
+
SUPABASE_ACCESS_TOKEN: ['api.supabase.com'],
|
|
81
|
+
RENDER_API_KEY: ['api.render.com'],
|
|
82
|
+
NEON_API_KEY: ['console.neon.tech'],
|
|
83
|
+
SENTRY_AUTH_TOKEN: ['sentry.io'],
|
|
84
|
+
LINEAR_API_KEY: ['api.linear.app'],
|
|
85
|
+
GITLAB_TOKEN: ['gitlab.com'],
|
|
86
|
+
DIGITALOCEAN_ACCESS_TOKEN: ['api.digitalocean.com'],
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
85
89
|
|
|
86
90
|
// Reserved Secret variable names (internal/secrets/validate.go).
|
|
87
91
|
const RESERVED = new Set([
|
|
@@ -206,28 +210,6 @@ export function configPathOK(p) {
|
|
|
206
210
|
return p.split('/').every((c) => c !== '' && c !== '.' && !c.startsWith('..'));
|
|
207
211
|
}
|
|
208
212
|
|
|
209
|
-
export function deliveryPolicy(delivery, hosts = []) {
|
|
210
|
-
if (!['env', 'proxy'].includes(delivery) || !Array.isArray(hosts)) throw new Error('delivery must be env or proxy');
|
|
211
|
-
if (delivery === 'env' && hosts.length) throw new Error('env delivery cannot enforce hosts; use -');
|
|
212
|
-
if (delivery === 'proxy' && (!hosts.length || hosts.length > 32))
|
|
213
|
-
throw new Error('proxy delivery requires 1-32 exact hosts');
|
|
214
|
-
const normalized = hosts.map((name) => {
|
|
215
|
-
if (typeof name !== 'string') throw new Error('hosts must be exact public DNS names');
|
|
216
|
-
const h = name.toLowerCase().replace(/\.$/, '');
|
|
217
|
-
if (
|
|
218
|
-
h.length > 253 ||
|
|
219
|
-
!h.includes('.') ||
|
|
220
|
-
isIP(h) ||
|
|
221
|
-
/(?:^|\.)(?:local|localhost)$/.test(h) ||
|
|
222
|
-
h.split('.').some((label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))
|
|
223
|
-
) {
|
|
224
|
-
throw new Error('hosts must be exact public DNS names without a scheme, port, path, IP address or wildcard');
|
|
225
|
-
}
|
|
226
|
-
return h;
|
|
227
|
-
});
|
|
228
|
-
return { delivery, hosts: [...new Set(normalized)].sort() };
|
|
229
|
-
}
|
|
230
|
-
|
|
231
213
|
export function validID(s) {
|
|
232
214
|
return typeof s === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(s);
|
|
233
215
|
}
|
|
@@ -237,7 +219,6 @@ export function secretIdentity(s) {
|
|
|
237
219
|
name: s.name,
|
|
238
220
|
orgId: s.orgId,
|
|
239
221
|
version: s.version,
|
|
240
|
-
delivery: s.delivery,
|
|
241
222
|
hosts: [...(s.hosts || [])].sort(),
|
|
242
223
|
};
|
|
243
224
|
}
|
|
@@ -11,7 +11,8 @@ import {
|
|
|
11
11
|
FILE_MAX_VALUE,
|
|
12
12
|
FILE_MAX_TOTAL,
|
|
13
13
|
configPathOK,
|
|
14
|
-
|
|
14
|
+
FORMAT,
|
|
15
|
+
TOKEN_HOSTS,
|
|
15
16
|
human,
|
|
16
17
|
isMain,
|
|
17
18
|
sameSecret,
|
|
@@ -60,30 +61,29 @@ export class PlanError extends Error {
|
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
function checkContext(scan, secrets, orgId) {
|
|
63
|
-
if (
|
|
64
|
+
if (
|
|
65
|
+
scan.version !== FORMAT ||
|
|
66
|
+
!Array.isArray(scan.items) ||
|
|
67
|
+
scan.items.some((it) => it.kind === 'env' && !TOKEN_HOSTS.has(it.name))
|
|
68
|
+
)
|
|
64
69
|
throw new PlanError(['scan format changed; run scan.mjs again']);
|
|
65
70
|
if (!validID(orgId)) throw new PlanError(['an explicit org ID is required']);
|
|
66
71
|
if (!Array.isArray(secrets) || secrets.some((s) => s.orgId !== orgId || !s.id || !Number.isSafeInteger(s.version)))
|
|
67
72
|
throw new PlanError(['Secret metadata must belong to the selected org and include IDs and versions']);
|
|
68
73
|
}
|
|
69
74
|
|
|
70
|
-
export function writePlan(
|
|
71
|
-
scan,
|
|
72
|
-
{ secrets = [], manifest = { secrets: [] }, orgId, sandboxes = [], delivery = 'env', hosts = [] } = {},
|
|
73
|
-
) {
|
|
75
|
+
export function writePlan(scan, { secrets = [], manifest = { secrets: [] }, orgId, sandboxes = [] } = {}) {
|
|
74
76
|
checkContext(scan, secrets, orgId);
|
|
75
77
|
if (sandboxes.some((id) => !validID(id) || !id.startsWith('m_')))
|
|
76
78
|
throw new PlanError(['sandboxes must use stable m_ IDs']);
|
|
77
|
-
const policy = deliveryPolicy(delivery, hosts);
|
|
78
79
|
const out = [
|
|
79
80
|
'# instabox basic setup: plan',
|
|
80
81
|
'',
|
|
81
82
|
'Check `[x]` the items to save or copy. Do not change IDs or delete lines.',
|
|
82
|
-
'Tokens become encrypted org Secrets.
|
|
83
|
-
'This run
|
|
84
|
-
'
|
|
83
|
+
'Tokens become encrypted org Secrets. A box sees a placeholder; the outbound proxy puts the token into HTTPS headers sent to the hosts listed for each token, and nowhere else.',
|
|
84
|
+
'This run selects them only on the sandbox IDs below; an empty list changes no selection. Existing selections remain in force.',
|
|
85
|
+
'Saved Secrets also become available to other org sandboxes whose selection is all, including future sandboxes.',
|
|
85
86
|
'Replacing an existing Secret affects its other authorized sandboxes. Config files are copied once to the named running sandboxes and may overwrite those paths; they are not Secrets.',
|
|
86
|
-
'Env values can be copied into files, processes or snapshots; removing a selection cannot recover those copies.',
|
|
87
87
|
'',
|
|
88
88
|
`Org: \`${orgId}\``,
|
|
89
89
|
`Sandboxes: \`${[...new Set(sandboxes)].join(',') || '-'}\``,
|
|
@@ -104,7 +104,7 @@ export function writePlan(
|
|
|
104
104
|
const checked = it.checked && !other && (it.kind === 'env' || sandboxes.length > 0);
|
|
105
105
|
let description =
|
|
106
106
|
it.kind === 'env'
|
|
107
|
-
? `${it.name} from ${it.from}`
|
|
107
|
+
? `${it.name} from ${it.from}, for ${TOKEN_HOSTS.get(it.name).join(', ')}`
|
|
108
108
|
: `~/${it.path}: ${it.files.length} file(s), ${human(it.bytes)}`;
|
|
109
109
|
if (it.reason) description += `; off by default: ${it.reason}`;
|
|
110
110
|
if (it.note) description += `; ${it.note}`;
|
|
@@ -112,12 +112,6 @@ export function writePlan(
|
|
|
112
112
|
else if (ex.length) description += '; replaces the unchanged Secret from your previous run';
|
|
113
113
|
if (it.kind === 'file' && !sandboxes.length) description += '; select running sandbox IDs before checking this';
|
|
114
114
|
out.push(`- [${checked ? 'x' : ' '}] \`${it.id}\` ${description}`);
|
|
115
|
-
if (it.kind === 'env') {
|
|
116
|
-
// Preserve an existing mode as a starting point; never silently change
|
|
117
|
-
// a proxy credential into a real-value env credential on a repeat run.
|
|
118
|
-
const p = ex.length ? deliveryPolicy(ex[0].item.delivery, ex[0].item.hosts || []) : policy;
|
|
119
|
-
out.push(`Delivery \`${it.id}\`: \`${p.delivery}\` hosts \`${p.hosts.join(',') || '-'}\``);
|
|
120
|
-
}
|
|
121
115
|
}
|
|
122
116
|
}
|
|
123
117
|
out.push('', '## Manual setup', '', 'These entries have no checkbox; no credentials are read for them.', '');
|
|
@@ -129,8 +123,7 @@ export function parsePlan(scan, text, { secrets = [], manifest = { secrets: [] }
|
|
|
129
123
|
checkContext(scan, secrets, orgId);
|
|
130
124
|
const errors = [];
|
|
131
125
|
const byId = new Map(scan.items.map((it) => [it.id, it]));
|
|
132
|
-
const states = new Map()
|
|
133
|
-
policies = new Map();
|
|
126
|
+
const states = new Map();
|
|
134
127
|
let planOrg, sandboxes, digest;
|
|
135
128
|
for (const [index, raw] of text.split('\n').entries()) {
|
|
136
129
|
const line = raw.replace(/\r$/, ''),
|
|
@@ -155,20 +148,6 @@ export function parsePlan(scan, text, { secrets = [], manifest = { secrets: [] }
|
|
|
155
148
|
}
|
|
156
149
|
continue;
|
|
157
150
|
}
|
|
158
|
-
if (/^Delivery\b/.test(line)) {
|
|
159
|
-
const m = line.match(/^Delivery `([^`]+)`: `([^`]+)` hosts `([^`]*)`$/);
|
|
160
|
-
if (!m || byId.get(m[1])?.kind !== 'env') {
|
|
161
|
-
errors.push(`line ${n}: invalid Delivery line`);
|
|
162
|
-
continue;
|
|
163
|
-
}
|
|
164
|
-
if (policies.has(m[1])) errors.push(`line ${n}: duplicate delivery for ${m[1]}`);
|
|
165
|
-
try {
|
|
166
|
-
policies.set(m[1], deliveryPolicy(m[2], m[3] === '-' ? [] : m[3].split(',').map((h) => h.trim())));
|
|
167
|
-
} catch (e) {
|
|
168
|
-
errors.push(`line ${n}: ${e.message}`);
|
|
169
|
-
}
|
|
170
|
-
continue;
|
|
171
|
-
}
|
|
172
151
|
if (/^Selector:/.test(line)) errors.push(`line ${n}: legacy selectors are retired`);
|
|
173
152
|
const d = line.match(/^<!-- plan ([0-9a-f]+):/);
|
|
174
153
|
if (d) {
|
|
@@ -195,27 +174,21 @@ export function parsePlan(scan, text, { secrets = [], manifest = { secrets: [] }
|
|
|
195
174
|
errors.push('different scan or org Secret state; scan, list Secrets and write a new plan');
|
|
196
175
|
for (const it of scan.items) {
|
|
197
176
|
if (!states.has(it.id)) errors.push(`\`${it.id}\` is missing from the plan`);
|
|
198
|
-
if (it.kind === 'env' && !policies.has(it.id))
|
|
199
|
-
errors.push(`\`${it.id}\` needs an explicit delivery mode and hosts line`);
|
|
200
177
|
}
|
|
201
178
|
if (errors.length) throw new PlanError(errors);
|
|
202
179
|
|
|
203
180
|
const picked = scan.items.filter((it) => states.get(it.id));
|
|
204
|
-
const ghToken = picked.some(
|
|
205
|
-
(it) => it.kind === 'env' && it.name === 'GH_TOKEN' && policies.get(it.id).delivery === 'env',
|
|
206
|
-
);
|
|
207
181
|
const items = picked.map((it) => {
|
|
208
182
|
const item = { ...it };
|
|
209
183
|
delete item.checked;
|
|
210
184
|
if (it.kind === 'env') {
|
|
211
|
-
Object.assign(item, policies.get(it.id));
|
|
212
185
|
item.expected = secrets.find((s) => s.name === it.name);
|
|
213
186
|
item.expected = item.expected ? secretIdentity(item.expected) : null;
|
|
214
187
|
} else {
|
|
215
188
|
if (!sandboxes.length) errors.push(`\`${it.id}\`: config copying requires explicit running sandbox IDs`);
|
|
216
189
|
item.files = it.files.map((f) => {
|
|
217
190
|
if (!configPathOK(f.path)) errors.push(`\`${it.id}\`: invalid config path`);
|
|
218
|
-
const c = fileContent(f, { home: scan.home
|
|
191
|
+
const c = fileContent(f, { home: scan.home });
|
|
219
192
|
if (c.error) errors.push(`\`${it.id}\`: ${c.error}`);
|
|
220
193
|
const bytes = c.error ? 0 : Buffer.byteLength(c.text);
|
|
221
194
|
if (bytes > FILE_MAX_VALUE) errors.push(`\`${it.id}\`: file exceeds the config-copy limit of 64 KiB`);
|
|
@@ -249,11 +222,10 @@ export function parsePlan(scan, text, { secrets = [], manifest = { secrets: [] }
|
|
|
249
222
|
if (fileBytes > FILE_MAX_TOTAL)
|
|
250
223
|
throw new PlanError(['config copies exceed 512 KiB; uncheck files or transfer them separately'], 3);
|
|
251
224
|
return {
|
|
252
|
-
version:
|
|
225
|
+
version: FORMAT,
|
|
253
226
|
home: scan.home,
|
|
254
227
|
orgId,
|
|
255
228
|
sandboxes,
|
|
256
|
-
ghToken,
|
|
257
229
|
items,
|
|
258
230
|
manual: scan.manual,
|
|
259
231
|
skipped: scan.items.filter((it) => !states.get(it.id)).map((it) => it.id),
|
|
@@ -285,14 +257,11 @@ function main() {
|
|
|
285
257
|
const secrets = JSON.parse(readFileSync(secretsFile, 'utf8')).items;
|
|
286
258
|
if (!Array.isArray(secrets)) throw new Error('metadata must be instabox secrets ls --json output');
|
|
287
259
|
if (command === 'write') {
|
|
288
|
-
const sandboxes = []
|
|
289
|
-
hosts = [];
|
|
260
|
+
const sandboxes = [];
|
|
290
261
|
let value;
|
|
291
262
|
while ((value = opt('--sandbox')) !== undefined) sandboxes.push(value);
|
|
292
|
-
while ((value = opt('--host')) !== undefined) hosts.push(value);
|
|
293
|
-
const delivery = opt('--delivery') || 'env';
|
|
294
263
|
if (args.length) throw new Error('unknown write arguments');
|
|
295
|
-
process.stdout.write(writePlan(scan, { secrets, orgId, manifest, sandboxes
|
|
264
|
+
process.stdout.write(writePlan(scan, { secrets, orgId, manifest, sandboxes }));
|
|
296
265
|
} else if (command === 'parse') {
|
|
297
266
|
const limitFile = opt('--limits');
|
|
298
267
|
const limits = limitFile ? JSON.parse(readFileSync(limitFile, 'utf8')) : {};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Save reviewed org Secrets, select them for named sandboxes, and copy
|
|
3
|
-
// config files separately. Credential values are held only in memory/stdin.
|
|
2
|
+
// Save reviewed org Secrets for their registry hosts, select them for named sandboxes, and copy
|
|
3
|
+
// ordinary config files separately. Credential values are held only in memory/stdin.
|
|
4
4
|
import { spawnSync } from 'node:child_process';
|
|
5
5
|
import { randomBytes } from 'node:crypto';
|
|
6
6
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
@@ -13,7 +13,8 @@ import {
|
|
|
13
13
|
FILE_MAX_VALUE,
|
|
14
14
|
FILE_MAX_TOTAL,
|
|
15
15
|
configPathOK,
|
|
16
|
-
|
|
16
|
+
FORMAT,
|
|
17
|
+
TOKEN_HOSTS,
|
|
17
18
|
human,
|
|
18
19
|
isMain,
|
|
19
20
|
run,
|
|
@@ -107,8 +108,8 @@ function validSource(it, home) {
|
|
|
107
108
|
}
|
|
108
109
|
|
|
109
110
|
function checkSelection(sel) {
|
|
110
|
-
if (sel.version !==
|
|
111
|
-
throw new Error('
|
|
111
|
+
if (sel.version !== FORMAT || Object.hasOwn(sel, 'selector'))
|
|
112
|
+
throw new Error('this selection was approved in an older format; scan and approve a new org Secret plan');
|
|
112
113
|
if (
|
|
113
114
|
!validID(sel.orgId) ||
|
|
114
115
|
!Array.isArray(sel.sandboxes) ||
|
|
@@ -124,12 +125,15 @@ function checkSelection(sel) {
|
|
|
124
125
|
if (ids.has(it.id)) throw new Error('duplicate selection ID');
|
|
125
126
|
ids.add(it.id);
|
|
126
127
|
if (it.kind === 'env') {
|
|
127
|
-
if (
|
|
128
|
+
if (
|
|
129
|
+
!TOKEN_HOSTS.has(it.name) ||
|
|
130
|
+
!secretNameOK(it.name) ||
|
|
131
|
+
names.has(it.name) ||
|
|
132
|
+
!validSource(it, sel.home) ||
|
|
133
|
+
!Object.hasOwn(it, 'expected')
|
|
134
|
+
)
|
|
128
135
|
throw new Error('invalid Secret selection or source');
|
|
129
136
|
names.add(it.name);
|
|
130
|
-
const policy = deliveryPolicy(it.delivery, it.hosts);
|
|
131
|
-
if (JSON.stringify(policy.hosts) !== JSON.stringify(it.hosts))
|
|
132
|
-
throw new Error('hosts must use the normalized policy from the approved plan');
|
|
133
137
|
} else if (it.kind === 'file') {
|
|
134
138
|
if (!sel.sandboxes.length || !Array.isArray(it.files))
|
|
135
139
|
throw new Error('config copying needs explicit sandbox IDs');
|
|
@@ -191,7 +195,7 @@ export function push(
|
|
|
191
195
|
if (sel.items.some((it) => it.kind === 'file') && box.state !== 'running')
|
|
192
196
|
throw new Error(`${id}: start the sandbox before copying config files`);
|
|
193
197
|
const selection = json(['secrets', 'show', id]);
|
|
194
|
-
if (
|
|
198
|
+
if (selection.secrets !== 'all' && !Array.isArray(selection.secrets))
|
|
195
199
|
throw new Error('sandbox returned invalid Secret selections');
|
|
196
200
|
return box;
|
|
197
201
|
});
|
|
@@ -211,7 +215,6 @@ export function push(
|
|
|
211
215
|
|
|
212
216
|
const tokens = [],
|
|
213
217
|
files = [];
|
|
214
|
-
const ghToken = sel.items.some((it) => it.kind === 'env' && it.name === 'GH_TOKEN' && it.delivery === 'env');
|
|
215
218
|
const rcNames = new Map(),
|
|
216
219
|
rcValues = new Map();
|
|
217
220
|
for (const it of sel.items)
|
|
@@ -229,13 +232,13 @@ export function push(
|
|
|
229
232
|
typeof value !== 'string' ||
|
|
230
233
|
value.includes('\0') ||
|
|
231
234
|
Buffer.byteLength(value) > SECRET_MAX_VALUE ||
|
|
232
|
-
|
|
235
|
+
/[\x00-\x08\x0a-\x1f\x7f]/.test(value)
|
|
233
236
|
)
|
|
234
|
-
return fail(`${it.name}: could not prepare a valid
|
|
235
|
-
tokens.push({ item: it, value });
|
|
237
|
+
return fail(`${it.name}: could not prepare a valid header value; nothing written`);
|
|
238
|
+
tokens.push({ item: it, hosts: TOKEN_HOSTS.get(it.name), value });
|
|
236
239
|
} else {
|
|
237
240
|
for (const f of it.files) {
|
|
238
|
-
const got = fileContent(f, { home: sel.home
|
|
241
|
+
const got = fileContent(f, { home: sel.home });
|
|
239
242
|
if (got.error || Buffer.byteLength(got.text) > FILE_MAX_VALUE)
|
|
240
243
|
return fail(`~/${f.path}: config could not be prepared; nothing written`);
|
|
241
244
|
files.push({ path: f.path, text: got.text });
|
|
@@ -267,11 +270,11 @@ export function push(
|
|
|
267
270
|
|
|
268
271
|
const manifest = readManifest(manifestPath);
|
|
269
272
|
let ok = true;
|
|
270
|
-
for (const { item, value } of tokens) {
|
|
273
|
+
for (const { item, hosts, value } of tokens) {
|
|
271
274
|
const r = {
|
|
272
275
|
name: item.name,
|
|
273
276
|
kind: 'secret',
|
|
274
|
-
|
|
277
|
+
hosts,
|
|
275
278
|
bytes: Buffer.byteLength(value),
|
|
276
279
|
status: dryRun ? 'would save' : 'failed',
|
|
277
280
|
};
|
|
@@ -284,9 +287,7 @@ export function push(
|
|
|
284
287
|
item.name,
|
|
285
288
|
'--org',
|
|
286
289
|
sel.orgId,
|
|
287
|
-
'--
|
|
288
|
-
item.delivery,
|
|
289
|
-
...item.hosts.flatMap((h) => ['--host', h]),
|
|
290
|
+
...hosts.flatMap((h) => ['--host', h]),
|
|
290
291
|
'--json',
|
|
291
292
|
];
|
|
292
293
|
const response = run(args, { env, input: value + '\n' });
|
|
@@ -298,8 +299,7 @@ export function push(
|
|
|
298
299
|
saved.orgId !== sel.orgId ||
|
|
299
300
|
!saved.id ||
|
|
300
301
|
!Number.isSafeInteger(saved.version) ||
|
|
301
|
-
saved.
|
|
302
|
-
JSON.stringify(deliveryPolicy(saved.delivery, saved.hosts || []).hosts) !== JSON.stringify(item.hosts)
|
|
302
|
+
JSON.stringify(unique(saved.hosts || [])) !== JSON.stringify(unique(hosts))
|
|
303
303
|
)
|
|
304
304
|
throw new Error();
|
|
305
305
|
r.status = 'saved';
|
|
@@ -310,7 +310,7 @@ export function push(
|
|
|
310
310
|
}
|
|
311
311
|
}
|
|
312
312
|
log(
|
|
313
|
-
`${r.status} ${item.name} (${
|
|
313
|
+
`${r.status} ${item.name} (for ${hosts.join(',')}, ${r.bytes} bytes) in ${sel.orgId}`,
|
|
314
314
|
);
|
|
315
315
|
}
|
|
316
316
|
if (!dryRun && tokens.length) saveManifest(manifestPath, manifest);
|
|
@@ -333,17 +333,14 @@ export function push(
|
|
|
333
333
|
try {
|
|
334
334
|
if (tokens.length) {
|
|
335
335
|
const current = json(['secrets', 'show', box.id]);
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
if (proxyNames.length && current.secrets !== 'all')
|
|
341
|
-
flags.push('--secrets', JSON.stringify(unique([...current.secrets, ...proxyNames])));
|
|
342
|
-
if (flags.length) json(['secrets', 'select', box.id, ...flags]);
|
|
336
|
+
if (current.secrets !== 'all') {
|
|
337
|
+
const names = unique([...current.secrets, ...tokens.map((t) => t.item.name)]);
|
|
338
|
+
json(['secrets', 'select', box.id, '--secrets', JSON.stringify(names)]);
|
|
339
|
+
}
|
|
343
340
|
if (box.state === 'running') {
|
|
344
341
|
const refreshed = json(['secrets', 'push', box.id]);
|
|
345
342
|
if (refreshed.guest?.state !== 'applied')
|
|
346
|
-
throw new Error('
|
|
343
|
+
throw new Error('the box has not applied the Secret selection; inspect secrets show and retry');
|
|
347
344
|
}
|
|
348
345
|
}
|
|
349
346
|
if (files.length) {
|
|
@@ -377,7 +374,7 @@ export function push(
|
|
|
377
374
|
}
|
|
378
375
|
result.status = 'configured';
|
|
379
376
|
log(
|
|
380
|
-
`configured ${box.id}${box.state === 'running' ? '; existing application processes may need a restart' : '; Secret
|
|
377
|
+
`configured ${box.id}${box.state === 'running' ? '; existing application processes may need a restart' : '; the Secret selection applies at the next start'}`,
|
|
381
378
|
);
|
|
382
379
|
} catch (e) {
|
|
383
380
|
ok = false;
|