feat(service): extend add-web/add-secret/pick-web; add get-credential
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -28,6 +28,7 @@ from db.accounts_repo import (
|
|||||||
find_accounts,
|
find_accounts,
|
||||||
find_credentials,
|
find_credentials,
|
||||||
get_account_by_id,
|
get_account_by_id,
|
||||||
|
get_active_lease_by_token,
|
||||||
get_credential_by_id,
|
get_credential_by_id,
|
||||||
get_platform_from_db,
|
get_platform_from_db,
|
||||||
insert_account,
|
insert_account,
|
||||||
@@ -42,6 +43,21 @@ from db.accounts_repo import (
|
|||||||
update_account_session_status,
|
update_account_session_status,
|
||||||
update_credential_last_used,
|
update_credential_last_used,
|
||||||
)
|
)
|
||||||
|
from db.auth_strategy import (
|
||||||
|
ALL_AUTH_STRATEGIES,
|
||||||
|
AUTH_STRATEGY_CLIENT_CERTIFICATE,
|
||||||
|
AUTH_STRATEGY_DEVICE_BOUND,
|
||||||
|
AUTH_STRATEGY_PASSWORD_PLUS_2FA,
|
||||||
|
AUTH_STRATEGY_PER_SESSION_MANUAL,
|
||||||
|
AUTH_STRATEGY_QR_CODE_MANUAL,
|
||||||
|
AUTH_STRATEGY_SSO_REDIRECT,
|
||||||
|
)
|
||||||
|
from db.credentials_repo import (
|
||||||
|
CredentialNotEncryptedError,
|
||||||
|
insert_credential_encrypted,
|
||||||
|
list_credentials_by_account,
|
||||||
|
read_credential_plaintext,
|
||||||
|
)
|
||||||
from db.connection import get_conn, init_db
|
from db.connection import get_conn, init_db
|
||||||
from util.constants import SKILL_SLUG
|
from util.constants import SKILL_SLUG
|
||||||
from util.logging_config import get_skill_logger
|
from util.logging_config import get_skill_logger
|
||||||
@@ -56,6 +72,8 @@ from util.runtime_paths import (
|
|||||||
get_skill_data_dir,
|
get_skill_data_dir,
|
||||||
_runtime_paths_debug_text,
|
_runtime_paths_debug_text,
|
||||||
)
|
)
|
||||||
|
from service.crypto import CredentialDecryptError
|
||||||
|
from service.master_key import MasterKeyMissingError, is_master_key_available
|
||||||
from service.secret_store import (
|
from service.secret_store import (
|
||||||
mask_secret,
|
mask_secret,
|
||||||
make_windows_credential_target,
|
make_windows_credential_target,
|
||||||
@@ -83,6 +101,20 @@ def _credential_ok_line(payload: dict) -> str:
|
|||||||
return json.dumps({"success": True, **payload}, ensure_ascii=False)
|
return json.dumps({"success": True, **payload}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_session_persistent(auth_strategy: str) -> int:
|
||||||
|
"""Derive default session_persistent from auth_strategy when CLI omits it."""
|
||||||
|
if auth_strategy in (
|
||||||
|
AUTH_STRATEGY_QR_CODE_MANUAL,
|
||||||
|
AUTH_STRATEGY_PASSWORD_PLUS_2FA,
|
||||||
|
AUTH_STRATEGY_DEVICE_BOUND,
|
||||||
|
AUTH_STRATEGY_SSO_REDIRECT,
|
||||||
|
):
|
||||||
|
return 1
|
||||||
|
if auth_strategy in (AUTH_STRATEGY_PER_SESSION_MANUAL, AUTH_STRATEGY_CLIENT_CERTIFICATE):
|
||||||
|
return 0
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
def _insert_secret_holder_account(
|
def _insert_secret_holder_account(
|
||||||
conn,
|
conn,
|
||||||
*,
|
*,
|
||||||
@@ -222,6 +254,10 @@ def cmd_account_add_web(
|
|||||||
url: Optional[str],
|
url: Optional[str],
|
||||||
extra_json_str: Optional[str],
|
extra_json_str: Optional[str],
|
||||||
profile_dir_override: Optional[str],
|
profile_dir_override: Optional[str],
|
||||||
|
*,
|
||||||
|
auth_strategy: Optional[str] = None,
|
||||||
|
session_persistent: Optional[int] = None,
|
||||||
|
device_fingerprint: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""account add-web — create a web/RPA account record."""
|
"""account add-web — create a web/RPA account record."""
|
||||||
log = get_skill_logger()
|
log = get_skill_logger()
|
||||||
@@ -263,7 +299,35 @@ def cmd_account_add_web(
|
|||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
try:
|
try:
|
||||||
seed_platforms(conn)
|
seed_platforms(conn)
|
||||||
resolved_auth = resolve_auth_strategy_for_platform(conn, key)
|
if auth_strategy is None:
|
||||||
|
resolved_auth = resolve_auth_strategy_for_platform(conn, key)
|
||||||
|
else:
|
||||||
|
if auth_strategy not in ALL_AUTH_STRATEGIES:
|
||||||
|
_say(_err_json(
|
||||||
|
"ERR_AUTH_STRATEGY_NOT_SUPPORTED",
|
||||||
|
f"不支持的 auth_strategy:{auth_strategy}",
|
||||||
|
))
|
||||||
|
return
|
||||||
|
resolved_auth = auth_strategy
|
||||||
|
|
||||||
|
if session_persistent is None:
|
||||||
|
sp = _derive_session_persistent(resolved_auth)
|
||||||
|
else:
|
||||||
|
if session_persistent not in (0, 1):
|
||||||
|
_say(_err_json(
|
||||||
|
"ERR_INVALID_SESSION_PERSISTENT",
|
||||||
|
"session_persistent 必须是 0 或 1。",
|
||||||
|
))
|
||||||
|
return
|
||||||
|
sp = session_persistent
|
||||||
|
|
||||||
|
if device_fingerprint is not None and resolved_auth != AUTH_STRATEGY_DEVICE_BOUND:
|
||||||
|
_say(_err_json(
|
||||||
|
"ERR_DEVICE_FINGERPRINT_NOT_APPLICABLE",
|
||||||
|
"device_fingerprint 仅在 auth_strategy=device_bound 时可设置。",
|
||||||
|
))
|
||||||
|
return
|
||||||
|
|
||||||
new_id = insert_account(
|
new_id = insert_account(
|
||||||
conn=conn,
|
conn=conn,
|
||||||
platform_key=key,
|
platform_key=key,
|
||||||
@@ -279,6 +343,8 @@ def cmd_account_add_web(
|
|||||||
extra_json=resolved_extra,
|
extra_json=resolved_extra,
|
||||||
now=now,
|
now=now,
|
||||||
auth_strategy=resolved_auth,
|
auth_strategy=resolved_auth,
|
||||||
|
session_persistent=sp,
|
||||||
|
device_fingerprint=device_fingerprint,
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -302,6 +368,9 @@ def cmd_account_add_web(
|
|||||||
"provider_code": resolved_provider,
|
"provider_code": resolved_provider,
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"session_status": "unknown",
|
"session_status": "unknown",
|
||||||
|
"auth_strategy": resolved_auth,
|
||||||
|
"session_persistent": sp,
|
||||||
|
"device_fingerprint": device_fingerprint,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
||||||
@@ -331,9 +400,9 @@ def cmd_account_add_secret(
|
|||||||
return
|
return
|
||||||
|
|
||||||
storage_n = secret_storage.strip().lower()
|
storage_n = secret_storage.strip().lower()
|
||||||
if storage_n not in ("none", "env", "windows_credential"):
|
if storage_n not in ("none", "env", "windows_credential", "local_encrypted"):
|
||||||
_say(_err_json("ERROR:SECRET_STORAGE_UNSUPPORTED",
|
_say(_err_json("ERROR:SECRET_STORAGE_UNSUPPORTED",
|
||||||
f"不支持的存储方式:{secret_storage}。支持:none / env / windows_credential"))
|
f"不支持的存储方式:{secret_storage}。支持:none / env / windows_credential / local_encrypted"))
|
||||||
return
|
return
|
||||||
|
|
||||||
# Validate storage/type combinations
|
# Validate storage/type combinations
|
||||||
@@ -351,6 +420,123 @@ def cmd_account_add_secret(
|
|||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
safe_label = (label or "").strip() or f"{platform_spec.get('display_name', key)} {credential_type}"
|
safe_label = (label or "").strip() or f"{platform_spec.get('display_name', key)} {credential_type}"
|
||||||
|
|
||||||
|
if storage_n == "local_encrypted":
|
||||||
|
if not is_master_key_available():
|
||||||
|
_say(_err_json(
|
||||||
|
"ERR_MASTER_KEY_MISSING",
|
||||||
|
"master key 未初始化。先跑 `account init-master-key`。",
|
||||||
|
))
|
||||||
|
return
|
||||||
|
if not secret_stdin:
|
||||||
|
_say(_err_json(
|
||||||
|
"ERR_LOCAL_ENCRYPTED_REQUIRES_STDIN",
|
||||||
|
"local_encrypted 后端必须 --secret-stdin 输入密文,避免命令历史泄漏。",
|
||||||
|
))
|
||||||
|
return
|
||||||
|
if credential_type not in (
|
||||||
|
"password",
|
||||||
|
"api_key",
|
||||||
|
"api_token",
|
||||||
|
"bearer_token",
|
||||||
|
"refresh_token",
|
||||||
|
"oauth_client",
|
||||||
|
):
|
||||||
|
_say(_err_json(
|
||||||
|
"ERR_LOCAL_ENCRYPTED_TYPE_NOT_ALLOWED",
|
||||||
|
f"local_encrypted 不接受 credential_type={credential_type}(仅 secret 类)。",
|
||||||
|
))
|
||||||
|
return
|
||||||
|
if secret_ref:
|
||||||
|
_say_err("[add-secret] WARNING: secret_ref ignored for local_encrypted (secret comes from stdin).")
|
||||||
|
|
||||||
|
extra: dict = {}
|
||||||
|
if extra_json_str:
|
||||||
|
try:
|
||||||
|
extra = json.loads(extra_json_str)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
extra = {}
|
||||||
|
resolved_env = environment or "production"
|
||||||
|
resolved_role = role or "api"
|
||||||
|
extra["environment"] = resolved_env
|
||||||
|
extra["role"] = resolved_role
|
||||||
|
resolved_extra = json.dumps(extra, ensure_ascii=False)
|
||||||
|
resolved_url = (platform_spec.get("default_url") or "").strip() or None
|
||||||
|
resolved_provider = platform_spec.get("provider_code") or key
|
||||||
|
|
||||||
|
plaintext = sys.stdin.readline().rstrip("\r\n")
|
||||||
|
if not plaintext:
|
||||||
|
_say(_err_json("ERR_LOCAL_ENCRYPTED_EMPTY_STDIN", "stdin 没读到密码内容。"))
|
||||||
|
return
|
||||||
|
secret_mask_out = mask_secret(plaintext)
|
||||||
|
|
||||||
|
conn = get_conn()
|
||||||
|
cred_id: Optional[int] = None
|
||||||
|
holder_id: Optional[int] = None
|
||||||
|
try:
|
||||||
|
seed_platforms(conn)
|
||||||
|
holder_id = account_id
|
||||||
|
if holder_id is not None:
|
||||||
|
acc = get_account_by_id(holder_id)
|
||||||
|
if not acc:
|
||||||
|
_say(_err_json(
|
||||||
|
"ERROR:ACCOUNT_NOT_FOUND",
|
||||||
|
f"account_id={holder_id} 不存在。",
|
||||||
|
))
|
||||||
|
return
|
||||||
|
if holder_id is None:
|
||||||
|
holder_id = _insert_secret_holder_account(
|
||||||
|
conn,
|
||||||
|
platform_key=key,
|
||||||
|
account_label=safe_label,
|
||||||
|
environment=resolved_env,
|
||||||
|
role=resolved_role,
|
||||||
|
provider_code=resolved_provider,
|
||||||
|
url=resolved_url,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
cred_id = insert_credential_encrypted(
|
||||||
|
conn,
|
||||||
|
account_id=holder_id,
|
||||||
|
platform_key=key,
|
||||||
|
credential_label=safe_label,
|
||||||
|
credential_type=credential_type,
|
||||||
|
plaintext=plaintext,
|
||||||
|
expires_at=None,
|
||||||
|
extra_json=resolved_extra,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
except MasterKeyMissingError as e:
|
||||||
|
conn.rollback()
|
||||||
|
_say(_err_json("ERR_MASTER_KEY_MISSING", str(e)))
|
||||||
|
return
|
||||||
|
except CredentialDecryptError as e:
|
||||||
|
conn.rollback()
|
||||||
|
_say(_err_json("ERR_CREDENTIAL_DECRYPT_FAILED", str(e)))
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
log.exception("account_add_secret_failure")
|
||||||
|
_say(_err_json("ERROR:DB_ERROR", "Database insert failed."))
|
||||||
|
return
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"account_add_secret_encrypted_success cred_id=%s account_id=%s",
|
||||||
|
cred_id,
|
||||||
|
holder_id,
|
||||||
|
)
|
||||||
|
_say(_ok_json({
|
||||||
|
"credential_id": cred_id,
|
||||||
|
"account_id": holder_id,
|
||||||
|
"platform_key": key,
|
||||||
|
"credential_type": credential_type,
|
||||||
|
"secret_storage": "local_encrypted",
|
||||||
|
"secret_mask": secret_mask_out,
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
|
||||||
# For env storage: just use the env var name as ref, mask is optional
|
# For env storage: just use the env var name as ref, mask is optional
|
||||||
if storage_n == "env":
|
if storage_n == "env":
|
||||||
secret_ref_value = secret_ref.strip()
|
secret_ref_value = secret_ref.strip()
|
||||||
@@ -593,6 +779,121 @@ def cmd_account_get(account_id, include_credentials: bool = False) -> None:
|
|||||||
_say(json.dumps(acc, ensure_ascii=False))
|
_say(json.dumps(acc, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_account_get_credential(
|
||||||
|
account_id: int,
|
||||||
|
*,
|
||||||
|
reveal: bool = False,
|
||||||
|
lease_token: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""account get-credential — metadata or plaintext when lease proves access."""
|
||||||
|
log = get_skill_logger()
|
||||||
|
log.info("cli_start subcommand=account_get_credential id=%s reveal=%s", account_id, reveal)
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
acc = get_account_by_id(account_id)
|
||||||
|
if not acc:
|
||||||
|
log.warning("account_get_credential_not_found id=%s", account_id)
|
||||||
|
_say(_err_json("ERROR:ACCOUNT_NOT_FOUND", f"账号 id={account_id} 不存在。"))
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = get_conn()
|
||||||
|
try:
|
||||||
|
creds_all = list_credentials_by_account(conn, account_id)
|
||||||
|
creds = [c for c in creds_all if (c.get("status") or "").strip().lower() == "active"]
|
||||||
|
if not creds:
|
||||||
|
_say(_err_json(
|
||||||
|
"ERR_CREDENTIAL_MISSING",
|
||||||
|
f"账号 id={account_id} 没有活跃凭据。",
|
||||||
|
))
|
||||||
|
return
|
||||||
|
cred = creds[0]
|
||||||
|
|
||||||
|
base_out = {
|
||||||
|
"success": True,
|
||||||
|
"account_id": account_id,
|
||||||
|
"credential_id": cred["id"],
|
||||||
|
"credential_type": cred["credential_type"],
|
||||||
|
"secret_storage": cred["secret_storage"],
|
||||||
|
"secret_mask": cred["secret_mask"],
|
||||||
|
}
|
||||||
|
|
||||||
|
if not reveal:
|
||||||
|
_say(json.dumps(base_out, ensure_ascii=False))
|
||||||
|
return
|
||||||
|
|
||||||
|
tok = (lease_token or "").strip()
|
||||||
|
if not tok:
|
||||||
|
log.warning("account_get_credential_reveal_missing_token id=%s", account_id)
|
||||||
|
_say(_err_json(
|
||||||
|
"ERR_LEASE_INVALID",
|
||||||
|
"reveal 必须带 --lease-token。",
|
||||||
|
))
|
||||||
|
return
|
||||||
|
|
||||||
|
lease = get_active_lease_by_token(conn, tok)
|
||||||
|
lease_prefix = tok[:8]
|
||||||
|
if lease is None:
|
||||||
|
log.warning(
|
||||||
|
"account_get_credential_reveal_bad_lease id=%s lease_prefix=%s",
|
||||||
|
account_id,
|
||||||
|
lease_prefix,
|
||||||
|
)
|
||||||
|
_say(_err_json(
|
||||||
|
"ERR_LEASE_INVALID",
|
||||||
|
"lease token 无效或已过期",
|
||||||
|
))
|
||||||
|
return
|
||||||
|
|
||||||
|
if int(lease["account_id"]) != int(account_id):
|
||||||
|
log.warning(
|
||||||
|
"account_get_credential_reveal_lease_mismatch id=%s lease_account=%s lease_prefix=%s",
|
||||||
|
account_id,
|
||||||
|
lease["account_id"],
|
||||||
|
lease_prefix,
|
||||||
|
)
|
||||||
|
_say(_err_json(
|
||||||
|
"ERR_LEASE_INVALID",
|
||||||
|
"lease token 不属于该 account",
|
||||||
|
))
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
pt = read_credential_plaintext(conn, int(cred["id"]))
|
||||||
|
except MasterKeyMissingError as e:
|
||||||
|
log.warning("account_get_credential_master_missing id=%s", account_id)
|
||||||
|
_say(_err_json("ERR_MASTER_KEY_MISSING", str(e)))
|
||||||
|
return
|
||||||
|
except CredentialDecryptError as e:
|
||||||
|
log.warning(
|
||||||
|
"account_get_credential_decrypt_failed id=%s cred_id=%s lease_prefix=%s",
|
||||||
|
account_id,
|
||||||
|
cred["id"],
|
||||||
|
lease_prefix,
|
||||||
|
)
|
||||||
|
_say(_err_json("ERR_CREDENTIAL_DECRYPT_FAILED", str(e)))
|
||||||
|
return
|
||||||
|
except CredentialNotEncryptedError:
|
||||||
|
out = {**base_out, "plaintext": None, "note": "credential storage=none, no plaintext to reveal"}
|
||||||
|
log.info(
|
||||||
|
"account_get_credential_reveal_note_plain id=%s cred_id=%s lease_prefix=%s",
|
||||||
|
account_id,
|
||||||
|
cred["id"],
|
||||||
|
lease_prefix,
|
||||||
|
)
|
||||||
|
_say(json.dumps(out, ensure_ascii=False))
|
||||||
|
return
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"account_get_credential_reveal_success account_id=%s cred_id=%s lease_prefix=%s",
|
||||||
|
account_id,
|
||||||
|
cred["id"],
|
||||||
|
lease_prefix,
|
||||||
|
)
|
||||||
|
_say(json.dumps({**base_out, "plaintext": pt}, ensure_ascii=False))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
# Legacy alias
|
# Legacy alias
|
||||||
def cmd_get(account_id) -> None:
|
def cmd_get(account_id) -> None:
|
||||||
"""Legacy: get <id> — same as account get <id>."""
|
"""Legacy: get <id> — same as account get <id>."""
|
||||||
@@ -711,6 +1012,9 @@ def cmd_account_pick_web(
|
|||||||
"role": acc["role"],
|
"role": acc["role"],
|
||||||
"provider_code": acc["provider_code"],
|
"provider_code": acc["provider_code"],
|
||||||
"session_status": acc["session_status"],
|
"session_status": acc["session_status"],
|
||||||
|
"auth_strategy": acc["auth_strategy"],
|
||||||
|
"session_persistent": acc["session_persistent"],
|
||||||
|
"device_fingerprint": acc.get("device_fingerprint"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Lease handling
|
# Lease handling
|
||||||
|
|||||||
Reference in New Issue
Block a user