Prepare bounded Coulomb API identity bootstrap with private restartable custody
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a17f16-d99c-7ea3-95f0-8212897fba6b
This commit is contained in:
parent
bd8ca57dc9
commit
7aa38fcacd
4 changed files with 741 additions and 0 deletions
383
scripts/application_recovery_identities.py
Normal file
383
scripts/application_recovery_identities.py
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
"""Coulomb-only identity bootstrap; stdin auth is temporary, API pairs stay private.
|
||||
|
||||
No personal Console session is a routine credential. Default preview has no
|
||||
effects. Native S3/escrow boundary checks remain mandatory after this IAM stage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
||||
|
||||
PUBLISHER = "681d00bf-b965-4323-b657-97bfde423511"
|
||||
PROJECT = "coulomb-social-recovery"
|
||||
DECISION = "27bbc1ac-e275-42be-ba34-6cf16eae4e78"
|
||||
PROGRAMMATIC_DIRECTION = "6139e02b-3fb7-4eff-be8a-efe127c45ce5"
|
||||
ROLES = {
|
||||
"bootstrap": {"name": "coulomb-social-recovery-bootstrap", "permissions": [
|
||||
"ObjectStorageFullAccess", "SecretManagerFullAccess"]},
|
||||
"reader": {"name": "coulomb-social-recovery-reader", "permissions": [
|
||||
"ObjectStorageReadOnly"]},
|
||||
"custodian": {"name": "coulomb-social-recovery-custodian", "permissions": [
|
||||
"ObjectStorageReadOnly", "ObjectStorageObjectsWrite", "SecretManagerReadOnly",
|
||||
"SecretManagerSecretAccess"]},
|
||||
"publisher": {"name": "coulomb-social-backup-primary", "permissions": [
|
||||
"ObjectStorageObjectsRead", "ObjectStorageObjectsWrite", "ObjectStorageObjectsDelete",
|
||||
"ObjectStorageBucketsRead"]},
|
||||
}
|
||||
|
||||
|
||||
class Refused(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class ProviderError(Refused):
|
||||
def __init__(self, status):
|
||||
super().__init__("provider_request_refused")
|
||||
self.status = status
|
||||
|
||||
|
||||
def inside_git(path):
|
||||
return any((parent / ".git").exists() for parent in [path.resolve(), *path.resolve().parents])
|
||||
|
||||
|
||||
def private_directory(path):
|
||||
info = path.lstat()
|
||||
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077:
|
||||
raise Refused("private_owner_directory_required")
|
||||
if inside_git(path):
|
||||
raise Refused("credential_directory_must_be_outside_git")
|
||||
|
||||
|
||||
def fsync_directory(path):
|
||||
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def private_json(path):
|
||||
info = path.lstat()
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077:
|
||||
raise Refused("private_owner_file_required")
|
||||
return json.loads(path.read_text())
|
||||
|
||||
|
||||
def checkpoint(path, value):
|
||||
fd, name = tempfile.mkstemp(prefix=".checkpoint-", dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w") as stream:
|
||||
json.dump(value, stream, sort_keys=True, indent=2)
|
||||
stream.write("\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(name, path)
|
||||
fsync_directory(path.parent)
|
||||
finally:
|
||||
if os.path.exists(name):
|
||||
os.unlink(name)
|
||||
|
||||
|
||||
class Transport:
|
||||
def __init__(self, auth):
|
||||
if set(auth) != {"session_token", "user_agent"} or any(
|
||||
not isinstance(v, str) or not v or "\n" in v or "\r" in v for v in auth.values()
|
||||
):
|
||||
raise Refused("temporary_stdin_auth_required")
|
||||
self.headers = {"X-Session-Token": auth["session_token"],
|
||||
"User-Agent": auth["user_agent"], "Content-Type": "application/json"}
|
||||
|
||||
class NoRedirect(HTTPRedirectHandler):
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
raise Refused("provider_redirect_refused")
|
||||
|
||||
self.opener = build_opener(NoRedirect())
|
||||
|
||||
def __call__(self, method, path, data=None):
|
||||
if not path.startswith(("/iam/v1alpha1/", "/account/v3/")) or ".." in path:
|
||||
raise Refused("provider_api_scope_refused")
|
||||
request = Request("https://api.scaleway.com" + path, method=method,
|
||||
headers=self.headers,
|
||||
data=None if data is None else json.dumps(data).encode())
|
||||
try:
|
||||
with self.opener.open(request, timeout=20) as response:
|
||||
raw = response.read(8 * 1024**2)
|
||||
return json.loads(raw) if raw else {}
|
||||
except HTTPError as error:
|
||||
raise ProviderError(error.code) from None
|
||||
|
||||
|
||||
class Bootstrap:
|
||||
def __init__(self, api, directory, now=None):
|
||||
self.api, self.directory = api, directory
|
||||
private_directory(directory)
|
||||
self.now = now or datetime.now(UTC)
|
||||
self.path = directory / "identity-state.json"
|
||||
self.state = private_json(self.path) if self.path.exists() else {
|
||||
"schema": "platform.coulomb-recovery-identities.v1", "decision": DECISION,
|
||||
"access_decision": PROGRAMMATIC_DIRECTION, "apps": {}, "policies": {}, "keys": {},
|
||||
}
|
||||
if self.state.get("decision") != DECISION or self.state.get("access_decision") != (
|
||||
PROGRAMMATIC_DIRECTION
|
||||
) or self.state.get("schema") != "platform.coulomb-recovery-identities.v1":
|
||||
raise Refused("journal_scope_refused")
|
||||
if self.state.get("pending_key_issue"):
|
||||
raise Refused("uncertain_key_issue_needs_reconciliation")
|
||||
|
||||
def save(self):
|
||||
checkpoint(self.path, self.state)
|
||||
|
||||
def inventory(self, path, key, **filters):
|
||||
rows = []
|
||||
for page in range(1, 101):
|
||||
value = self.api("GET", path + "?" + urlencode({**filters, "page_size": 100,
|
||||
"page": page}))
|
||||
batch = value[key]
|
||||
if not isinstance(batch, list):
|
||||
raise Refused("invalid_provider_inventory")
|
||||
rows.extend(batch)
|
||||
count = int(value["total_count"])
|
||||
if len(rows) == count:
|
||||
return rows
|
||||
if not batch or len(rows) > count:
|
||||
break
|
||||
raise Refused("complete_inventory_required")
|
||||
|
||||
def project(self, organization):
|
||||
if self.state.get("project"):
|
||||
project = self.api("GET", "/account/v3/projects/" + self.state["project"]["id"])
|
||||
else:
|
||||
found = [v for v in self.inventory("/account/v3/projects", "projects",
|
||||
organization_id=organization) if v["name"] == PROJECT]
|
||||
if found:
|
||||
raise Refused("untracked_existing_project_requires_review")
|
||||
project = self.api("POST", "/account/v3/projects", {
|
||||
"name": PROJECT, "organization_id": organization,
|
||||
"description": "Coulomb-only recovery custody; decision " + DECISION,
|
||||
})
|
||||
self.state["project"] = project
|
||||
self.save()
|
||||
if project["name"] != PROJECT or project["organization_id"] != organization:
|
||||
raise Refused("project_binding_refused")
|
||||
return project
|
||||
|
||||
def application(self, role, organization, applications):
|
||||
spec = ROLES[role]
|
||||
if role == "publisher":
|
||||
app = self.api("GET", "/iam/v1alpha1/applications/" + PUBLISHER)
|
||||
elif role in self.state["apps"]:
|
||||
app = self.api("GET", "/iam/v1alpha1/applications/" + self.state["apps"][role]["id"])
|
||||
else:
|
||||
if any(v["name"] == spec["name"] for v in applications):
|
||||
raise Refused("untracked_existing_application_requires_review")
|
||||
app = self.api("POST", "/iam/v1alpha1/applications", {
|
||||
"name": spec["name"], "organization_id": organization,
|
||||
"description": "Coulomb-only " + role + "; " + DECISION,
|
||||
})
|
||||
self.state["apps"][role] = app
|
||||
self.save()
|
||||
if app["name"] != spec["name"] or app["organization_id"] != organization:
|
||||
raise Refused("application_binding_refused")
|
||||
return app
|
||||
|
||||
def policy(self, role, app, project, organization):
|
||||
name = "coulomb-recovery-project-" + role
|
||||
if role in self.state["policies"]:
|
||||
policy = self.api("GET", "/iam/v1alpha1/policies/" + self.state["policies"][role]["id"])
|
||||
else:
|
||||
policies = self.inventory("/iam/v1alpha1/policies", "policies",
|
||||
organization_id=organization)
|
||||
if any(p["name"] == name for p in policies):
|
||||
raise Refused("untracked_existing_policy_requires_review")
|
||||
policy = self.api("POST", "/iam/v1alpha1/policies", {
|
||||
"name": name, "description": "Coulomb recovery project only; " + DECISION,
|
||||
"organization_id": organization, "application_id": app["id"],
|
||||
"rules": [{"project_ids": [project["id"]],
|
||||
"permission_set_names": ROLES[role]["permissions"]}],
|
||||
})
|
||||
self.state["policies"][role] = policy
|
||||
self.save()
|
||||
if (policy["application_id"] != app["id"] or policy["name"] != name
|
||||
or policy["organization_id"] != organization):
|
||||
raise Refused("policy_binding_refused")
|
||||
rules = self.api("GET", "/iam/v1alpha1/rules?" + urlencode({"policy_id": policy["id"]}))
|
||||
if len(rules["rules"]) != 1:
|
||||
raise Refused("single_project_rule_required")
|
||||
rule = rules["rules"][0]
|
||||
if (rule.get("organization_id") or rule.get("project_ids") != [project["id"]]
|
||||
or set(rule.get("permission_set_names", [])) != set(ROLES[role]["permissions"])
|
||||
or rule.get("condition") or rule.get("deny")):
|
||||
raise Refused("effective_project_rule_refused")
|
||||
|
||||
def key(self, role, app, project):
|
||||
path = self.directory / (role + "-pair.json")
|
||||
if role in self.state["keys"]:
|
||||
pair = private_json(path)
|
||||
if set(pair) != {"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"}:
|
||||
raise Refused("exact_pair_fields_required")
|
||||
current = self.api("GET", "/iam/v1alpha1/api-keys/" + pair["AWS_ACCESS_KEY_ID"])
|
||||
if (current["application_id"] != app["id"]
|
||||
or current["default_project_id"] != project["id"]):
|
||||
raise Refused("existing_key_binding_refused")
|
||||
return
|
||||
if path.exists() or path.is_symlink():
|
||||
raise Refused("untracked_pair_requires_review")
|
||||
self.state["pending_key_issue"] = {"role": role, "application_id": app["id"]}
|
||||
self.save()
|
||||
payload = {"application_id": app["id"], "default_project_id": project["id"],
|
||||
"description": "Coulomb recovery " + role + "; " + DECISION}
|
||||
if role == "bootstrap":
|
||||
payload["expires_at"] = (self.now + timedelta(hours=2)).isoformat()
|
||||
try:
|
||||
issued = self.api("POST", "/iam/v1alpha1/api-keys", payload)
|
||||
except ProviderError as error:
|
||||
if error.status in {400, 401, 403, 404, 422, 429}:
|
||||
self.state.pop("pending_key_issue")
|
||||
self.save()
|
||||
raise
|
||||
try:
|
||||
if issued.get("application_id") != app["id"]:
|
||||
raise Refused("issued_key_binding_refused")
|
||||
if role == "bootstrap":
|
||||
expires = datetime.fromisoformat(
|
||||
(issued.get("expires_at") or "").replace("Z", "+00:00"))
|
||||
if not self.now < expires <= self.now + timedelta(hours=2):
|
||||
raise Refused("bounded_bootstrap_expiry_required")
|
||||
pair = {"AWS_ACCESS_KEY_ID": issued["access_key"],
|
||||
"AWS_SECRET_ACCESS_KEY": issued["secret_key"]}
|
||||
if any(not isinstance(v, str) or not v for v in pair.values()):
|
||||
raise Refused("complete_pair_required")
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
with os.fdopen(fd, "w") as stream:
|
||||
json.dump(pair, stream)
|
||||
stream.write("\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
fsync_directory(path.parent)
|
||||
except (Refused, OSError, KeyError, ValueError, TypeError):
|
||||
if issued.get("access_key") and issued.get("application_id") == app["id"]:
|
||||
try:
|
||||
self.api("DELETE", "/iam/v1alpha1/api-keys/" + issued["access_key"])
|
||||
self.state.pop("pending_key_issue")
|
||||
self.save()
|
||||
except (Refused, OSError, KeyError, ValueError, TypeError):
|
||||
raise Refused("pair_persistence_and_revocation_failed_review_required") from None
|
||||
raise Refused("pair_persistence_failed_review_required") from None
|
||||
self.state["keys"][role] = {"application_id": app["id"], "file": path.name,
|
||||
"expires_at": issued.get("expires_at")}
|
||||
self.state.pop("pending_key_issue")
|
||||
self.save()
|
||||
|
||||
def run(self):
|
||||
publisher = self.api("GET", "/iam/v1alpha1/applications/" + PUBLISHER)
|
||||
if publisher["id"] != PUBLISHER or publisher["name"] != ROLES["publisher"]["name"]:
|
||||
raise Refused("publisher_binding_refused")
|
||||
organization = publisher["organization_id"]
|
||||
permissions = self.inventory("/iam/v1alpha1/permission-sets", "permission_sets",
|
||||
organization_id=organization)
|
||||
names = {v["name"] for v in permissions}
|
||||
if any(set(role["permissions"]) - names for role in ROLES.values()):
|
||||
raise Refused("required_permission_sets_unavailable")
|
||||
applications = self.inventory("/iam/v1alpha1/applications", "applications",
|
||||
organization_id=organization)
|
||||
project = self.project(organization)
|
||||
for role in ROLES:
|
||||
app = self.application(role, organization, applications)
|
||||
self.policy(role, app, project, organization)
|
||||
if role != "publisher":
|
||||
self.key(role, app, project)
|
||||
self.state["status"] = "identity_bootstrap_complete_native_boundaries_pending"
|
||||
self.save()
|
||||
return {"status": self.state["status"], "new_project": PROJECT,
|
||||
"managed_recovery_applications": 3, "project_policies_verified": 4,
|
||||
"private_pairs": 3,
|
||||
"publisher_pair_replaced": False, "organization_wide_grants": False,
|
||||
"bootstrap_key_max_hours": 2, "native_s3_escrow_boundaries_verified": False,
|
||||
"credential_values_printed": False}
|
||||
|
||||
def retire_bootstrap(self):
|
||||
if self.state.get("status") != "identity_bootstrap_complete_native_boundaries_pending":
|
||||
raise Refused("completed_identity_stage_required")
|
||||
app = self.state["apps"]["bootstrap"]
|
||||
actual = self.api("GET", "/iam/v1alpha1/applications/" + app["id"])
|
||||
if actual["name"] != ROLES["bootstrap"]["name"]:
|
||||
raise Refused("bootstrap_application_binding_refused")
|
||||
path = self.directory / "bootstrap-pair.json"
|
||||
if self.state["keys"]["bootstrap"].get("revoked_verified") and not path.exists():
|
||||
return {"status": "bootstrap_api_key_retired", "credential_values_printed": False}
|
||||
pair = private_json(path)
|
||||
key_path = "/iam/v1alpha1/api-keys/" + pair["AWS_ACCESS_KEY_ID"]
|
||||
try:
|
||||
current = self.api("GET", key_path)
|
||||
except ProviderError as error:
|
||||
if error.status != 404:
|
||||
raise
|
||||
else:
|
||||
if (current["application_id"] != app["id"] or current["default_project_id"] != (
|
||||
self.state["project"]["id"]
|
||||
)):
|
||||
raise Refused("retirement_key_binding_refused")
|
||||
self.api("DELETE", key_path)
|
||||
try:
|
||||
self.api("GET", key_path)
|
||||
except ProviderError as error:
|
||||
if error.status != 404:
|
||||
raise
|
||||
else:
|
||||
raise Refused("bootstrap_revocation_unconfirmed")
|
||||
self.state["keys"]["bootstrap"]["revoked_verified"] = True
|
||||
self.save()
|
||||
path.unlink() # Only this just-revoked temporary pair, never retained archives/pairs.
|
||||
fsync_directory(path.parent)
|
||||
return {"status": "bootstrap_api_key_retired", "own_temporary_pair_removed": True,
|
||||
"credential_values_printed": False}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--execute", action="store_true")
|
||||
parser.add_argument("--directory", type=Path)
|
||||
parser.add_argument("--retire-bootstrap", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if not args.execute:
|
||||
print(json.dumps({"status": "preview", "project": PROJECT,
|
||||
"applications": [v["name"] for k, v in ROLES.items() if k != "publisher"],
|
||||
"provider_contacted": False, "credential_values_printed": False}))
|
||||
return 0
|
||||
if not args.directory or not args.directory.is_absolute():
|
||||
raise Refused("private_absolute_directory_required")
|
||||
private_directory(args.directory)
|
||||
fd = os.open(args.directory / "identity-bootstrap.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW,
|
||||
0o600)
|
||||
with os.fdopen(fd, "w") as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
raw = sys.stdin.read(65537)
|
||||
if len(raw) > 65536:
|
||||
raise Refused("bounded_temporary_auth_required")
|
||||
auth = json.loads(raw)
|
||||
bootstrap = Bootstrap(Transport(auth), args.directory)
|
||||
print(json.dumps(bootstrap.retire_bootstrap() if args.retire_bootstrap else bootstrap.run()))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (Refused, OSError, KeyError, ValueError, TypeError) as error:
|
||||
print(json.dumps({"status": "failed", "failure_type": type(error).__name__,
|
||||
"http_status": getattr(error, "status", None),
|
||||
"refusal": str(error) if isinstance(error, Refused) and all(
|
||||
c.islower() or c == "_" for c in str(error)) else None,
|
||||
"credential_values_printed": False}))
|
||||
raise SystemExit(1) from None
|
||||
100
scripts/bootstrap_coulomb_recovery_session.mjs
Normal file
100
scripts/bootstrap_coulomb_recovery_session.mjs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
#!/usr/bin/env node
|
||||
/** One attended bootstrap; committed helper creates scoped routine API identities. */
|
||||
import {createHash} from 'node:crypto';
|
||||
import {execFileSync,spawn} from 'node:child_process';
|
||||
import {readFileSync,mkdirSync,lstatSync} from 'node:fs';
|
||||
import {homedir} from 'node:os';
|
||||
import {dirname,join,resolve} from 'node:path';
|
||||
import {fileURLToPath,pathToFileURL} from 'node:url';
|
||||
import {parseArgs} from 'node:util';
|
||||
import {createInterface} from 'node:readline/promises';
|
||||
|
||||
const {values}=parseArgs({options:{'playwright-module':{type:'string'},'preflight-only':{type:'boolean'}}});
|
||||
const root=resolve(dirname(fileURLToPath(import.meta.url)),'..');
|
||||
const factory=join(root,'scripts','application_recovery_identities.py');
|
||||
const directory=join(homedir(),'.config','railiance-platform','coulomb-social-recovery');
|
||||
const digest=data=>createHash('sha256').update(data).digest('hex');
|
||||
let browser,reader,child;
|
||||
|
||||
function sourcePreflight(){
|
||||
const committed=execFileSync('git',['show','HEAD:scripts/application_recovery_identities.py'],{cwd:root});
|
||||
if(digest(committed)!==digest(readFileSync(factory)))throw new Error('committed_factory_required');
|
||||
mkdirSync(directory,{recursive:true,mode:0o700});
|
||||
const info=lstatSync(directory);
|
||||
if(!info.isDirectory()||info.uid!==process.getuid()||(info.mode&0o077))throw new Error('private_directory_required');
|
||||
const validation="import importlib.util,sys;from pathlib import Path;"+
|
||||
"s=importlib.util.spec_from_file_location('recovery_bootstrap',sys.argv[1]);"+
|
||||
"m=importlib.util.module_from_spec(s);s.loader.exec_module(m);m.private_directory(Path(sys.argv[2]))";
|
||||
execFileSync('python3',['-c',validation,factory,directory],{stdio:['ignore','pipe','pipe']});
|
||||
return digest(committed);
|
||||
}
|
||||
|
||||
async function runFactory(page,retire=false){
|
||||
const sourceHash=sourcePreflight();
|
||||
if(new URL(page.url()).origin!=='https://console.scaleway.com')throw new Error('console_page_required');
|
||||
const auth=await page.evaluate(()=>{
|
||||
const names=Object.keys(localStorage).filter(n=>n.startsWith('_scw_session_console_scaleway_com_'));
|
||||
if(names.length!==1)throw new Error('unambiguous_session_required');
|
||||
const session=JSON.parse(localStorage.getItem(names[0]));
|
||||
if(new Date(session.jwt.expiresAt).getTime()<Date.now()+30000)throw new Error('fresh_bootstrap_session_required');
|
||||
return {session_token:session.token,user_agent:session.jwt.userAgent};
|
||||
});
|
||||
const result=await new Promise((resolveResult,reject)=>{
|
||||
child=spawn('python3',[factory,'--execute','--directory',directory,
|
||||
...(retire?['--retire-bootstrap']:[])],{stdio:['pipe','pipe','pipe']});
|
||||
let output='';
|
||||
child.stdout.on('data',data=>{if(output.length<65536)output+=data.toString();});
|
||||
child.stderr.on('data',()=>{}); // Provider/credential diagnostics never enter chat.
|
||||
child.on('error',()=>reject(new Error('bootstrap_child_failed')));
|
||||
child.on('close',code=>{
|
||||
child=null;
|
||||
try {resolveResult({exit_code:code,result:JSON.parse(output.trim()),factory_source_sha256:sourceHash});}
|
||||
catch {reject(new Error('redacted_bootstrap_result_required'));}
|
||||
});
|
||||
child.stdin.end(JSON.stringify(auth));
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
try{
|
||||
const sourceHash=sourcePreflight();
|
||||
const modulePath=resolve(values['playwright-module']||'');
|
||||
const pkg=JSON.parse(readFileSync(join(dirname(modulePath),'package.json'),'utf8'));
|
||||
if(pkg.name!=='playwright'||pkg.version!=='1.62.1')throw new Error('locked_playwright_required');
|
||||
if(values['preflight-only']){
|
||||
console.log(JSON.stringify({status:'preflight_passed',factory_source_sha256:sourceHash,
|
||||
private_directory_validated:true,locked_playwright_verified:true,provider_contacted:false}));
|
||||
process.exit(0);
|
||||
}
|
||||
const {chromium}=await import(pathToFileURL(modulePath).href);
|
||||
browser=await chromium.launch({headless:false});
|
||||
const context=await browser.newContext();const page=await context.newPage();
|
||||
await page.goto('https://console.scaleway.com/',{waitUntil:'domcontentloaded',timeout:60000});
|
||||
reader=createInterface({input:process.stdin,output:process.stdout});
|
||||
console.log('One-time bootstrap browser opened. Sign in privately; no personal state is saved.');
|
||||
for(;;){
|
||||
const answer=await reader.question('After Console login: Enter/run = bootstrap; finish = retire temporary key and close; stop = close. ');
|
||||
if(answer.trim()==='stop')break;
|
||||
try{
|
||||
const retire=answer.trim()==='finish';
|
||||
const outcome=await runFactory(page,retire);
|
||||
console.log(JSON.stringify(outcome));
|
||||
if(outcome.exit_code===0&&retire){
|
||||
try{await page.goto('https://account.scaleway.com/logout',{waitUntil:'domcontentloaded',timeout:30000});}catch{}
|
||||
await context.clearCookies();
|
||||
break;
|
||||
}
|
||||
if(outcome.exit_code===0){
|
||||
console.log('Identity stage complete. Use scoped APIs for resource/boundary proofs, then finish. Keep this window for bootstrap only.');
|
||||
continue;
|
||||
}
|
||||
}catch{console.error('Bootstrap stopped safely. No credential values printed. Keep this window for review.');}
|
||||
console.log('No automatic retry. Review the private journal; keep this browser open until the next reviewed attempt.');
|
||||
}
|
||||
}catch{
|
||||
console.error('Bootstrap preflight/session failed; no credential values printed.');process.exitCode=1;
|
||||
}finally{
|
||||
if(child)child.kill('SIGTERM');
|
||||
if(reader)reader.close();
|
||||
if(browser)await browser.close();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue